From ce66b55bc5d5e982a54b8fa1cd94a5b52194bd5b Mon Sep 17 00:00:00 2001 From: Boro Sitnikovski Date: Wed, 18 Sep 2024 14:14:30 +0200 Subject: [PATCH] In app search improvements feature branch (#51413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add search results count to the in-app marketplace (#51266) * Add searchResults to context * Use setSearchResults in Content * Add ribbons to the tabs * Changelog * Use setState as the function name * Only show ribbon counts when there's an active search * Refactor how 'setSearchResultsCount' is used (h/t @mcliwanow) * Don't populate initial search results * Unify css styling * Marketplace: bring back the loading state (#51342) * Marketplace: bring back the loading state * Add changefile(s) from automation for the following project(s): woocommerce --------- Co-authored-by: github-actions * Remove in-app marketplace Search results tab and unify results into existing tabs (#51297) * Remove search results component and any references to it * Persist current tab for searching, or default to extensions if tab is not set * Persist term when switching across tabs * Lint * When a search is initiated, fetch all categories to keep the tab counts up to date. The necessary filtering to display data to the current screen will be performed on the frontend. * Apply correct colors to the tabs, as per design * Beyond query.term, also rely on isLoading so that search result counts don't jump * Address an issue when the user searches for something that returns no results in the business services tab * Changelog * Addressed :) * Change key to category * Fix category filter being broken Whenever a category is requested, we need to do an additional request with the category param being the current category (overriding extensions/theme/business services). Ideally the backend API would make a distinction between type (theme/extension/business service) and category, but this hack should do for now. * Lint * Remove unused variables h/t @KokkieH * Lint * Revert "Lint" This reverts commit 0b2d2dca6de5087b3b0ae5599f8bb0114cebc4ab. * Actually fix lint without introducing infinite loop Reproducible at http://localhost:8080/wp-admin/admin.php?page=wc-admin&term=payments&tab=extensions&path=%2Fextensions&category=customer-service * Show category selector even if there are no results displayed * Update comment to be less misleading * Query isn't used here * Update Marketplace search component (#51313) * Update Search placeholder text * Replace search component with one from @wordpress/components * Make mobile search field font consistent with desktop * Add changefile(s) from automation for the following project(s): woocommerce * Handle import errors for SearchControl component --------- Co-authored-by: github-actions * Marketplace: update category selector (#51309) * Marketplace: update category selector Remove the dropdown on the desktop view and show all items, even if overflowing. Added helper buttons to scroll to the right to show more. * Add changefile(s) from automation for the following project(s): woocommerce * Marketplace: remove category sroll helpers from tabindex GitHub: https://github.com/woocommerce/woocommerce/pull/51309/files#r1758448638 * Marketplace: Remove selectedTab reference from product.tsx This is probably included due to the merge conflict * Marketplace: tweak category scroll button narrower --------- Co-authored-by: github-actions * Lint * Fix 2 lint errors * Fix another lint error (useMemo) h/t @KokkieH * Add load more button in-app (#51434) * Add additional fields returned by search API to marketplace types Ensure components have access to additional fields * Add LoadMoreButton component * Only render Load More button if there are additional pages of results * Fetch and display next page of results in Load More button is clicked * Simplify renderContent function to have less repetition - Hide load more button while fetching results * Improve loading of new products - Ensure keyboard focus goes to first new product after Load More is clicked * Add changefile(s) from automation for the following project(s): woocommerce * Add blank line to separate sections * Set category param based on current tab when loading more products * Improve busy-state screen reader text Co-authored-by: Boro Sitnikovski * Add missing dependency * Move getProductType() function to functions.tsx - Do not show load more button if isLoading state is true --------- Co-authored-by: github-actions Co-authored-by: Boro Sitnikovski * Rework the values used with `setSearchResultsCount` After https://github.com/Automattic/woocommerce.com/pull/21678/files we get a `totalProducts` so we can re-use that. Also remove setting the counts when paginating since we set them to the total. * Add search complete announcement h/t @KokkieH * Show update count only if greater than 0 h/t @andfinally * Switch to Extensions tab if on My subscriptions when searching * yoda --------- Co-authored-by: Cem Ünalan Co-authored-by: github-actions Co-authored-by: Herman --- .../category-selector/category-selector.scss | 26 +- .../category-selector/category-selector.tsx | 232 ++++++--- .../marketplace/components/constants.ts | 2 +- .../components/content/content.tsx | 443 +++++++++++++----- .../marketplace/components/header/header.scss | 15 +- .../load-more-button/load-more-button.tsx | 37 ++ .../components/product-card/product-card.tsx | 2 + .../product-list-content/no-results.tsx | 7 - .../components/product-list/types.ts | 2 + .../components/products/products.scss | 19 +- .../components/products/products.tsx | 71 +-- .../search-results/search-results.scss | 19 - .../search-results/search-results.tsx | 193 -------- .../marketplace/components/search/search.scss | 28 +- .../marketplace/components/search/search.tsx | 83 +--- .../marketplace/components/tabs/tabs.scss | 12 + .../marketplace/components/tabs/tabs.tsx | 130 ++--- .../contexts/marketplace-context.tsx | 33 +- .../client/marketplace/contexts/types.ts | 10 + .../client/marketplace/utils/functions.tsx | 25 +- .../client/marketplace/utils/tracking.ts | 10 - ...1309-update-21591-in-app-category-selector | 4 + ...update-wccom-21595-in-app-search-component | 4 + .../51342-fix-21596-search-loading-state | 4 + ...34-add-wccom-21568-in-app-load-more-button | 4 + .../changelog/refactor-wccom-21576-search-tab | 4 + .../tweak-21597-in-app-search-results-count | 4 + 27 files changed, 807 insertions(+), 616 deletions(-) create mode 100644 plugins/woocommerce-admin/client/marketplace/components/load-more-button/load-more-button.tsx delete mode 100644 plugins/woocommerce-admin/client/marketplace/components/search-results/search-results.scss delete mode 100644 plugins/woocommerce-admin/client/marketplace/components/search-results/search-results.tsx create mode 100644 plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector create mode 100644 plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component create mode 100644 plugins/woocommerce/changelog/51342-fix-21596-search-loading-state create mode 100644 plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button create mode 100644 plugins/woocommerce/changelog/refactor-wccom-21576-search-tab create mode 100644 plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss index d713efc1a80..952780f05e2 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.scss @@ -1,13 +1,17 @@ @import "../../stylesheets/_variables.scss"; .woocommerce-marketplace__category-selector { + position: relative; display: flex; align-items: stretch; - margin: $grid-unit-20 0 0 0; + margin: 0; + overflow-x: auto; } .woocommerce-marketplace__category-item { cursor: pointer; + white-space: nowrap; + margin-bottom: 0; .components-dropdown { height: 100%; @@ -50,7 +54,6 @@ .woocommerce-marketplace__category-selector--full-width { display: none; - margin-top: $grid-unit-15; } @media screen and (max-width: $break-medium) { @@ -122,3 +125,22 @@ background-color: $gray-900; } } + +.woocommerce-marketplace__category-navigation-button { + border: none; + position: absolute; + top: 0; + bottom: 0; + height: 100%; + width: 50px; +} + +.woocommerce-marketplace__category-navigation-button--prev { + background: linear-gradient(to right, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); + left: 0; +} + +.woocommerce-marketplace__category-navigation-button--next { + background: linear-gradient(to left, rgba(255, 255, 255, 1), rgba(255, 255, 255, 0)); + right: 0; +} diff --git a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx index 07b298cde66..9625aef5862 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/category-selector/category-selector.tsx @@ -1,20 +1,21 @@ /** * External dependencies */ -import { useState, useEffect } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { useState, useEffect, useRef } from '@wordpress/element'; import { useQuery } from '@woocommerce/navigation'; -import clsx from 'clsx'; +import { Icon } from '@wordpress/components'; +import { useDebounce } from '@wordpress/compose'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies */ import CategoryLink from './category-link'; -import CategoryDropdown from './category-dropdown'; import { Category, CategoryAPIItem } from './types'; import { fetchCategories } from '../../utils/functions'; -import './category-selector.scss'; import { ProductType } from '../product-list/types'; +import CategoryDropdown from './category-dropdown'; +import './category-selector.scss'; const ALL_CATEGORIES_SLUGS = { [ ProductType.extension ]: '_all', @@ -29,32 +30,21 @@ interface CategorySelectorProps { export default function CategorySelector( props: CategorySelectorProps ): JSX.Element { - const [ visibleItems, setVisibleItems ] = useState< Category[] >( [] ); - const [ dropdownItems, setDropdownItems ] = useState< Category[] >( [] ); const [ selected, setSelected ] = useState< Category >(); const [ isLoading, setIsLoading ] = useState( false ); + const [ categoriesToShow, setCategoriesToShow ] = useState< Category[] >( + [] + ); + const [ isOverflowing, setIsOverflowing ] = useState( false ); + const [ scrollPosition, setScrollPosition ] = useState< + 'start' | 'middle' | 'end' + >( 'start' ); + + const categorySelectorRef = useRef< HTMLUListElement >( null ); + const selectedCategoryRef = useRef< HTMLLIElement >( null ); const query = useQuery(); - useEffect( () => { - // If no category is selected, show All as selected - let categoryToSearch = ALL_CATEGORIES_SLUGS[ props.type ]; - - if ( query.category ) { - categoryToSearch = query.category; - } - - const allCategories = visibleItems.concat( dropdownItems ); - - const selectedCategory = allCategories.find( - ( category ) => category.slug === categoryToSearch - ); - - if ( selectedCategory ) { - setSelected( selectedCategory ); - } - }, [ query.category, props.type, visibleItems, dropdownItems ] ); - useEffect( () => { setIsLoading( true ); @@ -72,21 +62,125 @@ export default function CategorySelector( return category.slug !== '_featured'; } ); - // Split array into two from 7th item - const visibleCategoryItems = categories.slice( 0, 7 ); - const dropdownCategoryItems = categories.slice( 7 ); - - setVisibleItems( visibleCategoryItems ); - setDropdownItems( dropdownCategoryItems ); + setCategoriesToShow( categories ); } ) .catch( () => { - setVisibleItems( [] ); - setDropdownItems( [] ); + setCategoriesToShow( [] ); } ) .finally( () => { setIsLoading( false ); } ); - }, [ props.type ] ); + }, [ props.type, setCategoriesToShow ] ); + + useEffect( () => { + // If no category is selected, show All as selected + let categoryToSearch = ALL_CATEGORIES_SLUGS[ props.type ]; + + if ( query.category ) { + categoryToSearch = query.category; + } + + const selectedCategory = categoriesToShow.find( + ( category ) => category.slug === categoryToSearch + ); + + if ( selectedCategory ) { + setSelected( selectedCategory ); + } + }, [ query.category, props.type, categoriesToShow ] ); + + useEffect( () => { + if ( selectedCategoryRef.current ) { + selectedCategoryRef.current.scrollIntoView( { + block: 'nearest', + inline: 'center', + } ); + } + }, [ selected ] ); + + function checkOverflow() { + if ( + categorySelectorRef.current && + categorySelectorRef.current.parentElement?.scrollWidth + ) { + const isContentOverflowing = + categorySelectorRef.current.scrollWidth > + categorySelectorRef.current.parentElement.scrollWidth; + + setIsOverflowing( isContentOverflowing ); + } + } + + function checkScrollPosition() { + const ulElement = categorySelectorRef.current; + + if ( ! ulElement ) { + return; + } + + const { scrollLeft, scrollWidth, clientWidth } = ulElement; + + if ( scrollLeft < 10 ) { + setScrollPosition( 'start' ); + + return; + } + + if ( scrollLeft + clientWidth < scrollWidth ) { + setScrollPosition( 'middle' ); + + return; + } + + if ( scrollLeft + clientWidth === scrollWidth ) { + setScrollPosition( 'end' ); + } + } + + const debouncedCheckOverflow = useDebounce( checkOverflow, 300 ); + const debouncedScrollPosition = useDebounce( checkScrollPosition, 100 ); + + function scrollCategories( scrollAmount: number ) { + if ( categorySelectorRef.current ) { + categorySelectorRef.current.scrollTo( { + left: categorySelectorRef.current.scrollLeft + scrollAmount, + behavior: 'smooth', + } ); + } + } + + function scrollToNextCategories() { + scrollCategories( 200 ); + } + + function scrollToPrevCategories() { + scrollCategories( -200 ); + } + + useEffect( () => { + window.addEventListener( 'resize', debouncedCheckOverflow ); + + const ulElement = categorySelectorRef.current; + + if ( ulElement ) { + ulElement.addEventListener( 'scroll', debouncedScrollPosition ); + } + + return () => { + window.removeEventListener( 'resize', debouncedCheckOverflow ); + + if ( ulElement ) { + ulElement.removeEventListener( + 'scroll', + debouncedScrollPosition + ); + } + }; + }, [ debouncedCheckOverflow, debouncedScrollPosition ] ); + + useEffect( () => { + checkOverflow(); + }, [ categoriesToShow ] ); function mobileCategoryDropdownLabel() { const allCategoriesText = __( 'All Categories', 'woocommerce' ); @@ -102,16 +196,6 @@ export default function CategorySelector( return selected.label; } - function isSelectedInDropdown() { - if ( ! selected ) { - return false; - } - - return dropdownItems.find( - ( category ) => category.slug === selected.slug - ); - } - if ( isLoading ) { return ( <> @@ -131,50 +215,62 @@ export default function CategorySelector( return ( <> -
    - { visibleItems.map( ( category ) => ( +
      + { categoriesToShow.map( ( category ) => (
    • ) ) } -
    • - { dropdownItems.length > 0 && ( - - ) } -
    -
    + { isOverflowing && ( + <> + + + + ) } ); } diff --git a/plugins/woocommerce-admin/client/marketplace/components/constants.ts b/plugins/woocommerce-admin/client/marketplace/components/constants.ts index de76eded4e2..d9b7febe49a 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/constants.ts +++ b/plugins/woocommerce-admin/client/marketplace/components/constants.ts @@ -10,7 +10,7 @@ export const MARKETPLACE_SEARCH_API_PATH = '/wp-json/wccom-extensions/1.0/search'; export const MARKETPLACE_CATEGORY_API_PATH = '/wp-json/wccom-extensions/1.0/categories'; -export const MARKETPLACE_ITEMS_PER_PAGE = 60; +export const MARKETPLACE_ITEMS_PER_PAGE = 60; // This should match the number of results returned by the API export const MARKETPLACE_SEARCH_RESULTS_PER_PAGE = 8; export const MARKETPLACE_CART_PATH = MARKETPLACE_HOST + '/cart/'; export const MARKETPLACE_RENEW_SUBSCRIPTON_PATH = diff --git a/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx b/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx index 1a4679dc4c1..06f1a17825b 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx @@ -1,22 +1,29 @@ /** * External dependencies */ -import { useContext, useEffect, useState } from '@wordpress/element'; +import { + useContext, + useEffect, + useState, + useCallback, +} from '@wordpress/element'; import { useQuery } from '@woocommerce/navigation'; +import { speak } from '@wordpress/a11y'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ import './content.scss'; -import { Product, ProductType, SearchResultType } from '../product-list/types'; +import { Product, ProductType } from '../product-list/types'; import { getAdminSetting } from '~/utils/admin-settings'; import Discover from '../discover/discover'; import Products from '../products/products'; -import SearchResults from '../search-results/search-results'; import MySubscriptions from '../my-subscriptions/my-subscriptions'; import { MarketplaceContext } from '../../contexts/marketplace-context'; -import { fetchSearchResults } from '../../utils/functions'; +import { fetchSearchResults, getProductType } from '../../utils/functions'; import { SubscriptionsContextProvider } from '../../contexts/subscriptions-context'; +import { SearchResultsCountType } from '../../contexts/types'; import { recordMarketplaceView, recordLegacyTabView, @@ -26,149 +33,350 @@ import Promotions from '../promotions/promotions'; import ConnectNotice from '~/marketplace/components/connect-notice/connect-notice'; import PluginInstallNotice from '../woo-update-manager-plugin/plugin-install-notice'; import SubscriptionsExpiredExpiringNotice from '~/marketplace/components/my-subscriptions/subscriptions-expired-expiring-notice'; +import LoadMoreButton from '../load-more-button/load-more-button'; export default function Content(): JSX.Element { const marketplaceContextValue = useContext( MarketplaceContext ); - const [ products, setProducts ] = useState< Product[] >( [] ); - const { setIsLoading, selectedTab, setHasBusinessServices } = - marketplaceContextValue; + const [ allProducts, setAllProducts ] = useState< Product[] >( [] ); + const [ filteredProducts, setFilteredProducts ] = useState< Product[] >( + [] + ); + const [ currentPage, setCurrentPage ] = useState( 1 ); + const [ totalPagesCategory, setTotalPagesCategory ] = useState( 1 ); + const [ totalPagesExtensions, setTotalPagesExtensions ] = useState( 1 ); + const [ totalPagesThemes, setTotalPagesThemes ] = useState( 1 ); + const [ totalPagesBusinessServices, setTotalPagesBusinessServices ] = + useState( 1 ); + const [ firstNewProductId, setFirstNewProductId ] = useState< number >( 0 ); + const [ isLoadingMore, setIsLoadingMore ] = useState( false ); + + const { + isLoading, + setIsLoading, + selectedTab, + setHasBusinessServices, + setSearchResultsCount, + } = marketplaceContextValue; const query = useQuery(); - // On initial load of the in-app marketplace, fetch extensions, themes and business services - // and check if there are any business services available on WCCOM - useEffect( () => { - const categories = [ '', 'themes', 'business-services' ]; - const abortControllers = categories.map( () => new AbortController() ); + const searchCompleteAnnouncement = ( count: number ): void => { + speak( + sprintf( + // translators: %d is the number of products found. + __( '%d products found', 'woocommerce' ), + count + ) + ); + }; - categories.forEach( ( category: string, index ) => { - const params = new URLSearchParams(); - if ( category !== '' ) { - params.append( 'category', category ); - } + const tagProductsWithType = ( + products: Product[], + type: ProductType + ): Product[] => { + return products.map( ( product ) => ( { + ...product, + type, + } ) ); + }; - const wccomSettings = getAdminSetting( 'wccomHelper', false ); - if ( wccomSettings.storeCountry ) { - params.append( 'country', wccomSettings.storeCountry ); - } - - fetchSearchResults( params, abortControllers[ index ].signal ).then( - ( productList ) => { - if ( category === 'business-services' ) { - setHasBusinessServices( productList.length > 0 ); - } - } - ); - return () => { - abortControllers.forEach( ( controller ) => { - controller.abort(); - } ); - }; - } ); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [] ); - - // Get the content for this screen - useEffect( () => { + const loadMoreProducts = useCallback( () => { + setIsLoadingMore( true ); + const params = new URLSearchParams(); const abortController = new AbortController(); - if ( - query.tab === undefined || - ( query.tab && - [ '', 'discover', 'my-subscriptions' ].includes( query.tab ) ) - ) { - return; + if ( query.category && query.category !== '_all' ) { + params.append( 'category', query.category ); } - setIsLoading( true ); - setProducts( [] ); - - const params = new URLSearchParams(); + if ( query.tab === 'themes' || query.tab === 'business-services' ) { + params.append( 'category', query.tab ); + } if ( query.term ) { params.append( 'term', query.term ); } - if ( query.category ) { - params.append( - 'category', - query.category === '_all' ? '' : query.category - ); - } else if ( query?.tab === 'themes' ) { - params.append( 'category', 'themes' ); - } else if ( query?.tab === 'business-services' ) { - params.append( 'category', 'business-services' ); - } else if ( query?.tab === 'search' ) { - params.append( 'category', 'extensions-themes-business-services' ); - } - const wccomSettings = getAdminSetting( 'wccomHelper', false ); if ( wccomSettings.storeCountry ) { params.append( 'country', wccomSettings.storeCountry ); } + params.append( 'page', ( currentPage + 1 ).toString() ); + fetchSearchResults( params, abortController.signal ) .then( ( productList ) => { - setProducts( productList ); + setAllProducts( ( prevProducts ) => { + const flattenedPrevProducts = Array.isArray( + prevProducts[ 0 ] + ) + ? prevProducts.flat() + : prevProducts; + + const newProducts = productList.products.filter( + ( newProduct ) => + ! flattenedPrevProducts.some( + ( prevProduct ) => + prevProduct.id === newProduct.id + ) + ); + + if ( newProducts.length > 0 ) { + setFirstNewProductId( newProducts[ 0 ].id ?? 0 ); + } + + const combinedProducts = [ + ...flattenedPrevProducts, + ...newProducts, + ]; + + return combinedProducts; + } ); + + speak( __( 'More products loaded', 'woocommerce' ) ); + setCurrentPage( ( prevPage ) => prevPage + 1 ); + setIsLoadingMore( false ); } ) .catch( () => { - setProducts( [] ); + speak( __( 'Error loading more products', 'woocommerce' ) ); } ) .finally( () => { - // we are recording both the new and legacy events here for now - // they're separate methods to make it easier to remove the legacy one later - const marketplaceViewProps = { - view: query?.tab, - search_term: query?.term, - product_type: query?.section, - category: query?.category, - }; - - recordMarketplaceView( marketplaceViewProps ); - recordLegacyTabView( marketplaceViewProps ); - setIsLoading( false ); + setIsLoadingMore( false ); } ); + return () => { abortController.abort(); }; }, [ + currentPage, + query.category, + query.term, + query.tab, + setIsLoadingMore, + ] ); + + useEffect( () => { + // if it's a paginated request, don't use this effect + if ( currentPage > 1 ) { + return; + } + + const categories: Array< { + category: keyof SearchResultsCountType; + type: ProductType; + } > = [ + { category: 'extensions', type: ProductType.extension }, + { category: 'themes', type: ProductType.theme }, + { + category: 'business-services', + type: ProductType.businessService, + }, + ]; + const abortControllers = categories.map( () => new AbortController() ); + + setIsLoading( true ); + setAllProducts( [] ); + + // If query.category is present and not '_all', only fetch that category + if ( query.category && query.category !== '_all' ) { + const params = new URLSearchParams(); + + params.append( 'category', query.category ); + + if ( query.term ) { + params.append( 'term', query.term ); + } + + const wccomSettings = getAdminSetting( 'wccomHelper', false ); + if ( wccomSettings.storeCountry ) { + params.append( 'country', wccomSettings.storeCountry ); + } + + fetchSearchResults( params, abortControllers[ 0 ].signal ) + .then( ( productList ) => { + setAllProducts( productList.products ); + setTotalPagesCategory( productList.totalPages ); + setSearchResultsCount( { + [ query.tab ]: productList.totalProducts, + } ); + + searchCompleteAnnouncement( productList.totalProducts ); + } ) + .catch( () => { + setAllProducts( [] ); + } ) + .finally( () => { + setIsLoading( false ); + } ); + } else { + // Fetch all tabs when query.term or query.category changes + Promise.all( + categories.map( ( { category, type }, index ) => { + const params = new URLSearchParams(); + if ( category !== 'extensions' ) { + params.append( 'category', category ); + } + if ( query.term ) { + params.append( 'term', query.term ); + } + + const wccomSettings = getAdminSetting( + 'wccomHelper', + false + ); + if ( wccomSettings.storeCountry ) { + params.append( 'country', wccomSettings.storeCountry ); + } + + return fetchSearchResults( + params, + abortControllers[ index ].signal + ).then( ( productList ) => { + const typedProducts = tagProductsWithType( + productList.products, + type + ); + if ( category === 'business-services' ) { + setHasBusinessServices( typedProducts.length > 0 ); + } + return { + products: typedProducts, + totalPages: productList.totalPages, + totalProducts: productList.totalProducts, + type, + }; + } ); + } ) + ) + .then( ( results ) => { + const combinedProducts = results.flatMap( + ( result ) => result.products + ); + + setAllProducts( combinedProducts ); + + setSearchResultsCount( { + extensions: results.find( + ( i ) => i.type === 'extension' + )?.totalProducts, + themes: results.find( ( i ) => i.type === 'theme' ) + ?.totalProducts, + 'business-services': results.find( + ( i ) => i.type === 'business-service' + )?.totalProducts, + } ); + + results.forEach( ( result ) => { + switch ( result.type ) { + case ProductType.extension: + setTotalPagesExtensions( result.totalPages ); + break; + case ProductType.theme: + setTotalPagesThemes( result.totalPages ); + break; + case ProductType.businessService: + setTotalPagesBusinessServices( + result.totalPages + ); + break; + } + } ); + + searchCompleteAnnouncement( + results.reduce( ( acc, curr ) => { + return acc + curr.totalProducts; + }, 0 ) + ); + } ) + .catch( () => { + setAllProducts( [] ); + } ) + .finally( () => { + setIsLoading( false ); + } ); + } + + return () => { + abortControllers.forEach( ( controller ) => { + controller.abort(); + } ); + }; + }, [ + query.tab, query.term, query.category, - query?.tab, + setHasBusinessServices, setIsLoading, - query?.section, + setSearchResultsCount, + currentPage, ] ); + // Filter the products based on the selected tab + useEffect( () => { + let filtered: Product[] | null; + switch ( selectedTab ) { + case 'extensions': + filtered = allProducts.filter( + ( p ) => p.type === ProductType.extension + ); + break; + case 'themes': + filtered = allProducts.filter( + ( p ) => p.type === ProductType.theme + ); + break; + case 'business-services': + filtered = allProducts.filter( + ( p ) => p.type === ProductType.businessService + ); + break; + default: + filtered = []; + } + setFilteredProducts( filtered ); + }, [ selectedTab, allProducts ] ); + + // Record tab view events when the query changes + useEffect( () => { + const marketplaceViewProps = { + view: query?.tab, + search_term: query?.term, + product_type: query?.section, + category: query?.category, + }; + recordMarketplaceView( marketplaceViewProps ); + recordLegacyTabView( marketplaceViewProps ); + }, [ query?.tab, query?.term, query?.section, query?.category ] ); + + // Reset current page when tab, term, or category changes + useEffect( () => { + setCurrentPage( 1 ); + setFirstNewProductId( 0 ); + }, [ selectedTab, query?.category, query?.term ] ); + + // Maintain product focus for accessibility + useEffect( () => { + if ( firstNewProductId ) { + setTimeout( () => { + const firstNewProduct = document.getElementById( + `product-${ firstNewProductId }` + ); + if ( firstNewProduct ) { + firstNewProduct.focus(); + } + }, 0 ); + } + }, [ firstNewProductId ] ); + const renderContent = (): JSX.Element => { switch ( selectedTab ) { case 'extensions': - return ( - - ); case 'themes': - return ( - - ); case 'business-services': return ( - ); - case 'search': - return ( - ); case 'discover': @@ -184,10 +392,29 @@ export default function Content(): JSX.Element { } }; + const shouldShowLoadMoreButton = () => { + if ( ! query.category || query.category === '_all' ) { + // Check against total pages for the selected tab + switch ( selectedTab ) { + case 'extensions': + return currentPage < totalPagesExtensions; + case 'themes': + return currentPage < totalPagesThemes; + case 'business-services': + return currentPage < totalPagesBusinessServices; + default: + return false; + } + } else { + // Check against totalPagesCategory for specific category + return currentPage < totalPagesCategory; + } + }; + return (
    - + { selectedTab !== 'business-services' && selectedTab !== 'my-subscriptions' && } { selectedTab !== 'business-services' && } @@ -197,11 +424,15 @@ export default function Content(): JSX.Element { { selectedTab !== 'business-services' && ( ) } - { selectedTab !== 'business-services' && ( - - ) } { renderContent() } + { ! isLoading && shouldShowLoadMoreButton() && ( + + ) }
    ); } diff --git a/plugins/woocommerce-admin/client/marketplace/components/header/header.scss b/plugins/woocommerce-admin/client/marketplace/components/header/header.scss index 11616f62a7f..f4e14f0ff23 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/header/header.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/header/header.scss @@ -5,6 +5,7 @@ background: #fff; border-bottom: 1px solid $gutenberg-gray-300; display: grid; + gap: $medium-gap; grid-template: "mktpl-title mktpl-search mktpl-meta" 60px "mktpl-tabs mktpl-tabs mktpl-tabs" auto / 1fr 320px 36px; padding: 0 $content-spacing-large; @@ -73,17 +74,3 @@ padding: 0 $content-spacing-small; } } - -.woocommerce-marketplace__search { - margin-right: $medium-gap; - margin-top: 10px; - - input[type="search"] { - all: unset; - flex-grow: 1; - } - - @media (width <= $breakpoint-medium) { - margin: $content-spacing-small; - } -} diff --git a/plugins/woocommerce-admin/client/marketplace/components/load-more-button/load-more-button.tsx b/plugins/woocommerce-admin/client/marketplace/components/load-more-button/load-more-button.tsx new file mode 100644 index 00000000000..07fb2ea6c48 --- /dev/null +++ b/plugins/woocommerce-admin/client/marketplace/components/load-more-button/load-more-button.tsx @@ -0,0 +1,37 @@ +/** + * External dependencies + */ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { speak } from '@wordpress/a11y'; +import { queueRecordEvent } from '@woocommerce/tracks'; + +interface LoadMoreProps { + onLoadMore: () => void; + isBusy: boolean; + disabled: boolean; +} + +export default function LoadMoreButton( props: LoadMoreProps ) { + const { onLoadMore, isBusy, disabled } = props; + function handleClick() { + queueRecordEvent( 'marketplace_load_more_button_clicked', {} ); + onLoadMore(); + } + + if ( isBusy ) { + speak( __( 'Loading more products', 'woocommerce' ) ); + } + + return ( + + ); +} diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx index 79585e83189..00ba4a2d0e7 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx @@ -191,6 +191,8 @@ function ProductCard( props: ProductCardProps ): JSX.Element { return ( diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx index da81e0d8059..9ecdd54c98a 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/no-results.tsx @@ -3,7 +3,6 @@ */ import { __ } from '@wordpress/i18n'; import { useEffect, useState } from '@wordpress/element'; -import { useQuery } from '@woocommerce/navigation'; /** * Internal dependencies @@ -22,8 +21,6 @@ export default function NoResults( props: { } ): JSX.Element { const [ productGroups, setProductGroups ] = useState< ProductGroup[] >(); const [ isLoading, setIsLoading ] = useState( false ); - const query = useQuery(); - const showCategorySelector = query.tab === 'search' && query.section; const productGroupsForSearchType = { [ SearchResultType.all ]: [ 'most-popular', @@ -123,10 +120,6 @@ export default function NoResults( props: { } function categorySelector() { - if ( ! showCategorySelector ) { - return <>; - } - if ( props.type === SearchResultType.all ) { return <>; } diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts b/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts index 5008cef836a..9ee660ddb08 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts @@ -1,5 +1,7 @@ export type SearchAPIJSONType = { products: Array< SearchAPIProductType >; + total_pages: number; + total_products: number; }; export type SearchAPIProductType = { diff --git a/plugins/woocommerce-admin/client/marketplace/components/products/products.scss b/plugins/woocommerce-admin/client/marketplace/components/products/products.scss index 2d92bfd5259..4fb5f89218c 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/products/products.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/products/products.scss @@ -9,10 +9,21 @@ } } + .woocommerce-marketplace__sub-header { display: flex; - - .woocommerce-marketplace__customize-your-store-button { - margin: 16px 0 6px auto; - } + align-items: center; + justify-content: space-between; + gap: 32px; } + +.woocommerce-marketplace__sub-header__categories { + flex: 1; + overflow-x: auto; + position: relative; +} + +.woocommerce-marketplace__customize-your-store-button { + flex-shrink: 0; +} + diff --git a/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx b/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx index 8ecd211e4d2..a078e991198 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/products/products.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import { __, _n, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { createInterpolateElement, useContext, @@ -24,7 +24,6 @@ import ProductListContent from '../product-list-content/product-list-content'; import ProductLoader from '../product-loader/product-loader'; import NoResults from '../product-list-content/no-results'; import { Product, ProductType, SearchResultType } from '../product-list/types'; -import { MARKETPLACE_ITEMS_PER_PAGE } from '../constants'; import { ADMIN_URL } from '~/utils/admin-settings'; import { ThemeSwitchWarningModal } from '~/customize-store/intro/warning-modals'; @@ -54,12 +53,10 @@ const LABELS = { export default function Products( props: ProductsProps ) { const marketplaceContextValue = useContext( MarketplaceContext ); - const { isLoading, selectedTab } = marketplaceContextValue; + const { isLoading } = marketplaceContextValue; const label = LABELS[ props.type ].label; - const singularLabel = LABELS[ props.type ].singularLabel; const query = useQuery(); const category = query?.category; - const perPage = props.perPage ?? MARKETPLACE_ITEMS_PER_PAGE; interface Theme { stylesheet?: string; } @@ -94,42 +91,30 @@ export default function Products( props: ProductsProps ) { } // Store the total number of products before we slice it later. - const productTotalCount = props.products?.length ?? 0; - const products = props.products?.slice( 0, perPage ) ?? []; - - let title = sprintf( - // translators: %s: plural item type (e.g. extensions, themes) - __( '0 %s found', 'woocommerce' ), - label - ); - - if ( productTotalCount > 0 ) { - title = sprintf( - // translators: %1$s: number of items, %2$s: singular item label, %3$s: plural item label - _n( '%1$s %2$s', '%1$s %3$s', productTotalCount, 'woocommerce' ), - productTotalCount, - singularLabel, - label - ); - } + const products = props.products ?? []; const labelForClassName = label === 'business services' ? 'business-services' : label; const baseContainerClass = 'woocommerce-marketplace__search-'; - const baseProductListTitleClass = 'product-list-title--'; const containerClassName = clsx( baseContainerClass + labelForClassName ); - const productListTitleClassName = clsx( - 'woocommerce-marketplace__product-list-title', - baseContainerClass + baseProductListTitleClass + labelForClassName, - { 'is-loading': isLoading } - ); const viewAllButonClassName = clsx( 'woocommerce-marketplace__view-all-button', baseContainerClass + 'button-' + labelForClassName ); + if ( isLoading ) { + return ( + <> + { props.categorySelector && ( + + ) } + + + ); + } + if ( products.length === 0 ) { let type = SearchResultType.all; @@ -154,28 +139,14 @@ export default function Products( props: ProductsProps ) { : '' ); - if ( isLoading ) { - return ( - <> - { props.categorySelector && ( - - ) } - - - ); - } - return (
    - { selectedTab === 'search' && ( -

    - { isLoading ? ' ' : title } -

    - ) } -
    - { props.categorySelector && ( - - ) } +
    + { isModalOpen && ( product.type === ProductType.extension - ); - const themeList = props.products.filter( - ( product ) => product.type === ProductType.theme - ); - const businessServiceList = props.products.filter( - ( product ) => product.type === ProductType.businessService - ); - - const hasExtensions = extensionList.length > 0; - const hasThemes = themeList.length > 0; - const hasBusinessServices = businessServiceList.length > 0; - const hasOnlyExtensions = - hasExtensions && ! hasThemes && ! hasBusinessServices; - const hasOnlyThemes = hasThemes && ! hasExtensions && ! hasBusinessServices; - const hasOnlyBusinessServices = - hasBusinessServices && ! hasExtensions && ! hasThemes; - - const marketplaceContextValue = useContext( MarketplaceContext ); - const { isLoading, hasBusinessServices: canShowBusinessServices } = - marketplaceContextValue; - - const query = useQuery(); - const showCategorySelector = query.section ? true : false; - const searchTerm = query.term ? query.term : ''; - - type Overrides = { - categorySelector?: boolean; - showAllButton?: boolean; - perPage?: number; - }; - - function productsComponent( - products: Product[], - type: ProductType, - overrides: Overrides = {} - ) { - return ( - - ); - } - - function extensionsComponent( overrides: Overrides = {} ) { - return productsComponent( - extensionList, - ProductType.extension, - overrides - ); - } - - function themesComponent( overrides: Overrides = {} ) { - return productsComponent( themeList, ProductType.theme, overrides ); - } - - function businessServicesComponent( overrides: Overrides = {} ) { - return productsComponent( - businessServiceList, - ProductType.businessService, - overrides - ); - } - - const content = () => { - if ( query?.section === SearchResultType.extension ) { - return extensionsComponent( { showAllButton: false } ); - } - - if ( query?.section === SearchResultType.theme ) { - return themesComponent( { showAllButton: false } ); - } - - if ( query?.section === SearchResultType.businessService ) { - return businessServicesComponent( { showAllButton: false } ); - } - - // Components can handle their isLoading state. So we can put all three on the page. - if ( isLoading ) { - return ( - <> - { extensionsComponent() } - { themesComponent() } - { businessServicesComponent() } - - ); - } - - // If we did finish loading items, and there are no results, show the no results component. - if ( - ! isLoading && - ! hasExtensions && - ! hasThemes && - ! hasBusinessServices - ) { - return ( - - ); - } - - // If we're done loading, we can put these components on the page. - return ( - <> - { hasExtensions - ? extensionsComponent( { - categorySelector: hasOnlyExtensions || undefined, - showAllButton: hasOnlyExtensions - ? false - : undefined, - perPage: hasOnlyExtensions - ? MARKETPLACE_ITEMS_PER_PAGE - : MARKETPLACE_SEARCH_RESULTS_PER_PAGE, - } ) - : null } - { hasThemes - ? themesComponent( { - categorySelector: hasOnlyThemes || undefined, - showAllButton: hasOnlyThemes ? false : undefined, - perPage: hasOnlyThemes - ? MARKETPLACE_ITEMS_PER_PAGE - : MARKETPLACE_SEARCH_RESULTS_PER_PAGE, - } ) - : null } - { hasBusinessServices - ? businessServicesComponent( { - categorySelector: - hasOnlyBusinessServices || undefined, - showAllButton: hasOnlyBusinessServices - ? false - : undefined, - perPage: hasOnlyBusinessServices - ? MARKETPLACE_ITEMS_PER_PAGE - : MARKETPLACE_SEARCH_RESULTS_PER_PAGE, - } ) - : null } - - ); - }; - - return ( -
    - { content() } -
    - ); -} diff --git a/plugins/woocommerce-admin/client/marketplace/components/search/search.scss b/plugins/woocommerce-admin/client/marketplace/components/search/search.scss index e6725b7ca2d..0fb878f214f 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/search/search.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/search/search.scss @@ -2,29 +2,15 @@ .woocommerce-marketplace__search { grid-area: mktpl-search; - background: $gutenberg-gray-100; - border: 1.5px solid transparent; - border-radius: 2px; - display: flex; - height: 40px; - padding: 4px 8px 4px 12px; - - input[type="search"] { - all: unset; - flex-grow: 1; - } - - &:focus-within { - background: #fff; - border-color: var(--wp-admin-theme-color, #3858e9); - } + margin-top: 15px; + width: 320px; @media (width <= $breakpoint-medium) { margin: $grid-unit-20 $grid-unit-20 $grid-unit-10 $grid-unit-20; + width: calc(100% - $grid-unit-20 * 2); + + .components-input-control__input { + font-size: 13px !important; + } } } - -.woocommerce-marketplace__search-button { - all: unset; - cursor: pointer; -} diff --git a/plugins/woocommerce-admin/client/marketplace/components/search/search.tsx b/plugins/woocommerce-admin/client/marketplace/components/search/search.tsx index e03582ca4e8..f9572c7c7fa 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/search/search.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/search/search.tsx @@ -2,26 +2,20 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { Icon, search } from '@wordpress/icons'; -import { useContext, useEffect, useState } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; import { navigateTo, getNewPath, useQuery } from '@woocommerce/navigation'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +// eslint-disable-next-line @woocommerce/dependency-group +import { SearchControl } from '@wordpress/components'; +// The @ts-ignore is needed because the SearchControl types are not exported from the @wordpress/components package, +// even though the component itself is. This is likely due to an older version of the package being used. /** * Internal dependencies */ import './search.scss'; import { MARKETPLACE_PATH } from '../constants'; -import { MarketplaceContext } from '../../contexts/marketplace-context'; - -const searchPlaceholder = __( - 'Search for extensions, themes, and business services', - 'woocommerce' -); - -const searchPlaceholderNoBusinessServices = __( - 'Search for extensions and themes', - 'woocommerce' -); /** * Search component. @@ -30,14 +24,10 @@ const searchPlaceholderNoBusinessServices = __( */ function Search(): JSX.Element { const [ searchTerm, setSearchTerm ] = useState( '' ); - const { hasBusinessServices } = useContext( MarketplaceContext ); + const searchPlaceholder = __( 'Search Marketplace', 'woocommerce' ); const query = useQuery(); - const placeholder = hasBusinessServices - ? searchPlaceholder - : searchPlaceholderNoBusinessServices; - useEffect( () => { if ( query.term ) { setSearchTerm( query.term ); @@ -46,21 +36,16 @@ function Search(): JSX.Element { } }, [ query.term ] ); - useEffect( () => { - if ( query.tab !== 'search' ) { - setSearchTerm( '' ); - } - }, [ query.tab ] ); - const runSearch = () => { - const term = searchTerm.trim(); + const newQuery: { term?: string; tab?: string } = query; - const newQuery: { term?: string; tab?: string } = {}; - if ( term !== '' ) { - newQuery.term = term; - newQuery.tab = 'search'; + // If we're on 'Discover' or 'My subscriptions' when a search is initiated, move to the extensions tab + if ( ! newQuery.tab || newQuery.tab === 'my-subscriptions' ) { + newQuery.tab = 'extensions'; } + newQuery.term = searchTerm.trim(); + // When the search term changes, we reset the query string on purpose. navigateTo( { url: getNewPath( newQuery, MARKETPLACE_PATH, {} ), @@ -69,12 +54,6 @@ function Search(): JSX.Element { return []; }; - const handleInputChange = ( - event: React.ChangeEvent< HTMLInputElement > - ) => { - setSearchTerm( event.target.value ); - }; - const handleKeyUp = ( event: { key: string } ) => { if ( event.key === 'Enter' ) { runSearch(); @@ -86,32 +65,14 @@ function Search(): JSX.Element { }; return ( -
    - - - -
    + ); } diff --git a/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.scss b/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.scss index 008fe4e1b84..3a8e7c152e6 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.scss @@ -45,6 +45,18 @@ text-align: center; z-index: 26; } + + &__update-count-extensions, + &__update-count-themes, + &__update-count-business-services { + background-color: $gutenberg-gray-300; + color: $gutenberg-gray-700; + + &.is-active { + background-color: #000; + color: #fff; + } + } } @media (width <= $breakpoint-medium) { diff --git a/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.tsx b/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.tsx index 1f852a2b982..052a06df435 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/tabs/tabs.tsx @@ -2,7 +2,7 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import { useContext, useEffect, useState } from '@wordpress/element'; +import { useContext, useEffect, useState, useMemo } from '@wordpress/element'; import { Button } from '@wordpress/components'; import clsx from 'clsx'; import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation'; @@ -35,63 +35,26 @@ interface Tabs { const wccomSettings = getAdminSetting( 'wccomHelper', {} ); const wooUpdateCount = wccomSettings?.wooUpdateCount ?? 0; -const tabs: Tabs = { - search: { - name: 'search', - title: __( 'Search results', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - discover: { - name: 'discover', - title: __( 'Discover', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - extensions: { - name: 'extensions', - title: __( 'Extensions', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - themes: { - name: 'themes', - title: __( 'Themes', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - 'business-services': { - name: 'business-services', - title: __( 'Business services', 'woocommerce' ), - showUpdateCount: false, - updateCount: 0, - }, - 'my-subscriptions': { - name: 'my-subscriptions', - title: __( 'My subscriptions', 'woocommerce' ), - showUpdateCount: true, - updateCount: wooUpdateCount, - }, -}; - -const setUrlTabParam = ( tabKey: string ) => { +const setUrlTabParam = ( tabKey: string, query: Record< string, string > ) => { + const term = query.term ? { term: query.term.trim() } : {}; navigateTo( { url: getNewPath( { tab: tabKey === DEFAULT_TAB_KEY ? undefined : tabKey }, MARKETPLACE_PATH, - {} + term ), } ); }; -const getVisibleTabs = ( selectedTab: string, hasBusinessServices = false ) => { +const getVisibleTabs = ( + selectedTab: string, + hasBusinessServices = false, + tabs: Tabs +) => { if ( selectedTab === '' ) { return tabs; } const currentVisibleTabs = { ...tabs }; - if ( selectedTab !== 'search' ) { - delete currentVisibleTabs.search; - } if ( ! hasBusinessServices ) { delete currentVisibleTabs[ 'business-services' ]; } @@ -101,7 +64,9 @@ const getVisibleTabs = ( selectedTab: string, hasBusinessServices = false ) => { const renderTabs = ( marketplaceContextValue: MarketplaceContextType, - visibleTabs: Tabs + visibleTabs: Tabs, + tabs: Tabs, + query: Record< string, string > ) => { const { selectedTab, setSelectedTab } = marketplaceContextValue; @@ -110,7 +75,7 @@ const renderTabs = ( return; } setSelectedTab( tabKey ); - setUrlTabParam( tabKey ); + setUrlTabParam( tabKey, query ); }; const tabContent = []; @@ -143,7 +108,15 @@ const renderTabs = ( { tabs[ tabKey ]?.title } { tabs[ tabKey ]?.showUpdateCount && tabs[ tabKey ]?.updateCount > 0 && ( - + { tabs[ tabKey ]?.updateCount } ) } @@ -157,23 +130,70 @@ const renderTabs = ( const Tabs = ( props: TabsProps ): JSX.Element => { const { additionalClassNames } = props; const marketplaceContextValue = useContext( MarketplaceContext ); - const { selectedTab, setSelectedTab, hasBusinessServices } = + const { selectedTab, isLoading, setSelectedTab, hasBusinessServices } = marketplaceContextValue; - const [ visibleTabs, setVisibleTabs ] = useState( getVisibleTabs( '' ) ); + const { searchResultsCount } = marketplaceContextValue; const query: Record< string, string > = useQuery(); + const tabs: Tabs = useMemo( + () => ( { + discover: { + name: 'discover', + title: __( 'Discover', 'woocommerce' ), + showUpdateCount: false, + updateCount: 0, + }, + extensions: { + name: 'extensions', + title: __( 'Extensions', 'woocommerce' ), + showUpdateCount: !! query.term && ! isLoading, + updateCount: searchResultsCount.extensions, + }, + themes: { + name: 'themes', + title: __( 'Themes', 'woocommerce' ), + showUpdateCount: !! query.term && ! isLoading, + updateCount: searchResultsCount.themes, + }, + 'business-services': { + name: 'business-services', + title: __( 'Business services', 'woocommerce' ), + showUpdateCount: !! query.term && ! isLoading, + updateCount: searchResultsCount[ 'business-services' ], + }, + 'my-subscriptions': { + name: 'my-subscriptions', + title: __( 'My subscriptions', 'woocommerce' ), + showUpdateCount: true, + updateCount: wooUpdateCount, + }, + } ), + [ query, isLoading, searchResultsCount ] + ); + + const [ visibleTabs, setVisibleTabs ] = useState( + getVisibleTabs( '', false, tabs ) + ); + useEffect( () => { if ( query?.tab && tabs[ query.tab ] ) { setSelectedTab( query.tab ); } else if ( Object.keys( query ).length > 0 ) { setSelectedTab( DEFAULT_TAB_KEY ); } - }, [ query, setSelectedTab ] ); + }, [ query, setSelectedTab, tabs ] ); useEffect( () => { - setVisibleTabs( getVisibleTabs( selectedTab, hasBusinessServices ) ); - }, [ selectedTab, hasBusinessServices ] ); + setVisibleTabs( + getVisibleTabs( selectedTab, hasBusinessServices, tabs ) + ); + + if ( selectedTab === 'business-services' && ! hasBusinessServices ) { + setUrlTabParam( 'extensions', query ); + } + }, [ selectedTab, hasBusinessServices, query, tabs ] ); + return ( ); }; diff --git a/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx b/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx index 53dd1bb5789..0631bf5725e 100644 --- a/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx +++ b/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx @@ -1,12 +1,17 @@ /** * External dependencies */ -import { useState, useEffect, createContext } from '@wordpress/element'; +import { + useState, + useEffect, + useCallback, + createContext, +} from '@wordpress/element'; /** * Internal dependencies */ -import { MarketplaceContextType } from './types'; +import { SearchResultsCountType, MarketplaceContextType } from './types'; import { getAdminSetting } from '../../utils/admin-settings'; export const MarketplaceContext = createContext< MarketplaceContextType >( { @@ -18,6 +23,12 @@ export const MarketplaceContext = createContext< MarketplaceContextType >( { addInstalledProduct: () => {}, hasBusinessServices: false, setHasBusinessServices: () => {}, + searchResultsCount: { + extensions: 0, + themes: 0, + 'business-services': 0, + }, + setSearchResultsCount: () => {}, } ); export function MarketplaceContextProvider( props: { @@ -29,6 +40,22 @@ export function MarketplaceContextProvider( props: { [] ); const [ hasBusinessServices, setHasBusinessServices ] = useState( false ); + const [ searchResultsCount, setSearchResultsCountState ] = + useState< SearchResultsCountType >( { + extensions: 0, + themes: 0, + 'business-services': 0, + } ); + + const setSearchResultsCount = useCallback( + ( updatedCounts: Partial< SearchResultsCountType > ) => { + setSearchResultsCountState( ( prev ) => ( { + ...prev, + ...updatedCounts, + } ) ); + }, + [] + ); /** * Knowing installed products will help us to determine which products @@ -59,6 +86,8 @@ export function MarketplaceContextProvider( props: { addInstalledProduct, hasBusinessServices, setHasBusinessServices, + searchResultsCount, + setSearchResultsCount, }; return ( diff --git a/plugins/woocommerce-admin/client/marketplace/contexts/types.ts b/plugins/woocommerce-admin/client/marketplace/contexts/types.ts index 969ce88d2e1..3240729871e 100644 --- a/plugins/woocommerce-admin/client/marketplace/contexts/types.ts +++ b/plugins/woocommerce-admin/client/marketplace/contexts/types.ts @@ -8,6 +8,12 @@ import { Options } from '@wordpress/notices'; */ import { Subscription } from '../components/my-subscriptions/types'; +export interface SearchResultsCountType { + extensions: number; + themes: number; + 'business-services': number; +} + export type MarketplaceContextType = { isLoading: boolean; setIsLoading: ( isLoading: boolean ) => void; @@ -17,6 +23,10 @@ export type MarketplaceContextType = { addInstalledProduct: ( slug: string ) => void; hasBusinessServices: boolean; setHasBusinessServices: ( hasBusinessServices: boolean ) => void; + searchResultsCount: SearchResultsCountType; + setSearchResultsCount: ( + updatedCounts: Partial< SearchResultsCountType > + ) => void; }; export type SubscriptionsContextType = { diff --git a/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx index d7fbc982c15..a063490eefc 100644 --- a/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx +++ b/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx @@ -107,7 +107,11 @@ async function fetchJsonWithCache( async function fetchSearchResults( params: URLSearchParams, abortSignal?: AbortSignal -): Promise< Product[] > { +): Promise< { + products: Product[]; + totalPages: number; + totalProducts: number; +} > { const url = MARKETPLACE_HOST + MARKETPLACE_SEARCH_API_PATH + @@ -151,9 +155,12 @@ async function fetchSearchResults( }; } ); - resolve( products ); + const totalPages = ( json as SearchAPIJSONType ).total_pages; + const totalProducts = ( json as SearchAPIJSONType ) + .total_products; + resolve( { products, totalPages, totalProducts } ); } ) - .catch( () => reject ); + .catch( reject ); } ); } @@ -174,6 +181,17 @@ async function fetchDiscoverPageData(): Promise< ProductGroup[] > { } } +function getProductType( tab: string ): ProductType { + switch ( tab ) { + case 'themes': + return ProductType.theme; + case 'business-services': + return ProductType.businessService; + default: + return ProductType.extension; + } +} + function fetchCategories( type: ProductType ): Promise< CategoryAPIItem[] > { const url = new URL( MARKETPLACE_HOST + MARKETPLACE_CATEGORY_API_PATH ); @@ -478,6 +496,7 @@ export { fetchCategories, fetchDiscoverPageData, fetchSearchResults, + getProductType, fetchSubscriptions, refreshSubscriptions, getInstallUrl, diff --git a/plugins/woocommerce-admin/client/marketplace/utils/tracking.ts b/plugins/woocommerce-admin/client/marketplace/utils/tracking.ts index 6729c824c55..8b08027770e 100644 --- a/plugins/woocommerce-admin/client/marketplace/utils/tracking.ts +++ b/plugins/woocommerce-admin/client/marketplace/utils/tracking.ts @@ -42,11 +42,6 @@ function recordMarketplaceView( props: MarketplaceViewProps ) { eventProps.category = '_all'; } - // User clicks the `View All` button on search results - if ( view && view === 'search' && product_type && ! category ) { - eventProps.category = '_all'; - } - recordEvent( 'marketplace_view', eventProps ); } @@ -80,11 +75,6 @@ function recordLegacyTabView( props: MarketplaceViewProps ) { case 'themes': oldEventProps.section = 'themes'; break; - case 'search': - oldEventName = 'extensions_view_search'; - oldEventProps.section = view; - oldEventProps.search_term = search_term || ''; - break; case 'my-subscriptions': oldEventName = 'subscriptions_view'; oldEventProps.section = 'helper'; diff --git a/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector b/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector new file mode 100644 index 00000000000..504e3d4d2d2 --- /dev/null +++ b/plugins/woocommerce/changelog/51309-update-21591-in-app-category-selector @@ -0,0 +1,4 @@ +Significance: patch +Type: update + +Update In-App Marketplace category selector \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component b/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component new file mode 100644 index 00000000000..42a06d9bf86 --- /dev/null +++ b/plugins/woocommerce/changelog/51313-update-wccom-21595-in-app-search-component @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Replace marketplace search component with SearchControl from @wordpress/components \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state b/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state new file mode 100644 index 00000000000..a55cd0e6601 --- /dev/null +++ b/plugins/woocommerce/changelog/51342-fix-21596-search-loading-state @@ -0,0 +1,4 @@ +Significance: patch +Type: fix + +Fix the loading state for the In-App Marketplace search \ No newline at end of file diff --git a/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button b/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button new file mode 100644 index 00000000000..50f6c905990 --- /dev/null +++ b/plugins/woocommerce/changelog/51434-add-wccom-21568-in-app-load-more-button @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Added a Load More button to product lists on the Extensions page, to request additional search results from WooCommerce.com. \ No newline at end of file diff --git a/plugins/woocommerce/changelog/refactor-wccom-21576-search-tab b/plugins/woocommerce/changelog/refactor-wccom-21576-search-tab new file mode 100644 index 00000000000..ba8046f3451 --- /dev/null +++ b/plugins/woocommerce/changelog/refactor-wccom-21576-search-tab @@ -0,0 +1,4 @@ +Significance: minor +Type: update + +Change the way search results are displayed in the in-app marketplace diff --git a/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count b/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count new file mode 100644 index 00000000000..b0edda219c3 --- /dev/null +++ b/plugins/woocommerce/changelog/tweak-21597-in-app-search-results-count @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Add search result counts to the in-app marketplace header tabs (Extensions area)