From e61373f376bda50f2430cca5c7cd9271d2f97dbd Mon Sep 17 00:00:00 2001 From: Bruno Guilera Gutchenzo Date: Thu, 6 Feb 2025 12:03:34 -0300 Subject: [PATCH 1/9] feat: increments commerce client (#2647) ## What's the purpose of this pull request? To add the Reviews & Ratings API integration through commerce client ## How it works? Adds 3 new calls to the client: - client.commerce.rating (retrieves rating information for a specific product) - client.commerce.reviews.list (retrieves all reviews for a specific product) - client.commerce.reviews.create (creates a new review for a specific product) ## How to test it? Creates a `.ts` file on the root folder of the project and adds the following code: ```typescript import { getContextFactory, Options } from "./packages/api/src/platforms/vtex"; const apiOptions = { platform: 'vtex', account: 'storeframework', locale: 'en-US', environment: 'vtexcommercestable', channel: '{"salesChannel":"1"}', showSponsored: false, } as Options const apiCtx = getContextFactory(apiOptions) const commerceApiClient = apiCtx({}).clients.commerce ``` After that you can use the `commerceApiClient` to call the new methods. To run the file locally use the following command: ```bash npx tsx ``` ## References [JIRA Task: SFS-2092](https://vtex-dev.atlassian.net/browse/SFS-2092) [Reviews & Ratings API Doc](https://developers.vtex.com/docs/api-reference/reviews-and-ratings-api#overview) ## Checklist **PR Description** - [ ] Added Rating types - [ ] Added Reviews types - [ ] Incremented ProductSearchReviewResult - [ ] Created adapatObject function on `utils` - [ ] Created camelToSnakeCase function on `utils` --- .../platforms/vtex/clients/commerce/index.ts | 53 ++++++++++++++++++ .../clients/commerce/types/ProductRating.ts | 4 ++ .../clients/commerce/types/ProductReview.ts | 56 +++++++++++++++++++ .../search/types/ProductSearchResult.ts | 4 ++ .../src/platforms/vtex/utils/adaptObject.ts | 39 +++++++++++++ .../platforms/vtex/utils/camelToSnakeCase.ts | 3 + 6 files changed, 159 insertions(+) create mode 100644 packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts create mode 100644 packages/api/src/platforms/vtex/clients/commerce/types/ProductReview.ts create mode 100644 packages/api/src/platforms/vtex/utils/adaptObject.ts create mode 100644 packages/api/src/platforms/vtex/utils/camelToSnakeCase.ts diff --git a/packages/api/src/platforms/vtex/clients/commerce/index.ts b/packages/api/src/platforms/vtex/clients/commerce/index.ts index 3f34e049b0..03049d9c8c 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/index.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/index.ts @@ -20,6 +20,14 @@ 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 type { ProductRating } from './types/ProductRating' +import type { + CreateProductReviewInput, + ProductReviewsInput, + ProductReviewsResult, +} from './types/ProductReview' +import { adaptObject } from '../../utils/adaptObject' +import { camelToSnakeCase } from '../../utils/camelToSnakeCase' type ValueOf = T extends Record ? K : never @@ -30,6 +38,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 +374,48 @@ 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 => { + return fetchAPI( + `${base}/${REVIEWS_AND_RATINGS_API_PATH}/review`, + { + ...BASE_INIT, + 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..9aec165964 --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts @@ -0,0 +1,4 @@ +export interface ProductRating { + average: number + totalCount: 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..23f9ee3acf --- /dev/null +++ b/packages/api/src/platforms/vtex/clients/commerce/types/ProductReview.ts @@ -0,0 +1,56 @@ +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 interface ProductReviewsInput { + searchTerm?: string + from?: number + to?: number + orderBy?: ProductReviewsInputOrderBy + orderWay?: 'asc' | 'desc' + 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/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()}`) +} From 1729eefe60402e59c864eeb92e080e0d3d2e33b8 Mon Sep 17 00:00:00 2001 From: Bruno Guilera Gutchenzo Date: Fri, 7 Feb 2025 12:55:11 -0300 Subject: [PATCH 2/9] feat: reviews query resolver (#2649) ## What's the purpose of this pull request? To add reviews query resolver on graphQL ## How it works? It defines a few new types and adds a new query resolver called: `reviews` wich retrieves an array of reviews for a specific product. For input we have: - productId (`required`) - after and first for pagination, (both are `optional`) - sort (`optional`) - rating (`optional`)(for filtering) ## How to test it? run the api graphql server locally with the following command: ```bash yarn dev:server ``` and make a query call ## References [JIRA TASK: SFS-2094](https://vtex-dev.atlassian.net/browse/SFS-2094) ![image](https://github.com/user-attachments/assets/d07547af-329e-4dbe-8b83-75694ec18fbc) ![image](https://github.com/user-attachments/assets/f9bcef6d-359e-464e-893a-74f1ffc06464) ## Checklist You may erase this after checking them all :wink: **PR Description** - [ ] Adds graphQL `Reviews` types - [ ] Creates a new query resolver for `Reviews` --- packages/api/src/__generated__/schema.ts | 63 ++++++++++++++ .../clients/commerce/types/ProductReview.ts | 4 +- .../api/src/platforms/vtex/resolvers/query.ts | 32 +++++++ .../api/src/typeDefs/productReview.graphql | 84 +++++++++++++++++++ packages/api/src/typeDefs/query.graphql | 27 ++++++ packages/api/test/schema.test.ts | 1 + packages/core/test/server/index.test.ts | 1 + 7 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/typeDefs/productReview.graphql diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 83f0480b3f..77c847ef17 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -467,6 +467,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 +506,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']; @@ -1057,6 +1068,58 @@ 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 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/vtex/clients/commerce/types/ProductReview.ts b/packages/api/src/platforms/vtex/clients/commerce/types/ProductReview.ts index 23f9ee3acf..3d002d8063 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/types/ProductReview.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/types/ProductReview.ts @@ -26,12 +26,14 @@ export enum ProductReviewsInputOrderBy { locale = 'Locale', } +export type ProductReviewsInputOrderWay = 'asc' | 'desc' + export interface ProductReviewsInput { searchTerm?: string from?: number to?: number orderBy?: ProductReviewsInputOrderBy - orderWay?: 'asc' | 'desc' + orderWay?: ProductReviewsInputOrderWay status?: boolean productId?: string rating?: number diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 4f607a72da..9a77d6e037 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -21,11 +21,16 @@ 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' export const Query = { product: async (_: unknown, { locator }: QueryProductArgs, ctx: Context) => { @@ -335,4 +340,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/typeDefs/productReview.graphql b/packages/api/src/typeDefs/productReview.graphql new file mode 100644 index 0000000000..6345cb8775 --- /dev/null +++ b/packages/api/src/typeDefs/productReview.graphql @@ -0,0 +1,84 @@ +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 +} 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/schema.test.ts b/packages/api/test/schema.test.ts index 47fa97b71d..48bc1d81ce 100644 --- a/packages/api/test/schema.test.ts +++ b/packages/api/test/schema.test.ts @@ -67,6 +67,7 @@ const QUERIES = [ 'shipping', 'redirect', 'sellers', + 'reviews', ] const MUTATIONS = ['validateCart', 'validateSession', 'subscribeToNewsletter'] diff --git a/packages/core/test/server/index.test.ts b/packages/core/test/server/index.test.ts index 0a47f5f145..7bc2a374b3 100644 --- a/packages/core/test/server/index.test.ts +++ b/packages/core/test/server/index.test.ts @@ -71,6 +71,7 @@ const QUERIES = [ 'shipping', 'redirect', 'sellers', + 'reviews', ] const MUTATIONS = ['validateCart', 'validateSession', 'subscribeToNewsletter'] From f999df56b0cd64fcead1984db571f0a4491afa63 Mon Sep 17 00:00:00 2001 From: Bruno Guilera Gutchenzo Date: Wed, 12 Feb 2025 17:09:46 -0300 Subject: [PATCH 3/9] feat: reviews mutation resolver (#2650) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit >⚠️ THIS PR DEPENDS ON [PR#2647](https://github.com/vtex/faststore/pull/2647) ⚠️ ## What's the purpose of this pull request? To add a reviews mutation resolver on graphQL for allowing a new review to be created ## How it works? It defines a few new types and adds a new mutation resolver called: `createProductReview` ## How to test it? run the api graphql server locally with the following command: ```bash yarn dev:server ``` and make a query call ## References [JIRA TASK: SFS-2095](https://vtex-dev.atlassian.net/browse/SFS-2095) [API Reviews Docs](https://developers.vtex.com/docs/api-reference/reviews-and-ratings-api#post-/reviews-and-ratings/api/review) ## Checklist You may erase this after checking them all :wink: **PR Description** - [ ] Adds graphQL types - [ ] Creates a new mutation resolver for `Reviews` --- packages/api/src/__generated__/schema.ts | 20 ++++++++++++++++ packages/api/src/platforms/errors.ts | 12 +++++++++- .../platforms/vtex/clients/commerce/index.ts | 19 ++++++++++++++- .../vtex/resolvers/createProductReview.ts | 13 +++++++++++ .../src/platforms/vtex/resolvers/mutation.ts | 2 ++ .../api/src/platforms/vtex/utils/cookies.ts | 13 +++++++++++ packages/api/src/typeDefs/mutation.graphql | 4 ++++ .../api/src/typeDefs/productReview.graphql | 23 +++++++++++++++++++ packages/api/test/schema.test.ts | 8 ++++++- packages/core/test/server/index.test.ts | 8 ++++++- 10 files changed, 118 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/platforms/vtex/resolvers/createProductReview.ts diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 77c847ef17..a35cd716ef 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; }; 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 03049d9c8c..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,11 @@ 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, @@ -28,6 +32,7 @@ import type { } from './types/ProductReview' import { adaptObject } from '../../utils/adaptObject' import { camelToSnakeCase } from '../../utils/camelToSnakeCase' +import { NotAuthorizedError } from '../../../errors' type ValueOf = T extends Record ? K : never @@ -383,10 +388,22 @@ export const VtexCommerce = ( }, 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', }, 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/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/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/productReview.graphql b/packages/api/src/typeDefs/productReview.graphql index 6345cb8775..29399f46ad 100644 --- a/packages/api/src/typeDefs/productReview.graphql +++ b/packages/api/src/typeDefs/productReview.graphql @@ -82,3 +82,26 @@ enum StoreProductListReviewsSort { """ 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/test/schema.test.ts b/packages/api/test/schema.test.ts index 48bc1d81ce..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 = [ @@ -70,7 +71,12 @@ const QUERIES = [ 'reviews', ] -const MUTATIONS = ['validateCart', 'validateSession', 'subscribeToNewsletter'] +const MUTATIONS = [ + 'validateCart', + 'validateSession', + 'subscribeToNewsletter', + 'createProductReview', +] let schema: GraphQLSchema diff --git a/packages/core/test/server/index.test.ts b/packages/core/test/server/index.test.ts index 7bc2a374b3..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 = [ @@ -74,7 +75,12 @@ const QUERIES = [ 'reviews', ] -const MUTATIONS = ['validateCart', 'validateSession', 'subscribeToNewsletter'] +const MUTATIONS = [ + 'validateCart', + 'validateSession', + 'subscribeToNewsletter', + 'createProductReview', +] describe('FastStore GraphQL Layer', () => { describe('@faststore/api', () => { From d84b1dc6425deb32cb1adcf070be9e2fe29a9fc7 Mon Sep 17 00:00:00 2001 From: Bruno Guilera Gutchenzo Date: Fri, 14 Feb 2025 10:26:14 -0300 Subject: [PATCH 4/9] feat: rating resolvers (#2648) ## What's the purpose of this pull request? To add product rating resolvers on graphQL ## How it works? It defines a new type called: `StoreProductRating` and increments the query resolver for `product` and for `searchResult` It was necessary to adapt the `EnhancedSku` type and also to adds a rating callback to the searchResult's promise resolution ## How to test it? run the api graphql server locally with the following command: ```bash yarn dev:server ``` and make a query call ## References [JIRA Task: SFS-2093](https://vtex-dev.atlassian.net/browse/SFS-2093) ## Checklist You may erase this after checking them all :wink: **PR Description** - [ ] Adds graphQL Rating type - [ ] Increments `product` resolver - [ ] Increments `searchResult` resolver --- packages/api/mocks/ProductQuery.ts | 10 ++++ packages/api/src/__generated__/schema.ts | 10 ++++ .../src/platforms/vtex/resolvers/product.ts | 1 + .../api/src/platforms/vtex/resolvers/query.ts | 12 ++++- .../platforms/vtex/resolvers/searchResult.ts | 47 ++++++++++++------- .../src/platforms/vtex/utils/enhanceSku.ts | 8 +++- packages/api/src/typeDefs/product.graphql | 4 ++ .../api/src/typeDefs/productRating.graphql | 10 ++++ packages/api/test/queries.test.ts | 21 +++++++-- 9 files changed, 100 insertions(+), 23 deletions(-) create mode 100644 packages/api/src/typeDefs/productRating.graphql 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 a35cd716ef..2762e48e15 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -1030,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. */ @@ -1116,6 +1118,14 @@ export const enum StoreProductListReviewsSort { ReviewDateTimeDesc = 'reviewDateTime_desc' }; +export type StoreProductRating = { + __typename?: 'StoreProductRating'; + /** Product average rating. */ + average: Scalars['Float']; + /** Product amount of ratings received. */ + totalCount: Scalars['Int']; +}; + export type StoreProductReview = { __typename?: 'StoreProductReview'; /** Indicates if the review was approved by the store owner. */ diff --git a/packages/api/src/platforms/vtex/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index 6998c28a3d..67ce03e4fb 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -157,4 +157,5 @@ export const StoreProduct: Record> & { }, releaseDate: ({ isVariantOf: { releaseDate } }) => releaseDate ?? '', advertisement: ({ isVariantOf: { advertisement } }) => advertisement, + rating: (item) => item.rating, } diff --git a/packages/api/src/platforms/vtex/resolvers/query.ts b/packages/api/src/platforms/vtex/resolvers/query.ts index 9a77d6e037..38f1fd12a5 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -79,6 +79,10 @@ export const Query = { ) } + const rating = await commerce.rating(sku.itemId) + + sku.rating = rating + return sku } catch (err) { if (slug == null) { @@ -103,9 +107,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 = rating + + return enhancedSku } }, collection: (_: unknown, { slug }: QueryCollectionArgs, ctx: Context) => { 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/enhanceSku.ts b/packages/api/src/platforms/vtex/utils/enhanceSku.ts index 01cfa928bc..4468b3093a 100644 --- a/packages/api/src/platforms/vtex/utils/enhanceSku.ts +++ b/packages/api/src/platforms/vtex/utils/enhanceSku.ts @@ -1,7 +1,9 @@ import type { Product, Item } from '../clients/search/types/ProductSearchResult' import { sanitizeHtml } from './sanitizeHtml' -export type EnhancedSku = Item & { isVariantOf: Product } +export type EnhancedSku = Item & { isVariantOf: Product } & { + rating: { average: number; totalCount: number } +} function sanitizeProduct(product: Product): Product { return { @@ -14,5 +16,9 @@ function sanitizeProduct(product: Product): Product { export const enhanceSku = (item: Item, product: Product): EnhancedSku => ({ ...item, + rating: { + average: 0, + totalCount: 0, + }, isVariantOf: sanitizeProduct(product), }) 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..2b0e1a19ea --- /dev/null +++ b/packages/api/src/typeDefs/productRating.graphql @@ -0,0 +1,10 @@ +type StoreProductRating { + """ + Product average rating. + """ + average: Float! + """ + Product amount of ratings received. + """ + totalCount: Int! +} 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( From 736e0f8c631b79845c882276eb749561d4357f9b Mon Sep 17 00:00:00 2001 From: Artur Santiago Date: Fri, 14 Feb 2025 12:52:29 -0300 Subject: [PATCH 5/9] feat: create review and ratings section structure (#2663) ## What's the purpose of this pull request? Establish the initial structure for the Review & Ratings section, enabling new components to branch from this foundation, thereby facilitating their testing and preview. ## How it works? - **Directory Creation**: A new directory has been created at `packages/core/src/components/sections/ReviewAndRatings`, following the Overridable section structure. - **Support Files**: Files have been added to `packages/core/src/components/plugins/overrides` and `packages/core/src/components/customizations/src/components` to support overrides. - **Product Page Integration**: The section has been integrated into the product page rendering component located at `packages/core/src/pages/[slug]/p.tsx`. - **CMS Configuration**: An entry for the Review and Ratings section has been added to the CMS JSON file at `packages/core/cms/faststore/sections.json`. Currently, only a `title` property is included; additional properties will be incorporated as new components are developed. ## How to test it You can access the preview link and open any pdp. It already should be able to show the reviews sections with the title `Reviews`. If the title doesn't appear you can run `yarn cms-sync` in the `start.store` [branch](https://github.com/vtex-sites/starter.store/pull/686) ### Starters Deploy Preview [Preview](https://starter-git-feat-preview-review-ratings-section-vtex.vercel.app/) ## References [Jira Task: SFS-2084](https://vtex-dev.atlassian.net/browse/SFS-2084) --- packages/core/cms/faststore/sections.json | 15 +++++++++ packages/core/index.ts | 1 + .../ReviewsAndRatings/DefaultComponents.ts | 6 ++++ .../OverriddenDefaultReviewsAndRatings.ts | 16 ++++++++++ .../ReviewsAndRatings/ReviewsAndRatings.tsx | 31 +++++++++++++++++++ .../sections/ReviewsAndRatings/index.tsx | 1 + .../ReviewsAndRatings/section.module.scss | 6 ++++ .../ReviewsAndRatings/ReviewsAndRatings.tsx | 13 ++++++++ .../components/ui/ReviewsAndRatings/index.ts | 2 ++ .../overrides/ReviewsAndRatings.tsx | 11 +++++++ packages/core/src/pages/[slug]/p.tsx | 4 ++- .../plugins/overrides/ReviewsAndRatings.tsx | 3 ++ packages/core/src/typings/overrides.ts | 6 ++++ 13 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/components/sections/ReviewsAndRatings/DefaultComponents.ts create mode 100644 packages/core/src/components/sections/ReviewsAndRatings/OverriddenDefaultReviewsAndRatings.ts create mode 100644 packages/core/src/components/sections/ReviewsAndRatings/ReviewsAndRatings.tsx create mode 100644 packages/core/src/components/sections/ReviewsAndRatings/index.tsx create mode 100644 packages/core/src/components/sections/ReviewsAndRatings/section.module.scss create mode 100644 packages/core/src/components/ui/ReviewsAndRatings/ReviewsAndRatings.tsx create mode 100644 packages/core/src/components/ui/ReviewsAndRatings/index.ts create mode 100644 packages/core/src/customizations/src/components/overrides/ReviewsAndRatings.tsx create mode 100644 packages/core/src/plugins/overrides/ReviewsAndRatings.tsx 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: { From 9a9e1b29b8d5d1b650a0495bb8a4b6b61b969001 Mon Sep 17 00:00:00 2001 From: gutchenzo Date: Tue, 18 Feb 2025 17:06:09 -0300 Subject: [PATCH 6/9] chore: updates generated file --- packages/core/@generated/graphql.ts | 86 +++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/packages/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index 0f71d6e880..e3ae3b190f 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,61 @@ 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 amount of ratings received. */ + totalCount: 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. */ From 1f0060a9d516252d3460074dedbdeb15bfe087ae Mon Sep 17 00:00:00 2001 From: gutchenzo Date: Thu, 6 Feb 2025 10:31:31 -0300 Subject: [PATCH 7/9] feat: retrieves rating distribution on api client and graphql resolver --- packages/api/src/__generated__/schema.ts | 17 +++++ .../clients/commerce/types/ProductRating.ts | 5 ++ .../src/platforms/vtex/resolvers/product.ts | 12 +++- .../api/src/platforms/vtex/resolvers/query.ts | 5 +- .../src/platforms/vtex/utils/enhanceSku.ts | 15 ++++- .../api/src/platforms/vtex/utils/rating.ts | 66 +++++++++++++++++++ .../api/src/typeDefs/productRating.graphql | 30 +++++++++ packages/core/@generated/graphql.ts | 16 +++++ 8 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 packages/api/src/platforms/vtex/utils/rating.ts diff --git a/packages/api/src/__generated__/schema.ts b/packages/api/src/__generated__/schema.ts index 2762e48e15..c3273dbc2c 100644 --- a/packages/api/src/__generated__/schema.ts +++ b/packages/api/src/__generated__/schema.ts @@ -1122,10 +1122,27 @@ 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. */ diff --git a/packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts b/packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts index 9aec165964..00239bad87 100644 --- a/packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts +++ b/packages/api/src/platforms/vtex/clients/commerce/types/ProductRating.ts @@ -1,4 +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/resolvers/product.ts b/packages/api/src/platforms/vtex/resolvers/product.ts index 67ce03e4fb..6e1734546b 100644 --- a/packages/api/src/platforms/vtex/resolvers/product.ts +++ b/packages/api/src/platforms/vtex/resolvers/product.ts @@ -157,5 +157,15 @@ export const StoreProduct: Record> & { }, releaseDate: ({ isVariantOf: { releaseDate } }) => releaseDate ?? '', advertisement: ({ isVariantOf: { advertisement } }) => advertisement, - rating: (item) => item.rating, + 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 38f1fd12a5..7bb9577234 100644 --- a/packages/api/src/platforms/vtex/resolvers/query.ts +++ b/packages/api/src/platforms/vtex/resolvers/query.ts @@ -31,6 +31,7 @@ import { ProductReviewsInputOrderBy, type ProductReviewsInputOrderWay, } from '../clients/commerce/types/ProductReview' +import { buildRatingDistribution } from '../utils/rating' export const Query = { product: async (_: unknown, { locator }: QueryProductArgs, ctx: Context) => { @@ -81,7 +82,7 @@ export const Query = { const rating = await commerce.rating(sku.itemId) - sku.rating = rating + sku.rating = buildRatingDistribution(rating) return sku } catch (err) { @@ -113,7 +114,7 @@ export const Query = { const enhancedSku = enhanceSku(sku, product) - enhancedSku.rating = rating + enhancedSku.rating = buildRatingDistribution(rating) return enhancedSku } diff --git a/packages/api/src/platforms/vtex/utils/enhanceSku.ts b/packages/api/src/platforms/vtex/utils/enhanceSku.ts index 4468b3093a..2155a45418 100644 --- a/packages/api/src/platforms/vtex/utils/enhanceSku.ts +++ b/packages/api/src/platforms/vtex/utils/enhanceSku.ts @@ -1,8 +1,14 @@ import type { Product, Item } from '../clients/search/types/ProductSearchResult' import { sanitizeHtml } from './sanitizeHtml' +export type ProductRating = { + average: number + totalCount: number + distribution: Record +} + export type EnhancedSku = Item & { isVariantOf: Product } & { - rating: { average: number; totalCount: number } + rating: ProductRating } function sanitizeProduct(product: Product): Product { @@ -19,6 +25,13 @@ export const enhanceSku = (item: Item, product: Product): EnhancedSku => ({ 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..3c029385d5 --- /dev/null +++ b/packages/api/src/platforms/vtex/utils/rating.ts @@ -0,0 +1,66 @@ +import type { ProductRating as ApiClientProductRating } from '../clients/commerce/types/ProductRating' +import type { ProductRating } from './enhanceSku' + +export function buildRatingDistribution( + apiClientRating: ApiClientProductRating +): ProductRating { + const rating: ProductRating = { + average: apiClientRating.average, + totalCount: apiClientRating.totalCount, + distribution: { + 1: 0, + 2: 0, + 3: 0, + 4: 0, + 5: 0, + }, + } + + if (rating.totalCount === 0) { + return rating + } + + const percentages = [ + calculateIntegerPercentage(apiClientRating.starsOne, rating.totalCount), + calculateIntegerPercentage(apiClientRating.starsTwo, rating.totalCount), + calculateIntegerPercentage(apiClientRating.starsThree, rating.totalCount), + calculateIntegerPercentage(apiClientRating.starsFour, rating.totalCount), + calculateIntegerPercentage(apiClientRating.starsFive, rating.totalCount), + ] + + const totalPercentage = percentages.reduce((acc, curr) => acc + curr, 0) + + if (totalPercentage !== 100) { + const missingPercentage = 100 - totalPercentage + const [maxValue, matchedIndexes] = findMaxInArray(percentages) + + const changingIndex = + missingPercentage > 0 + ? Math.max(...matchedIndexes) + : Math.min(...matchedIndexes) + + percentages[changingIndex] = maxValue + missingPercentage + } + + percentages.forEach( + (percentage, index) => (rating.distribution[index + 1] = percentage) + ) + + return rating +} + +function calculateIntegerPercentage(value: number, total: number): number { + return Math.round((value / total) * 100) +} + +function findMaxInArray(arr: number[]): [number, number[]] { + const maxValue = Math.max(...arr) + const matchedIndexes = arr.reduce((acc: number[], curr, index) => { + if (curr === maxValue) { + acc.push(index) + } + return acc + }, []) + + return [maxValue, matchedIndexes] +} diff --git a/packages/api/src/typeDefs/productRating.graphql b/packages/api/src/typeDefs/productRating.graphql index 2b0e1a19ea..1848f9b9db 100644 --- a/packages/api/src/typeDefs/productRating.graphql +++ b/packages/api/src/typeDefs/productRating.graphql @@ -7,4 +7,34 @@ type StoreProductRating { 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/core/@generated/graphql.ts b/packages/core/@generated/graphql.ts index e3ae3b190f..1c72c0f3ab 100644 --- a/packages/core/@generated/graphql.ts +++ b/packages/core/@generated/graphql.ts @@ -1067,10 +1067,26 @@ export type StoreProductListReviewsSort = 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'] From 33a769bd225ab9ed58275e522b806de42c390feb Mon Sep 17 00:00:00 2001 From: gutchenzo Date: Tue, 18 Feb 2025 16:22:04 -0300 Subject: [PATCH 8/9] chore: improves code readability --- .../api/src/platforms/vtex/utils/rating.ts | 91 ++++++++++++------- 1 file changed, 58 insertions(+), 33 deletions(-) diff --git a/packages/api/src/platforms/vtex/utils/rating.ts b/packages/api/src/platforms/vtex/utils/rating.ts index 3c029385d5..f53f2f0b26 100644 --- a/packages/api/src/platforms/vtex/utils/rating.ts +++ b/packages/api/src/platforms/vtex/utils/rating.ts @@ -1,12 +1,18 @@ import type { ProductRating as ApiClientProductRating } from '../clients/commerce/types/ProductRating' import type { ProductRating } from './enhanceSku' -export function buildRatingDistribution( - apiClientRating: ApiClientProductRating -): ProductRating { +export function buildRatingDistribution({ + average, + totalCount, + starsOne, + starsTwo, + starsThree, + starsFour, + starsFive, +}: ApiClientProductRating): ProductRating { const rating: ProductRating = { - average: apiClientRating.average, - totalCount: apiClientRating.totalCount, + average, + totalCount, distribution: { 1: 0, 2: 0, @@ -20,47 +26,66 @@ export function buildRatingDistribution( return rating } - const percentages = [ - calculateIntegerPercentage(apiClientRating.starsOne, rating.totalCount), - calculateIntegerPercentage(apiClientRating.starsTwo, rating.totalCount), - calculateIntegerPercentage(apiClientRating.starsThree, rating.totalCount), - calculateIntegerPercentage(apiClientRating.starsFour, rating.totalCount), - calculateIntegerPercentage(apiClientRating.starsFive, rating.totalCount), - ] + const integerPercentages = [ + starsOne, + starsTwo, + starsThree, + starsFour, + starsFive, + ].map((value) => calculateIntegerPercentage(value, totalCount)) - const totalPercentage = percentages.reduce((acc, curr) => acc + curr, 0) + const totalPercentage = integerPercentages.reduce( + (acc, curr) => acc + curr, + 0 + ) if (totalPercentage !== 100) { - const missingPercentage = 100 - totalPercentage - const [maxValue, matchedIndexes] = findMaxInArray(percentages) - - const changingIndex = - missingPercentage > 0 - ? Math.max(...matchedIndexes) - : Math.min(...matchedIndexes) - - percentages[changingIndex] = maxValue + missingPercentage + const percentageDifference = 100 - totalPercentage + adjustPercentageDistributionTo100(integerPercentages, percentageDifference) } - percentages.forEach( + 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) } -function findMaxInArray(arr: number[]): [number, number[]] { - const maxValue = Math.max(...arr) - const matchedIndexes = arr.reduce((acc: number[], curr, index) => { - if (curr === maxValue) { - acc.push(index) - } - return acc - }, []) - - return [maxValue, matchedIndexes] +/** + * 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 } From 026d81241196814497055b2cde185a6c7bd449aa Mon Sep 17 00:00:00 2001 From: gutchenzo Date: Tue, 18 Feb 2025 16:22:31 -0300 Subject: [PATCH 9/9] test: implements test for 'buildRatingDistribution' --- packages/api/test/rating.test.ts | 222 +++++++++++++++++++++++++++++++ 1 file changed, 222 insertions(+) create mode 100644 packages/api/test/rating.test.ts 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, + }, + }) + }) + }) +})