Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: basic rating display #2653

Open
wants to merge 7 commits into
base: feat/reviews-and-ratings
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/api/mocks/ProductQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
})
10 changes: 10 additions & 0 deletions packages/api/src/__generated__/schema.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/api/src/platforms/vtex/resolvers/product.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,4 +157,5 @@ export const StoreProduct: Record<string, Resolver<Root>> & {
},
releaseDate: ({ isVariantOf: { releaseDate } }) => releaseDate ?? '',
advertisement: ({ isVariantOf: { advertisement } }) => advertisement,
rating: (item) => item.rating,
}
12 changes: 11 additions & 1 deletion packages/api/src/platforms/vtex/resolvers/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export const Query = {
)
}

const rating = await commerce.rating(sku.itemId)

sku.rating = rating

return sku
} catch (err) {
if (slug == null) {
Expand All @@ -98,9 +102,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
Comment on lines +109 to +113
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Could we pass the rating directly here?

Suggested change
const enhancedSku = enhanceSku(sku, product)
enhancedSku.rating = rating
return enhancedSku
const enhancedSku = enhanceSku(sku, { ...product, rating })
return enhancedSku

Copy link
Collaborator Author

@Guilera Guilera Feb 10, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my intention was to not change the method signature. I didn't want to introduce a break change for all the calls.

do you thing this is a must do ?

}
},
collection: (_: unknown, { slug }: QueryCollectionArgs, ctx: Context) => {
Expand Down
47 changes: 30 additions & 17 deletions packages/api/src/platforms/vtex/resolvers/searchResult.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ const isRootFacet = (facet: Facet, isDepartment: boolean, isBrand: boolean) =>
export const StoreSearchResult: Record<string, Resolver<Root>> = {
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) {
Expand All @@ -38,21 +38,26 @@ export const StoreSearchResult: Record<string, Resolver<Root>> = {
}
}

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

Expand All @@ -61,7 +66,11 @@ export const StoreSearchResult: Record<string, Resolver<Root>> = {
products: skus,
}
},
products: async ({ productSearchPromise }) => {
products: async ({ productSearchPromise }, _, ctx) => {
const {
clients: { commerce },
} = ctx

const productSearchResult = await productSearchPromise

const skus = productSearchResult.products
Expand All @@ -74,6 +83,13 @@ export const StoreSearchResult: Record<string, Resolver<Root>> = {
})
.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,
Expand All @@ -82,10 +98,7 @@ export const StoreSearchResult: Record<string, Resolver<Root>> = {
endCursor: productSearchResult.recordsFiltered.toString(),
totalCount: productSearchResult.recordsFiltered,
},
edges: skus.map((sku, index) => ({
node: sku,
cursor: index.toString(),
})),
edges,
}
},
facets: async ({ searchArgs }, _, ctx) => {
Expand Down
8 changes: 7 additions & 1 deletion packages/api/src/platforms/vtex/utils/enhanceSku.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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),
})
4 changes: 4 additions & 0 deletions packages/api/src/typeDefs/product.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ type StoreProduct {
Advertisement information about the product.
"""
advertisement: Advertisement
"""
Product rating.
"""
rating: StoreProductRating!
}

"""
Expand Down
10 changes: 10 additions & 0 deletions packages/api/src/typeDefs/productRating.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
type StoreProductRating {
"""
Product average rating.
"""
average: Float!
"""
Product amount of ratings received.
"""
totalCount: Int!
}
21 changes: 17 additions & 4 deletions packages/api/test/queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -138,15 +142,19 @@ 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)
)

const response = await run(ProductByIdQuery)

expect(mockedFetch).toHaveBeenCalledTimes(2)
expect(mockedFetch).toHaveBeenCalledTimes(3)

