From ec7567b7164d53e94d6939b781b3028a91971c16 Mon Sep 17 00:00:00 2001 From: Tiago Gimenes Date: Thu, 9 Sep 2021 10:39:09 -0300 Subject: [PATCH] add vtex resolvers --- packages/store-api/README.md | 41 ++++ packages/store-api/jest.config.js | 11 + packages/store-api/package.json | 35 +++ packages/store-api/src/index.ts | 25 ++ .../platforms/vtex/clients/commerce/index.ts | 48 ++++ .../vtex/clients/commerce/types/Brand.ts | 8 + .../clients/commerce/types/CategoryTree.ts | 9 + .../vtex/clients/commerce/types/Checkout.ts | 170 +++++++++++++ .../src/platforms/vtex/clients/common.ts | 14 ++ .../src/platforms/vtex/clients/index.ts | 15 ++ .../platforms/vtex/clients/search/index.ts | 83 +++++++ .../search/types/AttributeSearchResult.ts | 61 +++++ .../search/types/ProductSearchResult.ts | 223 ++++++++++++++++++ .../store-api/src/platforms/vtex/index.ts | 61 +++++ .../vtex/resolvers/aggregateOffer.ts | 19 ++ .../vtex/resolvers/aggregateRating.ts | 7 + .../platforms/vtex/resolvers/collection.ts | 43 ++++ .../src/platforms/vtex/resolvers/facet.ts | 11 + .../platforms/vtex/resolvers/facetValue.ts | 11 + .../src/platforms/vtex/resolvers/offer.ts | 18 ++ .../platforms/vtex/resolvers/organization.ts | 5 + .../src/platforms/vtex/resolvers/product.ts | 73 ++++++ .../platforms/vtex/resolvers/productGroup.ts | 9 + .../src/platforms/vtex/resolvers/query.ts | 147 ++++++++++++ .../src/platforms/vtex/resolvers/review.ts | 11 + .../platforms/vtex/resolvers/searchResult.ts | 46 ++++ .../src/platforms/vtex/resolvers/seo.ts | 10 + .../src/platforms/vtex/utils/enhanceSku.ts | 8 + .../src/platforms/vtex/utils/slugify.ts | 4 + .../src/platforms/vtex/utils/sort.ts | 10 + packages/store-api/src/typings/schema.d.ts | 7 + packages/store-api/test/index.test.ts | 13 + packages/store-api/tsconfig.json | 35 +++ packages/store-api/tsdx.config.js | 9 + 34 files changed, 1300 insertions(+) create mode 100644 packages/store-api/README.md create mode 100644 packages/store-api/jest.config.js create mode 100644 packages/store-api/package.json create mode 100644 packages/store-api/src/index.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/commerce/index.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/commerce/types/Brand.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/commerce/types/CategoryTree.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/commerce/types/Checkout.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/common.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/index.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/search/index.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/search/types/AttributeSearchResult.ts create mode 100644 packages/store-api/src/platforms/vtex/clients/search/types/ProductSearchResult.ts create mode 100644 packages/store-api/src/platforms/vtex/index.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/aggregateOffer.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/aggregateRating.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/collection.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/facet.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/facetValue.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/offer.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/organization.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/product.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/productGroup.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/query.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/review.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/searchResult.ts create mode 100644 packages/store-api/src/platforms/vtex/resolvers/seo.ts create mode 100644 packages/store-api/src/platforms/vtex/utils/enhanceSku.ts create mode 100644 packages/store-api/src/platforms/vtex/utils/slugify.ts create mode 100644 packages/store-api/src/platforms/vtex/utils/sort.ts create mode 100644 packages/store-api/src/typings/schema.d.ts create mode 100644 packages/store-api/test/index.test.ts create mode 100644 packages/store-api/tsconfig.json create mode 100644 packages/store-api/tsdx.config.js diff --git a/packages/store-api/README.md b/packages/store-api/README.md new file mode 100644 index 0000000000..0d714f8fcc --- /dev/null +++ b/packages/store-api/README.md @@ -0,0 +1,41 @@ +# @vtex/store-api + +The only API you need for building your next ecommerce. + +This package defines a front-end first, GraphQL API inspired by clean architecture and schema.org. + +GraphQL types defined in this repo extends and simplifies schema.org. This makes it easier to make your frontend search friendly. +Also, by using the clean architecture, all types defined on this schema are not platform specific, but created to resolve an specific business case on your frontend, like rendering listprices, sellers etc. + +Alongside the GraphQL type definitions, we provide standard implementations for common ecommerce platforms. Currently we support: +1. VTEX +2. Maybe add yours? + +With the typedefs and resolvers, you can create an executable schema and deploy anywhere you want. For instance, one use case would be: +1. Create an Apollo Server instane on Heroku +2. Run the executable schema in a function on Next.JS +3. Run the executable schema during a Gatsby build. + +## Install + +```bash +yarn add @vtex/store-api +``` + +## Usage +GraphQL is very versatile and can run in many places. To setup the schema in an apollo server, just: +```ts +import { getSchema } from '@vtex/store-api' +import { ApolloServer } from 'apollo-server' + +// Get the Store schema +const schema = await getSchema({ platform: 'vtex', account: 'my-account', environment: 'vtexcommercestable' }) + +// Setup Apollo Server +const server = new ApolloServer({ schema }); + +// The `listen` method launches a web server. +server.listen().then(({ url }) => { + console.log(`🚀 Server ready at ${url}`); +}); +``` diff --git a/packages/store-api/jest.config.js b/packages/store-api/jest.config.js new file mode 100644 index 0000000000..fea5fca65d --- /dev/null +++ b/packages/store-api/jest.config.js @@ -0,0 +1,11 @@ +// File created to resolve .graphql files. +// The main content was extracted from https://github.com/formium/tsdx/blob/master/src/createJestConfig.ts +// Copying some code was necessary because tsdx does shallow merges + +module.exports = { + preset: 'ts-jest', + transform: { + '.(graphql)$': 'jest-transform-graphql', + '.(js|jsx)$': 'babel-jest', // jest's default + }, +} diff --git a/packages/store-api/package.json b/packages/store-api/package.json new file mode 100644 index 0000000000..d9f95331cc --- /dev/null +++ b/packages/store-api/package.json @@ -0,0 +1,35 @@ +{ + "name": "@vtex/store-api", + "version": "0.1.0", + "license": "MIT", + "main": "dist/index.js", + "typings": "dist/index.d.ts", + "module": "dist/store-api.esm.js", + "files": [ + "dist", + "src" + ], + "engines": { + "node": ">=10" + }, + "scripts": { + "develop": "tsdx watch", + "build": "tsdx build", + "test": "tsdx test", + "lint": "tsdx lint" + }, + "peerDependencies": {}, + "dependencies": { + "rollup-plugin-graphql": "^0.1.0", + "slugify": "^1.6.0", + "ts-jest": "25.5.1" + }, + "devDependencies": { + "babel-jest": "^27.1.1", + "jest-transform-graphql": "^2.1.0", + "ts-jest": "^27.0.5", + "tsdx": "^0.14.1", + "tslib": "^2.3.1", + "typescript": "^4.4.2" + } +} diff --git a/packages/store-api/src/index.ts b/packages/store-api/src/index.ts new file mode 100644 index 0000000000..a3027bc76d --- /dev/null +++ b/packages/store-api/src/index.ts @@ -0,0 +1,25 @@ +import { makeExecutableSchema } from '@graphql-tools/schema' + +import { getResolvers as getResolversVTEX } from './platforms/vtex' +import { typeDefs } from './typeDefs' +import type { Options as OptionsVTEX } from './platforms/vtex' + +export type Options = OptionsVTEX + +const getResolversByPlatform = { + vtex: getResolversVTEX, +} + +const getTypeDefs = async () => typeDefs + +const getResolvers = (options: Options) => + getResolversByPlatform[options.platform](options) + +export const getSchema = async (options: Options) => { + const [resolvers, defs] = await Promise.all([ + getResolvers(options), + getTypeDefs(), + ]) + + return makeExecutableSchema({ resolvers, typeDefs: defs }) +} diff --git a/packages/store-api/src/platforms/vtex/clients/commerce/index.ts b/packages/store-api/src/platforms/vtex/clients/commerce/index.ts new file mode 100644 index 0000000000..b39485ecf6 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/commerce/index.ts @@ -0,0 +1,48 @@ +import { fetchAPI } from '../common' +import type { + Simulation, + SimulationArgs, + SimulationOptions, +} from './types/Checkout' +import type { CategoryTree } from './types/CategoryTree' +import type { Options } from '../..' +import type { Brand } from './types/Brand' + +const getBase = ({ account, environment }: Options) => + `http://${account}.${environment}.com.br` + +export const VtexCommerce = (opts: Options) => { + const base = getBase(opts) + + return { + catalog: { + brand: { + list: (): Promise => + fetchAPI(`${base}/api/catalog_system/pub/brand/list`), + }, + category: { + tree: (depth = 3): Promise => + fetchAPI(`${base}/api/catalog_system/pub/category/tree/${depth}`), + }, + }, + checkout: { + simulation: ( + args: SimulationArgs, + options: SimulationOptions = { sc: '1' } + ): Promise => { + const params = new URLSearchParams({ ...options }) + + return fetchAPI( + `${base}/api/checkout/pub/orderForms/simulation?${params.toString()}`, + { + method: 'POST', + body: JSON.stringify(args), + headers: { + 'content-type': 'application/json', + }, + } + ) + }, + }, + } +} diff --git a/packages/store-api/src/platforms/vtex/clients/commerce/types/Brand.ts b/packages/store-api/src/platforms/vtex/clients/commerce/types/Brand.ts new file mode 100644 index 0000000000..5e37f2ed8d --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/commerce/types/Brand.ts @@ -0,0 +1,8 @@ +export interface Brand { + id: number + name: string + isActive: boolean + title: string + metaTagDescription: string + imageURL: null | string +} diff --git a/packages/store-api/src/platforms/vtex/clients/commerce/types/CategoryTree.ts b/packages/store-api/src/platforms/vtex/clients/commerce/types/CategoryTree.ts new file mode 100644 index 0000000000..453c5de41e --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/commerce/types/CategoryTree.ts @@ -0,0 +1,9 @@ +export interface CategoryTree { + id: number + name: string + hasChildren: boolean + url: string + children: CategoryTree[] + Title: string + MetaTagDescription: string +} diff --git a/packages/store-api/src/platforms/vtex/clients/commerce/types/Checkout.ts b/packages/store-api/src/platforms/vtex/clients/commerce/types/Checkout.ts new file mode 100644 index 0000000000..3dd23440b9 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/commerce/types/Checkout.ts @@ -0,0 +1,170 @@ +export interface PayloadItem { + id: string + quantity: number + seller: string + parentItemIndex?: number | null + parentAssemblyBinding?: string | null +} + +export interface ShippingData { + logisticsInfo?: Array<{ regionId?: string | null }> +} + +export interface SimulationArgs { + country?: string + items: PayloadItem[] + postalCode?: string + isCheckedIn?: boolean + priceTables?: string[] + marketingData?: Record + shippingData?: ShippingData +} + +export interface SimulationOptions { + sc: string +} + +export interface Simulation { + items: Item[] + ratesAndBenefitsData: RatesAndBenefitsData + paymentData: PaymentData + selectableGifts: any[] + marketingData: MarketingData + postalCode: null + country: null + logisticsInfo: LogisticsInfo[] + messages: any[] + purchaseConditions: PurchaseConditions + pickupPoints: any[] + subscriptionData: null + totals: Total[] + itemMetadata: null +} + +export interface Item { + id: string + requestIndex: number + quantity: number + seller: string + sellerChain: string[] + tax: number + priceValidUntil: Date + price: number + listPrice: number + rewardValue: number + sellingPrice: number + offerings: any[] + priceTags: any[] + measurementUnit: string + unitMultiplier: number + parentItemIndex: null + parentAssemblyBinding: null + availability: string + catalogProvider: string + priceDefinition: PriceDefinition +} + +export interface PriceDefinition { + calculatedSellingPrice: number + total: number + sellingPrices: SellingPrice[] +} + +export interface SellingPrice { + value: number + quantity: number +} + +export interface LogisticsInfo { + itemIndex: number + addressId: null + selectedSla: null + selectedDeliveryChannel: null + quantity: number + shipsTo: string[] + slas: any[] + deliveryChannels: DeliveryChannel[] +} + +export interface DeliveryChannel { + id: string +} + +export interface MarketingData { + utmSource: null + utmMedium: null + utmCampaign: null + utmipage: null + utmiPart: null + utmiCampaign: null + coupon: null + marketingTags: string[] +} + +export interface PaymentData { + installmentOptions: InstallmentOption[] + paymentSystems: PaymentSystem[] + payments: any[] + giftCards: any[] + giftCardMessages: any[] + availableAccounts: any[] + availableTokens: any[] +} + +export interface InstallmentOption { + paymentSystem: string + bin: null + paymentName: string + paymentGroupName: string + value: number + installments: Installment[] +} + +export interface Installment { + count: number + hasInterestRate: boolean + interestRate: number + value: number + total: number + sellerMerchantInstallments?: Installment[] + id?: string +} + +export interface PaymentSystem { + id: number + name: string + groupName: string + validator: null + stringId: string + template: string + requiresDocument: boolean + isCustom: boolean + description: null | string + requiresAuthentication: boolean + dueDate: Date + availablePayments: null +} + +export interface PurchaseConditions { + itemPurchaseConditions: ItemPurchaseCondition[] +} + +export interface ItemPurchaseCondition { + id: string + seller: string + sellerChain: string[] + slas: any[] + price: number + listPrice: number +} + +export interface RatesAndBenefitsData { + rateAndBenefitsIdentifiers: any[] + teaser: any[] +} + +export interface Total { + id: string + name: string + value: number +} diff --git a/packages/store-api/src/platforms/vtex/clients/common.ts b/packages/store-api/src/platforms/vtex/clients/common.ts new file mode 100644 index 0000000000..6fe8d4170f --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/common.ts @@ -0,0 +1,14 @@ +import fetch from 'isomorphic-unfetch' + +export const fetchAPI = async (info: RequestInfo, init?: RequestInit) => { + const response = await fetch(info, init) + + if (response.ok) { + return response.json() + } + + const text = await response.text() + + console.error(text) + throw new Error(text) +} diff --git a/packages/store-api/src/platforms/vtex/clients/index.ts b/packages/store-api/src/platforms/vtex/clients/index.ts new file mode 100644 index 0000000000..aca69900b0 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/index.ts @@ -0,0 +1,15 @@ +import { VtexCommerce } from './commerce' +import { IntelligentSearch } from './search' +import type { Options } from '..' + +export type Clients = ReturnType + +export const getClients = (options: Options) => { + const search = IntelligentSearch(options) + const commerce = VtexCommerce(options) + + return { + search, + commerce, + } +} diff --git a/packages/store-api/src/platforms/vtex/clients/search/index.ts b/packages/store-api/src/platforms/vtex/clients/search/index.ts new file mode 100644 index 0000000000..667ddae167 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/search/index.ts @@ -0,0 +1,83 @@ +import { fetchAPI } from '../common' +import type { Options } from '../..' +import type { ProductSearchResult } from './types/ProductSearchResult' +import type { AttributeSearchResult } from './types/AttributeSearchResult' + +export type Sort = + | 'price:desc' + | 'price:asc' + | 'orders:desc' + | 'name:desc' + | 'name:asc' + | 'release:desc' + | 'discount:desc' + | '' + +export interface SelectedFacet { + key: string + value: string +} + +export interface SearchArgs { + query?: string + page: number + count: number + type: 'product_search' | 'attribute_search' + sort?: Sort + selectedFacets?: SelectedFacet[] + fuzzy?: '0' | '1' +} + +export interface ProductLocator { + field: 'id' | 'slug' + value: string +} + +// TODO: change here once supporting sales channel +const defaultFacets = [ + { + key: 'trade-policy', + value: '1', + }, +] + +export const IntelligentSearch = (opts: Options) => { + const base = `http://search.biggylabs.com.br/search-api/v1/${opts.account}` + + const search = ({ + query = '', + page, + count, + sort = '', + selectedFacets = [], + type, + fuzzy = '0', + }: SearchArgs): Promise => { + const params = new URLSearchParams({ + page: (page + 1).toString(), + count: count.toString(), + query, + sort, + fuzzy, + }) + + const pathname = [...defaultFacets, ...selectedFacets] + .map(({ key, value }) => `${key}/${value}`) + .join('/') + + return fetchAPI( + `${base}/api/split/${type}/${pathname}?${params.toString()}` + ) + } + + const products = (args: Omit) => + search({ ...args, type: 'product_search' }) + + const facets = (args: Omit) => + search({ ...args, type: 'attribute_search' }) + + return { + facets, + products, + } +} diff --git a/packages/store-api/src/platforms/vtex/clients/search/types/AttributeSearchResult.ts b/packages/store-api/src/platforms/vtex/clients/search/types/AttributeSearchResult.ts new file mode 100644 index 0000000000..4e11d96c20 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/search/types/AttributeSearchResult.ts @@ -0,0 +1,61 @@ +export interface AttributeSearchResult { + total: number + pagination: Pagination + sampling: boolean + translated: boolean + locale: string + query: string + operator: string + fuzzy: string + attributes: Attribute[] +} + +export interface Attribute { + ids: string[] + visible: boolean + values: Value[] + active: boolean + key: string + originalKey: string + label: string + originalLabel: string + type: string + minValue?: number + maxValue?: number + templateURL?: string + proxyURL?: string +} + +export interface Value { + count: number + active: boolean + key?: string + label?: string + id?: string + originalKey?: string + originalLabel?: string + proxyURL: string + from?: string + to?: string +} + +export interface Pagination { + count: number + current: Current + before: any[] + after: any[] + perPage: number + next: First + previous: First + first: First + last: First +} + +export interface Current { + index: number + proxyURL: string +} + +export interface First { + index: number +} diff --git a/packages/store-api/src/platforms/vtex/clients/search/types/ProductSearchResult.ts b/packages/store-api/src/platforms/vtex/clients/search/types/ProductSearchResult.ts new file mode 100644 index 0000000000..46679bc0d7 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/clients/search/types/ProductSearchResult.ts @@ -0,0 +1,223 @@ +export interface ProductSearchResult { + total: number + products: Product[] + pagination: Pagination + sampling: boolean + options: Options + translated: boolean + locale: string + query: string + operator: string + fuzzy: string + correction: Correction +} + +export interface Correction { + misspelled: boolean +} + +export interface Options { + sorts: Sort[] + counts: Count[] +} + +export interface Count { + count: number + proxyURL: string +} + +export interface Sort { + field: string + order: string + active?: boolean + proxyURL: string +} + +export interface Pagination { + count: number + current: Current + before: any[] + after: Current[] + perPage: number + next: Current + previous: First + first: First + last: Current +} + +export interface Current { + index: number + proxyURL: string +} + +export interface First { + index: number +} + +export interface Product { + unitMultiplier: number + year: number + extraData: ExtraDatum[] + release: number + discount: number + reference: string + split: Split + collections: Collection[] + price: number + customSort: number + stickers: Sticker[] + id: string + stock: number + brand: string + availableTradePolicies: string[] + categoryTrees: CategoryTree[] + images: Image[] + locationAttributes: any[] + tax: number + productScore: number + storeSplitAttribute: string + brandID: string + installment: Installment + name: string + boost: Boost + skus: Sku[] + link: string + wear: number + description: string + showIfNotAvailable: boolean + clusterHighlights: ClusterHighlights + categories: string[] + timestamp: number + product: string + oldPrice: number + productSpecifications: any[] + url: string + measurementUnit: string + categoryIDS: string[] + textAttributes: TextAttribute[] + numberAttributes: NumberAttribute[] + headSku: string + specificationGroups: string + extraInfo: ExtraInfo + oldPriceText: string + priceText: string +} + +export interface Boost { + newness: number + image: number + revenue: number + discount: number + productScore: number + click: number + availableSpecsCount: number + promotion: number + order: number +} + +export interface CategoryTree { + categoryNames: string[] + categoryIDS: string[] +} + +export interface ClusterHighlights { + the140: string +} + +export interface Collection { + id: string + position: number +} + +export interface ExtraDatum { + value: string + key: string +} + +export interface ExtraInfo { + sellerID: string +} + +export interface Image { + name: string + value: string +} + +export interface Installment { + interest: boolean + count: number + paymentGroupName: string + value: number + paymentName: string + valueText?: string +} + +export interface NumberAttribute { + labelKey: string + value: number + key: string +} + +export interface Sku { + images: Image[] + nameComplete: string + complementName: string + policies: Policy[] + videos: any[] + reference: string + idWithSplit: string + ean: string + name: string + attributes: ExtraDatum[] + id: string + sellers: Seller[] +} + +export interface Policy { + id: string + sellers: Seller[] +} + +export interface Seller { + default: boolean + oldPrice?: number + price?: number + installment?: Installment + name: string + tax: number + teasers: any[] + id: string +} + +export interface Split { + labelValue: string + labelKey: string +} + +export interface Sticker { + image: string + name: string + location: string + target: string +} + +export interface TextAttribute { + joinedValue: string + isSku: boolean + joinedKey: string + joinedKeyTranslations: JoinedTranslations + isFilter: boolean + labelValue: string + id: string[] + labelKey: string + value: string + key: string + joinedValueTranslations: JoinedTranslations + valueID?: string +} + +export interface JoinedTranslations { + spanish: string + english: string + italian: string +} diff --git a/packages/store-api/src/platforms/vtex/index.ts b/packages/store-api/src/platforms/vtex/index.ts new file mode 100644 index 0000000000..b7600aa252 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/index.ts @@ -0,0 +1,61 @@ +import { StoreSearchResult } from './resolvers/searchResult' +import { getClients } from './clients' +import { StoreAggregateOffer } from './resolvers/aggregateOffer' +import { StoreAggregateRating } from './resolvers/aggregateRating' +import { StoreCollection } from './resolvers/collection' +import { StoreFacet } from './resolvers/facet' +import { StoreFacetValue } from './resolvers/facetValue' +import { StoreOffer } from './resolvers/offer' +import { StoreProduct } from './resolvers/product' +import { StoreProductGroup } from './resolvers/productGroup' +import { Query } from './resolvers/query' +import { StoreReview } from './resolvers/review' +import { StoreSeo } from './resolvers/seo' +import type { Clients } from './clients' + +export interface Options { + platform: 'vtex' + account: string + environment: 'vtexcommercestable' | 'vtexcommercebeta' +} + +export interface Context { + clients: Clients +} + +export type Resolver = ( + root: R, + args: A, + ctx: Context, + info: any +) => any + +const Resolvers = { + StoreCollection, + StoreAggregateOffer, + StoreProduct, + StoreSeo, + StoreFacet, + StoreFacetValue, + StoreOffer, + StoreAggregateRating, + StoreReview, + StoreProductGroup, + StoreSearchResult, + Query, +} + +const Wrap = (resolvers: Record, options: Options) => + Object.keys(resolvers).reduce((acc, key) => { + acc[key] = (root, args, ctx, info) => + resolvers[key](root, args, { ...ctx, clients: getClients(options) }, info) + + return acc + }, {} as Record) + +export const getResolvers = (options: Options) => + Object.keys(Resolvers).reduce((acc, key) => { + acc[key] = Wrap((Resolvers as any)[key], options) + + return acc + }, {} as Record>) diff --git a/packages/store-api/src/platforms/vtex/resolvers/aggregateOffer.ts b/packages/store-api/src/platforms/vtex/resolvers/aggregateOffer.ts new file mode 100644 index 0000000000..bc8a8c5cf7 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/aggregateOffer.ts @@ -0,0 +1,19 @@ +import type { Simulation } from '../clients/commerce/types/Checkout' + +type Resolvers = (root: Simulation) => unknown + +export const StoreAggregateOffer: Record = { + highPrice: ({ items }) => + items.reduce( + (acc, curr) => (acc > curr.sellingPrice ? acc : curr.sellingPrice), + items[0]?.sellingPrice ?? 0 + ) / 1e2, + lowPrice: ({ items }) => + items.reduce( + (acc, curr) => (acc < curr.sellingPrice ? acc : curr.sellingPrice), + items[0]?.sellingPrice ?? 0 + ) / 1e2, + offerCount: ({ items }) => items.length, + priceCurrency: () => '', + offers: ({ items }) => items, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/aggregateRating.ts b/packages/store-api/src/platforms/vtex/resolvers/aggregateRating.ts new file mode 100644 index 0000000000..99bf349a06 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/aggregateRating.ts @@ -0,0 +1,7 @@ +import type { Resolver } from '..' + +// TODO: Add a review system integration +export const StoreAggregateRating: Record = { + ratingValue: () => 5, + reviewCount: () => 0, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/collection.ts b/packages/store-api/src/platforms/vtex/resolvers/collection.ts new file mode 100644 index 0000000000..fe3e16a60e --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/collection.ts @@ -0,0 +1,43 @@ +import type { Resolver } from '..' +import type { Brand } from '../clients/commerce/types/Brand' +import type { CategoryTree } from '../clients/commerce/types/CategoryTree' +import { slugify } from '../utils/slugify' + +type Root = Brand | (CategoryTree & { level: number }) + +const isBrand = (x: any): x is Brand => x.type === 'brand' + +export const StoreCollection: Record> = { + id: ({ id }) => id.toString(), + slug: ({ name }) => slugify(name), + seo: (root) => + isBrand(root) + ? { + title: root.title, + description: root.metaTagDescription, + } + : { + title: root.Title, + description: root.MetaTagDescription, + }, + type: (root) => + isBrand(root) ? 'Brand' : root.level === 0 ? 'Department' : 'Category', + meta: (root) => + isBrand(root) + ? { + selectedFacets: [{ key: 'brand', value: slugify(root.name) }], + } + : { + selectedFacets: new URL(root.url).pathname + .slice(1) + .split('/') + .map((segment, index) => ({ + key: `category-${index + 1}`, + value: slugify(segment), + })), + }, + breadcrumbList: () => ({ + itemListElement: [], + numberOfItems: 0, + }), +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/facet.ts b/packages/store-api/src/platforms/vtex/resolvers/facet.ts new file mode 100644 index 0000000000..c08e2111ae --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/facet.ts @@ -0,0 +1,11 @@ +import type { Resolver } from '..' +import type { Attribute } from '../clients/search/types/AttributeSearchResult' + +type Root = Attribute + +export const StoreFacet: Record> = { + key: ({ key }) => key, + label: ({ label }) => label, + values: ({ values }) => values, + type: ({ type }) => (type === 'text' ? 'BOOLEAN' : 'RANGE'), +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/facetValue.ts b/packages/store-api/src/platforms/vtex/resolvers/facetValue.ts new file mode 100644 index 0000000000..524ff7f00e --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/facetValue.ts @@ -0,0 +1,11 @@ +import type { Resolver } from '..' +import type { Value } from '../clients/search/types/AttributeSearchResult' + +type Root = Value + +export const StoreFacetValue: Record> = { + value: ({ key, from, to }) => key ?? `${from}-to-${to}`, + label: ({ label }) => label ?? 'unknown', + selected: ({ active }) => active, + quantity: ({ count }) => count, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/offer.ts b/packages/store-api/src/platforms/vtex/resolvers/offer.ts new file mode 100644 index 0000000000..69b2797f09 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/offer.ts @@ -0,0 +1,18 @@ +import type { Resolver } from '..' +import type { Item } from '../clients/commerce/types/Checkout' + +export const StoreOffer: Record> = { + priceCurrency: () => '', + priceValidUntil: ({ priceValidUntil }) => priceValidUntil ?? '', + itemCondition: () => 'https://schema.org/NewCondition', + availability: ({ availability }) => + availability === 'available' + ? 'https://schema.org/InStock' + : 'https://schema.org/OutOfStock', + seller: ({ seller }) => ({ + identifier: seller, + }), + price: ({ sellingPrice }) => sellingPrice / 1e2, // TODO add spot price calculation + sellingPrice: ({ sellingPrice }) => sellingPrice / 1e2, + listPrice: ({ listPrice }) => listPrice / 1e2, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/organization.ts b/packages/store-api/src/platforms/vtex/resolvers/organization.ts new file mode 100644 index 0000000000..f842f6d038 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/organization.ts @@ -0,0 +1,5 @@ +import type { Resolver } from '..' + +export const StoreOrganization: Record = { + identifier: () => '', +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/product.ts b/packages/store-api/src/platforms/vtex/resolvers/product.ts new file mode 100644 index 0000000000..d923e50942 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/product.ts @@ -0,0 +1,73 @@ +import type { Resolver } from '..' +import type { EnhancedSku } from '../utils/enhanceSku' + +type Root = EnhancedSku + +const DEFAULT_IMAGE = { + name: 'image', + value: + 'https://storecomponents.vtexassets.com/assets/faststore/images/image___117a6d3e229a96ad0e0d0876352566e2.svg', +} + +const getSlug = (link: string, id: string) => `${link}-${id}` +const getPath = (link: string, id: string) => `/${getSlug(link, id)}/p` + +export const StoreProduct: Record> = { + productID: ({ id }) => id, + name: ({ isVariantOf, name }) => name ?? isVariantOf.name, + slug: ({ isVariantOf: { link }, id }) => getSlug(link, id), + description: ({ isVariantOf: { description } }) => description, + seo: ({ isVariantOf: { name, description } }) => ({ + title: name, + description, + }), + brand: ({ isVariantOf: { brand } }) => ({ name: brand }), + breadcrumbList: ({ isVariantOf: { categoryTrees, name, link }, id }) => ({ + itemListElement: [ + ...categoryTrees.map(({ categoryNames }, index) => ({ + name: categoryNames[categoryNames.length - 1], + item: `/${categoryNames.join('/').toLowerCase()}`, + position: index + 1, + })), + { + name, + item: getPath(link, id), + position: categoryTrees.length + 1, + }, + ], + numberOfItems: categoryTrees.length, + }), + image: ({ isVariantOf, images }) => + (images ?? isVariantOf.images ?? [DEFAULT_IMAGE]).map( + ({ name, value }) => ({ + alternateName: name ?? '', + url: value.replace('vteximg.com.br', 'vtexassets.com'), + }) + ), + sku: ({ + isVariantOf: { + skus: [sku], + }, + }) => sku.id, + gtin: ({ reference }) => reference ?? '', + review: () => [], + aggregateRating: () => ({}), + offers: async ({ sellers, id }, _, ctx) => { + const { + clients: { commerce }, + } = ctx + + // Unique seller ids + const sellerIds = sellers.map((seller) => seller.id) + const items = Array.from(new Set(sellerIds)).map((seller) => ({ + quantity: 1, + seller, + id, + })) + + return commerce.checkout.simulation({ + items, + }) + }, + isVariantOf: ({ isVariantOf }) => isVariantOf, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/productGroup.ts b/packages/store-api/src/platforms/vtex/resolvers/productGroup.ts new file mode 100644 index 0000000000..c326909779 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/productGroup.ts @@ -0,0 +1,9 @@ +import { enhanceSku } from '../utils/enhanceSku' +import type { Product } from '../clients/search/types/ProductSearchResult' +import type { Resolver } from '..' + +export const StoreProductGroup: Record> = { + hasVariant: (root) => root.skus.map((sku) => enhanceSku(sku, root)), + productGroupID: ({ product }) => product, + name: ({ name }) => name, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/query.ts b/packages/store-api/src/platforms/vtex/resolvers/query.ts new file mode 100644 index 0000000000..4e21c439d8 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/query.ts @@ -0,0 +1,147 @@ +import { enhanceSku } from '../utils/enhanceSku' +import { SORT_MAP } from '../utils/sort' +import type { ProductLocator } from '../clients/search' +import type { CategoryTree } from '../clients/commerce/types/CategoryTree' +import type { Context } from '../index' + +export interface SelectedFacets { + key: string + value: string +} + +export interface SearchArgs { + term?: string + first: number + after?: string + sort: + | 'price_desc' + | 'price_asc' + | 'orders_desc' + | 'name_desc' + | 'name_asc' + | 'release_desc' + | 'discount_desc' + | 'score_desc' + selectedFacets: SelectedFacets[] +} + +export const Query = { + product: async ( + _: unknown, + { locator }: { locator: ProductLocator }, + ctx: Context + ) => { + const { + clients: { search }, + } = ctx + + const skuId = + locator.field === 'id' + ? locator.value + : locator.value.split('-').reverse()[0] + + const { + products: [product], + } = await search.products({ query: `sku:${skuId}`, page: 0, count: 1 }) + + const sku = product.skus.find((x) => x.id === skuId) + + if (sku == null) { + throw new Error( + `Could not find sku of id: ${skuId} for locator field: ${locator.field}, value: ${locator.value}` + ) + } + + return enhanceSku(sku, product) + }, + search: async ( + _: unknown, + { first, after: maybeAfter, sort, term, selectedFacets }: SearchArgs + ) => { + const after = maybeAfter ? Number(maybeAfter) : 0 + const searchArgs = { + page: Math.ceil(after / first), + count: first, + query: term, + sort: SORT_MAP[sort], + selectedFacets, + } + + return searchArgs + }, + allProducts: async ( + _: unknown, + { first, after: maybeAfter }: { first: number; after: string | null }, + ctx: Context + ) => { + const { + clients: { search }, + } = ctx + + const after = maybeAfter ? Number(maybeAfter) : 0 + const products = await search.products({ + page: Math.ceil(after / first), + count: first, + }) + + const skus = products.products + .map((product) => product.skus.map((sku) => enhanceSku(sku, product))) + .flat() + .filter((sku) => sku.sellers.length > 0) + + return { + pageInfo: { + hasNextPage: products.pagination.after.length > 0, + hasPreviousPage: products.pagination.before.length > 0, + startCursor: '0', + endCursor: products.total.toString(), + totalCount: products.total, + }, + edges: skus.map((sku, index) => ({ + node: sku, + cursor: (after + index).toString(), + })), + } + }, + allCollections: async (_: unknown, __: unknown, ctx: Context) => { + const { + clients: { commerce }, + } = ctx + + const [brands, tree] = await Promise.all([ + commerce.catalog.brand.list(), + commerce.catalog.category.tree(), + ]) + + const categories: Array = [] + const dfs = (node: CategoryTree, level: number) => { + categories.push({ ...node, level }) + + for (const child of node.children) { + dfs(child, level + 1) + } + } + + for (const node of tree) { + dfs(node, 0) + } + + const collections = [ + ...brands.map((x) => ({ ...x, type: 'brand' })), + ...categories, + ] + + return { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: '0', + endCursor: '0', + }, + edges: collections.map((node, index) => ({ + node, + cursor: index.toString(), + })), + } + }, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/review.ts b/packages/store-api/src/platforms/vtex/resolvers/review.ts new file mode 100644 index 0000000000..c424ce0a54 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/review.ts @@ -0,0 +1,11 @@ +import type { Resolver } from '..' + +export const StoreReview: Record = { + reviewRating: () => ({ + ratingValue: 5, + bestRating: 5, + }), + author: () => ({ + name: '', + }), +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/searchResult.ts b/packages/store-api/src/platforms/vtex/resolvers/searchResult.ts new file mode 100644 index 0000000000..8e942387e4 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/searchResult.ts @@ -0,0 +1,46 @@ +import { enhanceSku } from '../utils/enhanceSku' +import type { Resolver } from '..' +import type { SearchArgs } from '../clients/search' + +type Root = Omit + +export const StoreSearchResult: Record> = { + products: async (searchArgs, _, ctx) => { + const { + clients: { search }, + } = ctx + + const products = await search.products(searchArgs) + + const skus = products.products + .map((product) => { + const maybeSku = product.skus.find((x) => x.sellers.length > 0) + + return maybeSku && enhanceSku(maybeSku, product) + }) + .filter((sku) => !!sku) + + return { + pageInfo: { + hasNextPage: products.pagination.after.length > 0, + hasPreviousPage: products.pagination.before.length > 0, + startCursor: '0', + endCursor: products.total.toString(), + totalCount: products.total, + }, + edges: skus.map((sku, index) => ({ + node: sku, + cursor: index.toString(), + })), + } + }, + facets: async (searchArgs, _, ctx) => { + const { + clients: { search: is }, + } = ctx + + const facets = await is.facets(searchArgs) + + return facets.attributes + }, +} diff --git a/packages/store-api/src/platforms/vtex/resolvers/seo.ts b/packages/store-api/src/platforms/vtex/resolvers/seo.ts new file mode 100644 index 0000000000..5b569a31d8 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/resolvers/seo.ts @@ -0,0 +1,10 @@ +import type { Resolver } from '..' + +type Root = { title?: string; description?: string } + +export const StoreSeo: Record> = { + title: ({ title }) => title ?? '', + description: ({ description }) => description ?? '', + titleTemplate: () => '', + canonical: () => '', +} diff --git a/packages/store-api/src/platforms/vtex/utils/enhanceSku.ts b/packages/store-api/src/platforms/vtex/utils/enhanceSku.ts new file mode 100644 index 0000000000..f96a822572 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/utils/enhanceSku.ts @@ -0,0 +1,8 @@ +import type { Product, Sku } from '../clients/search/types/ProductSearchResult' + +export type EnhancedSku = Sku & { isVariantOf: Product } + +export const enhanceSku = (sku: Sku, product: Product): EnhancedSku => ({ + ...sku, + isVariantOf: product, +}) diff --git a/packages/store-api/src/platforms/vtex/utils/slugify.ts b/packages/store-api/src/platforms/vtex/utils/slugify.ts new file mode 100644 index 0000000000..90d8c41746 --- /dev/null +++ b/packages/store-api/src/platforms/vtex/utils/slugify.ts @@ -0,0 +1,4 @@ +import rawSlugify from 'slugify' + +export const slugify = (path: string) => + rawSlugify(path, { replacement: '-', lower: true }) diff --git a/packages/store-api/src/platforms/vtex/utils/sort.ts b/packages/store-api/src/platforms/vtex/utils/sort.ts new file mode 100644 index 0000000000..53613822bd --- /dev/null +++ b/packages/store-api/src/platforms/vtex/utils/sort.ts @@ -0,0 +1,10 @@ +export const SORT_MAP = { + price_desc: 'price:desc', + price_asc: 'price:asc', + orders_desc: 'orders:desc', + name_desc: 'name:desc', + name_asc: 'name:asc', + release_desc: 'release:desc', + discount_desc: 'discount:desc', + score_desc: '', +} as const diff --git a/packages/store-api/src/typings/schema.d.ts b/packages/store-api/src/typings/schema.d.ts new file mode 100644 index 0000000000..cf3e5d1a76 --- /dev/null +++ b/packages/store-api/src/typings/schema.d.ts @@ -0,0 +1,7 @@ +declare module '*.graphql' { + import type { ASTNode } from 'graphql' + + const schema: ASTNode + + export default schema +} diff --git a/packages/store-api/test/index.test.ts b/packages/store-api/test/index.test.ts new file mode 100644 index 0000000000..10081511dc --- /dev/null +++ b/packages/store-api/test/index.test.ts @@ -0,0 +1,13 @@ +import { getSchema } from '../src' + +describe('Schema', () => { + it('returns a valid graphql schema for vtex platform', async () => { + const schema = await getSchema({ + platform: 'vtex', + account: 'storecomponents', + environment: 'vtexcommercestable', + }) + + expect(schema).not.toBeNull() + }) +}) diff --git a/packages/store-api/tsconfig.json b/packages/store-api/tsconfig.json new file mode 100644 index 0000000000..2d7419fbbf --- /dev/null +++ b/packages/store-api/tsconfig.json @@ -0,0 +1,35 @@ +{ + // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs + "include": ["src", "types"], + "compilerOptions": { + "module": "esnext", + "lib": ["dom", "esnext"], + "importHelpers": true, + // output .d.ts declaration files for consumers + "declaration": true, + // output .js.map sourcemap files for consumers + "sourceMap": true, + // match output dir to input dir. e.g. dist/index instead of dist/src/index + "rootDir": "./src", + // stricter type-checking for stronger correctness. Recommended by TS + "strict": true, + // linter checks for common issues + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative + "noUnusedLocals": true, + "noUnusedParameters": true, + // use Node's module resolution algorithm, instead of the legacy TS one + "moduleResolution": "node", + // transpile JSX to React.createElement + "jsx": "react", + // interop between ESM and CJS modules. Recommended by TS + "esModuleInterop": true, + // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS + "skipLibCheck": true, + // error out if import and file system have a casing mismatch. Recommended by TS + "forceConsistentCasingInFileNames": true, + // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` + "noEmit": true + } +} diff --git a/packages/store-api/tsdx.config.js b/packages/store-api/tsdx.config.js new file mode 100644 index 0000000000..7f337627b4 --- /dev/null +++ b/packages/store-api/tsdx.config.js @@ -0,0 +1,9 @@ +const graphql = require('rollup-plugin-graphql') + +module.exports = { + rollup(config) { + config.plugins.push(graphql()) + + return config + }, +}