Marketplace: Implement search and category filtering (#39608)
This commit is contained in:
commit
a1f26ada0f
|
@ -4,15 +4,39 @@
|
||||||
import { Dropdown } from '@wordpress/components';
|
import { Dropdown } from '@wordpress/components';
|
||||||
import { chevronDown, chevronUp, Icon } from '@wordpress/icons';
|
import { chevronDown, chevronUp, Icon } from '@wordpress/icons';
|
||||||
import { __ } from '@wordpress/i18n';
|
import { __ } from '@wordpress/i18n';
|
||||||
|
import { navigateTo, getNewPath } from '@woocommerce/navigation';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { Category } from './category-selector';
|
import { Category } from './types';
|
||||||
|
|
||||||
function DropdownContent( props: {
|
function DropdownContent( props: {
|
||||||
readonly categories: Category[];
|
readonly categories: Category[];
|
||||||
|
readonly selected?: Category;
|
||||||
|
readonly onClick: () => void;
|
||||||
} ): JSX.Element {
|
} ): JSX.Element {
|
||||||
|
function updateCategorySelection(
|
||||||
|
event: React.MouseEvent< HTMLButtonElement >
|
||||||
|
) {
|
||||||
|
const slug = event.currentTarget.value;
|
||||||
|
|
||||||
|
if ( ! slug ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger the onClick event on the parent component to close the dropdown.
|
||||||
|
* This closes the dropdown automatically when a user clicks on an item.
|
||||||
|
*/
|
||||||
|
props.onClick();
|
||||||
|
|
||||||
|
navigateTo( {
|
||||||
|
url: getNewPath( { category: slug } ),
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className="woocommerce-marketplace__category-dropdown-list">
|
<ul className="woocommerce-marketplace__category-dropdown-list">
|
||||||
{ props.categories.map( ( category ) => (
|
{ props.categories.map( ( category ) => (
|
||||||
|
@ -20,7 +44,17 @@ function DropdownContent( props: {
|
||||||
className="woocommerce-marketplace__category-dropdown-item"
|
className="woocommerce-marketplace__category-dropdown-item"
|
||||||
key={ category.slug }
|
key={ category.slug }
|
||||||
>
|
>
|
||||||
<button className="woocommerce-marketplace__category-dropdown-item-button">
|
<button
|
||||||
|
className={ classNames(
|
||||||
|
'woocommerce-marketplace__category-dropdown-item-button',
|
||||||
|
{
|
||||||
|
'woocommerce-marketplace__category-dropdown-item-button--selected':
|
||||||
|
category.slug === props.selected?.slug,
|
||||||
|
}
|
||||||
|
) }
|
||||||
|
value={ category.slug }
|
||||||
|
onClick={ updateCategorySelection }
|
||||||
|
>
|
||||||
{ category.label }
|
{ category.label }
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
@ -36,6 +70,7 @@ type CategoryDropdownProps = {
|
||||||
buttonClassName?: string;
|
buttonClassName?: string;
|
||||||
contentClassName?: string;
|
contentClassName?: string;
|
||||||
arrowIconSize?: number;
|
arrowIconSize?: number;
|
||||||
|
selected?: Category;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CategoryDropdown(
|
export default function CategoryDropdown(
|
||||||
|
@ -60,8 +95,12 @@ export default function CategoryDropdown(
|
||||||
</button>
|
</button>
|
||||||
) }
|
) }
|
||||||
className={ props.className }
|
className={ props.className }
|
||||||
renderContent={ () => (
|
renderContent={ ( { onToggle } ) => (
|
||||||
<DropdownContent categories={ props.categories } />
|
<DropdownContent
|
||||||
|
categories={ props.categories }
|
||||||
|
selected={ props.selected }
|
||||||
|
onClick={ onToggle }
|
||||||
|
/>
|
||||||
) }
|
) }
|
||||||
contentClassName={ props.contentClassName }
|
contentClassName={ props.contentClassName }
|
||||||
/>
|
/>
|
||||||
|
|
|
@ -2,13 +2,28 @@
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
|
import { navigateTo, getNewPath } from '@woocommerce/navigation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { Category } from './category-selector';
|
import { Category } from './types';
|
||||||
|
|
||||||
export default function CategoryLink( props: Category ): JSX.Element {
|
export default function CategoryLink( props: Category ): JSX.Element {
|
||||||
|
function updateCategorySelection(
|
||||||
|
event: React.MouseEvent< HTMLButtonElement >
|
||||||
|
) {
|
||||||
|
const slug = event.currentTarget.value;
|
||||||
|
|
||||||
|
if ( ! slug ) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
navigateTo( {
|
||||||
|
url: getNewPath( { category: slug } ),
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
const classes = classNames(
|
const classes = classNames(
|
||||||
'woocommerce-marketplace__category-item-button',
|
'woocommerce-marketplace__category-item-button',
|
||||||
{
|
{
|
||||||
|
@ -17,5 +32,13 @@ export default function CategoryLink( props: Category ): JSX.Element {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
return <button className={ classes }>{ props.label }</button>;
|
return (
|
||||||
|
<button
|
||||||
|
className={ classes }
|
||||||
|
onClick={ updateCategorySelection }
|
||||||
|
value={ props.slug }
|
||||||
|
>
|
||||||
|
{ props.label }
|
||||||
|
</button>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
.woocommerce-marketplace__category-selector {
|
.woocommerce-marketplace__category-selector {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
|
margin: $grid-unit-20 0 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.woocommerce-marketplace__category-item {
|
.woocommerce-marketplace__category-item {
|
||||||
|
@ -29,6 +30,7 @@
|
||||||
&--selected {
|
&--selected {
|
||||||
color: $white;
|
color: $white;
|
||||||
background-color: $gray-900;
|
background-color: $gray-900;
|
||||||
|
fill: $white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,4 +108,19 @@
|
||||||
padding: 6px $grid-unit-10;
|
padding: 6px $grid-unit-10;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|
||||||
|
&--selected {
|
||||||
|
color: $white;
|
||||||
|
background-color: $gray-900;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.woocommerce-marketplace__category-selector-loading {
|
||||||
|
display: flex;
|
||||||
|
margin-top: $grid-unit-20;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
line-height: $grid-unit-30;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,50 +4,50 @@
|
||||||
import { useState, useEffect } from '@wordpress/element';
|
import { useState, useEffect } from '@wordpress/element';
|
||||||
import { __ } from '@wordpress/i18n';
|
import { __ } from '@wordpress/i18n';
|
||||||
import { Spinner } from '@wordpress/components';
|
import { Spinner } from '@wordpress/components';
|
||||||
|
import { useQuery } from '@woocommerce/navigation';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import CategoryLink from './category-link';
|
import CategoryLink from './category-link';
|
||||||
import './category-selector.scss';
|
|
||||||
import CategoryDropdown from './category-dropdown';
|
import CategoryDropdown from './category-dropdown';
|
||||||
import { MARKETPLACE_URL } from '../constants';
|
import { Category, CategoryAPIItem } from './types';
|
||||||
|
import { fetchCategories } from '../../utils/functions';
|
||||||
|
import './category-selector.scss';
|
||||||
|
|
||||||
export type Category = {
|
const ALL_CATEGORIES_SLUG = '_all';
|
||||||
readonly slug: string;
|
|
||||||
readonly label: string;
|
|
||||||
selected: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type CategoryAPIItem = {
|
|
||||||
readonly slug: string;
|
|
||||||
readonly label: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function fetchCategories(): Promise< CategoryAPIItem[] > {
|
|
||||||
return fetch( MARKETPLACE_URL + '/wp-json/wccom-extensions/1.0/categories' )
|
|
||||||
.then( ( response ) => {
|
|
||||||
if ( ! response.ok ) {
|
|
||||||
throw new Error( response.statusText );
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
} )
|
|
||||||
.then( ( json ) => {
|
|
||||||
return json;
|
|
||||||
} )
|
|
||||||
.catch( () => {
|
|
||||||
return [];
|
|
||||||
} );
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function CategorySelector(): JSX.Element {
|
export default function CategorySelector(): JSX.Element {
|
||||||
const [ firstBatch, setFirstBatch ] = useState< Category[] >( [] );
|
const [ visibleItems, setVisibleItems ] = useState< Category[] >( [] );
|
||||||
const [ secondBatch, setSecondBatch ] = useState< Category[] >( [] );
|
const [ dropdownItems, setDropdownItems ] = useState< Category[] >( [] );
|
||||||
|
const [ selected, setSelected ] = useState< Category >();
|
||||||
const [ isLoading, setIsLoading ] = useState( false );
|
const [ isLoading, setIsLoading ] = useState( false );
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
|
||||||
|
useEffect( () => {
|
||||||
|
// If no category is selected, show All as selected
|
||||||
|
let categoryToSearch = ALL_CATEGORIES_SLUG;
|
||||||
|
|
||||||
|
if ( query.category ) {
|
||||||
|
categoryToSearch = query.category;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allCategories = visibleItems.concat( dropdownItems );
|
||||||
|
|
||||||
|
const selectedCategory = allCategories.find(
|
||||||
|
( category ) => category.slug === categoryToSearch
|
||||||
|
);
|
||||||
|
|
||||||
|
if ( selectedCategory ) {
|
||||||
|
setSelected( selectedCategory );
|
||||||
|
}
|
||||||
|
}, [ query, visibleItems, dropdownItems ] );
|
||||||
|
|
||||||
useEffect( () => {
|
useEffect( () => {
|
||||||
setIsLoading( true );
|
setIsLoading( true );
|
||||||
|
|
||||||
fetchCategories()
|
fetchCategories()
|
||||||
.then( ( categoriesFromAPI: CategoryAPIItem[] ) => {
|
.then( ( categoriesFromAPI: CategoryAPIItem[] ) => {
|
||||||
const categories: Category[] = categoriesFromAPI.map(
|
const categories: Category[] = categoriesFromAPI.map(
|
||||||
|
@ -61,7 +61,7 @@ export default function CategorySelector(): JSX.Element {
|
||||||
|
|
||||||
// Put the "All" category to the beginning
|
// Put the "All" category to the beginning
|
||||||
categories.sort( ( a ) => {
|
categories.sort( ( a ) => {
|
||||||
if ( a.slug === '_all' ) {
|
if ( a.slug === ALL_CATEGORIES_SLUG ) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -69,55 +69,94 @@ export default function CategorySelector(): JSX.Element {
|
||||||
} );
|
} );
|
||||||
|
|
||||||
// Split array into two from 7th item
|
// Split array into two from 7th item
|
||||||
const firstBatchCategories = categories.slice( 0, 7 );
|
const visibleCategoryItems = categories.slice( 0, 7 );
|
||||||
const secondBatchCategories = categories.slice( 7 );
|
const dropdownCategoryItems = categories.slice( 7 );
|
||||||
|
|
||||||
setFirstBatch( firstBatchCategories );
|
setVisibleItems( visibleCategoryItems );
|
||||||
setSecondBatch( secondBatchCategories );
|
setDropdownItems( dropdownCategoryItems );
|
||||||
|
} )
|
||||||
|
.catch( () => {
|
||||||
|
setVisibleItems( [] );
|
||||||
|
setDropdownItems( [] );
|
||||||
} )
|
} )
|
||||||
.finally( () => {
|
.finally( () => {
|
||||||
setIsLoading( false );
|
setIsLoading( false );
|
||||||
} );
|
} );
|
||||||
}, [] );
|
}, [] );
|
||||||
|
|
||||||
|
function mobileCategoryDropdownLabel() {
|
||||||
|
const allCategoriesText = __( 'All Categories', 'woocommerce' );
|
||||||
|
|
||||||
|
if ( ! selected ) {
|
||||||
|
return allCategoriesText;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( selected.label === 'All' ) {
|
||||||
|
return allCategoriesText;
|
||||||
|
}
|
||||||
|
|
||||||
|
return selected.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectedInDropdown() {
|
||||||
|
if ( ! selected ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return dropdownItems.find(
|
||||||
|
( category ) => category.slug === selected.slug
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if ( isLoading ) {
|
if ( isLoading ) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="woocommerce-marketplace__category-selector-loading">
|
||||||
{ __( 'Loading categories…', 'woocommerce' ) }
|
<p>{ __( 'Loading categories…', 'woocommerce' ) }</p>
|
||||||
<Spinner />
|
<Spinner />
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ul className="woocommerce-marketplace__category-selector">
|
<ul className="woocommerce-marketplace__category-selector">
|
||||||
{ firstBatch.map( ( category ) => (
|
{ visibleItems.map( ( category ) => (
|
||||||
<li
|
<li
|
||||||
className="woocommerce-marketplace__category-item"
|
className="woocommerce-marketplace__category-item"
|
||||||
key={ category.slug }
|
key={ category.slug }
|
||||||
>
|
>
|
||||||
<CategoryLink { ...category } />
|
<CategoryLink
|
||||||
|
{ ...category }
|
||||||
|
selected={ category.slug === selected?.slug }
|
||||||
|
/>
|
||||||
</li>
|
</li>
|
||||||
) ) }
|
) ) }
|
||||||
<li className="woocommerce-marketplace__category-item">
|
<li className="woocommerce-marketplace__category-item">
|
||||||
<CategoryDropdown
|
<CategoryDropdown
|
||||||
label={ __( 'More', 'woocommerce' ) }
|
label={ __( 'More', 'woocommerce' ) }
|
||||||
categories={ secondBatch }
|
categories={ dropdownItems }
|
||||||
buttonClassName="woocommerce-marketplace__category-item-button"
|
buttonClassName={ classNames(
|
||||||
|
'woocommerce-marketplace__category-item-button',
|
||||||
|
{
|
||||||
|
'woocommerce-marketplace__category-item-button--selected':
|
||||||
|
isSelectedInDropdown(),
|
||||||
|
}
|
||||||
|
) }
|
||||||
contentClassName="woocommerce-marketplace__category-item-content"
|
contentClassName="woocommerce-marketplace__category-item-content"
|
||||||
arrowIconSize={ 20 }
|
arrowIconSize={ 20 }
|
||||||
|
selected={ selected }
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="woocommerce-marketplace__category-selector--full-width">
|
<div className="woocommerce-marketplace__category-selector--full-width">
|
||||||
<CategoryDropdown
|
<CategoryDropdown
|
||||||
label={ __( 'All Categories', 'woocommerce' ) }
|
label={ mobileCategoryDropdownLabel() }
|
||||||
categories={ firstBatch.concat( secondBatch ) }
|
categories={ visibleItems.concat( dropdownItems ) }
|
||||||
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 }
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
export type Category = {
|
||||||
|
readonly slug: string;
|
||||||
|
readonly label: string;
|
||||||
|
selected: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CategoryAPIItem = {
|
||||||
|
readonly slug: string;
|
||||||
|
readonly label: string;
|
||||||
|
};
|
|
@ -6,14 +6,5 @@
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 40px;
|
gap: 40px;
|
||||||
padding: 0 $content-spacing-small;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (min-width: $breakpoint-medium) {
|
|
||||||
.woocommerce-marketplace {
|
|
||||||
&__discover {
|
|
||||||
padding: 0 $content-spacing-large;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,45 @@
|
||||||
/**
|
/**
|
||||||
* External dependencies
|
* External dependencies
|
||||||
*/
|
*/
|
||||||
|
import { useContext } from '@wordpress/element';
|
||||||
|
import { __, sprintf } from '@wordpress/i18n';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import './extensions.scss';
|
import './extensions.scss';
|
||||||
import CategorySelector from '../category-selector/category-selector';
|
import CategorySelector from '../category-selector/category-selector';
|
||||||
|
import { ProductListContext } from '../../contexts/product-list-context';
|
||||||
|
import ProductListContent from '../product-list-content/product-list-content';
|
||||||
|
import ProductLoader from '../product-loader/product-loader';
|
||||||
|
|
||||||
export default function Extensions(): JSX.Element {
|
export default function Extensions(): JSX.Element {
|
||||||
|
const productListContextValue = useContext( ProductListContext );
|
||||||
|
|
||||||
|
const { productList, isLoading } = productListContextValue;
|
||||||
|
const products = productList.slice( 0, 60 );
|
||||||
|
|
||||||
|
let title = __( 'Extensions', 'woocommerce' );
|
||||||
|
|
||||||
|
if ( products.length > 0 ) {
|
||||||
|
title = sprintf(
|
||||||
|
// translators: %s: number of extensions
|
||||||
|
__( '%s extensions', 'woocommerce' ),
|
||||||
|
products.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="woocommerce-marketplace__extensions">
|
<div className="woocommerce-marketplace__extensions">
|
||||||
|
<h2 className="woocommerce-marketplace__product-list-title woocommerce-marketplace__product-list-title--extensions">
|
||||||
|
{ title }
|
||||||
|
</h2>
|
||||||
<CategorySelector />
|
<CategorySelector />
|
||||||
<div className="woocommerce-marketplace__product-list-content">
|
{ isLoading ? (
|
||||||
<div className="woocommerce-marketplace__extension-card"></div>
|
<ProductLoader />
|
||||||
<div className="woocommerce-marketplace__extension-card"></div>
|
) : (
|
||||||
<div className="woocommerce-marketplace__extension-card"></div>
|
<ProductListContent products={ products } />
|
||||||
<div className="woocommerce-marketplace__extension-card"></div>
|
) }
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,7 +8,7 @@ import { Button, Card } from '@wordpress/components';
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import './product-card.scss';
|
import './product-card.scss';
|
||||||
import { Product } from '../product-list-content/product-list-content';
|
import { Product } from '../product-list/types';
|
||||||
import { getAdminSetting } from '../../../../client/utils/admin-settings';
|
import { getAdminSetting } from '../../../../client/utils/admin-settings';
|
||||||
|
|
||||||
export interface ProductCardProps {
|
export interface ProductCardProps {
|
||||||
|
|
|
@ -9,6 +9,7 @@
|
||||||
|
|
||||||
&__extension-card {
|
&__extension-card {
|
||||||
background-color: #3c3c3c;
|
background-color: #3c3c3c;
|
||||||
|
color: $white;
|
||||||
height: 270px;
|
height: 270px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,30 +2,12 @@
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import './product-list-content.scss';
|
import './product-list-content.scss';
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @woocommerce/dependency-group
|
|
||||||
import ProductCard from '../product-card/product-card';
|
import ProductCard from '../product-card/product-card';
|
||||||
|
import { Product } from '../product-list/types';
|
||||||
|
|
||||||
export interface Product {
|
export default function ProductListContent( props: {
|
||||||
id?: number;
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
vendorName: string;
|
|
||||||
vendorUrl: string;
|
|
||||||
icon: string;
|
|
||||||
url: string;
|
|
||||||
price: string | number;
|
|
||||||
productType?: string;
|
|
||||||
averageRating?: number | null;
|
|
||||||
reviewsCount?: number | null;
|
|
||||||
currency?: string;
|
|
||||||
}
|
|
||||||
interface ProductListContentProps {
|
|
||||||
products: Product[];
|
products: Product[];
|
||||||
}
|
} ): JSX.Element {
|
||||||
|
|
||||||
export default function ProductListContent(
|
|
||||||
props: ProductListContentProps
|
|
||||||
): JSX.Element {
|
|
||||||
const { products } = props;
|
const { products } = props;
|
||||||
return (
|
return (
|
||||||
<div className="woocommerce-marketplace__product-list-content">
|
<div className="woocommerce-marketplace__product-list-content">
|
||||||
|
|
|
@ -13,6 +13,11 @@
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
margin-top: $small-gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__product-list-title--extensions {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
&__product-list-link {
|
&__product-list-link {
|
||||||
|
|
|
@ -1,14 +1,9 @@
|
||||||
/**
|
|
||||||
* External dependencies
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import ProductListContent, {
|
import ProductListContent from '../product-list-content/product-list-content';
|
||||||
Product,
|
|
||||||
} from '../product-list-content/product-list-content';
|
|
||||||
import ProductListHeader from '../product-list-header/product-list-header';
|
import ProductListHeader from '../product-list-header/product-list-header';
|
||||||
|
import { Product } from './types';
|
||||||
|
|
||||||
interface ProductListProps {
|
interface ProductListProps {
|
||||||
title: string;
|
title: string;
|
||||||
|
|
|
@ -0,0 +1,31 @@
|
||||||
|
export type SearchAPIProductType = {
|
||||||
|
title: string;
|
||||||
|
image: string;
|
||||||
|
excerpt: string;
|
||||||
|
link: string;
|
||||||
|
demo_url: string;
|
||||||
|
price: string;
|
||||||
|
hash: string;
|
||||||
|
slug: string;
|
||||||
|
id: number;
|
||||||
|
rating: number | null;
|
||||||
|
reviews_count: number | null;
|
||||||
|
vendor_name: string;
|
||||||
|
vendor_url: string;
|
||||||
|
icon: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface Product {
|
||||||
|
id?: number;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
vendorName: string;
|
||||||
|
vendorUrl: string;
|
||||||
|
icon: string;
|
||||||
|
url: string;
|
||||||
|
price: string | number;
|
||||||
|
productType?: string;
|
||||||
|
averageRating?: number | null;
|
||||||
|
reviewsCount?: number | null;
|
||||||
|
currency?: string;
|
||||||
|
}
|
|
@ -1,6 +1,10 @@
|
||||||
@import '../../stylesheets/_variables.scss';
|
@import '../../stylesheets/_variables.scss';
|
||||||
|
|
||||||
.woocommerce-marketplace {
|
.woocommerce-marketplace {
|
||||||
|
&__product-loader {
|
||||||
|
margin-top: $grid-unit-20;
|
||||||
|
}
|
||||||
|
|
||||||
&__product-loader-cards {
|
&__product-loader-cards {
|
||||||
display: grid;
|
display: grid;
|
||||||
background: linear-gradient(to right, $gray-0 40%, $gray-5 60%, $gray-0 80%);
|
background: linear-gradient(to right, $gray-0 40%, $gray-5 60%, $gray-0 80%);
|
||||||
|
|
|
@ -3,60 +3,40 @@
|
||||||
*/
|
*/
|
||||||
import { __ } from '@wordpress/i18n';
|
import { __ } from '@wordpress/i18n';
|
||||||
import { Icon, search } from '@wordpress/icons';
|
import { Icon, search } from '@wordpress/icons';
|
||||||
import { useState } from '@wordpress/element';
|
import { useEffect, useState } from '@wordpress/element';
|
||||||
|
import { navigateTo, getNewPath, useQuery } from '@woocommerce/navigation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import './search.scss';
|
import './search.scss';
|
||||||
import { MARKETPLACE_URL } from '../constants';
|
|
||||||
|
|
||||||
const searchPlaceholder = __( 'Search extensions and themes', 'woocommerce' );
|
const searchPlaceholder = __( 'Search extensions and themes', 'woocommerce' );
|
||||||
|
|
||||||
const marketplaceAPI = MARKETPLACE_URL + '/wp-json/wccom-extensions/1.0/search';
|
|
||||||
|
|
||||||
export interface SearchProps {
|
|
||||||
locale?: string | 'en_US';
|
|
||||||
country?: string | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search component.
|
* Search component.
|
||||||
*
|
*
|
||||||
* @param {SearchProps} props - Search props: locale and country.
|
|
||||||
* @return {JSX.Element} Search component.
|
* @return {JSX.Element} Search component.
|
||||||
*/
|
*/
|
||||||
function Search( props: SearchProps ): JSX.Element {
|
function Search(): JSX.Element {
|
||||||
const locale = props.locale ?? 'en_US';
|
|
||||||
const country = props.country ?? '';
|
|
||||||
const [ searchTerm, setSearchTerm ] = useState( '' );
|
const [ searchTerm, setSearchTerm ] = useState( '' );
|
||||||
|
|
||||||
const build_parameter_string = (
|
const query = useQuery();
|
||||||
query_string: string,
|
|
||||||
query_country: string,
|
useEffect( () => {
|
||||||
query_locale: string
|
if ( query.term ) {
|
||||||
): string => {
|
setSearchTerm( query.term );
|
||||||
const params = new URLSearchParams();
|
}
|
||||||
params.append( 'term', query_string );
|
}, [ query.term ] );
|
||||||
params.append( 'country', query_country );
|
|
||||||
params.append( 'locale', query_locale );
|
|
||||||
return params.toString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const runSearch = () => {
|
const runSearch = () => {
|
||||||
const query = searchTerm.trim();
|
const term = searchTerm.trim();
|
||||||
if ( ! query ) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const params = build_parameter_string( query, country, locale );
|
// When the search term changes, we reset the category on purpose.
|
||||||
fetch( marketplaceAPI + '?' + params, {
|
navigateTo( {
|
||||||
method: 'GET',
|
url: getNewPath( { term, category: null, tab: 'extensions' } ),
|
||||||
} )
|
|
||||||
.then( ( response ) => response.json() )
|
|
||||||
.then( ( response ) => {
|
|
||||||
return response;
|
|
||||||
} );
|
} );
|
||||||
|
|
||||||
return [];
|
return [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -70,6 +50,7 @@ function Search( props: SearchProps ): JSX.Element {
|
||||||
if ( event.key === 'Enter' ) {
|
if ( event.key === 'Enter' ) {
|
||||||
runSearch();
|
runSearch();
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( event.key === 'Escape' ) {
|
if ( event.key === 'Escape' ) {
|
||||||
setSearchTerm( '' );
|
setSearchTerm( '' );
|
||||||
}
|
}
|
||||||
|
|
|
@ -34,9 +34,3 @@
|
||||||
border-bottom: 1px solid $gutenberg-gray-300;
|
border-bottom: 1px solid $gutenberg-gray-300;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: $breakpoint-medium) {
|
|
||||||
.woocommerce-marketplace__tabs {
|
|
||||||
padding: 0 $content-spacing-large;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,104 @@
|
||||||
|
/**
|
||||||
|
* External dependencies
|
||||||
|
*/
|
||||||
|
import { useQuery } from '@woocommerce/navigation';
|
||||||
|
import { useState, useEffect, createContext } from '@wordpress/element';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal dependencies
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
Product,
|
||||||
|
SearchAPIProductType,
|
||||||
|
} from '../components/product-list/types';
|
||||||
|
import { MARKETPLACE_URL } from '../components/constants';
|
||||||
|
|
||||||
|
type ProductListContextType = {
|
||||||
|
productList: Product[];
|
||||||
|
isLoading: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ProductListContext = createContext< ProductListContextType >( {
|
||||||
|
productList: [],
|
||||||
|
isLoading: false,
|
||||||
|
} );
|
||||||
|
|
||||||
|
type ProductListContextProviderProps = {
|
||||||
|
children: JSX.Element;
|
||||||
|
country?: string;
|
||||||
|
locale?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ProductListContextProvider(
|
||||||
|
props: ProductListContextProviderProps
|
||||||
|
): JSX.Element {
|
||||||
|
const [ isLoading, setIsLoading ] = useState( false );
|
||||||
|
const [ productList, setProductList ] = useState< Product[] >( [] );
|
||||||
|
|
||||||
|
const contextValue = {
|
||||||
|
productList,
|
||||||
|
isLoading,
|
||||||
|
};
|
||||||
|
|
||||||
|
const query = useQuery();
|
||||||
|
|
||||||
|
useEffect( () => {
|
||||||
|
setIsLoading( true );
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
params.append( 'term', query.term ?? '' );
|
||||||
|
params.append( 'country', props.country ?? '' );
|
||||||
|
params.append( 'locale', props.locale ?? '' );
|
||||||
|
|
||||||
|
if ( query.category ) {
|
||||||
|
params.append( 'category', query.category );
|
||||||
|
}
|
||||||
|
|
||||||
|
const wccomSearchEndpoint =
|
||||||
|
MARKETPLACE_URL +
|
||||||
|
'/wp-json/wccom-extensions/1.0/search?' +
|
||||||
|
params.toString();
|
||||||
|
|
||||||
|
// Fetch data from WCCOM API
|
||||||
|
fetch( wccomSearchEndpoint )
|
||||||
|
.then( ( response ) => response.json() )
|
||||||
|
.then( ( response ) => {
|
||||||
|
/**
|
||||||
|
* Product card component expects a Product type.
|
||||||
|
* So we build that object from the API response.
|
||||||
|
*/
|
||||||
|
const products = response.products.map(
|
||||||
|
( product: SearchAPIProductType ): Product => {
|
||||||
|
return {
|
||||||
|
id: product.id,
|
||||||
|
title: product.title,
|
||||||
|
description: product.excerpt,
|
||||||
|
vendorName: product.vendor_name,
|
||||||
|
vendorUrl: product.vendor_url,
|
||||||
|
icon: product.icon,
|
||||||
|
url: product.link,
|
||||||
|
price: product.price,
|
||||||
|
averageRating: product.rating ?? 0,
|
||||||
|
reviewsCount: product.reviews_count ?? 0,
|
||||||
|
currency: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setProductList( products );
|
||||||
|
} )
|
||||||
|
.catch( () => {
|
||||||
|
setProductList( [] );
|
||||||
|
} )
|
||||||
|
.finally( () => {
|
||||||
|
setIsLoading( false );
|
||||||
|
} );
|
||||||
|
}, [ query, props.country, props.locale ] );
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ProductListContext.Provider value={ contextValue }>
|
||||||
|
{ props.children }
|
||||||
|
</ProductListContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
|
@ -10,11 +10,13 @@ import './marketplace.scss';
|
||||||
import { DEFAULT_TAB_KEY } from './components/constants';
|
import { DEFAULT_TAB_KEY } from './components/constants';
|
||||||
import Header from './components/header/header';
|
import Header from './components/header/header';
|
||||||
import Content from './components/content/content';
|
import Content from './components/content/content';
|
||||||
|
import { ProductListContextProvider } from './contexts/product-list-context';
|
||||||
|
|
||||||
export default function Marketplace() {
|
export default function Marketplace() {
|
||||||
const [ selectedTab, setSelectedTab ] = useState( DEFAULT_TAB_KEY );
|
const [ selectedTab, setSelectedTab ] = useState( DEFAULT_TAB_KEY );
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<ProductListContextProvider>
|
||||||
<div className="woocommerce-marketplace">
|
<div className="woocommerce-marketplace">
|
||||||
<Header
|
<Header
|
||||||
selectedTab={ selectedTab }
|
selectedTab={ selectedTab }
|
||||||
|
@ -22,5 +24,6 @@ export default function Marketplace() {
|
||||||
/>
|
/>
|
||||||
<Content selectedTab={ selectedTab } />
|
<Content selectedTab={ selectedTab } />
|
||||||
</div>
|
</div>
|
||||||
|
</ProductListContextProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,9 @@
|
||||||
/**
|
/**
|
||||||
* Internal dependencies
|
* Internal dependencies
|
||||||
*/
|
*/
|
||||||
import { Product } from '../components/product-list-content/product-list-content';
|
import { Product } from '../components/product-list/types';
|
||||||
import { MARKETPLACE_URL } from '../components/constants';
|
import { MARKETPLACE_URL } from '../components/constants';
|
||||||
|
import { CategoryAPIItem } from '../components/category-selector/types';
|
||||||
|
|
||||||
interface ProductGroup {
|
interface ProductGroup {
|
||||||
id: number;
|
id: number;
|
||||||
|
@ -30,4 +31,21 @@ const fetchDiscoverPageData = async (): Promise< Array< ProductGroup > > => {
|
||||||
} );
|
} );
|
||||||
};
|
};
|
||||||
|
|
||||||
export { fetchDiscoverPageData, ProductGroup };
|
function fetchCategories(): Promise< CategoryAPIItem[] > {
|
||||||
|
return fetch( MARKETPLACE_URL + '/wp-json/wccom-extensions/1.0/categories' )
|
||||||
|
.then( ( response ) => {
|
||||||
|
if ( ! response.ok ) {
|
||||||
|
throw new Error( response.statusText );
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
} )
|
||||||
|
.then( ( json ) => {
|
||||||
|
return json;
|
||||||
|
} )
|
||||||
|
.catch( () => {
|
||||||
|
return [];
|
||||||
|
} );
|
||||||
|
}
|
||||||
|
|
||||||
|
export { fetchDiscoverPageData, fetchCategories, ProductGroup };
|
||||||
|
|
Loading…
Reference in New Issue