fetchAPICalls.forEach((fetchAPICall) => {
expect(mockedFetch).toHaveBeenCalledWith(
Expand Down Expand Up @@ -215,6 +223,11 @@ test('`search` query', async () => {
const fetchAPICalls = [
productSearchCategory1Fetch,
attributeSearchCategory1Fetch,
productRatingFetch('2791588'),
productRatingFetch('44903104'),
productRatingFetch('96175310'),
productRatingFetch('12405783'),
productRatingFetch('24041857'),
salesChannelStaleFetch,
]

Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ const ProductCardContent = forwardRef<HTMLElement, ProductCardContentProps>(
{includeTaxes && (
<Label data-fs-product-card-taxes-label>{includeTaxesLabel}</Label>
)}
{ratingValue && (
{ratingValue != undefined && (
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (non-blocking): Since you restricted the value type here, is good to set default value on line 87 (ratingValue = undefined) just to make sure we won't have any different types here (null, for instance)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is typed as number | undefined there is no way it can be null

<Rating value={ratingValue} icon={<Icon name="Star" />} />
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@ const ProductTitle = forwardRef<HTMLElement, ProductTitleProps>(
{!!label && label}
</div>

{(refNumber || ratingValue) && (
{(refNumber || ratingValue != undefined) && (
<div data-fs-product-title-addendum>
{ratingValue && <Rating value={ratingValue} />}
{ratingValue != undefined && <Rating value={ratingValue} />}
{refNumber && (
<>
{refTag} {refNumber}
Expand Down
8 changes: 4 additions & 4 deletions packages/core/@generated/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import * as types from './graphql'
* Therefore it is highly recommended to use the babel or swc plugin for production.
*/
const documents = {
'\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\n isVariantOf {\n productGroupID\n name\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n advertisement {\n adId\n adResponseId\n }\n }\n':
'\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\n isVariantOf {\n productGroupID\n name\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n rating {\n average\n totalCount\n }\n }\n':
types.ProductSummary_ProductFragmentDoc,
'\n fragment Filter_facets on StoreFacet {\n ... on StoreFacetRange {\n key\n label\n\n min {\n selected\n absolute\n }\n\n max {\n selected\n absolute\n }\n\n __typename\n }\n ... on StoreFacetBoolean {\n key\n label\n values {\n label\n value\n selected\n quantity\n }\n\n __typename\n }\n }\n':
types.Filter_FacetsFragmentDoc,
'\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n':
'\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n rating {\n average\n totalCount\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n':
types.ProductDetailsFragment_ProductFragmentDoc,
'\n fragment ProductSKUMatrixSidebarFragment_product on StoreProduct {\n id: productID\n isVariantOf {\n name\n productGroupID\n skuVariants {\n activeVariations\n slugsMap\n availableVariations\n allVariantProducts {\n\t\t\t\t\tsku\n name\n image {\n url\n alternateName\n }\n offers {\n highPrice\n lowPrice\n lowPriceWithTaxes\n offerCount\n priceCurrency\n offers {\n listPrice\n listPriceWithTaxes\n sellingPrice\n priceCurrency\n price\n priceWithTaxes\n priceValidUntil\n itemCondition\n availability\n quantity\n }\n }\n additionalProperty {\n propertyID\n value\n name\n valueReference\n }\n }\n }\n }\n }\n':
types.ProductSkuMatrixSidebarFragment_ProductFragmentDoc,
Expand Down Expand Up @@ -66,7 +66,7 @@ const documents = {
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(
source: '\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\n isVariantOf {\n productGroupID\n name\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n advertisement {\n adId\n adResponseId\n }\n }\n'
source: '\n fragment ProductSummary_product on StoreProduct {\n id: productID\n slug\n sku\n brand {\n brandName: name\n }\n name\n gtin\n\n isVariantOf {\n productGroupID\n name\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n listPrice\n listPriceWithTaxes\n quantity\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n advertisement {\n adId\n adResponseId\n }\n\n rating {\n average\n totalCount\n }\n }\n'
): typeof import('./graphql').ProductSummary_ProductFragmentDoc
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
Expand All @@ -78,7 +78,7 @@ export function gql(
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function gql(
source: '\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n'
source: '\n fragment ProductDetailsFragment_product on StoreProduct {\n id: productID\n sku\n name\n gtin\n description\n unitMultiplier\n isVariantOf {\n name\n productGroupID\n\t\t\tskuVariants {\n activeVariations\n slugsMap\n availableVariations\n }\n }\n\n image {\n url\n alternateName\n }\n\n brand {\n name\n }\n\n offers {\n lowPrice\n lowPriceWithTaxes\n offers {\n availability\n price\n priceWithTaxes\n listPrice\n listPriceWithTaxes\n seller {\n identifier\n }\n }\n }\n\n additionalProperty {\n propertyID\n name\n value\n valueReference\n }\n\n rating {\n average\n totalCount\n }\n\n # Contains necessary info to add this item to cart\n ...CartProductItem\n }\n'
): typeof import('./graphql').ProductDetailsFragment_ProductFragmentDoc
/**
* The gql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
Expand Down
Loading