diff --git a/packages/api/mocks/ProductQuery.ts b/packages/api/mocks/ProductQuery.ts index e4e01377b1..871dd09bbb 100644 --- a/packages/api/mocks/ProductQuery.ts +++ b/packages/api/mocks/ProductQuery.ts @@ -257,3 +257,13 @@ export const productSearchFetch = { }, }, } + +export const productRatingFetch = (productId: string) => ({ + info: `https://storeframework.vtexcommercestable.com.br/api/io/reviews-and-ratings/api/rating/${productId}`, + init: undefined, + options: { storeCookies: expect.any(Function) }, + result: { + average: 4.5, + totalCount: 20, + }, +}) diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 83f0480b3f..c3273dbc2c 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -142,6 +142,19 @@ export type DeliveryIds = { warehouseId?: Maybe; }; +export type ICreateProductReview = { + /** Product ID. */ + productId: Scalars['String']; + /** Review rating. */ + rating: Scalars['Int']; + /** Review author name. */ + reviewerName: Scalars['String']; + /** Review content. */ + text: Scalars['String']; + /** Review title. */ + title: Scalars['String']; +}; + export type IGeoCoordinates = { /** The latitude of the geographic coordinates. */ latitude: Scalars['Float']; @@ -379,6 +392,8 @@ export type MessageInfo = { export type Mutation = { __typename?: 'Mutation'; + /** Create a new product review. */ + createProductReview: Scalars['String']; /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter?: Maybe; /** Checks for changes between the cart presented in the UI and the cart stored in the ecommerce platform. If changes are detected, it returns the cart stored on the platform. Otherwise, it returns `null`. */ @@ -388,6 +403,11 @@ export type Mutation = { }; +export type MutationCreateProductReviewArgs = { + data: ICreateProductReview; +}; + + export type MutationSubscribeToNewsletterArgs = { data: IPersonNewsletter; }; @@ -467,6 +487,8 @@ export type Query = { product: StoreProduct; /** Returns if there's a redirect for a search. */ redirect?: Maybe; + /** Returns a list of approved reviews for a specific product. */ + reviews?: Maybe; /** Returns the result of a product, facet, or suggestion search. */ search: StoreSearchResult; /** Returns a list of sellers available for a specific localization. */ @@ -504,6 +526,15 @@ export type QueryRedirectArgs = { }; +export type QueryReviewsArgs = { + after?: Maybe; + first?: Maybe; + productId: Scalars['String']; + rating?: Maybe; + sort?: Maybe; +}; + + export type QuerySearchArgs = { after?: Maybe; first: Scalars['Int']; @@ -999,6 +1030,8 @@ export type StoreProduct = { offers: StoreAggregateOffer; /** Product ID, such as [ISBN](https://www.isbn-international.org/content/what-isbn) or similar global IDs. */ productID: Scalars['String']; + /** Product rating. */ + rating: StoreProductRating; /** The product's release date. Formatted using https://en.wikipedia.org/wiki/ISO_8601 */ releaseDate: Scalars['String']; /** Array with review information. */ @@ -1057,6 +1090,83 @@ export type StoreProductGroup = { skuVariants?: Maybe; }; +export type StoreProductListReviewsRange = { + __typename?: 'StoreProductListReviewsRange'; + /** Index of the first review */ + from: Scalars['Int']; + /** Index of the last review */ + to: Scalars['Int']; + /** Total number of reviews. */ + total: Scalars['Int']; +}; + +export type StoreProductListReviewsResult = { + __typename?: 'StoreProductListReviewsResult'; + /** Array of product reviews. */ + data: Array; + range: StoreProductListReviewsRange; +}; + +export const enum StoreProductListReviewsSort { + /** Sort by review rating, from lowest to highest. */ + RatingAsc = 'rating_asc', + /** Sort by review rating, from highest to lowest. */ + RatingDesc = 'rating_desc', + /** Sort by review creation date, from oldest to newest. */ + ReviewDateTimeAsc = 'reviewDateTime_asc', + /** Sort by review creation date, from newest to oldest. */ + ReviewDateTimeDesc = 'reviewDateTime_desc' +}; + +export type StoreProductRating = { + __typename?: 'StoreProductRating'; + /** Product average rating. */ + average: Scalars['Float']; + /** Product rating distribution in percentages. */ + distribution: StoreProductRatingDistribution; + /** Product amount of ratings received. */ + totalCount: Scalars['Int']; +}; + +/** Product rating distribution in percentages. */ +export type StoreProductRatingDistribution = { + __typename?: 'StoreProductRatingDistribution'; + /** 5 star rating percentage. */ + starsFive: Scalars['Int']; + /** 4 star rating percentage. */ + starsFour: Scalars['Int']; + /** 1 star rating percentage. */ + starsOne: Scalars['Int']; + /** 3 star rating percentage. */ + starsThree: Scalars['Int']; + /** 2 star rating percentage. */ + starsTwo: Scalars['Int']; +}; + +export type StoreProductReview = { + __typename?: 'StoreProductReview'; + /** Indicates if the review was approved by the store owner. */ + approved: Scalars['Boolean']; + /** Review ID. */ + id: Scalars['String']; + /** Product ID. */ + productId: Scalars['String']; + /** Review rating. */ + rating: Scalars['Int']; + /** Review creation date. */ + reviewDateTime: Scalars['String']; + /** Review author name. */ + reviewerName?: Maybe; + /** Review author ID. */ + shopperId: Scalars['String']; + /** Review content. */ + text: Scalars['String']; + /** Review title. */ + title: Scalars['String']; + /** Indicates if the review was made by a verified purchaser. */ + verifiedPurchaser: Scalars['Boolean']; +}; + /** Properties that can be associated with products and products groups. */ export type StorePropertyValue = { __typename?: 'StorePropertyValue'; diff --git a/packages/api/src/platforms/errors.ts b/packages/api/src/platforms/errors.ts index 0576a011d0..b91b7436f1 100644 --- a/packages/api/src/platforms/errors.ts +++ b/packages/api/src/platforms/errors.ts @@ -1,4 +1,8 @@ -type ErrorType = 'BadRequestError' | 'NotFoundError' | 'RedirectError' +type ErrorType = + | 'BadRequestError' + | 'NotFoundError' + | 'RedirectError' + | 'NotAuthorizedError' interface Extension { type: ErrorType @@ -27,6 +31,12 @@ export class NotFoundError extends FastStoreError { } } +export class NotAuthorizedError extends FastStoreError { + constructor(message?: string) { + super({ status: 401, type: 'NotAuthorizedError' }, message) + } +} + export const isFastStoreError = (error: any): error is FastStoreError => error?.name === 'FastStoreError' diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index 3f34e049b0..4ca7d333d2 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -19,7 +19,20 @@ import type { SalesChannel } from './types/SalesChannel' import type { MasterDataResponse } from './types/Newsletter' import type { Address, AddressInput } from './types/Address' import type { DeliveryMode, SelectedAddress } from './types/ShippingData' -import { getStoreCookie, getWithCookie } from '../../utils/cookies' +import { + getCookieFromRequestHeaders, + getStoreCookie, + getWithCookie, +} from '../../utils/cookies' +import type { ProductRating } from './types/ProductRating' +import type { + CreateProductReviewInput, + ProductReviewsInput, + ProductReviewsResult, +} from './types/ProductReview' +import { adaptObject } from '../../utils/adaptObject' +import { camelToSnakeCase } from '../../utils/camelToSnakeCase' +import { NotAuthorizedError } from '../../../errors' type ValueOf = T extends Record ? K : never @@ -30,6 +43,8 @@ const BASE_INIT = { }, } +const REVIEWS_AND_RATINGS_API_PATH = 'api/io/reviews-and-ratings/api' + export const VtexCommerce = ( { account, environment, incrementAddress, subDomainPrefix }: Options, ctx: Context @@ -364,5 +379,60 @@ export const VtexCommerce = ( { storeCookies } ) }, + rating: (productId: string): Promise => { + return fetchAPI( + `${base}/${REVIEWS_AND_RATINGS_API_PATH}/rating/${productId}`, + undefined, + { storeCookies } + ) + }, + reviews: { + create: (input: CreateProductReviewInput): Promise => { + const authCookieKey: string = `VtexIdclientAutCookie_${account}` + + const authCookie = getCookieFromRequestHeaders(ctx, authCookieKey) ?? '' + + if (!authCookie) { + throw new NotAuthorizedError('Missing auth cookie') + } + + return fetchAPI( + `${base}/${REVIEWS_AND_RATINGS_API_PATH}/review`, + { + ...BASE_INIT, + headers: { + ...BASE_INIT.headers, + VtexIdclientAutCookie: authCookie, + }, + body: JSON.stringify(input), + method: 'POST', + }, + { storeCookies } + ) + }, + list: ({ + orderBy, + orderWay, + ...partialInput + }: ProductReviewsInput): Promise => { + const formattedInput = adaptObject( + { + orderBy: orderBy ? `${orderBy}:${orderWay ?? 'asc'}` : undefined, + ...partialInput, + }, + (_, value) => value !== undefined, + camelToSnakeCase, + String + ) + + const params = new URLSearchParams(formattedInput) + + return fetchAPI( + `${base}/${REVIEWS_AND_RATINGS_API_PATH}/reviews?${params.toString()}`, + undefined, + { storeCookies } + ) + }, + }, } } diff --git a/packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts b/packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts new file mode 100644 index 0000000000..00239bad87 --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts @@ -0,0 +1,9 @@ +export interface ProductRating { + average: number + totalCount: number + starsOne: number + starsTwo: number + starsThree: number + starsFour: number + starsFive: number +} diff --git a/packages/api/src/platforms/vtex/clients/commerce/types/ProductReview.ts b/packages/api/src/platforms/vtex/clients/commerce/types/ProductReview.ts new file mode 100644 index 0000000000..3d002d8063 --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/commerce/types/ProductReview.ts @@ -0,0 +1,58 @@ +export interface ProductReview { + id: string + productId: string + rating: number + title: string + text: string + reviewerName: string + shopperId: string + reviewDateTime: string + searchDate: string + verifiedPurchaser: boolean + sku: string | null + approved: boolean + location: string | null + locale: string | null + pastReviews: string | null +} + +export enum ProductReviewsInputOrderBy { + productId = 'ProductId', + shopperId = 'ShopperId', + approved = 'Approved', + reviewDateTime = 'ReviewDateTime', + searchDate = 'SearchDate', + rating = 'Rating', + locale = 'Locale', +} + +export type ProductReviewsInputOrderWay = 'asc' | 'desc' + +export interface ProductReviewsInput { + searchTerm?: string + from?: number + to?: number + orderBy?: ProductReviewsInputOrderBy + orderWay?: ProductReviewsInputOrderWay + status?: boolean + productId?: string + rating?: number +} + +export interface ProductReviewsResult { + data: ProductReview[] + range: { + from: number + to: number + total: number + } +} + +export interface CreateProductReviewInput { + productId: string + rating: number + title: string + text: string + reviewerName: string + approved: boolean +} diff --git a/packages/api/src/platforms/vtex/clients/search/types/ProductSearchResult.ts b/packages/api/src/platforms/vtex/clients/search/types/ProductSearchResult.ts index 6f73f64ae8..eba4e392bc 100644 --- a/packages/api/src/platforms/vtex/clients/search/types/ProductSearchResult.ts +++ b/packages/api/src/platforms/vtex/clients/search/types/ProductSearchResult.ts @@ -93,6 +93,10 @@ export interface Product { selectedProperties: Array<{ key: string; value: string }> releaseDate: string advertisement?: Advertisement + rating: { + average: number + totalCount: number + } } interface Image { diff --git a/packages/api/src/platforms/vtex/resolvers/createProductReview.ts b/packages/api/src/platforms/vtex/resolvers/createProductReview.ts new file mode 100644 index 0000000000..62c9f25367 --- /dev/null +++ b/packages/api/src/platforms/vtex/resolvers/createProductReview.ts @@ -0,0 +1,13 @@ +import type { Context } from '..' +import type { MutationCreateProductReviewArgs } from '../../../__generated__/schema' + +export const createProductReview = async ( + _: any, + { data }: MutationCreateProductReviewArgs, + { clients: { commerce } }: Context +): Promise => { + return await commerce.reviews.create({ + ...data, + approved: true, + }) +} diff --git a/packages/api/src/platforms/vtex/resolvers/mutation.ts b/packages/api/src/platforms/vtex/resolvers/mutation.ts index 3b18c13bd5..f91f3de8ec 100644 --- a/packages/api/src/platforms/vtex/resolvers/mutation.ts +++ b/packages/api/src/platforms/vtex/resolvers/mutation.ts @@ -1,9 +1,11 @@ import { subscribeToNewsletter } from './subscribeToNewsletter' import { validateCart } from './validateCart' import { validateSession } from './validateSession' +import { createProductReview } from './createProductReview' export const Mutation = { validateCart, validateSession, subscribeToNewsletter, + createProductReview, } diff --git a/packages/api/src/platforms/vtex/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index 6998c28a3d..6e1734546b 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -157,4 +157,15 @@ export const StoreProduct: Record> & { }, releaseDate: ({ isVariantOf: { releaseDate } }) => releaseDate ?? '', advertisement: ({ isVariantOf: { advertisement } }) => advertisement, + rating: ({ rating: { average, totalCount, distribution } }) => ({ + average, + totalCount, + distribution: { + starsOne: distribution?.[1] ?? 0, + starsTwo: distribution?.[2] ?? 0, + starsThree: distribution?.[3] ?? 0, + starsFour: distribution?.[4] ?? 0, + starsFive: distribution?.[5] ?? 0, + }, + }), } diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 4f607a72da..7bb9577234 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -21,11 +21,17 @@ import type { QuerySellersArgs, QueryShippingArgs, QueryRedirectArgs, + QueryReviewsArgs, } from '../../../__generated__/schema' import type { CategoryTree } from '../clients/commerce/types/CategoryTree' import type { Context } from '../index' import { isValidSkuId, pickBestSku } from '../utils/sku' import type { SearchArgs } from '../clients/search' +import { + ProductReviewsInputOrderBy, + type ProductReviewsInputOrderWay, +} from '../clients/commerce/types/ProductReview' +import { buildRatingDistribution } from '../utils/rating' export const Query = { product: async (_: unknown, { locator }: QueryProductArgs, ctx: Context) => { @@ -74,6 +80,10 @@ export const Query = { ) } + const rating = await commerce.rating(sku.itemId) + + sku.rating = buildRatingDistribution(rating) + return sku } catch (err) { if (slug == null) { @@ -98,9 +108,15 @@ export const Query = { throw new NotFoundError(`No product found for id ${route.id}`) } + const rating = await commerce.rating(product.productId) + const sku = pickBestSku(product.items) - return enhanceSku(sku, product) + const enhancedSku = enhanceSku(sku, product) + + enhancedSku.rating = buildRatingDistribution(rating) + + return enhancedSku } }, collection: (_: unknown, { slug }: QueryCollectionArgs, ctx: Context) => { @@ -335,4 +351,31 @@ export const Query = { sellers, } }, + reviews: async ( + _: unknown, + { productId, after, first, rating, sort }: QueryReviewsArgs, + ctx: Context + ) => { + const { + clients: { commerce }, + } = ctx + + const from = after ?? 0 + const to = from + (first ?? 6) + + const [orderByKey, orderWay] = sort?.split('_') as [ + keyof typeof ProductReviewsInputOrderBy, + ProductReviewsInputOrderWay, + ] + + return await commerce.reviews.list({ + productId, + from, + to, + orderBy: ProductReviewsInputOrderBy[orderByKey], + orderWay, + status: true, + rating: rating ?? undefined, + }) + }, } diff --git a/packages/api/src/platforms/vtex/resolvers/searchResult.ts b/packages/api/src/platforms/vtex/resolvers/searchResult.ts index e51a9af8cd..75bd533b9a 100644 --- a/packages/api/src/platforms/vtex/resolvers/searchResult.ts +++ b/packages/api/src/platforms/vtex/resolvers/searchResult.ts @@ -20,10 +20,10 @@ const isRootFacet = (facet: Facet, isDepartment: boolean, isBrand: boolean) => export const StoreSearchResult: Record> = { suggestions: async (root, _, ctx) => { const { - clients: { search }, + clients: { search, commerce }, } = ctx - const { searchArgs } = root + const { searchArgs, productSearchPromise } = root // If there's no search query, suggest the most popular searches. if (!searchArgs.query) { @@ -38,21 +38,26 @@ export const StoreSearchResult: Record> = { } } - const { productSearchPromise } = root const [terms, productSearchResult] = await Promise.all([ search.suggestedTerms(searchArgs), productSearchPromise, ]) - const skus = productSearchResult.products - .map((product) => { - // What determines the presentation of the SKU is the price order - // https://help.vtex.com/pt/tutorial/ordenando-imagens-na-vitrine-e-na-pagina-de-produto--tutorials_278 - const maybeSku = pickBestSku(product.items) - - return maybeSku && enhanceSku(maybeSku, product) - }) - .filter((sku) => !!sku) + const skus = await Promise.all( + productSearchResult.products + .map((product) => { + // What determines the presentation of the SKU is the price order + // https://help.vtex.com/pt/tutorial/ordenando-imagens-na-vitrine-e-na-pagina-de-produto--tutorials_278 + const maybeSku = pickBestSku(product.items) + + return maybeSku && enhanceSku(maybeSku, product) + }) + .filter((sku) => !!sku) + .map(async (sku) => ({ + ...sku, + rating: await commerce.rating(sku.itemId), + })) + ) const { searches } = terms @@ -61,7 +66,11 @@ export const StoreSearchResult: Record> = { products: skus, } }, - products: async ({ productSearchPromise }) => { + products: async ({ productSearchPromise }, _, ctx) => { + const { + clients: { commerce }, + } = ctx + const productSearchResult = await productSearchPromise const skus = productSearchResult.products @@ -74,6 +83,13 @@ export const StoreSearchResult: Record> = { }) .filter((sku) => !!sku) + const edges = await Promise.all( + skus.map(async (sku, index) => ({ + node: { ...sku, rating: await commerce.rating(sku.itemId) }, + cursor: index.toString(), + })) + ) + return { pageInfo: { hasNextPage: productSearchResult.pagination.after.length > 0, @@ -82,10 +98,7 @@ export const StoreSearchResult: Record> = { endCursor: productSearchResult.recordsFiltered.toString(), totalCount: productSearchResult.recordsFiltered, }, - edges: skus.map((sku, index) => ({ - node: sku, - cursor: index.toString(), - })), + edges, } }, facets: async ({ searchArgs }, _, ctx) => { diff --git a/packages/api/src/platforms/vtex/utils/adaptObject.ts b/packages/api/src/platforms/vtex/utils/adaptObject.ts new file mode 100644 index 0000000000..0336d79ed8 --- /dev/null +++ b/packages/api/src/platforms/vtex/utils/adaptObject.ts @@ -0,0 +1,39 @@ +/** + * Transforms an object's keys and values based on provided formatters and a predicate filter. + * + * @template T - The type of the transformed values. + * @param obj - The object to transform. + * @param predicate - A predicate function that determines whether a key-value pair should be included in the output. + * @param keyFormatter - A function that formats the object keys. Defaults to returning the key as is. + * @param valueFormatter - A function that formats the object values. Defaults to returning the value as is. + * @returns A new object with transformed keys and values, including only the key-value pairs that satisfy the predicate. + * + * @example Select all keys that have a defined value and also makes all keys uppercase and all values as numbers + * ```ts + * const obj = { john: "25", will: "10", bob: undefined }; + * const result = adaptObject( + * obj, + * (key, value) => value !== undefined, + * key => key.toUpperCase(), + * Integer.parseInt + * ); + * console.log(result); // { JOHN: 25, WILL: 10 } + * ``` + */ +export function adaptObject( + obj: Record, + predicate: (key: string, value: unknown) => boolean, + keyFormatter: (key: string) => string = (key) => key, + valueFormatter: (value: unknown) => T = (value) => value as T +): Record { + return Object.entries(obj).reduce( + (acc, [key, value]) => { + if (predicate(key, value)) { + acc[keyFormatter(key)] = valueFormatter(value) + } + + return acc + }, + {} as Record + ) +} diff --git a/packages/api/src/platforms/vtex/utils/camelToSnakeCase.ts b/packages/api/src/platforms/vtex/utils/camelToSnakeCase.ts new file mode 100644 index 0000000000..4b94b1f6d4 --- /dev/null +++ b/packages/api/src/platforms/vtex/utils/camelToSnakeCase.ts @@ -0,0 +1,3 @@ +export function camelToSnakeCase(str: string): string { + return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`) +} diff --git a/packages/api/src/platforms/vtex/utils/cookies.ts b/packages/api/src/platforms/vtex/utils/cookies.ts index 2a928b715b..130012ad7f 100644 --- a/packages/api/src/platforms/vtex/utils/cookies.ts +++ b/packages/api/src/platforms/vtex/utils/cookies.ts @@ -124,3 +124,16 @@ export const updatesCookieValueByKey = ( // add new storage cookie to the original list of cookies return `${existingCookies};${storageCookieKey}=${storageCookieValue}` } + +export function getCookieFromRequestHeaders( + ctx: ContextForCookies, + cookieKey: string +): string | undefined { + const compoundKey = `${cookieKey}=` + + return new Headers(ctx.headers) + .get('cookie') + ?.split('; ') + .find((x) => x.startsWith(compoundKey)) + ?.replace(compoundKey, '') +} diff --git a/packages/api/src/platforms/vtex/utils/enhanceSku.ts b/packages/api/src/platforms/vtex/utils/enhanceSku.ts index 01cfa928bc..2155a45418 100644 --- a/packages/api/src/platforms/vtex/utils/enhanceSku.ts +++ b/packages/api/src/platforms/vtex/utils/enhanceSku.ts @@ -1,7 +1,15 @@ import type { Product, Item } from '../clients/search/types/ProductSearchResult' import { sanitizeHtml } from './sanitizeHtml' -export type EnhancedSku = Item & { isVariantOf: Product } +export type ProductRating = { + average: number + totalCount: number + distribution: Record +} + +export type EnhancedSku = Item & { isVariantOf: Product } & { + rating: ProductRating +} function sanitizeProduct(product: Product): Product { return { @@ -14,5 +22,16 @@ function sanitizeProduct(product: Product): Product { export const enhanceSku = (item: Item, product: Product): EnhancedSku => ({ ...item, + rating: { + average: 0, + totalCount: 0, + distribution: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + }, isVariantOf: sanitizeProduct(product), }) diff --git a/packages/api/src/platforms/vtex/utils/rating.ts b/packages/api/src/platforms/vtex/utils/rating.ts new file mode 100644 index 0000000000..f53f2f0b26 --- /dev/null +++ b/packages/api/src/platforms/vtex/utils/rating.ts @@ -0,0 +1,91 @@ +import type { ProductRating as ApiClientProductRating } from '../clients/commerce/types/ProductRating' +import type { ProductRating } from './enhanceSku' + +export function buildRatingDistribution({ + average, + totalCount, + starsOne, + starsTwo, + starsThree, + starsFour, + starsFive, +}: ApiClientProductRating): ProductRating { + const rating: ProductRating = { + average, + totalCount, + distribution: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + } + + if (rating.totalCount === 0) { + return rating + } + + const integerPercentages = [ + starsOne, + starsTwo, + starsThree, + starsFour, + starsFive, + ].map((value) => calculateIntegerPercentage(value, totalCount)) + + const totalPercentage = integerPercentages.reduce( + (acc, curr) => acc + curr, + 0 + ) + + if (totalPercentage !== 100) { + const percentageDifference = 100 - totalPercentage + adjustPercentageDistributionTo100(integerPercentages, percentageDifference) + } + + integerPercentages.forEach( + (percentage, index) => (rating.distribution[index + 1] = percentage) + ) + + return rating +} + +/** + * Calculates the integer percentage of a value relative to a total. + * + * @param {number} value - The value to calculate the percentage for. + * @param {number} total - The total value to calculate the percentage against. + * @returns {number} - The calculated integer percentage. + */ +function calculateIntegerPercentage(value: number, total: number): number { + return Math.round((value / total) * 100) +} + +/** + * Adjusts the percentage distribution to ensure the total is 100%. + * + * Cases where the total is not 100% are handled by adjusting the highest percentage. + * When the are multiple highest percentages, the index is chosen based on the percentage difference. + * If the difference is positive, meaning that the percentage sum is lower than 100%, than the highest index is chosen, otherwise the lowest. + * + * @param {number[]} percentages - The array of percentages to adjust. + * @param {number} percentageDifference - The difference needed to reach 100%. + */ +function adjustPercentageDistributionTo100( + percentages: number[], + percentageDifference: number +) { + const maxPercentage = Math.max(...percentages) + const highestIndexWithMaxPercentage = percentages.findLastIndex( + (percent) => percent === maxPercentage + ) + const lowestIndexWithMaxPercentage = percentages.findIndex( + (percent) => percent === maxPercentage + ) + const changingIndex = + percentageDifference > 0 + ? highestIndexWithMaxPercentage + : lowestIndexWithMaxPercentage + percentages[changingIndex] += percentageDifference +} diff --git a/packages/api/src/typeDefs/mutation.graphql b/packages/api/src/typeDefs/mutation.graphql index e89381bebf..db9c7c3cf2 100644 --- a/packages/api/src/typeDefs/mutation.graphql +++ b/packages/api/src/typeDefs/mutation.graphql @@ -11,4 +11,8 @@ type Mutation { Subscribes a new person to the newsletter list. """ subscribeToNewsletter(data: IPersonNewsletter!): PersonNewsletter + """ + Create a new product review. + """ + createProductReview(data: ICreateProductReview!): String! } diff --git a/packages/api/src/typeDefs/product.graphql b/packages/api/src/typeDefs/product.graphql index b1a7dfcb50..1efcf43cf9 100644 --- a/packages/api/src/typeDefs/product.graphql +++ b/packages/api/src/typeDefs/product.graphql @@ -74,6 +74,10 @@ type StoreProduct { Advertisement information about the product. """ advertisement: Advertisement + """ + Product rating. + """ + rating: StoreProductRating! } """ diff --git a/packages/api/src/typeDefs/productRating.graphql b/packages/api/src/typeDefs/productRating.graphql new file mode 100644 index 0000000000..1848f9b9db --- /dev/null +++ b/packages/api/src/typeDefs/productRating.graphql @@ -0,0 +1,40 @@ +type StoreProductRating { + """ + Product average rating. + """ + average: Float! + """ + Product amount of ratings received. + """ + totalCount: Int! + """ + Product rating distribution in percentages. + """ + distribution: StoreProductRatingDistribution! +} + +""" +Product rating distribution in percentages. +""" +type StoreProductRatingDistribution { + """ + 1 star rating percentage. + """ + starsOne: Int! + """ + 2 star rating percentage. + """ + starsTwo: Int! + """ + 3 star rating percentage. + """ + starsThree: Int! + """ + 4 star rating percentage. + """ + starsFour: Int! + """ + 5 star rating percentage. + """ + starsFive: Int! +} diff --git a/packages/api/src/typeDefs/productReview.graphql b/packages/api/src/typeDefs/productReview.graphql new file mode 100644 index 0000000000..29399f46ad --- /dev/null +++ b/packages/api/src/typeDefs/productReview.graphql @@ -0,0 +1,107 @@ +type StoreProductReview { + """ + Review ID. + """ + id: String! + """ + Product ID. + """ + productId: String! + """ + Review rating. + """ + rating: Int! + """ + Review title. + """ + title: String! + """ + Review content. + """ + text: String! + """ + Review author name. + """ + reviewerName: String + """ + Review author ID. + """ + shopperId: String! + """ + Review creation date. + """ + reviewDateTime: String! + """ + Indicates if the review was made by a verified purchaser. + """ + verifiedPurchaser: Boolean! + """ + Indicates if the review was approved by the store owner. + """ + approved: Boolean! +} + +type StoreProductListReviewsRange { + """ + Total number of reviews. + """ + total: Int! + """ + Index of the first review + """ + from: Int! + """ + Index of the last review + """ + to: Int! +} + +type StoreProductListReviewsResult { + """ + Array of product reviews. + """ + data: [StoreProductReview!]! + range: StoreProductListReviewsRange! +} + +enum StoreProductListReviewsSort { + """ + Sort by review creation date, from newest to oldest. + """ + reviewDateTime_desc + """ + Sort by review creation date, from oldest to newest. + """ + reviewDateTime_asc + """ + Sort by review rating, from highest to lowest. + """ + rating_desc + """ + Sort by review rating, from lowest to highest. + """ + rating_asc +} + +input ICreateProductReview { + """ + Product ID. + """ + productId: String! + """ + Review rating. + """ + rating: Int! + """ + Review title. + """ + title: String! + """ + Review content. + """ + text: String! + """ + Review author name. + """ + reviewerName: String! +} diff --git a/packages/api/src/typeDefs/query.graphql b/packages/api/src/typeDefs/query.graphql index 9889b0e7ca..2a2b95be29 100644 --- a/packages/api/src/typeDefs/query.graphql +++ b/packages/api/src/typeDefs/query.graphql @@ -338,6 +338,33 @@ type Query { salesChannel: String ): SellersData @cacheControl(scope: "public", sMaxAge: 120, staleWhileRevalidate: 3600) + + """ + Returns a list of approved reviews for a specific product. + """ + reviews( + """ + Product Id + """ + productId: String! + """ + Reviews results sorting mode + """ + sort: StoreProductListReviewsSort = reviewDateTime_desc + """ + Reviews pagination argument, indicating how many items should be returned from the complete result list. + """ + first: Int = 6 + """ + Reviews pagination argument, indicating the cursor corresponding with the item after which the items should be fetched. + """ + after: Int = 0 + """ + Rating filter + """ + rating: Int + ): StoreProductListReviewsResult + @cacheControl(scope: "public", sMaxAge: 120, staleWhileRevalidate: 3600) } """ diff --git a/packages/api/test/queries.test.ts b/packages/api/test/queries.test.ts index ea86829e6d..5e66e6cc1b 100644 --- a/packages/api/test/queries.test.ts +++ b/packages/api/test/queries.test.ts @@ -20,7 +20,11 @@ import { pageTypeOfficeDesksFetch, pageTypeOfficeFetch, } from '../mocks/CollectionQuery' -import { ProductByIdQuery, productSearchFetch } from '../mocks/ProductQuery' +import { + ProductByIdQuery, + productRatingFetch, + productSearchFetch, +} from '../mocks/ProductQuery' import { RedirectQueryTermTech, redirectTermTechFetch, @@ -138,7 +142,11 @@ test('`collection` query', async () => { }) test('`product` query', async () => { - const fetchAPICalls = [productSearchFetch, salesChannelStaleFetch] + const fetchAPICalls = [ + productSearchFetch, + productRatingFetch('64953394'), + salesChannelStaleFetch, + ] mockedFetch.mockImplementation((info, init) => pickFetchAPICallResult(info, init, fetchAPICalls) @@ -146,7 +154,7 @@ test('`product` query', async () => { const response = await run(ProductByIdQuery) - expect(mockedFetch).toHaveBeenCalledTimes(2) + expect(mockedFetch).toHaveBeenCalledTimes(3) fetchAPICalls.forEach((fetchAPICall) => { expect(mockedFetch).toHaveBeenCalledWith( @@ -215,6 +223,11 @@ test('`search` query', async () => { const fetchAPICalls = [ productSearchCategory1Fetch, attributeSearchCategory1Fetch, + productRatingFetch('2791588'), + productRatingFetch('44903104'), + productRatingFetch('96175310'), + productRatingFetch('12405783'), + productRatingFetch('24041857'), salesChannelStaleFetch, ] @@ -224,7 +237,7 @@ test('`search` query', async () => { const response = await run(SearchQueryFirst5Products) - expect(mockedFetch).toHaveBeenCalledTimes(3) + expect(mockedFetch).toHaveBeenCalledTimes(8) fetchAPICalls.forEach((fetchAPICall) => { expect(mockedFetch).toHaveBeenCalledWith( diff --git a/packages/api/test/rating.test.ts b/packages/api/test/rating.test.ts new file mode 100644 index 0000000000..993a4cd6f6 --- /dev/null +++ b/packages/api/test/rating.test.ts @@ -0,0 +1,222 @@ +import { buildRatingDistribution } from '../src/platforms/vtex/utils/rating' +import type { ProductRating as ApiClientProductRating } from '../src/platforms/vtex/clients/commerce/types/ProductRating' + +describe('rating', () => { + describe('buildRatingDistribution', () => { + it('should return rating with 0 distribution when totalCount is 0', () => { + const distribution: ApiClientProductRating = { + average: 0, + totalCount: 0, + starsOne: 0, + starsTwo: 0, + starsThree: 0, + starsFour: 0, + starsFive: 0, + } + + const result = buildRatingDistribution(distribution) + + expect.assertions(1) + + expect(result).toStrictEqual({ + average: 0, + totalCount: 0, + distribution: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + }) + }) + + it('should return rating with 100% distribution when only one grade is presented', () => { + const distribution: ApiClientProductRating = { + average: 5, + totalCount: 5, + starsOne: 0, + starsTwo: 0, + starsThree: 0, + starsFour: 0, + starsFive: 5, + } + + const result = buildRatingDistribution(distribution) + + expect.assertions(1) + + expect(result).toStrictEqual({ + average: 5, + totalCount: 5, + distribution: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 100, + }, + }) + }) + + it('should calculate distribution when the sum of percentages is exactly 100%', () => { + const distribution: ApiClientProductRating = { + average: 3, + totalCount: 10, + starsOne: 1, + starsTwo: 2, + starsThree: 3, + starsFour: 2, + starsFive: 2, + } + + const result = buildRatingDistribution(distribution) + + expect.assertions(1) + + expect(result).toStrictEqual({ + average: 3, + totalCount: 10, + distribution: { + 1: 10, + 2: 20, + 3: 30, + 4: 20, + 5: 20, + }, + }) + }) + + it('should increments the most frequently when the sum of percentages is less than 100%', () => { + const distribution: ApiClientProductRating = { + average: 3.64, + totalCount: 11, + starsOne: 1, + starsTwo: 1, + starsThree: 3, + starsFour: 2, + starsFive: 4, + } + + const result = buildRatingDistribution(distribution) + const percentageSum = Object.values(result.distribution).reduce( + (acc, curr) => acc + curr, + 0 + ) + + expect.assertions(2) + + expect(percentageSum).toBe(100) + expect(result).toStrictEqual({ + average: 3.64, + totalCount: 11, + distribution: { + 1: 9, + 2: 9, + 3: 27, + 4: 18, + 5: 37, + }, + }) + }) + + it('should decreases the most frequently grade when the sum of percentages is greater than 100%', () => { + const distribution: ApiClientProductRating = { + average: 3.5, + totalCount: 14, + starsOne: 1, + starsTwo: 4, + starsThree: 0, + starsFour: 5, + starsFive: 4, + } + + const result = buildRatingDistribution(distribution) + const percentageSum = Object.values(result.distribution).reduce( + (acc, curr) => acc + curr, + 0 + ) + + expect.assertions(2) + + expect(percentageSum).toBe(100) + expect(result).toStrictEqual({ + average: 3.5, + totalCount: 14, + distribution: { + 1: 7, + 2: 29, + 3: 0, + 4: 35, + 5: 29, + }, + }) + }) + + it('should decreases the most frequently grade with the lower index when the sum of percentages is greater than 100% and there are more than 1 one grade with the highest frequency', () => { + const distribution: ApiClientProductRating = { + average: 3.43, + totalCount: 14, + starsOne: 0, + starsTwo: 4, + starsThree: 0, + starsFour: 5, + starsFive: 5, + } + + const result = buildRatingDistribution(distribution) + const percentageSum = Object.values(result.distribution).reduce( + (acc, curr) => acc + curr, + 0 + ) + + expect.assertions(2) + + expect(percentageSum).toBe(100) + expect(result).toStrictEqual({ + average: 3.43, + totalCount: 14, + distribution: { + 1: 0, + 2: 29, + 3: 0, + 4: 35, + 5: 36, + }, + }) + }) + + it('should increases the most frequently grade with the higher index when the sum of percentages is lower than 100% and there are more than 1 one grade with the highest frequency', () => { + const distribution: ApiClientProductRating = { + average: 3.32, + totalCount: 19, + starsOne: 0, + starsTwo: 4, + starsThree: 5, + starsFour: 5, + starsFive: 5, + } + + const result = buildRatingDistribution(distribution) + const percentageSum = Object.values(result.distribution).reduce( + (acc, curr) => acc + curr, + 0 + ) + + expect.assertions(2) + + expect(percentageSum).toBe(100) + expect(result).toStrictEqual({ + average: 3.32, + totalCount: 19, + distribution: { + 1: 0, + 2: 21, + 3: 26, + 4: 26, + 5: 27, + }, + }) + }) + }) +}) diff --git a/packages/api/test/schema.test.ts b/packages/api/test/schema.test.ts index 47fa97b71d..3a2e3632eb 100644 --- a/packages/api/test/schema.test.ts +++ b/packages/api/test/schema.test.ts @@ -56,6 +56,7 @@ const TYPES = [ 'PickupAddress', 'MessageInfo', 'MessageFields', + 'ICreateProductReview', ] const QUERIES = [ @@ -67,9 +68,15 @@ const QUERIES = [ 'shipping', 'redirect', 'sellers', + 'reviews', ] -const MUTATIONS = ['validateCart', 'validateSession', 'subscribeToNewsletter'] +const MUTATIONS = [ + 'validateCart', + 'validateSession', + 'subscribeToNewsletter', + 'createProductReview', +] let schema: GraphQLSchema diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index 0f71d6e880..1c72c0f3ab 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -156,6 +156,19 @@ export type DeliveryIds = { warehouseId: Maybe } +export type ICreateProductReview = { + /** Product ID. */ + productId: Scalars['String']['input'] + /** Review rating. */ + rating: Scalars['Int']['input'] + /** Review author name. */ + reviewerName: Scalars['String']['input'] + /** Review content. */ + text: Scalars['String']['input'] + /** Review title. */ + title: Scalars['String']['input'] +} + export type IGeoCoordinates = { /** The latitude of the geographic coordinates. */ latitude: Scalars['Float']['input'] @@ -388,6 +401,8 @@ export type MessageInfo = { } export type Mutation = { + /** Create a new product review. */ + createProductReview: Scalars['String']['output'] /** Subscribes a new person to the newsletter list. */ subscribeToNewsletter: Maybe /** Checks for changes between the cart presented in the UI and the cart stored in the ecommerce platform. If changes are detected, it returns the cart stored on the platform. Otherwise, it returns `null`. */ @@ -396,6 +411,10 @@ export type Mutation = { validateSession: Maybe } +export type MutationCreateProductReviewArgs = { + data: ICreateProductReview +} + export type MutationSubscribeToNewsletterArgs = { data: IPersonNewsletter } @@ -469,6 +488,8 @@ export type Query = { product: StoreProduct /** Returns if there's a redirect for a search. */ redirect: Maybe + /** Returns a list of approved reviews for a specific product. */ + reviews: Maybe /** Returns the result of a product, facet, or suggestion search. */ search: StoreSearchResult /** Returns a list of sellers available for a specific localization. */ @@ -500,6 +521,14 @@ export type QueryRedirectArgs = { term: InputMaybe } +export type QueryReviewsArgs = { + after?: InputMaybe + first?: InputMaybe + productId: Scalars['String']['input'] + rating: InputMaybe + sort?: InputMaybe +} + export type QuerySearchArgs = { after: InputMaybe first: Scalars['Int']['input'] @@ -954,6 +983,8 @@ export type StoreProduct = { offers: StoreAggregateOffer /** Product ID, such as [ISBN](https://www.isbn-international.org/content/what-isbn) or similar global IDs. */ productID: Scalars['String']['output'] + /** Product rating. */ + rating: StoreProductRating /** The product's release date. Formatted using https://en.wikipedia.org/wiki/ISO_8601 */ releaseDate: Scalars['String']['output'] /** Array with review information. */ @@ -1008,6 +1039,77 @@ export type StoreProductGroup = { skuVariants: Maybe } +export type StoreProductListReviewsRange = { + /** Index of the first review */ + from: Scalars['Int']['output'] + /** Index of the last review */ + to: Scalars['Int']['output'] + /** Total number of reviews. */ + total: Scalars['Int']['output'] +} + +export type StoreProductListReviewsResult = { + /** Array of product reviews. */ + data: Array + range: StoreProductListReviewsRange +} + +export type StoreProductListReviewsSort = + /** Sort by review rating, from lowest to highest. */ + | 'rating_asc' + /** Sort by review rating, from highest to lowest. */ + | 'rating_desc' + /** Sort by review creation date, from oldest to newest. */ + | 'reviewDateTime_asc' + /** Sort by review creation date, from newest to oldest. */ + | 'reviewDateTime_desc' + +export type StoreProductRating = { + /** Product average rating. */ + average: Scalars['Float']['output'] + /** Product rating distribution in percentages. */ + distribution: StoreProductRatingDistribution + /** Product amount of ratings received. */ + totalCount: Scalars['Int']['output'] +} + +/** Product rating distribution in percentages. */ +export type StoreProductRatingDistribution = { + /** 5 star rating percentage. */ + starsFive: Scalars['Int']['output'] + /** 4 star rating percentage. */ + starsFour: Scalars['Int']['output'] + /** 1 star rating percentage. */ + starsOne: Scalars['Int']['output'] + /** 3 star rating percentage. */ + starsThree: Scalars['Int']['output'] + /** 2 star rating percentage. */ + starsTwo: Scalars['Int']['output'] +} + +export type StoreProductReview = { + /** Indicates if the review was approved by the store owner. */ + approved: Scalars['Boolean']['output'] + /** Review ID. */ + id: Scalars['String']['output'] + /** Product ID. */ + productId: Scalars['String']['output'] + /** Review rating. */ + rating: Scalars['Int']['output'] + /** Review creation date. */ + reviewDateTime: Scalars['String']['output'] + /** Review author name. */ + reviewerName: Maybe + /** Review author ID. */ + shopperId: Scalars['String']['output'] + /** Review content. */ + text: Scalars['String']['output'] + /** Review title. */ + title: Scalars['String']['output'] + /** Indicates if the review was made by a verified purchaser. */ + verifiedPurchaser: Scalars['Boolean']['output'] +} + /** Properties that can be associated with products and products groups. */ export type StorePropertyValue = { /** Property name. */ diff --git a/packages/core/cms/faststore/sections.json b/packages/core/cms/faststore/sections.json index 8c4f1cf1dd..9da106bd52 100644 --- a/packages/core/cms/faststore/sections.json +++ b/packages/core/cms/faststore/sections.json @@ -964,6 +964,21 @@ } } }, + { + "name": "ReviewsAndRatings", + "schema": { + "title": "Reviews And Ratings", + "description": "A section to display product reviews and ratings", + "type": "object", + "properties": { + "title": { + "title": "Title", + "type": "string", + "default": "Reviews" + } + } + } + }, { "name": "CrossSellingShelf", "requiredScopes": ["pdp", "custom"], diff --git a/packages/core/index.ts b/packages/core/index.ts index 1a444d5408..cd9752923f 100644 --- a/packages/core/index.ts +++ b/packages/core/index.ts @@ -16,6 +16,7 @@ export { default as AlertSection } from './src/components/sections/Alert' export { default as BannerTextSection } from './src/components/sections/BannerText' export { default as BreadcrumbSection } from './src/components/sections/Breadcrumb' export { default as CrossSellingShelfSection } from './src/components/sections/CrossSellingShelf' +export { default as ReviewsAndRatingsSection } from './src/components/sections/ReviewsAndRatings' export { default as EmptyState } from './src/components/sections/EmptyState' export { default as HeroSection } from './src/components/sections/Hero' export { default as NavbarSection } from './src/components/sections/Navbar' diff --git a/packages/core/src/components/sections/ReviewsAndRatings/DefaultComponents.ts b/packages/core/src/components/sections/ReviewsAndRatings/DefaultComponents.ts new file mode 100644 index 0000000000..120f6e6ad3 --- /dev/null +++ b/packages/core/src/components/sections/ReviewsAndRatings/DefaultComponents.ts @@ -0,0 +1,6 @@ +export const ReviewsAndRatingsDefaultComponents = { + // TODO: Update this with the components that will be used in ReviewsAndRatings section + // Olhar o packages/core/src/components/sections/ProductGallery/DefaultComponents.ts + // ou o packages/core/src/components/sections/ProductShelf/DefaultComponents.ts + // para se basear +} as const diff --git a/packages/core/src/components/sections/ReviewsAndRatings/OverriddenDefaultReviewsAndRatings.ts b/packages/core/src/components/sections/ReviewsAndRatings/OverriddenDefaultReviewsAndRatings.ts new file mode 100644 index 0000000000..8db4275763 --- /dev/null +++ b/packages/core/src/components/sections/ReviewsAndRatings/OverriddenDefaultReviewsAndRatings.ts @@ -0,0 +1,16 @@ +import { override } from 'src/customizations/src/components/overrides/ReviewsAndRatings' +import { override as overridePlugin } from 'src/plugins/overrides/ReviewsAndRatings' +import { getOverriddenSection } from 'src/sdk/overrides/getOverriddenSection' +import type { SectionOverrideDefinitionV1 } from 'src/typings/overridesDefinition' +import ReviewsAndRatings from '.' + +/** + * This component exists to support overrides 1.0 + * + * This allows users to override the default ReviewsAndRatings section present in the Headless CMS + */ +export const OverriddenDefaultReviewsAndRatings = getOverriddenSection({ + ...(overridePlugin as SectionOverrideDefinitionV1<'ReviewsAndRatings'>), + ...(override as SectionOverrideDefinitionV1<'ReviewsAndRatings'>), + Section: ReviewsAndRatings, +}) diff --git a/packages/core/src/components/sections/ReviewsAndRatings/ReviewsAndRatings.tsx b/packages/core/src/components/sections/ReviewsAndRatings/ReviewsAndRatings.tsx new file mode 100644 index 0000000000..d182f806a2 --- /dev/null +++ b/packages/core/src/components/sections/ReviewsAndRatings/ReviewsAndRatings.tsx @@ -0,0 +1,31 @@ +import { useMemo } from 'react' + +import ReviewsAndRatings from '../../ui/ReviewsAndRatings' +import styles from '../ReviewsAndRatings/section.module.scss' +import Section from '../Section' +import { ReviewsAndRatingsDefaultComponents } from './DefaultComponents' +import { getOverridableSection } from '../../../sdk/overrides/getOverriddenSection' + +interface Props { + title: string +} +const ReviewsAndRatingsSection = ({ title }: Props) => { + return ( +
+ +
+ ) +} + +const OverridableReviewsAndRatings = getOverridableSection< + typeof ReviewsAndRatingsSection +>( + 'ReviewsAndRatings', + ReviewsAndRatingsSection, + ReviewsAndRatingsDefaultComponents +) + +export default OverridableReviewsAndRatings diff --git a/packages/core/src/components/sections/ReviewsAndRatings/index.tsx b/packages/core/src/components/sections/ReviewsAndRatings/index.tsx new file mode 100644 index 0000000000..aa8bd71d71 --- /dev/null +++ b/packages/core/src/components/sections/ReviewsAndRatings/index.tsx @@ -0,0 +1 @@ +export { default } from './ReviewsAndRatings' diff --git a/packages/core/src/components/sections/ReviewsAndRatings/section.module.scss b/packages/core/src/components/sections/ReviewsAndRatings/section.module.scss new file mode 100644 index 0000000000..9303d73f18 --- /dev/null +++ b/packages/core/src/components/sections/ReviewsAndRatings/section.module.scss @@ -0,0 +1,6 @@ +@layer components { + .section { + // TODO: Ajustar esses componentes para a nova section ReviewsAndRatings + // @import '@faststore/ui/src/components/atoms/Ratings/styles'; + } +} diff --git a/packages/core/src/components/ui/ReviewsAndRatings/ReviewsAndRatings.tsx b/packages/core/src/components/ui/ReviewsAndRatings/ReviewsAndRatings.tsx new file mode 100644 index 0000000000..989f518873 --- /dev/null +++ b/packages/core/src/components/ui/ReviewsAndRatings/ReviewsAndRatings.tsx @@ -0,0 +1,13 @@ +export type ReviewsAndRatingsProps = { + title: string +} + +function ReviewsAndRatings({ title, ...otherProps }: ReviewsAndRatingsProps) { + return ( + <> +

{title}

+ + ) +} + +export default ReviewsAndRatings diff --git a/packages/core/src/components/ui/ReviewsAndRatings/index.ts b/packages/core/src/components/ui/ReviewsAndRatings/index.ts new file mode 100644 index 0000000000..2b506c2bcf --- /dev/null +++ b/packages/core/src/components/ui/ReviewsAndRatings/index.ts @@ -0,0 +1,2 @@ +export { default } from './ReviewsAndRatings' +export type { ReviewsAndRatingsProps } from './ReviewsAndRatings' diff --git a/packages/core/src/customizations/src/components/overrides/ReviewsAndRatings.tsx b/packages/core/src/customizations/src/components/overrides/ReviewsAndRatings.tsx new file mode 100644 index 0000000000..12d1353a69 --- /dev/null +++ b/packages/core/src/customizations/src/components/overrides/ReviewsAndRatings.tsx @@ -0,0 +1,11 @@ +// This is an example of how it can be used on the starter. + +import type { SectionOverride } from 'src/typings/overrides' + +const SECTION = 'ReviewsAndRatings' as const + +const override: SectionOverride = { + section: SECTION, +} + +export { override } diff --git a/packages/core/src/pages/[slug]/p.tsx b/packages/core/src/pages/[slug]/p.tsx index ce3924dd43..b54fdcee6a 100644 --- a/packages/core/src/pages/[slug]/p.tsx +++ b/packages/core/src/pages/[slug]/p.tsx @@ -1,5 +1,5 @@ import { isNotFoundError } from '@faststore/api' -import type { Locator } from '@vtex/client-cms' +import type { Locator, Section } from '@vtex/client-cms' import deepmerge from 'deepmerge' import type { GetStaticPaths, GetStaticProps } from 'next' import { BreadcrumbJsonLd, NextSeo, ProductJsonLd } from 'next-seo' @@ -17,6 +17,7 @@ import BannerNewsletter from 'src/components/sections/BannerNewsletter/BannerNew import { OverriddenDefaultBannerText as BannerText } from 'src/components/sections/BannerText/OverriddenDefaultBannerText' import { OverriddenDefaultBreadcrumb as Breadcrumb } from 'src/components/sections/Breadcrumb/OverriddenDefaultBreadcrumb' import { OverriddenDefaultCrossSellingShelf as CrossSellingShelf } from 'src/components/sections/CrossSellingShelf/OverriddenDefaultCrossSellingShelf' +import { OverriddenDefaultReviewsAndRatings as ReviewsAndRatings } from 'src/components/sections/ReviewsAndRatings/OverriddenDefaultReviewsAndRatings' import { OverriddenDefaultHero as Hero } from 'src/components/sections/Hero/OverriddenDefaultHero' import { OverriddenDefaultNewsletter as Newsletter } from 'src/components/sections/Newsletter/OverriddenDefaultNewsletter' import { OverriddenDefaultProductDetails as ProductDetails } from 'src/components/sections/ProductDetails/OverriddenDefaultProductDetails' @@ -59,6 +60,7 @@ const COMPONENTS: Record> = { ProductShelf, ProductTiles, CrossSellingShelf, + ReviewsAndRatings, ...PLUGINS_COMPONENTS, ...CUSTOM_COMPONENTS, } diff --git a/packages/core/src/plugins/overrides/ReviewsAndRatings.tsx b/packages/core/src/plugins/overrides/ReviewsAndRatings.tsx new file mode 100644 index 0000000000..9c2cd484bb --- /dev/null +++ b/packages/core/src/plugins/overrides/ReviewsAndRatings.tsx @@ -0,0 +1,3 @@ +// This is an example of how it can be used on the plugins. + +export { override } from 'src/customizations/src/components/overrides/ReviewsAndRatings' diff --git a/packages/core/src/typings/overrides.ts b/packages/core/src/typings/overrides.ts index 65ef1869cf..f6f4d7b1ce 100644 --- a/packages/core/src/typings/overrides.ts +++ b/packages/core/src/typings/overrides.ts @@ -52,6 +52,7 @@ import type Alert from '../components/sections/Alert' import type Breadcrumb from '../components/sections/Breadcrumb' import type BannerText from '../components/sections/BannerText' import type CrossSellingShelf from '../components/sections/CrossSellingShelf' +import type ReviewsAndRatings from '../components/sections/ReviewsAndRatings' import type EmptyState from '../components/sections/EmptyState' import type Hero from '../components/sections/Hero' import type ProductShelf from '../components/sections/ProductShelf' @@ -356,6 +357,11 @@ export type SectionsOverrides = { > } } + ReviewsAndRatings: { + Section: typeof ReviewsAndRatings + // TODO: Add components + components: {} + } RegionBar: { Section: typeof RegionBar components: { diff --git a/packages/core/test/server/index.test.ts b/packages/core/test/server/index.test.ts index 0a47f5f145..4052f43fd4 100644 --- a/packages/core/test/server/index.test.ts +++ b/packages/core/test/server/index.test.ts @@ -60,6 +60,7 @@ const TYPES = [ 'PickupAddress', 'MessageInfo', 'MessageFields', + 'ICreateProductReview', ] const QUERIES = [ @@ -71,9 +72,15 @@ const QUERIES = [ 'shipping', 'redirect', 'sellers', + 'reviews', ] -const MUTATIONS = ['validateCart', 'validateSession', 'subscribeToNewsletter'] +const MUTATIONS = [ + 'validateCart', + 'validateSession', + 'subscribeToNewsletter', + 'createProductReview', +] describe('FastStore GraphQL Layer', () => { describe('@faststore/api', () => {