diff --git a/CHANGELOG.md b/CHANGELOG.md index b37072506..a136e1d58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added +- `pageInfo` event for custom search pages. + ## [3.122.6] - 2023-06-26 ### Changed diff --git a/react/components/SearchResultCustomQueryWrapper.tsx b/react/components/SearchResultCustomQueryWrapper.tsx index ef3001ef2..e51399562 100644 --- a/react/components/SearchResultCustomQueryWrapper.tsx +++ b/react/components/SearchResultCustomQueryWrapper.tsx @@ -1,8 +1,19 @@ -import React, { Fragment } from 'react' +import React, { Fragment, useMemo } from 'react' import type { RuntimeWithRoute } from 'vtex.render-runtime' -import { useRuntime, Helmet } from 'vtex.render-runtime' +import { canUseDOM, useRuntime, Helmet } from 'vtex.render-runtime' import queryString from 'query-string' +import useDataPixel from '../hooks/useDataPixel' +import { usePageView } from '../hooks/usePageView' +import type { SearchQuery } from '../utils/searchMetadata' +import { + getCategoryMetadata, + getDepartmentMetadata, + getPageEventName, + getSearchMetadata, + getTitleTag, +} from '../utils/searchMetadata' + interface GetHelmetLinkParams { canonicalLink: string | undefined page: number @@ -96,30 +107,121 @@ function isNotLastPage({ products, to, recordsFiltered }: IsNotLastPageParams) { return to + 1 < recordsFiltered } +const getSearchIdentifier = ( + searchQuery: SearchQuery, + orderBy?: string, + page?: string +) => { + const { variables } = searchQuery + + if (!variables) { + return + } + + const { query, map } = variables + + return query + map + (orderBy ?? '') + (page ?? '') +} + const SearchResultCustomQueryWrapper = (props: any) => { const { localSearchQueryData, children } = props const { maxItemsPerPage, + searchQuery, searchQuery: { data: { + searchMetadata: { titleTag = '' } = {}, productSearch: { products: currProducts = undefined, recordsFiltered: currRecords = undefined, } = {}, - facets: { recordsFiltered: legacyRecords }, + facets: { recordsFiltered: legacyRecords, queryArgs }, products: legacyProducts, }, + variables: { fullText }, }, + orderBy, + facetsLoading, } = localSearchQueryData + const loading = searchQuery ? searchQuery.loading : undefined const products = currProducts ?? legacyProducts const recordsFiltered = currRecords ?? legacyRecords const { - query: { page: pageFromQuery = 1 }, + getSettings, + account, + query: { page: pageFromQuery = '1' }, + route: { title: pageTitle }, } = useRuntime() as RuntimeWithRoute + const settings = getSettings('vtex.store') || {} + const { + titleTag: defaultStoreTitle, + storeName, + enablePageNumberTitle = false, + removeStoreNameTitle = false, + } = settings + + const title = getTitleTag({ + titleTag, + storeTitle: storeName || defaultStoreTitle, + term: fullText, + pageTitle, + pageNumber: enablePageNumberTitle ? Number(pageFromQuery) : 0, + removeStoreNameTitle, + }) + + const pixelEvents = useMemo(() => { + if (!canUseDOM || !currProducts || !queryArgs || facetsLoading) { + return null + } + + const event = getPageEventName( + currProducts, + localSearchQueryData.searchQuery.variables + ) + + const pageInfoEvent = { + event: 'pageInfo', + eventType: event, + accountName: account, + pageUrl: window.location.href, + orderBy, + page: pageFromQuery, + category: searchQuery?.data + ? getCategoryMetadata(searchQuery.data) + : null, + department: searchQuery?.data + ? getDepartmentMetadata(searchQuery.data) + : null, + search: searchQuery?.data ? getSearchMetadata(searchQuery.data) : null, + } + + return [ + pageInfoEvent, + { + event, + products: currProducts, + }, + ] + }, [ + currProducts, + queryArgs, + facetsLoading, + localSearchQueryData.searchQuery.variables, + account, + orderBy, + pageFromQuery, + searchQuery.data, + ]) + + const pixelCacheKey = getSearchIdentifier(searchQuery, orderBy, pageFromQuery) + + usePageView({ title, cacheKey: pixelCacheKey }) + useDataPixel(pixelEvents, pixelCacheKey, loading) + const canonicalLink = useCanonicalLink() const pageNumber = Number(pageFromQuery) diff --git a/react/hooks/useDataPixel.ts b/react/hooks/useDataPixel.ts new file mode 100644 index 000000000..1470b2577 --- /dev/null +++ b/react/hooks/useDataPixel.ts @@ -0,0 +1,33 @@ +/* eslint-disable no-restricted-imports */ +import { useEffect, useRef } from 'react' +import { usePixel } from 'vtex.pixel-manager/PixelContext' +import { isEmpty } from 'ramda' + +type Data = unknown[] | unknown + +const useDataPixel = (data: Data, pageIdentifier = '', isLoading = false) => { + const { push } = usePixel() + const previousIdRef = useRef(null) + + const previousId = previousIdRef.current + + useEffect(() => { + if (!pageIdentifier || isLoading || previousId === pageIdentifier) { + return + } + + if (!data || isEmpty(data)) { + return + } + + if (Array.isArray(data)) { + data.forEach(push) + } else { + push(data) + } + + previousIdRef.current = pageIdentifier + }, [data, isLoading, pageIdentifier, previousId, push]) +} + +export default useDataPixel diff --git a/react/hooks/usePageView.ts b/react/hooks/usePageView.ts new file mode 100644 index 000000000..04828db6f --- /dev/null +++ b/react/hooks/usePageView.ts @@ -0,0 +1,41 @@ +/* eslint-disable no-restricted-globals */ +import { useMemo } from 'react' +import { useRuntime, canUseDOM } from 'vtex.render-runtime' + +import useDataPixel from './useDataPixel' + +interface UsePageViewArgs { + title?: string + cacheKey?: string + skip?: boolean +} + +export const usePageView = ({ + title, + cacheKey, + skip, +}: UsePageViewArgs = {}) => { + const { route, account } = useRuntime() + const pixelCacheKey = cacheKey ?? route.routeId + + const eventData = useMemo(() => { + if (!canUseDOM || skip) { + return null + } + + return { + event: 'pageView', + pageTitle: title ?? document.title, + pageUrl: location.href, + referrer: + document.referrer.indexOf(location.origin) === 0 + ? undefined + : document.referrer, + accountName: account, + routeId: route?.routeId ? route.routeId : '', + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [account, title, canUseDOM, pixelCacheKey]) + + useDataPixel(skip ? null : eventData, pixelCacheKey) +} diff --git a/react/typings/vtex.pixel-manager.d.ts b/react/typings/vtex.pixel-manager.d.ts new file mode 100644 index 000000000..f90797138 --- /dev/null +++ b/react/typings/vtex.pixel-manager.d.ts @@ -0,0 +1,7 @@ +declare module 'vtex.pixel-manager/PixelContext' { + interface Pixel { + push(data: unknown): void + } + + export function usePixel(): Pixel +} diff --git a/react/typings/vtex.render-runtime.d.ts b/react/typings/vtex.render-runtime.d.ts index a15e6e53f..2936fd858 100644 --- a/react/typings/vtex.render-runtime.d.ts +++ b/react/typings/vtex.render-runtime.d.ts @@ -21,4 +21,5 @@ declare module 'vtex.render-runtime' { export function useRuntime(): Runtime export const Helmet + export const canUseDOM: boolean } diff --git a/react/utils/searchMetadata.ts b/react/utils/searchMetadata.ts new file mode 100644 index 000000000..89585f6d1 --- /dev/null +++ b/react/utils/searchMetadata.ts @@ -0,0 +1,298 @@ +import { zipObj } from 'ramda' + +interface SpecificationFilter { + facets?: any[] + hidden: boolean + name: string + quantity: number + type: string +} + +interface CategoriesTrees { + id: string + name: string + selected: boolean + children?: CategoriesTrees[] +} + +interface QueryArgs { + query: string + map: string +} + +interface Facets { + categoriesTrees?: CategoriesTrees[] + queryArgs?: QueryArgs + specificationFilters?: SpecificationFilter[] +} + +interface Breadcrumb { + name: string + href: string +} + +interface SearchQueryData { + searchMetadata?: { + titleTag: string + metaTagDescription: string + } + productSearch?: { + breadcrumb: Breadcrumb[] + recordsFiltered: number + operator?: string + searchState?: string + correction?: { + misspelled: boolean + } + } + facets?: Facets +} + +export interface SearchQuery { + loading: boolean + products: any + data?: SearchQueryData + variables: { + query: string + map: string + } +} + +interface GetTitleTagParams { + titleTag: string + storeTitle: string + term?: string + pageTitle?: string + pageNumber?: number + removeStoreNameTitle?: boolean +} + +type PageEventName = + | 'internalSiteSearchView' + | 'categoryView' + | 'departmentView' + | 'emptySearchView' + +const mapEvent = { + InternalSiteSearch: 'internalSiteSearchView', + Category: 'categoryView', + Department: 'departmentView', + EmptySearch: 'emptySearchView', +} + +const fallbackView = 'otherView' + +interface Variables { + fullText?: string + category?: any +} + +const pageCategory = (products: unknown[], variables: Variables) => { + if (!products || products.length === 0) { + return 'EmptySearch' + } + + const { category, fullText } = variables + + return fullText ? 'InternalSiteSearch' : category ? 'Category' : 'Department' +} + +export const getPageEventName = ( + products: unknown[], + variables: Variables +): PageEventName => { + if (!products) { + return fallbackView as PageEventName + } + + const category = pageCategory(products, variables) + + return (mapEvent[category] || fallbackView) as PageEventName +} + +const getDecodeURIComponent = (encodedURIComponent: string) => { + try { + return decodeURIComponent(encodedURIComponent) + } catch { + return encodedURIComponent + } +} + +const capitalize = (str?: string) => { + return str && str.charAt(0).toUpperCase() + str.slice(1) +} + +export const getTitleTag = ({ + titleTag, + storeTitle, + term, + pageTitle, + pageNumber = 0, + removeStoreNameTitle, +}: GetTitleTagParams) => { + /* + titleNumber and storeTitleFormatted depend on the value of enablePageNumberTitle and removeStoreNameTitle params. + by default, the value of enablePageNumberTitle and removeStoreNameTitle is false, only if the value of these + parameters is true, it will affect the value of titleNumber or storeTitleFormatted. + */ + const titleNumber = pageNumber > 0 ? ` #${pageNumber}` : '' + const storeTitleFormatted = removeStoreNameTitle ? '' : ` - ${storeTitle}` + + if (titleTag) { + return `${getDecodeURIComponent( + titleTag + )}${titleNumber}${storeTitleFormatted}` + } + + if (pageTitle) { + return `${getDecodeURIComponent( + pageTitle + )}${titleNumber}${storeTitleFormatted}` + } + + if (term) { + return `${capitalize( + getDecodeURIComponent(term) + )}${titleNumber}${storeTitleFormatted}` + } + + return storeTitle +} + +const getDepartmentFromSpecificationFilters = (facets?: Facets) => { + if (!facets?.queryArgs?.map.split(',').includes('c')) { + return + } + + const departmentFilter = facets.specificationFilters?.find(specFilter => { + return specFilter.facets?.[0].key === 'category-1' + }) + + return departmentFilter?.facets?.find(facet => facet.selected) +} + +const getDepartment = (searchQuery: SearchQueryData) => { + if (searchQuery.facets?.categoriesTrees?.length) { + return searchQuery.facets.categoriesTrees.find( + department => department.selected + ) + } + + return getDepartmentFromSpecificationFilters(searchQuery.facets) +} + +export const getDepartmentMetadata = (searchQuery?: SearchQueryData) => { + if ( + !searchQuery || + !searchQuery.facets || + !searchQuery.facets.categoriesTrees + ) { + return + } + + const department = getDepartment(searchQuery) + + if (!department) { + return + } + + return { + id: department.id, + name: department.name, + } +} + +const getCategoryFromSpecificationFilters = (facets?: Facets) => { + const totalCategories = + facets?.queryArgs?.map.split(',').filter((key: string) => key === 'c') + .length ?? 0 + + if (totalCategories <= 1) { + return + } + + const categoryFilter = facets?.specificationFilters?.find(specFilter => { + return specFilter.facets?.[0].key === 'category-2' + }) + + return categoryFilter?.facets?.find(facet => facet.selected) +} + +const getLastCategory = ( + category: CategoriesTrees, + facets?: Facets +): CategoriesTrees => { + const selectedCategory = + category.children && + category.children.length > 0 && + category.children.find(currCategory => currCategory.selected) + + if (!selectedCategory) { + return getCategoryFromSpecificationFilters(facets) ?? category + } + + return getLastCategory(selectedCategory) +} + +export const getCategoryMetadata = (searchQuery?: SearchQueryData) => { + if ( + !searchQuery || + !searchQuery.facets || + !searchQuery.facets.categoriesTrees + ) { + return + } + + const department = getDepartment(searchQuery) + + if (!department) { + return + } + + const category = getLastCategory(department, searchQuery.facets) + + if (category === department) { + return + } + + return { + id: category.id, + name: category.name, + } +} + +export const getSearchMetadata = (searchQuery?: SearchQueryData) => { + if ( + !searchQuery || + !searchQuery.productSearch || + !searchQuery.facets || + !searchQuery.facets.queryArgs + ) { + return + } + + const { query, map } = searchQuery.facets.queryArgs + const queryMap = zipObj(map.split(','), query.split('/')) + + const searchTerm = queryMap.ft + + let decodedTerm = '' + + // This try/catch works to prevent decoding search terms that end in "%". + try { + decodedTerm = decodeURIComponent(searchTerm || '') + } catch (e) { + decodedTerm = decodeURIComponent(encodeURIComponent(searchTerm || '')) + } + + const department = getDepartment(searchQuery) + + return { + term: decodedTerm || undefined, + category: department ? { id: department.id, name: department.name } : null, + results: searchQuery.productSearch.recordsFiltered, + operator: searchQuery.productSearch.operator, + searchState: searchQuery.productSearch.searchState, + correction: searchQuery.productSearch.correction, + } +}