In app search improvements feature branch (#51413)

* 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 <github-actions@github.com>

* 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 0b2d2dca6d.

* 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 <github-actions@github.com>

* 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 <github-actions@github.com>

* 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 <buritomath@gmail.com>

* 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 <github-actions@github.com>
Co-authored-by: Boro Sitnikovski <buritomath@gmail.com>

* 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 <raicem@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Herman <KokkieH@users.noreply.github.com>
This commit is contained in:
Boro Sitnikovski 2024-09-18 14:14:30 +02:00 committed by GitHub
parent 4bc4649008
commit ce66b55bc5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 807 additions and 616 deletions

View File

@ -1,13 +1,17 @@
@import "../../stylesheets/_variables.scss"; @import "../../stylesheets/_variables.scss";
.woocommerce-marketplace__category-selector { .woocommerce-marketplace__category-selector {
position: relative;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
margin: $grid-unit-20 0 0 0; margin: 0;
overflow-x: auto;
} }
.woocommerce-marketplace__category-item { .woocommerce-marketplace__category-item {
cursor: pointer; cursor: pointer;
white-space: nowrap;
margin-bottom: 0;
.components-dropdown { .components-dropdown {
height: 100%; height: 100%;
@ -50,7 +54,6 @@
.woocommerce-marketplace__category-selector--full-width { .woocommerce-marketplace__category-selector--full-width {
display: none; display: none;
margin-top: $grid-unit-15;
} }
@media screen and (max-width: $break-medium) { @media screen and (max-width: $break-medium) {
@ -122,3 +125,22 @@
background-color: $gray-900; 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;
}

View File

@ -1,20 +1,21 @@
/** /**
* External dependencies * External dependencies
*/ */
import { useState, useEffect } from '@wordpress/element'; import { useState, useEffect, useRef } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useQuery } from '@woocommerce/navigation'; 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 * Internal dependencies
*/ */
import CategoryLink from './category-link'; import CategoryLink from './category-link';
import CategoryDropdown from './category-dropdown';
import { Category, CategoryAPIItem } from './types'; import { Category, CategoryAPIItem } from './types';
import { fetchCategories } from '../../utils/functions'; import { fetchCategories } from '../../utils/functions';
import './category-selector.scss';
import { ProductType } from '../product-list/types'; import { ProductType } from '../product-list/types';
import CategoryDropdown from './category-dropdown';
import './category-selector.scss';
const ALL_CATEGORIES_SLUGS = { const ALL_CATEGORIES_SLUGS = {
[ ProductType.extension ]: '_all', [ ProductType.extension ]: '_all',
@ -29,32 +30,21 @@ interface CategorySelectorProps {
export default function CategorySelector( export default function CategorySelector(
props: CategorySelectorProps props: CategorySelectorProps
): JSX.Element { ): JSX.Element {
const [ visibleItems, setVisibleItems ] = useState< Category[] >( [] );
const [ dropdownItems, setDropdownItems ] = useState< Category[] >( [] );
const [ selected, setSelected ] = useState< Category >(); const [ selected, setSelected ] = useState< Category >();
const [ isLoading, setIsLoading ] = useState( false ); 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(); 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( () => { useEffect( () => {
setIsLoading( true ); setIsLoading( true );
@ -72,21 +62,125 @@ export default function CategorySelector(
return category.slug !== '_featured'; return category.slug !== '_featured';
} ); } );
// Split array into two from 7th item setCategoriesToShow( categories );
const visibleCategoryItems = categories.slice( 0, 7 );
const dropdownCategoryItems = categories.slice( 7 );
setVisibleItems( visibleCategoryItems );
setDropdownItems( dropdownCategoryItems );
} ) } )
.catch( () => { .catch( () => {
setVisibleItems( [] ); setCategoriesToShow( [] );
setDropdownItems( [] );
} ) } )
.finally( () => { .finally( () => {
setIsLoading( false ); 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() { function mobileCategoryDropdownLabel() {
const allCategoriesText = __( 'All Categories', 'woocommerce' ); const allCategoriesText = __( 'All Categories', 'woocommerce' );
@ -102,16 +196,6 @@ export default function CategorySelector(
return selected.label; return selected.label;
} }
function isSelectedInDropdown() {
if ( ! selected ) {
return false;
}
return dropdownItems.find(
( category ) => category.slug === selected.slug
);
}
if ( isLoading ) { if ( isLoading ) {
return ( return (
<> <>
@ -131,50 +215,62 @@ export default function CategorySelector(
return ( return (
<> <>
<ul className="woocommerce-marketplace__category-selector"> <ul
{ visibleItems.map( ( category ) => ( className="woocommerce-marketplace__category-selector"
aria-label="Categories"
ref={ categorySelectorRef }
>
{ categoriesToShow.map( ( category ) => (
<li <li
className="woocommerce-marketplace__category-item" className="woocommerce-marketplace__category-item"
key={ category.slug } key={ category.slug }
ref={
category.slug === selected?.slug
? selectedCategoryRef
: null
}
> >
<CategoryLink <CategoryLink
{ ...category } { ...category }
selected={ category.slug === selected?.slug } selected={ category.slug === selected?.slug }
aria-current={ category.slug === selected?.slug }
/> />
</li> </li>
) ) } ) ) }
<li className="woocommerce-marketplace__category-item">
{ dropdownItems.length > 0 && (
<CategoryDropdown
type={ props.type }
label={ __( 'More', 'woocommerce' ) }
categories={ dropdownItems }
buttonClassName={ clsx(
'woocommerce-marketplace__category-item-button',
{
'woocommerce-marketplace__category-item-button--selected':
isSelectedInDropdown(),
}
) }
contentClassName="woocommerce-marketplace__category-item-content"
arrowIconSize={ 20 }
selected={ selected }
/>
) }
</li>
</ul> </ul>
<div className="woocommerce-marketplace__category-selector--full-width"> <div className="woocommerce-marketplace__category-selector--full-width">
<CategoryDropdown <CategoryDropdown
type={ props.type } type={ props.type }
label={ mobileCategoryDropdownLabel() } label={ mobileCategoryDropdownLabel() }
categories={ visibleItems.concat( dropdownItems ) } categories={ categoriesToShow }
buttonClassName="woocommerce-marketplace__category-dropdown-button" buttonClassName="woocommerce-marketplace__category-dropdown-button"
className="woocommerce-marketplace__category-dropdown" className="woocommerce-marketplace__category-dropdown"
contentClassName="woocommerce-marketplace__category-dropdown-content" contentClassName="woocommerce-marketplace__category-dropdown-content"
selected={ selected } selected={ selected }
/> />
</div> </div>
{ isOverflowing && (
<>
<button
onClick={ scrollToPrevCategories }
className="woocommerce-marketplace__category-navigation-button woocommerce-marketplace__category-navigation-button--prev"
hidden={ scrollPosition === 'start' }
aria-label="Scroll to previous categories"
tabIndex={ -1 }
>
<Icon icon="arrow-left-alt2" />
</button>
<button
onClick={ scrollToNextCategories }
className="woocommerce-marketplace__category-navigation-button woocommerce-marketplace__category-navigation-button--next"
hidden={ scrollPosition === 'end' }
aria-label="Scroll to next categories"
tabIndex={ -1 }
>
<Icon icon="arrow-right-alt2" />
</button>
</>
) }
</> </>
); );
} }

View File

@ -10,7 +10,7 @@ export const MARKETPLACE_SEARCH_API_PATH =
'/wp-json/wccom-extensions/1.0/search'; '/wp-json/wccom-extensions/1.0/search';
export const MARKETPLACE_CATEGORY_API_PATH = export const MARKETPLACE_CATEGORY_API_PATH =
'/wp-json/wccom-extensions/1.0/categories'; '/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_SEARCH_RESULTS_PER_PAGE = 8;
export const MARKETPLACE_CART_PATH = MARKETPLACE_HOST + '/cart/'; export const MARKETPLACE_CART_PATH = MARKETPLACE_HOST + '/cart/';
export const MARKETPLACE_RENEW_SUBSCRIPTON_PATH = export const MARKETPLACE_RENEW_SUBSCRIPTON_PATH =

View File

@ -1,22 +1,29 @@
/** /**
* External dependencies * External dependencies
*/ */
import { useContext, useEffect, useState } from '@wordpress/element'; import {
useContext,
useEffect,
useState,
useCallback,
} from '@wordpress/element';
import { useQuery } from '@woocommerce/navigation'; import { useQuery } from '@woocommerce/navigation';
import { speak } from '@wordpress/a11y';
import { __, sprintf } from '@wordpress/i18n';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import './content.scss'; 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 { getAdminSetting } from '~/utils/admin-settings';
import Discover from '../discover/discover'; import Discover from '../discover/discover';
import Products from '../products/products'; import Products from '../products/products';
import SearchResults from '../search-results/search-results';
import MySubscriptions from '../my-subscriptions/my-subscriptions'; import MySubscriptions from '../my-subscriptions/my-subscriptions';
import { MarketplaceContext } from '../../contexts/marketplace-context'; import { MarketplaceContext } from '../../contexts/marketplace-context';
import { fetchSearchResults } from '../../utils/functions'; import { fetchSearchResults, getProductType } from '../../utils/functions';
import { SubscriptionsContextProvider } from '../../contexts/subscriptions-context'; import { SubscriptionsContextProvider } from '../../contexts/subscriptions-context';
import { SearchResultsCountType } from '../../contexts/types';
import { import {
recordMarketplaceView, recordMarketplaceView,
recordLegacyTabView, recordLegacyTabView,
@ -26,149 +33,350 @@ import Promotions from '../promotions/promotions';
import ConnectNotice from '~/marketplace/components/connect-notice/connect-notice'; import ConnectNotice from '~/marketplace/components/connect-notice/connect-notice';
import PluginInstallNotice from '../woo-update-manager-plugin/plugin-install-notice'; import PluginInstallNotice from '../woo-update-manager-plugin/plugin-install-notice';
import SubscriptionsExpiredExpiringNotice from '~/marketplace/components/my-subscriptions/subscriptions-expired-expiring-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 { export default function Content(): JSX.Element {
const marketplaceContextValue = useContext( MarketplaceContext ); const marketplaceContextValue = useContext( MarketplaceContext );
const [ products, setProducts ] = useState< Product[] >( [] ); const [ allProducts, setAllProducts ] = useState< Product[] >( [] );
const { setIsLoading, selectedTab, setHasBusinessServices } = const [ filteredProducts, setFilteredProducts ] = useState< Product[] >(
marketplaceContextValue; []
);
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(); const query = useQuery();
// On initial load of the in-app marketplace, fetch extensions, themes and business services const searchCompleteAnnouncement = ( count: number ): void => {
// and check if there are any business services available on WCCOM speak(
useEffect( () => { sprintf(
const categories = [ '', 'themes', 'business-services' ]; // translators: %d is the number of products found.
const abortControllers = categories.map( () => new AbortController() ); __( '%d products found', 'woocommerce' ),
count
)
);
};
categories.forEach( ( category: string, index ) => { const tagProductsWithType = (
const params = new URLSearchParams(); products: Product[],
if ( category !== '' ) { type: ProductType
params.append( 'category', category ); ): Product[] => {
} return products.map( ( product ) => ( {
...product,
type,
} ) );
};
const wccomSettings = getAdminSetting( 'wccomHelper', false ); const loadMoreProducts = useCallback( () => {
if ( wccomSettings.storeCountry ) { setIsLoadingMore( true );
params.append( 'country', wccomSettings.storeCountry ); const params = new URLSearchParams();
}
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 abortController = new AbortController(); const abortController = new AbortController();
if ( if ( query.category && query.category !== '_all' ) {
query.tab === undefined || params.append( 'category', query.category );
( query.tab &&
[ '', 'discover', 'my-subscriptions' ].includes( query.tab ) )
) {
return;
} }
setIsLoading( true ); if ( query.tab === 'themes' || query.tab === 'business-services' ) {
setProducts( [] ); params.append( 'category', query.tab );
}
const params = new URLSearchParams();
if ( query.term ) { if ( query.term ) {
params.append( 'term', 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 ); const wccomSettings = getAdminSetting( 'wccomHelper', false );
if ( wccomSettings.storeCountry ) { if ( wccomSettings.storeCountry ) {
params.append( 'country', wccomSettings.storeCountry ); params.append( 'country', wccomSettings.storeCountry );
} }
params.append( 'page', ( currentPage + 1 ).toString() );
fetchSearchResults( params, abortController.signal ) fetchSearchResults( params, abortController.signal )
.then( ( productList ) => { .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( () => { .catch( () => {
setProducts( [] ); speak( __( 'Error loading more products', 'woocommerce' ) );
} ) } )
.finally( () => { .finally( () => {
// we are recording both the new and legacy events here for now setIsLoadingMore( false );
// 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 );
} ); } );
return () => { return () => {
abortController.abort(); 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.term,
query.category, query.category,
query?.tab, setHasBusinessServices,
setIsLoading, 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 => { const renderContent = (): JSX.Element => {
switch ( selectedTab ) { switch ( selectedTab ) {
case 'extensions': case 'extensions':
return (
<Products
products={ products }
categorySelector={ true }
type={ ProductType.extension }
/>
);
case 'themes': case 'themes':
return (
<Products
products={ products }
categorySelector={ true }
type={ ProductType.theme }
/>
);
case 'business-services': case 'business-services':
return ( return (
<Products <Products
products={ products } products={ filteredProducts }
categorySelector={ true } categorySelector={ true }
type={ ProductType.businessService } type={ getProductType( selectedTab ) }
/>
);
case 'search':
return (
<SearchResults
products={ products }
type={ SearchResultType.all }
/> />
); );
case 'discover': 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 ( return (
<div className="woocommerce-marketplace__content"> <div className="woocommerce-marketplace__content">
<Promotions /> <Promotions />
<InstallNewProductModal products={ products } /> <InstallNewProductModal products={ filteredProducts } />
{ selectedTab !== 'business-services' && { selectedTab !== 'business-services' &&
selectedTab !== 'my-subscriptions' && <ConnectNotice /> } selectedTab !== 'my-subscriptions' && <ConnectNotice /> }
{ selectedTab !== 'business-services' && <PluginInstallNotice /> } { selectedTab !== 'business-services' && <PluginInstallNotice /> }
@ -197,11 +424,15 @@ export default function Content(): JSX.Element {
{ selectedTab !== 'business-services' && ( { selectedTab !== 'business-services' && (
<SubscriptionsExpiredExpiringNotice type="expiring" /> <SubscriptionsExpiredExpiringNotice type="expiring" />
) } ) }
{ selectedTab !== 'business-services' && (
<SubscriptionsExpiredExpiringNotice type="missing" />
) }
{ renderContent() } { renderContent() }
{ ! isLoading && shouldShowLoadMoreButton() && (
<LoadMoreButton
onLoadMore={ loadMoreProducts }
isBusy={ isLoadingMore }
disabled={ isLoadingMore }
/>
) }
</div> </div>
); );
} }

View File

@ -5,6 +5,7 @@
background: #fff; background: #fff;
border-bottom: 1px solid $gutenberg-gray-300; border-bottom: 1px solid $gutenberg-gray-300;
display: grid; display: grid;
gap: $medium-gap;
grid-template: "mktpl-title mktpl-search mktpl-meta" 60px grid-template: "mktpl-title mktpl-search mktpl-meta" 60px
"mktpl-tabs mktpl-tabs mktpl-tabs" auto / 1fr 320px 36px; "mktpl-tabs mktpl-tabs mktpl-tabs" auto / 1fr 320px 36px;
padding: 0 $content-spacing-large; padding: 0 $content-spacing-large;
@ -73,17 +74,3 @@
padding: 0 $content-spacing-small; 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;
}
}

View File

@ -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 (
<Button
className="woocommerce-marketplace__load-more"
variant={ 'secondary' }
onClick={ handleClick }
isBusy={ isBusy }
disabled={ disabled }
>
{ __( 'Load more', 'woocommerce' ) }
</Button>
);
}

View File

@ -191,6 +191,8 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
return ( return (
<Card <Card
className={ classNames } className={ classNames }
id={ `product-${ product.id }` }
tabIndex={ -1 }
aria-hidden={ isLoading } aria-hidden={ isLoading }
style={ inlineCss() } style={ inlineCss() }
> >

View File

@ -3,7 +3,6 @@
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useEffect, useState } from '@wordpress/element'; import { useEffect, useState } from '@wordpress/element';
import { useQuery } from '@woocommerce/navigation';
/** /**
* Internal dependencies * Internal dependencies
@ -22,8 +21,6 @@ export default function NoResults( props: {
} ): JSX.Element { } ): JSX.Element {
const [ productGroups, setProductGroups ] = useState< ProductGroup[] >(); const [ productGroups, setProductGroups ] = useState< ProductGroup[] >();
const [ isLoading, setIsLoading ] = useState( false ); const [ isLoading, setIsLoading ] = useState( false );
const query = useQuery();
const showCategorySelector = query.tab === 'search' && query.section;
const productGroupsForSearchType = { const productGroupsForSearchType = {
[ SearchResultType.all ]: [ [ SearchResultType.all ]: [
'most-popular', 'most-popular',
@ -123,10 +120,6 @@ export default function NoResults( props: {
} }
function categorySelector() { function categorySelector() {
if ( ! showCategorySelector ) {
return <></>;
}
if ( props.type === SearchResultType.all ) { if ( props.type === SearchResultType.all ) {
return <></>; return <></>;
} }

View File

@ -1,5 +1,7 @@
export type SearchAPIJSONType = { export type SearchAPIJSONType = {
products: Array< SearchAPIProductType >; products: Array< SearchAPIProductType >;
total_pages: number;
total_products: number;
}; };
export type SearchAPIProductType = { export type SearchAPIProductType = {

View File

@ -9,10 +9,21 @@
} }
} }
.woocommerce-marketplace__sub-header { .woocommerce-marketplace__sub-header {
display: flex; display: flex;
align-items: center;
.woocommerce-marketplace__customize-your-store-button { justify-content: space-between;
margin: 16px 0 6px auto; gap: 32px;
}
} }
.woocommerce-marketplace__sub-header__categories {
flex: 1;
overflow-x: auto;
position: relative;
}
.woocommerce-marketplace__customize-your-store-button {
flex-shrink: 0;
}

View File

@ -1,7 +1,7 @@
/** /**
* External dependencies * External dependencies
*/ */
import { __, _n, sprintf } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { import {
createInterpolateElement, createInterpolateElement,
useContext, useContext,
@ -24,7 +24,6 @@ import ProductListContent from '../product-list-content/product-list-content';
import ProductLoader from '../product-loader/product-loader'; import ProductLoader from '../product-loader/product-loader';
import NoResults from '../product-list-content/no-results'; import NoResults from '../product-list-content/no-results';
import { Product, ProductType, SearchResultType } from '../product-list/types'; import { Product, ProductType, SearchResultType } from '../product-list/types';
import { MARKETPLACE_ITEMS_PER_PAGE } from '../constants';
import { ADMIN_URL } from '~/utils/admin-settings'; import { ADMIN_URL } from '~/utils/admin-settings';
import { ThemeSwitchWarningModal } from '~/customize-store/intro/warning-modals'; import { ThemeSwitchWarningModal } from '~/customize-store/intro/warning-modals';
@ -54,12 +53,10 @@ const LABELS = {
export default function Products( props: ProductsProps ) { export default function Products( props: ProductsProps ) {
const marketplaceContextValue = useContext( MarketplaceContext ); const marketplaceContextValue = useContext( MarketplaceContext );
const { isLoading, selectedTab } = marketplaceContextValue; const { isLoading } = marketplaceContextValue;
const label = LABELS[ props.type ].label; const label = LABELS[ props.type ].label;
const singularLabel = LABELS[ props.type ].singularLabel;
const query = useQuery(); const query = useQuery();
const category = query?.category; const category = query?.category;
const perPage = props.perPage ?? MARKETPLACE_ITEMS_PER_PAGE;
interface Theme { interface Theme {
stylesheet?: string; stylesheet?: string;
} }
@ -94,42 +91,30 @@ export default function Products( props: ProductsProps ) {
} }
// Store the total number of products before we slice it later. // Store the total number of products before we slice it later.
const productTotalCount = props.products?.length ?? 0; const products = props.products ?? [];
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 labelForClassName = const labelForClassName =
label === 'business services' ? 'business-services' : label; label === 'business services' ? 'business-services' : label;
const baseContainerClass = 'woocommerce-marketplace__search-'; const baseContainerClass = 'woocommerce-marketplace__search-';
const baseProductListTitleClass = 'product-list-title--';
const containerClassName = clsx( baseContainerClass + labelForClassName ); const containerClassName = clsx( baseContainerClass + labelForClassName );
const productListTitleClassName = clsx(
'woocommerce-marketplace__product-list-title',
baseContainerClass + baseProductListTitleClass + labelForClassName,
{ 'is-loading': isLoading }
);
const viewAllButonClassName = clsx( const viewAllButonClassName = clsx(
'woocommerce-marketplace__view-all-button', 'woocommerce-marketplace__view-all-button',
baseContainerClass + 'button-' + labelForClassName baseContainerClass + 'button-' + labelForClassName
); );
if ( isLoading ) {
return (
<>
{ props.categorySelector && (
<CategorySelector type={ props.type } />
) }
<ProductLoader hasTitle={ false } type={ props.type } />
</>
);
}
if ( products.length === 0 ) { if ( products.length === 0 ) {
let type = SearchResultType.all; let type = SearchResultType.all;
@ -154,28 +139,14 @@ export default function Products( props: ProductsProps ) {
: '' : ''
); );
if ( isLoading ) {
return (
<>
{ props.categorySelector && (
<CategorySelector type={ props.type } />
) }
<ProductLoader hasTitle={ false } type={ props.type } />
</>
);
}
return ( return (
<div className={ containerClassName }> <div className={ containerClassName }>
{ selectedTab === 'search' && ( <nav className="woocommerce-marketplace__sub-header">
<h2 className={ productListTitleClassName }> <div className="woocommerce-marketplace__sub-header__categories">
{ isLoading ? ' ' : title } { props.categorySelector && (
</h2> <CategorySelector type={ props.type } />
) } ) }
<div className="woocommerce-marketplace__sub-header"> </div>
{ props.categorySelector && (
<CategorySelector type={ props.type } />
) }
{ props.type === 'theme' && ( { props.type === 'theme' && (
<Button <Button
className="woocommerce-marketplace__customize-your-store-button" className="woocommerce-marketplace__customize-your-store-button"
@ -192,7 +163,7 @@ export default function Products( props: ProductsProps ) {
} } } }
/> />
) } ) }
</div> </nav>
{ isModalOpen && ( { isModalOpen && (
<ThemeSwitchWarningModal <ThemeSwitchWarningModal
setIsModalOpen={ setIsModalOpen } setIsModalOpen={ setIsModalOpen }

View File

@ -1,19 +0,0 @@
@import "../../stylesheets/_variables.scss";
.woocommerce-marketplace__search-results {
.woocommerce-marketplace {
&__view-all-button {
margin-top: 24px;
}
}
.woocommerce-marketplace__product-list-content--collapsed {
.woocommerce-marketplace__product-card {
&:nth-child(n+7) {
display: none;
@media screen and (min-width: $breakpoint-huge) {
display: block;
}
}
}
}
}

View File

@ -1,193 +0,0 @@
/**
* External dependencies
*/
import { useQuery } from '@woocommerce/navigation';
import { useContext } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import './search-results.scss';
import { Product, ProductType, SearchResultType } from '../product-list/types';
import Products from '../products/products';
import NoResults from '../product-list-content/no-results';
import { MarketplaceContext } from '../../contexts/marketplace-context';
import {
MARKETPLACE_ITEMS_PER_PAGE,
MARKETPLACE_SEARCH_RESULTS_PER_PAGE,
} from '../../../marketplace/components/constants';
export interface SearchResultProps {
products: Product[];
type: SearchResultType;
}
export default function SearchResults( props: SearchResultProps ): JSX.Element {
const extensionList = props.products.filter(
( product ) => 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 (
<Products
products={ products }
type={ type }
categorySelector={
overrides.categorySelector ?? showCategorySelector
}
searchTerm={ searchTerm }
showAllButton={ overrides.showAllButton ?? true }
perPage={ overrides.perPage ?? MARKETPLACE_ITEMS_PER_PAGE }
/>
);
}
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 (
<NoResults
type={ SearchResultType.all }
showHeading={ true }
heading={
canShowBusinessServices
? __(
'No extensions, themes or business services found…',
'woocommerce'
)
: __(
'No extensions or themes found…',
'woocommerce'
)
}
/>
);
}
// 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 (
<div className="woocommerce-marketplace__search-results">
{ content() }
</div>
);
}

View File

@ -2,29 +2,15 @@
.woocommerce-marketplace__search { .woocommerce-marketplace__search {
grid-area: mktpl-search; grid-area: mktpl-search;
background: $gutenberg-gray-100; margin-top: 15px;
border: 1.5px solid transparent; width: 320px;
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);
}
@media (width <= $breakpoint-medium) { @media (width <= $breakpoint-medium) {
margin: $grid-unit-20 $grid-unit-20 $grid-unit-10 $grid-unit-20; 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;
}

View File

@ -2,26 +2,20 @@
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { Icon, search } from '@wordpress/icons'; import { useEffect, useState } from '@wordpress/element';
import { useContext, useEffect, useState } from '@wordpress/element';
import { navigateTo, getNewPath, useQuery } from '@woocommerce/navigation'; 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 * Internal dependencies
*/ */
import './search.scss'; import './search.scss';
import { MARKETPLACE_PATH } from '../constants'; 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. * Search component.
@ -30,14 +24,10 @@ const searchPlaceholderNoBusinessServices = __(
*/ */
function Search(): JSX.Element { function Search(): JSX.Element {
const [ searchTerm, setSearchTerm ] = useState( '' ); const [ searchTerm, setSearchTerm ] = useState( '' );
const { hasBusinessServices } = useContext( MarketplaceContext ); const searchPlaceholder = __( 'Search Marketplace', 'woocommerce' );
const query = useQuery(); const query = useQuery();
const placeholder = hasBusinessServices
? searchPlaceholder
: searchPlaceholderNoBusinessServices;
useEffect( () => { useEffect( () => {
if ( query.term ) { if ( query.term ) {
setSearchTerm( query.term ); setSearchTerm( query.term );
@ -46,21 +36,16 @@ function Search(): JSX.Element {
} }
}, [ query.term ] ); }, [ query.term ] );
useEffect( () => {
if ( query.tab !== 'search' ) {
setSearchTerm( '' );
}
}, [ query.tab ] );
const runSearch = () => { const runSearch = () => {
const term = searchTerm.trim(); const newQuery: { term?: string; tab?: string } = query;
const newQuery: { term?: string; tab?: string } = {}; // If we're on 'Discover' or 'My subscriptions' when a search is initiated, move to the extensions tab
if ( term !== '' ) { if ( ! newQuery.tab || newQuery.tab === 'my-subscriptions' ) {
newQuery.term = term; newQuery.tab = 'extensions';
newQuery.tab = 'search';
} }
newQuery.term = searchTerm.trim();
// When the search term changes, we reset the query string on purpose. // When the search term changes, we reset the query string on purpose.
navigateTo( { navigateTo( {
url: getNewPath( newQuery, MARKETPLACE_PATH, {} ), url: getNewPath( newQuery, MARKETPLACE_PATH, {} ),
@ -69,12 +54,6 @@ function Search(): JSX.Element {
return []; return [];
}; };
const handleInputChange = (
event: React.ChangeEvent< HTMLInputElement >
) => {
setSearchTerm( event.target.value );
};
const handleKeyUp = ( event: { key: string } ) => { const handleKeyUp = ( event: { key: string } ) => {
if ( event.key === 'Enter' ) { if ( event.key === 'Enter' ) {
runSearch(); runSearch();
@ -86,32 +65,14 @@ function Search(): JSX.Element {
}; };
return ( return (
<div className="woocommerce-marketplace__search"> <SearchControl
<label label={ searchPlaceholder }
className="screen-reader-text" placeholder={ searchPlaceholder }
htmlFor="woocommerce-marketplace-search-query" value={ searchTerm }
> onChange={ setSearchTerm }
{ placeholder } onKeyUp={ handleKeyUp }
</label> className="woocommerce-marketplace__search"
<input />
id="woocommerce-marketplace-search-query"
value={ searchTerm }
className="woocommerce-marketplace__search-input"
type="search"
name="woocommerce-marketplace-search-query"
placeholder={ placeholder }
onChange={ handleInputChange }
onKeyUp={ handleKeyUp }
/>
<button
id="woocommerce-marketplace-search-button"
className="woocommerce-marketplace__search-button"
aria-label={ __( 'Search', 'woocommerce' ) }
onClick={ runSearch }
>
<Icon icon={ search } size={ 32 } />
</button>
</div>
); );
} }

View File

@ -45,6 +45,18 @@
text-align: center; text-align: center;
z-index: 26; 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) { @media (width <= $breakpoint-medium) {

View File

@ -2,7 +2,7 @@
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; 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 { Button } from '@wordpress/components';
import clsx from 'clsx'; import clsx from 'clsx';
import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation'; import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation';
@ -35,63 +35,26 @@ interface Tabs {
const wccomSettings = getAdminSetting( 'wccomHelper', {} ); const wccomSettings = getAdminSetting( 'wccomHelper', {} );
const wooUpdateCount = wccomSettings?.wooUpdateCount ?? 0; const wooUpdateCount = wccomSettings?.wooUpdateCount ?? 0;
const tabs: Tabs = { const setUrlTabParam = ( tabKey: string, query: Record< string, string > ) => {
search: { const term = query.term ? { term: query.term.trim() } : {};
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 ) => {
navigateTo( { navigateTo( {
url: getNewPath( url: getNewPath(
{ tab: tabKey === DEFAULT_TAB_KEY ? undefined : tabKey }, { tab: tabKey === DEFAULT_TAB_KEY ? undefined : tabKey },
MARKETPLACE_PATH, MARKETPLACE_PATH,
{} term
), ),
} ); } );
}; };
const getVisibleTabs = ( selectedTab: string, hasBusinessServices = false ) => { const getVisibleTabs = (
selectedTab: string,
hasBusinessServices = false,
tabs: Tabs
) => {
if ( selectedTab === '' ) { if ( selectedTab === '' ) {
return tabs; return tabs;
} }
const currentVisibleTabs = { ...tabs }; const currentVisibleTabs = { ...tabs };
if ( selectedTab !== 'search' ) {
delete currentVisibleTabs.search;
}
if ( ! hasBusinessServices ) { if ( ! hasBusinessServices ) {
delete currentVisibleTabs[ 'business-services' ]; delete currentVisibleTabs[ 'business-services' ];
} }
@ -101,7 +64,9 @@ const getVisibleTabs = ( selectedTab: string, hasBusinessServices = false ) => {
const renderTabs = ( const renderTabs = (
marketplaceContextValue: MarketplaceContextType, marketplaceContextValue: MarketplaceContextType,
visibleTabs: Tabs visibleTabs: Tabs,
tabs: Tabs,
query: Record< string, string >
) => { ) => {
const { selectedTab, setSelectedTab } = marketplaceContextValue; const { selectedTab, setSelectedTab } = marketplaceContextValue;
@ -110,7 +75,7 @@ const renderTabs = (
return; return;
} }
setSelectedTab( tabKey ); setSelectedTab( tabKey );
setUrlTabParam( tabKey ); setUrlTabParam( tabKey, query );
}; };
const tabContent = []; const tabContent = [];
@ -143,7 +108,15 @@ const renderTabs = (
{ tabs[ tabKey ]?.title } { tabs[ tabKey ]?.title }
{ tabs[ tabKey ]?.showUpdateCount && { tabs[ tabKey ]?.showUpdateCount &&
tabs[ tabKey ]?.updateCount > 0 && ( tabs[ tabKey ]?.updateCount > 0 && (
<span className="woocommerce-marketplace__update-count"> <span
className={ clsx(
'woocommerce-marketplace__update-count',
`woocommerce-marketplace__update-count-${ tabKey }`,
{
'is-active': tabKey === selectedTab,
}
) }
>
<span> { tabs[ tabKey ]?.updateCount } </span> <span> { tabs[ tabKey ]?.updateCount } </span>
</span> </span>
) } ) }
@ -157,23 +130,70 @@ const renderTabs = (
const Tabs = ( props: TabsProps ): JSX.Element => { const Tabs = ( props: TabsProps ): JSX.Element => {
const { additionalClassNames } = props; const { additionalClassNames } = props;
const marketplaceContextValue = useContext( MarketplaceContext ); const marketplaceContextValue = useContext( MarketplaceContext );
const { selectedTab, setSelectedTab, hasBusinessServices } = const { selectedTab, isLoading, setSelectedTab, hasBusinessServices } =
marketplaceContextValue; marketplaceContextValue;
const [ visibleTabs, setVisibleTabs ] = useState( getVisibleTabs( '' ) ); const { searchResultsCount } = marketplaceContextValue;
const query: Record< string, string > = useQuery(); 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( () => { useEffect( () => {
if ( query?.tab && tabs[ query.tab ] ) { if ( query?.tab && tabs[ query.tab ] ) {
setSelectedTab( query.tab ); setSelectedTab( query.tab );
} else if ( Object.keys( query ).length > 0 ) { } else if ( Object.keys( query ).length > 0 ) {
setSelectedTab( DEFAULT_TAB_KEY ); setSelectedTab( DEFAULT_TAB_KEY );
} }
}, [ query, setSelectedTab ] ); }, [ query, setSelectedTab, tabs ] );
useEffect( () => { useEffect( () => {
setVisibleTabs( getVisibleTabs( selectedTab, hasBusinessServices ) ); setVisibleTabs(
}, [ selectedTab, hasBusinessServices ] ); getVisibleTabs( selectedTab, hasBusinessServices, tabs )
);
if ( selectedTab === 'business-services' && ! hasBusinessServices ) {
setUrlTabParam( 'extensions', query );
}
}, [ selectedTab, hasBusinessServices, query, tabs ] );
return ( return (
<nav <nav
className={ clsx( className={ clsx(
@ -181,7 +201,7 @@ const Tabs = ( props: TabsProps ): JSX.Element => {
additionalClassNames || [] additionalClassNames || []
) } ) }
> >
{ renderTabs( marketplaceContextValue, visibleTabs ) } { renderTabs( marketplaceContextValue, visibleTabs, tabs, query ) }
</nav> </nav>
); );
}; };

View File

@ -1,12 +1,17 @@
/** /**
* External dependencies * External dependencies
*/ */
import { useState, useEffect, createContext } from '@wordpress/element'; import {
useState,
useEffect,
useCallback,
createContext,
} from '@wordpress/element';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { MarketplaceContextType } from './types'; import { SearchResultsCountType, MarketplaceContextType } from './types';
import { getAdminSetting } from '../../utils/admin-settings'; import { getAdminSetting } from '../../utils/admin-settings';
export const MarketplaceContext = createContext< MarketplaceContextType >( { export const MarketplaceContext = createContext< MarketplaceContextType >( {
@ -18,6 +23,12 @@ export const MarketplaceContext = createContext< MarketplaceContextType >( {
addInstalledProduct: () => {}, addInstalledProduct: () => {},
hasBusinessServices: false, hasBusinessServices: false,
setHasBusinessServices: () => {}, setHasBusinessServices: () => {},
searchResultsCount: {
extensions: 0,
themes: 0,
'business-services': 0,
},
setSearchResultsCount: () => {},
} ); } );
export function MarketplaceContextProvider( props: { export function MarketplaceContextProvider( props: {
@ -29,6 +40,22 @@ export function MarketplaceContextProvider( props: {
[] []
); );
const [ hasBusinessServices, setHasBusinessServices ] = useState( false ); 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 * Knowing installed products will help us to determine which products
@ -59,6 +86,8 @@ export function MarketplaceContextProvider( props: {
addInstalledProduct, addInstalledProduct,
hasBusinessServices, hasBusinessServices,
setHasBusinessServices, setHasBusinessServices,
searchResultsCount,
setSearchResultsCount,
}; };
return ( return (

View File

@ -8,6 +8,12 @@ import { Options } from '@wordpress/notices';
*/ */
import { Subscription } from '../components/my-subscriptions/types'; import { Subscription } from '../components/my-subscriptions/types';
export interface SearchResultsCountType {
extensions: number;
themes: number;
'business-services': number;
}
export type MarketplaceContextType = { export type MarketplaceContextType = {
isLoading: boolean; isLoading: boolean;
setIsLoading: ( isLoading: boolean ) => void; setIsLoading: ( isLoading: boolean ) => void;
@ -17,6 +23,10 @@ export type MarketplaceContextType = {
addInstalledProduct: ( slug: string ) => void; addInstalledProduct: ( slug: string ) => void;
hasBusinessServices: boolean; hasBusinessServices: boolean;
setHasBusinessServices: ( hasBusinessServices: boolean ) => void; setHasBusinessServices: ( hasBusinessServices: boolean ) => void;
searchResultsCount: SearchResultsCountType;
setSearchResultsCount: (
updatedCounts: Partial< SearchResultsCountType >
) => void;
}; };
export type SubscriptionsContextType = { export type SubscriptionsContextType = {

View File

@ -107,7 +107,11 @@ async function fetchJsonWithCache(
async function fetchSearchResults( async function fetchSearchResults(
params: URLSearchParams, params: URLSearchParams,
abortSignal?: AbortSignal abortSignal?: AbortSignal
): Promise< Product[] > { ): Promise< {
products: Product[];
totalPages: number;
totalProducts: number;
} > {
const url = const url =
MARKETPLACE_HOST + MARKETPLACE_HOST +
MARKETPLACE_SEARCH_API_PATH + 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[] > { function fetchCategories( type: ProductType ): Promise< CategoryAPIItem[] > {
const url = new URL( MARKETPLACE_HOST + MARKETPLACE_CATEGORY_API_PATH ); const url = new URL( MARKETPLACE_HOST + MARKETPLACE_CATEGORY_API_PATH );
@ -478,6 +496,7 @@ export {
fetchCategories, fetchCategories,
fetchDiscoverPageData, fetchDiscoverPageData,
fetchSearchResults, fetchSearchResults,
getProductType,
fetchSubscriptions, fetchSubscriptions,
refreshSubscriptions, refreshSubscriptions,
getInstallUrl, getInstallUrl,

View File

@ -42,11 +42,6 @@ function recordMarketplaceView( props: MarketplaceViewProps ) {
eventProps.category = '_all'; 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 ); recordEvent( 'marketplace_view', eventProps );
} }
@ -80,11 +75,6 @@ function recordLegacyTabView( props: MarketplaceViewProps ) {
case 'themes': case 'themes':
oldEventProps.section = 'themes'; oldEventProps.section = 'themes';
break; break;
case 'search':
oldEventName = 'extensions_view_search';
oldEventProps.section = view;
oldEventProps.search_term = search_term || '';
break;
case 'my-subscriptions': case 'my-subscriptions':
oldEventName = 'subscriptions_view'; oldEventName = 'subscriptions_view';
oldEventProps.section = 'helper'; oldEventProps.section = 'helper';

View File

@ -0,0 +1,4 @@
Significance: patch
Type: update
Update In-App Marketplace category selector

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Replace marketplace search component with SearchControl from @wordpress/components

View File

@ -0,0 +1,4 @@
Significance: patch
Type: fix
Fix the loading state for the In-App Marketplace search

View File

@ -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.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Change the way search results are displayed in the in-app marketplace

View File

@ -0,0 +1,4 @@
Significance: minor
Type: add
Add search result counts to the in-app marketplace header tabs (Extensions area)