diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b96b6ad..9cd1d76d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- `ProductSummaryList` component. ## [2.50.2] - 2020-02-05 diff --git a/docs/ProductSummaryList.md b/docs/ProductSummaryList.md new file mode 100644 index 00000000..5fad7c97 --- /dev/null +++ b/docs/ProductSummaryList.md @@ -0,0 +1,37 @@ +# ProductSummaryList + +The `list-context.product-list` interface is a instance of the `list-context` interfaces, which means its part of a set of special interfaces that enables you to create lists of content that can be edited via Site Editor. + +In order to create a list of products, you need to use the `list-context.product-list` block and a `product-summary.shelf`. + +## product-list-block + +This block is used to specify what variation of `product-summary` to be used to create the list of products, and the `list-context.product-list` you want as follows: + +```json + "product-summary.shelf#demo1": { + "children": [ + "stack-layout#prodsum", + "product-summary-name", + "product-rating-inline", + "product-summary-space", + "product-summary-price", + "product-summary-buy-button" + ] + }, + "list-context.product-list#demo1": { + "blocks": ["product-summary.shelf#demo1"], + "children": ["slider-layout#demo-products"] + }, +``` + +`list-context.product-list` is also responsible for performing the GraphQL query that fetches the list of products, so it can receive the following props: + +| Prop name | Type | Description | Default value | +| ---------------------- | -------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | +| `category` | `String` | Category ID of the listed items. For sub-categories, use "/" (e.g. "1/2/3") | - | +| `specificationFilters` | `Array({ id: String, value: String })` | Specification Filters of the listed items. | [] | +| `collection` | `String` | Filter by collection. | - | +| `orderBy` | `Enum` | Ordination type of the items. Possible values: `OrderByTopSaleDESC`, `OrderByReleaseDateDESC`, `OrderByBestDiscountDESC`, `OrderByPriceDESC`, `OrderByPriceASC`, `OrderByNameASC`, `OrderByNameDESC` | `OrderByTopSaleDESC` | +| `hideUnavailableItems` | `Boolean` | Hides items that are unavailable. | `false` | +| `maxItems` | `Number` | Maximum items to be fetched. | `10` | diff --git a/manifest.json b/manifest.json index f0fe5524..8da1e435 100644 --- a/manifest.json +++ b/manifest.json @@ -19,23 +19,16 @@ "vtex.native-types": "0.x", "vtex.store-components": "3.x", "vtex.store-resources": "0.x", - "vtex.product-review-interfaces": "1.x", "vtex.product-summary-context": "0.x", - "vtex.product-identifier": "0.x", "vtex.styleguide": "9.x", "vtex.pixel-manager": "1.x", - "vtex.product-teaser-interfaces": "1.x", "vtex.device-detector": "0.x", - "vtex.product-quantity": "1.x", "vtex.product-specification-badges": "0.x", - "vtex.stack-layout": "0.x", "vtex.responsive-values": "0.x", "vtex.css-handles": "0.x", "vtex.product-context": "0.x", - "vtex.flex-layout": "0.x", - "vtex.rich-text": "0.x", - "vtex.add-to-cart-button": "0.x", - "vtex.product-bookmark-interfaces": "1.x" + "vtex.list-context": "0.x", + "vtex.product-list-context": "0.x" }, "$schema": "https://raw.githubusercontent.com/vtex/node-vtex-api/master/gen/manifest.schema" } diff --git a/messages/context.json b/messages/context.json index 29cb8143..06bae374 100644 --- a/messages/context.json +++ b/messages/context.json @@ -34,5 +34,25 @@ "admin/editor.productSummaryImage.hoverImageLabel.title": "admin/editor.productSummaryImage.hoverImageLabel.title", "admin/editor.productSummaryBuyButton.title": "admin/editor.productSummaryBuyButton.title", "admin/editor.productSummaryName.title": "admin/editor.productSummaryName.title", - "admin/editor.product-summary-specification-badges.title": "admin/editor.product-summary-specification-badges.title" + "admin/editor.product-summary-specification-badges.title": "admin/editor.product-summary-specification-badges.title", + "admin/editor.productSummaryList.title": "Product List", + "admin/editor.productSummaryList.description": "A product list featuring a collection", + "admin/editor.productSummaryList.category.title": "Category Id", + "admin/editor.productSummaryList.category.description": "For sub-categories, use \"/\" (e.g. 1/2/3)", + "admin/editor.productSummaryList.collection.title": "Collection", + "admin/editor.productSummaryList.orderBy.title": "List Ordenation", + "admin/editor.productSummaryList.hideUnavailableItems": "Hide unavailable items", + "admin/editor.productSummaryList.maxItems.title": "Max Items", + "admin/editor.productSummaryList.specificationFilters.title": "Specification Filters", + "admin/editor.productSummaryList.specificationFilters.item.title": "Specification Filter Item", + "admin/editor.productSummaryList.specificationFilters.item.id.title": "Specification Filter ID", + "admin/editor.productSummaryList.specificationFilters.item.value.title": "Specification Filter Value", + "admin/editor.productSummaryList.orderType.sales": "Sales", + "admin/editor.productSummaryList.orderType.priceDesc": "Price, descending", + "admin/editor.productSummaryList.orderType.priceAsc": "Price, ascending", + "admin/editor.productSummaryList.orderType.nameAsc": "Name, ascending", + "admin/editor.productSummaryList.orderType.nameDesc": "Name, descending", + "admin/editor.productSummaryList.orderType.releaseDate": "Release Date", + "admin/editor.productSummaryList.orderType.discount": "Discount", + "admin/editor.productSummaryList.orderType.relevance": "Relevance" } diff --git a/messages/en.json b/messages/en.json index ed144e3d..8b53fd3c 100644 --- a/messages/en.json +++ b/messages/en.json @@ -34,5 +34,25 @@ "admin/editor.productSummaryImage.hoverImageLabel.title": "Hover Image Label", "admin/editor.productSummaryBuyButton.title": "Product Summary Buy Button", "admin/editor.productSummaryName.title": "Product Summary Name", - "admin/editor.product-summary-specification-badges.title": "Product Summary Specification Badges" + "admin/editor.product-summary-specification-badges.title": "Product Summary Specification Badges", + "admin/editor.productSummaryList.title": "Product List", + "admin/editor.productSummaryList.description": "A product list featuring a collection", + "admin/editor.productSummaryList.category.title": "Category Id", + "admin/editor.productSummaryList.category.description": "For sub-categories, use \"/\" (e.g. 1/2/3)", + "admin/editor.productSummaryList.collection.title": "Collection", + "admin/editor.productSummaryList.orderBy.title": "List Ordination", + "admin/editor.productSummaryList.hideUnavailableItems": "Hide unavailable items", + "admin/editor.productSummaryList.maxItems.title": "Max Items", + "admin/editor.productSummaryList.specificationFilters.title": "Specification Filters", + "admin/editor.productSummaryList.specificationFilters.item.title": "Specification Filter Item", + "admin/editor.productSummaryList.specificationFilters.item.id.title": "Specification Filter ID", + "admin/editor.productSummaryList.specificationFilters.item.value.title": "Specification Filter Value", + "admin/editor.productSummaryList.orderType.sales": "Sales", + "admin/editor.productSummaryList.orderType.priceDesc": "Price, descending", + "admin/editor.productSummaryList.orderType.priceAsc": "Price, ascending", + "admin/editor.productSummaryList.orderType.nameAsc": "Name, ascending", + "admin/editor.productSummaryList.orderType.nameDesc": "Name, descending", + "admin/editor.productSummaryList.orderType.releaseDate": "Release Date", + "admin/editor.productSummaryList.orderType.discount": "Discount", + "admin/editor.productSummaryList.orderType.relevance": "Relevance" } diff --git a/messages/es.json b/messages/es.json index 7ad86593..dd952c84 100644 --- a/messages/es.json +++ b/messages/es.json @@ -34,5 +34,25 @@ "admin/editor.productSummaryImage.hoverImageLabel.title": "Etiqueta de imagen secundaria", "admin/editor.productSummaryBuyButton.title": "Botón de compra", "admin/editor.productSummaryName.title": "Nombre de Resumen del producto", - "admin/editor.product-summary-specification-badges.title": "Resumen del producto Especificaciones Medallas" + "admin/editor.product-summary-specification-badges.title": "Resumen del producto Especificaciones Medallas", + "admin/editor.productSummaryList.title": "Lista de productos", + "admin/editor.productSummaryList.description": "Una lista de productos con una colección de productos", + "admin/editor.productSummaryList.category.title": "Id de la Categoría", + "admin/editor.productSummaryList.category.description": "Para subcategorías, utilice \"/\" (por ejemplo: 1/2/3)", + "admin/editor.productSummaryList.collection.title": "Colección", + "admin/editor.productSummaryList.orderBy.title": "Ordenación de la Lista", + "admin/editor.productSummaryList.hideUnavailableItems": "Ocultar artículos no disponibles", + "admin/editor.productSummaryList.maxItems.title": "Cantidad máxima de Elementos", + "admin/editor.productSummaryList.specificationFilters.title": "Filtros de especificación", + "admin/editor.productSummaryList.specificationFilters.item.title": "Elemento de filtro de especificación", + "admin/editor.productSummaryList.specificationFilters.item.id.title": "ID del filtro de especificación", + "admin/editor.productSummaryList.specificationFilters.item.value.title": "Valor del filtro de especificación", + "admin/editor.productSummaryList.orderType.sales": "Ventas", + "admin/editor.productSummaryList.orderType.priceDesc": "Precio, descendiendo", + "admin/editor.productSummaryList.orderType.priceAsc": "Precio, ascendente", + "admin/editor.productSummaryList.orderType.nameAsc": "Nombre, ascendente", + "admin/editor.productSummaryList.orderType.nameDesc": "Nombre, descendiendo", + "admin/editor.productSummaryList.orderType.releaseDate": "Fecha de lanzamiento", + "admin/editor.productSummaryList.orderType.discount": "Descuento", + "admin/editor.productSummaryList.orderType.relevance": "Relevancia" } diff --git a/messages/pt.json b/messages/pt.json index 258a8a87..48fa8475 100644 --- a/messages/pt.json +++ b/messages/pt.json @@ -34,5 +34,25 @@ "admin/editor.productSummaryImage.hoverImageLabel.title": "Rótulo da imagem secundária", "admin/editor.productSummaryBuyButton.title": "Botão de compra", "admin/editor.productSummaryName.title": "Nome do resumo do produto", - "admin/editor.product-summary-specification-badges.title": "Medalhas de Especificação do resumo do produto" + "admin/editor.product-summary-specification-badges.title": "Medalhas de Especificação do resumo do produto", + "admin/editor.productSummaryList.title": "Lista de produtos", + "admin/editor.productSummaryList.description": "Uma lista de produtos de uma coleção", + "admin/editor.productSummaryList.category.title": "Id da Categoria", + "admin/editor.productSummaryList.category.description": "Para sub-categorias, use \"/\" (por exemplo: 1/2/3)", + "admin/editor.productSummaryList.collection.title": "Coleção", + "admin/editor.productSummaryList.orderBy.title": "Ordenação da Lista", + "admin/editor.productSummaryList.hideUnavailableItems": "Esconder itens não disponíveis", + "admin/editor.productSummaryList.maxItems.title": "Quantidade máxima de itens", + "admin/editor.productSummaryList.specificationFilters.title": "Filtros de Especificação", + "admin/editor.productSummaryList.specificationFilters.item.title": "Item de Filtro de Especificação", + "admin/editor.productSummaryList.specificationFilters.item.id.title": "ID do Filtro de Especificação", + "admin/editor.productSummaryList.specificationFilters.item.value.title": "Valor do Filtro de Especificação", + "admin/editor.productSummaryList.orderType.sales": "Vendas", + "admin/editor.productSummaryList.orderType.priceDesc": "Preço, descrescente", + "admin/editor.productSummaryList.orderType.priceAsc": "Preço, crescente", + "admin/editor.productSummaryList.orderType.nameAsc": "Nome, crescente", + "admin/editor.productSummaryList.orderType.nameDesc": "Nome, decrescente", + "admin/editor.productSummaryList.orderType.releaseDate": "Data de lançamento", + "admin/editor.productSummaryList.orderType.discount": "Desconto", + "admin/editor.productSummaryList.orderType.relevance": "Relevância" } diff --git a/react/ProductSummaryList.js b/react/ProductSummaryList.js new file mode 100644 index 00000000..a9d7f678 --- /dev/null +++ b/react/ProductSummaryList.js @@ -0,0 +1,181 @@ +import React from 'react' +import { useQuery } from 'react-apollo' +import { ProductListContext } from 'vtex.product-list-context' +import { ExtensionPoint, useTreePath } from 'vtex.render-runtime' +import { useListContext, ListContextProvider } from 'vtex.list-context' + +import { mapCatalogProductToProductSummary } from './utils/normalize' +import ProductListEventCaller from './components/ProductListEventCaller' + +import { productSearchV2 } from 'vtex.store-resources/Queries' + +const ORDER_BY_OPTIONS = { + RELEVANCE: { + name: 'admin/editor.productSummaryList.orderType.relevance', + value: 'OrderByScoreDESC', + }, + TOP_SALE_DESC: { + name: 'admin/editor.productSummaryList.orderType.sales', + value: 'OrderByTopSaleDESC', + }, + PRICE_DESC: { + name: 'admin/editor.productSummaryList.orderType.priceDesc', + value: 'OrderByPriceDESC', + }, + PRICE_ASC: { + name: 'admin/editor.productSummaryList.orderType.priceAsc', + value: 'OrderByPriceASC', + }, + NAME_ASC: { + name: 'admin/editor.productSummaryList.orderType.nameAsc', + value: 'OrderByNameASC', + }, + NAME_DESC: { + name: 'admin/editor.productSummaryList.orderType.nameDesc', + value: 'OrderByNameDESC', + }, + RELEASE_DATE_DESC: { + name: 'admin/editor.productSummaryList.orderType.releaseDate', + value: 'OrderByReleaseDateDESC', + }, + BEST_DISCOUNT_DESC: { + name: 'admin/editor.productSummaryList.orderType.discount', + value: 'OrderByBestDiscountDESC', + }, +} +const parseFilters = ({ id, value }) => `specificationFilter_${id}:${value}` + +function getOrdinationProp(attribute) { + return Object.keys(ORDER_BY_OPTIONS).map( + key => ORDER_BY_OPTIONS[key][attribute] + ) +} + +const ProductSummaryList = ({ + children, + category = '', + collection, + hideUnavailableItems = false, + orderBy = ORDER_BY_OPTIONS.TOP_SALE_DESC.value, + specificationFilters = [], + maxItems = 10, + withFacets = false, +}) => { + const { data } = useQuery(productSearchV2, { + ssr: true, + name: 'productList', + variables: { + category, + ...(collection != null + ? { + collection, + } + : {}), + specificationFilters: specificationFilters.map(parseFilters), + orderBy, + from: 0, + to: maxItems - 1, + hideUnavailableItems, + withFacets, + }, + }) + + const { list } = useListContext() + const { treePath } = useTreePath() + + const componentList = + data.productSearch && + data.productSearch.products.map(product => { + const normalizedProduct = mapCatalogProductToProductSummary(product) + + return ( + + ) + }) + + const newListContextValue = list.concat(componentList) + + return ( + + {children} + + ) +} + +const EnhancedProductList = ({ children }) => { + const { ProductListProvider } = ProductListContext + + return ( + + {children} + + + ) +} + +EnhancedProductList.getSchema = () => ({ + title: 'admin/editor.productSummaryList.title', + description: 'admin/editor.productSummaryList.description', + type: 'object', + properties: { + category: { + title: 'admin/editor.productSummaryList.category.title', + description: 'admin/editor.productSummaryList.category.description', + type: 'string', + isLayout: false, + }, + specificationFilters: { + title: 'admin/editor.productSummaryList.specificationFilters.title', + type: 'array', + items: { + title: + 'admin/editor.productSummaryList.specificationFilters.item.title', + type: 'object', + properties: { + id: { + type: 'string', + title: + 'admin/editor.productSummaryList.specificationFilters.item.id.title', + }, + value: { + type: 'string', + title: + 'admin/editor.productSummaryList.specificationFilters.item.value.title', + }, + }, + }, + }, + collection: { + title: 'admin/editor.productSummaryList.collection.title', + type: 'number', + isLayout: false, + }, + orderBy: { + title: 'admin/editor.productSummaryList.orderBy.title', + type: 'string', + enum: getOrdinationProp('value'), + enumNames: getOrdinationProp('name'), + default: ORDER_BY_OPTIONS.TOP_SALE_DESC.value, + isLayout: false, + }, + hideUnavailableItems: { + title: 'admin/editor.productSummaryList.hideUnavailableItems', + type: 'boolean', + default: false, + isLayout: false, + }, + maxItems: { + title: 'admin/editor.productSummaryList.maxItems.title', + type: 'number', + isLayout: false, + default: 10, + }, + }, +}) + +export default EnhancedProductList diff --git a/react/__mocks__/vtex.product-list-context.js b/react/__mocks__/vtex.product-list-context.js new file mode 100644 index 00000000..c03fb313 --- /dev/null +++ b/react/__mocks__/vtex.product-list-context.js @@ -0,0 +1,8 @@ +import React, { Fragment } from 'react' + +export const useProductImpression = () => null +export const ProductListContext = { + useProductListDispatch: () => null, + // eslint-disable-next-line react/display-name + ProductListProvider: ({ children }) => {children}, +} diff --git a/react/components/ProductListEventCaller.js b/react/components/ProductListEventCaller.js new file mode 100644 index 00000000..9a18f63b --- /dev/null +++ b/react/components/ProductListEventCaller.js @@ -0,0 +1,8 @@ +import { useProductImpression } from 'vtex.product-list-context' + +const ProductListEventCaller = () => { + useProductImpression() + return null +} + +export default ProductListEventCaller diff --git a/react/components/ProductSummary.js b/react/components/ProductSummary.js index 251212e0..3616b092 100644 --- a/react/components/ProductSummary.js +++ b/react/components/ProductSummary.js @@ -2,7 +2,9 @@ import React, { useCallback, useMemo, useEffect } from 'react' import PropTypes from 'prop-types' import classNames from 'classnames' import { pathOr, path } from 'ramda' +import { useInView } from 'react-intersection-observer' import { Link } from 'vtex.render-runtime' +import { ProductListContext } from 'vtex.product-list-context' import ProductSummaryContext from './ProductSummaryContext' import { ProductSummaryProvider, @@ -17,11 +19,39 @@ import { useCssHandles } from 'vtex.css-handles' const PRODUCT_SUMMARY_MAX_WIDTH = 300 const CSS_HANDLES = ['container', 'containerNormal', 'element', 'clearLink'] -const ProductSummaryCustom = ({ product, actionOnClick, children, containerRef }) => { +const ProductSummaryCustom = ({ + product, + actionOnClick, + children, + containerRef, +}) => { const { isLoading, isHovering, selectedItem, query } = useProductSummary() const dispatch = useProductSummaryDispatch() const handles = useCssHandles(CSS_HANDLES) + /* + Use ProductListContext to send pixel events. + Beware that productListDispatch could be undefined if + this component is not wrapped by a . + In that case we don't need to send events. + */ + const { useProductListDispatch } = ProductListContext + const productListDispatch = useProductListDispatch() + const [inViewRef, inView] = useInView({ + // Triggers the event when the element is 75% visible + threshold: 0.75, + triggerOnce: true, + }) + useEffect(() => { + if (inView) { + productListDispatch && + productListDispatch({ + type: 'SEND_IMPRESSION', + args: { product: product }, + }) + } + }, [productListDispatch, inView, product]) + useEffect(() => { if (product) { dispatch({ @@ -76,10 +106,7 @@ const ProductSummaryCustom = ({ product, actionOnClick, children, containerRef } 'pointer pt3 pb4 flex flex-column h-100' ) - const linkClasses = classNames( - handles.clearLink, - 'h-100 flex flex-column' - ) + const linkClasses = classNames(handles.clearLink, 'h-100 flex flex-column') const skuId = pathOr( path(['sku', 'itemId'], product), @@ -95,7 +122,8 @@ const ProductSummaryCustom = ({ product, actionOnClick, children, containerRef } onMouseEnter={handleMouseEnter} onMouseLeave={handleMouseLeave} style={{ maxWidth: PRODUCT_SUMMARY_MAX_WIDTH }} - ref={containerRef} + // If containerRef is passed, it should be used + ref={containerRef || inViewRef} >