Marketplace Themes: Feature Branch (#40159)

* Support for themes in in-app marketplace.

Contains the changes from:

https://github.com/woocommerce/woocommerce/pull/40247
https://github.com/woocommerce/woocommerce/pull/40272
https://github.com/woocommerce/woocommerce/pull/40302
https://github.com/woocommerce/woocommerce/pull/40303
https://github.com/woocommerce/woocommerce/pull/40333
https://github.com/woocommerce/woocommerce/pull/40368
https://github.com/woocommerce/woocommerce/pull/40375
https://github.com/woocommerce/woocommerce/pull/40375
https://github.com/woocommerce/woocommerce/pull/40389

* `.woocommerce-marketplace__discover`: changed `align-items` `flex-start` to `stretch` to properly display products on large and very large viewports.

* Delete plugins/woocommerce/changelog/add-18026-marketplace-theme-cards

Removing from feature branch before final review

* Delete plugins/woocommerce/changelog/add-18027-themes-to-in-app-search

Removing from feature branch before final review

* Delete plugins/woocommerce/changelog/add-marketplace-theme-discover-section

Removing from feature branch before final review

* Delete plugins/woocommerce/changelog/update-in-app-multiple-category-filters

Removing from feature branch before final review

* Delete plugins/woocommerce/changelog/update-theme-no-result-style

Removing from feature branch before final review

* Add changefile(s) from automation for the following project(s): woocommerce

---------

Co-authored-by: And Finally <andfinally@users.noreply.github.com>
Co-authored-by: Dan Q <dan@danq.me>
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Dan Q <danq@automattic.com>
This commit is contained in:
Kyle Nel 2023-10-04 18:59:34 +02:00 committed by GitHub
parent 793e4a821d
commit d8adecf783
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 481 additions and 135 deletions

View File

@ -0,0 +1,25 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2584_6481)">
<path d="M79.9999 0H40V39.9999H79.9999V0Z" fill="#757575"/>
<path d="M71.4366 14.3426C74.7128 14.3426 77.3687 11.6867 77.3687 8.41043C77.3687 5.13419 74.7128 2.47827 71.4366 2.47827C68.1603 2.47827 65.5044 5.13419 65.5044 8.41043C65.5044 11.6867 68.1603 14.3426 71.4366 14.3426Z" fill="#E0E0E0"/>
<path d="M70.8753 24.5383C65.3379 24.5383 62.8872 32.6033 59.0083 32.6033C54.9361 32.6033 56.7333 15.4783 50.9219 15.4783C46.9533 15.4783 41.9711 26.1877 40 33.159V40H43.6031C43.626 39.927 45.908 26.8388 50.3351 26.9083C53.8943 26.9639 53.9435 36.1975 57.9296 36.6593C62.1283 37.1472 63.0067 31.0476 68.4492 31.0476C73.2785 31.0476 75.6994 40 75.6994 40H79.9999V35.3189C79.9999 33.4455 76.4126 24.54 70.877 24.54L70.8753 24.5383Z" fill="#E0E0E0"/>
<path d="M25.9218 50H4.07821C1.82588 50 0 51.8259 0 54.0782V75.9218C0 78.1741 1.82588 80 4.07821 80H25.9218C28.1741 80 30 78.1741 30 75.9218V54.0782C30 51.8259 28.1741 50 25.9218 50Z" fill="#E0E0E0"/>
<path d="M14.5919 69.7092L13.4895 66.928H7.28028L6.17781 69.7092H4.40088L9.43142 57.2632H11.3585L16.3711 69.7092H14.5942H14.5919ZM10.3837 58.8505L7.74635 65.5687H13.0391L10.3837 58.8505Z" fill="#757575"/>
<path d="M23.5888 69.7093V68.6831C22.8606 69.5036 21.8119 69.9328 20.5974 69.9328C19.0826 69.9328 17.4558 68.9066 17.4558 66.9482C17.4558 64.9897 19.0647 63.9815 20.5974 63.9815C21.832 63.9815 22.8606 64.3727 23.5888 65.1932V63.5701C23.5888 62.3762 22.6163 61.6854 21.3077 61.6854C20.2232 61.6854 19.3448 62.0588 18.5403 62.9352L17.886 61.9649C18.8585 60.9566 20.017 60.4714 21.4959 60.4714C23.423 60.4714 24.9938 61.3299 24.9938 63.512V69.707H23.5911L23.5888 69.7093ZM23.5888 67.8067V66.0897C23.0465 65.3609 22.092 64.9897 21.1195 64.9897C19.7907 64.9897 18.8742 65.8102 18.8742 66.9482C18.8742 68.0862 19.7907 68.9268 21.1195 68.9268C22.092 68.9268 23.0465 68.5534 23.5888 67.8067Z" fill="#757575"/>
<circle cx="15.7997" cy="9.52504" r="3.52504" transform="rotate(90 15.7997 9.52504)" fill="#E0E0E0"/>
<circle cx="15.7997" cy="21.1237" r="3.52504" transform="rotate(90 15.7997 21.1237)" fill="#E0E0E0"/>
<circle cx="15.7997" cy="32.7223" r="3.52504" transform="rotate(90 15.7997 32.7223)" fill="#757575"/>
<circle cx="15.7994" cy="32.7216" r="5.68555" transform="rotate(90 15.7994 32.7216)" stroke="#271B3D" stroke-width="0.227422"/>
<path d="M75.001 80L75.001 50L70.001 50L70.001 80L75.001 80Z" fill="#757575"/>
<path d="M62.001 80L62.001 50L57.001 50L57.001 80L62.001 80Z" fill="#757575"/>
<path d="M49.0002 80L49.0002 50L44.0002 50L44.0002 80L49.0002 80Z" fill="#757575"/>
<path d="M76.3637 69C74.9082 69 74.4716 70.0689 72.7182 70.0689C70.9647 70.0689 70.5281 69 69.0726 69C67.5125 69 67.0008 70.3509 67.0008 71.9929C67.0008 73.6349 67.5125 74.9857 69.0726 74.9857C70.5281 74.9857 70.9647 73.9169 72.7182 73.9169C74.4716 73.9169 74.9082 74.9857 76.3637 74.9857C77.9238 74.9857 78.4355 73.6349 78.4355 71.9929C78.4355 70.3509 77.9238 69 76.3637 69Z" fill="#E0E0E0"/>
<path d="M63.3637 58C61.9082 58 61.4716 59.0689 59.7182 59.0689C57.9647 59.0689 57.5281 58 56.0726 58C54.5125 58 54.0008 59.3509 54.0008 60.9929C54.0008 62.6349 54.5125 63.9857 56.0726 63.9857C57.5281 63.9857 57.9647 62.9169 59.7182 62.9169C61.4716 62.9169 61.9082 63.9857 63.3637 63.9857C64.9238 63.9857 65.4355 62.6349 65.4355 60.9929C65.4355 59.3509 64.9238 58 63.3637 58Z" fill="#E0E0E0"/>
<path d="M50.363 66C48.9075 66 48.4709 67.0689 46.7174 67.0689C44.964 67.0689 44.5274 66 43.0719 66C41.5117 66 41 67.3509 41 68.9929C41 70.6349 41.5117 71.9857 43.0719 71.9857C44.5274 71.9857 44.964 70.9169 46.7174 70.9169C48.4709 70.9169 48.9075 71.9857 50.363 71.9857C51.9231 71.9857 52.4348 70.6349 52.4348 68.9929C52.4348 67.3509 51.9231 66 50.363 66Z" fill="#E0E0E0"/>
</g>
<defs>
<clipPath id="clip0_2584_6481">
<rect width="80" height="80" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

View File

@ -15,10 +15,20 @@ import CategoryDropdown from './category-dropdown';
import { Category, CategoryAPIItem } from './types';
import { fetchCategories } from '../../utils/functions';
import './category-selector.scss';
import { ProductType } from '../product-list/types';
const ALL_CATEGORIES_SLUG = '_all';
const ALL_CATEGORIES_SLUGS = {
[ ProductType.extension ]: '_all',
[ ProductType.theme ]: 'themes',
};
export default function CategorySelector(): JSX.Element {
interface CategorySelectorProps {
type: ProductType;
}
export default function CategorySelector(
props: CategorySelectorProps
): JSX.Element {
const [ visibleItems, setVisibleItems ] = useState< Category[] >( [] );
const [ dropdownItems, setDropdownItems ] = useState< Category[] >( [] );
const [ selected, setSelected ] = useState< Category >();
@ -28,7 +38,7 @@ export default function CategorySelector(): JSX.Element {
useEffect( () => {
// If no category is selected, show All as selected
let categoryToSearch = ALL_CATEGORIES_SLUG;
let categoryToSearch = ALL_CATEGORIES_SLUGS[ props.type ];
if ( query.category ) {
categoryToSearch = query.category;
@ -43,12 +53,12 @@ export default function CategorySelector(): JSX.Element {
if ( selectedCategory ) {
setSelected( selectedCategory );
}
}, [ query, visibleItems, dropdownItems ] );
}, [ query.category, props.type, visibleItems, dropdownItems ] );
useEffect( () => {
setIsLoading( true );
fetchCategories()
fetchCategories( props.type )
.then( ( categoriesFromAPI: CategoryAPIItem[] ) => {
const categories: Category[] = categoriesFromAPI
.map( ( categoryAPIItem: CategoryAPIItem ): Category => {
@ -76,7 +86,7 @@ export default function CategorySelector(): JSX.Element {
.finally( () => {
setIsLoading( false );
} );
}, [] );
}, [ props.type ] );
function mobileCategoryDropdownLabel() {
const allCategoriesText = __( 'All Categories', 'woocommerce' );

View File

@ -5,3 +5,5 @@ export const MARKETPLACE_SEARCH_API_PATH =
'/wp-json/wccom-extensions/1.0/search';
export const MARKETPLACE_CATEGORY_API_PATH =
'/wp-json/wccom-extensions/1.0/categories';
export const MARKETPLACE_ITEMS_PER_PAGE = 60;
export const MARKETPLACE_SEARCH_RESULTS_PER_PAGE = 6;

View File

@ -1,31 +1,121 @@
/**
* External dependencies
*/
import { useContext } from '@wordpress/element';
import { useContext, useEffect, useState } from '@wordpress/element';
import { useQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import './content.scss';
import { Product, SearchAPIProductType } from '../product-list/types';
import { MARKETPLACE_SEARCH_API_PATH, MARKETPLACE_HOST } from '../constants';
import { getAdminSetting } from '../../../utils/admin-settings';
import Discover from '../discover/discover';
import Extensions from '../extensions/extensions';
import SearchResults from '../search-results/search-results';
import Themes from '../themes/themes';
import { MarketplaceContext } from '../../contexts/marketplace-context';
const renderContent = ( selectedTab?: string ): JSX.Element => {
switch ( selectedTab ) {
case 'extensions':
return <Extensions />;
default:
return <Discover />;
}
};
export default function Content(): JSX.Element {
const marketplaceContextValue = useContext( MarketplaceContext );
const { selectedTab } = marketplaceContextValue;
const [ products, setProducts ] = useState< Product[] >( [] );
const { setIsLoading, selectedTab } = marketplaceContextValue;
const query = useQuery();
// Get the content for this screen
useEffect( () => {
if ( [ '', 'discover' ].includes( selectedTab ) ) {
return;
}
setIsLoading( true );
setProducts( [] );
const params = new URLSearchParams();
if ( query.term ) {
params.append( 'term', query.term );
}
if ( query.category ) {
params.append(
'category',
query.category === '_all' ? '' : query.category
);
} else if ( selectedTab === 'themes' ) {
params.append( 'category', 'themes' );
} else if ( selectedTab === 'search' ) {
params.append( 'category', 'extensions-themes' );
}
const wccomSettings = getAdminSetting( 'wccomHelper', false );
if ( wccomSettings.storeCountry ) {
params.append( 'country', wccomSettings.storeCountry );
}
const wccomSearchEndpoint =
MARKETPLACE_HOST +
MARKETPLACE_SEARCH_API_PATH +
'?' +
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 productList = response.products.map(
( product: SearchAPIProductType ): Product => {
return {
id: product.id,
title: product.title,
image: product.image,
type: product.type,
description: product.excerpt,
vendorName: product.vendor_name,
vendorUrl: product.vendor_url,
icon: product.icon,
url: product.link,
// Due to backwards compatibility, raw_price is from search API, price is from featured API
price: product.raw_price ?? product.price,
averageRating: product.rating ?? 0,
reviewsCount: product.reviews_count ?? 0,
};
}
);
setProducts( productList );
} )
.catch( () => {
setProducts( [] );
} )
.finally( () => {
setIsLoading( false );
} );
}, [ query.term, query.category, selectedTab, setIsLoading ] );
const renderContent = (): JSX.Element => {
switch ( selectedTab ) {
case 'extensions':
return <Extensions products={ products } />;
case 'themes':
return <Themes products={ products } />;
case 'search':
return <SearchResults products={ products } />;
case 'discover':
return <Discover />;
default:
return <></>;
}
};
return (
<div className="woocommerce-marketplace__content">
{ renderContent( selectedTab ) }
{ renderContent() }
</div>
);
}

View File

@ -4,7 +4,7 @@
&__discover {
display: flex;
flex-direction: column;
align-items: center;
align-items: stretch;
gap: 40px;
}
}

View File

@ -24,6 +24,14 @@ export default function Discover(): JSX.Element | null {
setIsLoading( true );
fetchDiscoverPageData()
.then(
( response: Array< ProductGroup > | { success: boolean } ) => {
if ( ! Array.isArray( response ) ) {
return [];
}
return response as Array< ProductGroup >;
}
)
.then( ( products: Array< ProductGroup > ) => {
setProductGroups( products );
} )
@ -45,6 +53,7 @@ export default function Discover(): JSX.Element | null {
title={ groups.title }
products={ groups.items }
groupURL={ groups.url }
type={ groups.itemType }
/>
) ) }
</div>

View File

@ -1,92 +1,35 @@
/**
* External dependencies
*/
import { useContext, useEffect, useState } from '@wordpress/element';
import { useQuery } from '@woocommerce/navigation';
import { __, _n, sprintf } from '@wordpress/i18n';
import { useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import './extensions.scss';
import CategorySelector from '../category-selector/category-selector';
import { MarketplaceContext } from '../../contexts/marketplace-context';
import CategorySelector from '../category-selector/category-selector';
import ProductListContent from '../product-list-content/product-list-content';
import ProductLoader from '../product-loader/product-loader';
import NoResults from '../product-list-content/no-results';
import { Product, SearchAPIProductType } from '../product-list/types';
import { MARKETPLACE_SEARCH_API_PATH, MARKETPLACE_HOST } from '../constants';
import { getAdminSetting } from '../../../utils/admin-settings';
import { Product, ProductType } from '../product-list/types';
import { MARKETPLACE_ITEMS_PER_PAGE } from '../constants';
export default function Extensions(): JSX.Element {
const [ productList, setProductList ] = useState< Product[] >( [] );
interface ExtensionsProps {
products?: Product[];
perPage?: number;
}
export default function Extensions( props: ExtensionsProps ): JSX.Element {
const marketplaceContextValue = useContext( MarketplaceContext );
const { isLoading, setIsLoading } = marketplaceContextValue;
const { isLoading } = marketplaceContextValue;
const query = useQuery();
// Get the content for this screen
useEffect( () => {
setIsLoading( true );
const params = new URLSearchParams();
if ( query.term ) {
params.append( 'term', query.term );
}
if ( query.category ) {
params.append( 'category', query.category );
}
const wccomSettings = getAdminSetting( 'wccomHelper', false );
if ( wccomSettings.storeCountry ) {
params.append( 'country', wccomSettings.storeCountry );
}
const wccomSearchEndpoint =
MARKETPLACE_HOST +
MARKETPLACE_SEARCH_API_PATH +
'?' +
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,
// Due to backwards compatibility, raw_price is from search API, price is from featured API
price: product.raw_price ?? product.price,
averageRating: product.rating ?? 0,
reviewsCount: product.reviews_count ?? 0,
};
}
);
setProductList( products );
} )
.catch( () => {
setProductList( [] );
} )
.finally( () => {
setIsLoading( false );
} );
}, [ query ] );
const products = productList.slice( 0, 60 );
const products =
props.products?.slice(
0,
props.perPage ?? MARKETPLACE_ITEMS_PER_PAGE
) ?? [];
let title = __( '0 extensions found', 'woocommerce' );
@ -109,13 +52,16 @@ export default function Extensions(): JSX.Element {
}
if ( products.length === 0 ) {
return <NoResults />;
return <NoResults type={ ProductType.extension } />;
}
return (
<>
<CategorySelector />
<ProductListContent products={ products } />
<CategorySelector type={ ProductType.extension } />
<ProductListContent
products={ products }
type={ ProductType.extension }
/>
</>
);
}

View File

@ -121,6 +121,46 @@
font-weight: 400;
line-height: $medium-gap;
}
&.woocommerce-marketplace__product-card--theme {
padding: 0;
overflow: hidden;
.woocommerce-marketplace__product-card__content {
grid-template-rows: auto 1fr auto;
grid-template-columns: 2fr 1fr;
}
.woocommerce-marketplace__product-card__image {
grid-column-start: span 2;
overflow: hidden;
padding-top: 75%;
position: relative;
}
.woocommerce-marketplace__product-card__image-inner {
width: 100%;
position: absolute;
top: 0;
left: 0;
bottom: 0;
right: 0;
}
.woocommerce-marketplace__product-card__header {
padding-left: $medium-gap;
}
.woocommerce-marketplace__product-card__price {
padding-right: $medium-gap;
text-align: right;
display: inline-flex;
align-items: flex-end;
}
.woocommerce-marketplace__product-card__price-label {
margin-left: auto;
line-height: 1.5;
}
.woocommerce-marketplace__product-card__price-billing {
line-height: 1.5;
}
}
}
}

View File

@ -8,18 +8,19 @@ import { Card } from '@wordpress/components';
* Internal dependencies
*/
import './product-card.scss';
import { Product } from '../product-list/types';
import { Product, ProductType } from '../product-list/types';
export interface ProductCardProps {
type?: string;
type: ProductType;
product: Product;
}
function ProductCard( props: ProductCardProps ): JSX.Element {
const { product } = props;
const { product, type } = props;
// We hardcode this for now while we only display prices in USD.
const currencySymbol = '$';
const isTheme = type === ProductType.theme;
let productVendor: string | JSX.Element | null = product?.vendorName;
if ( product?.vendorName && product?.vendorUrl ) {
productVendor = (
@ -34,11 +35,22 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
}
return (
<Card className="woocommerce-marketplace__product-card">
<Card
className={ `woocommerce-marketplace__product-card woocommerce-marketplace__product-card--${ type }` }
>
<div className="woocommerce-marketplace__product-card__content">
{ isTheme && (
<div className="woocommerce-marketplace__product-card__image">
<img
className="woocommerce-marketplace__product-card__image-inner"
src={ product.image }
alt={ product.title }
/>
</div>
) }
<div className="woocommerce-marketplace__product-card__header">
<div className="woocommerce-marketplace__product-card__details">
{ product.icon && (
{ ! isTheme && product.icon && (
<img
className="woocommerce-marketplace__product-card__icon"
src={ product.icon }
@ -65,11 +77,13 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
</div>
</div>
</div>
<p className="woocommerce-marketplace__product-card__description">
{ product.description }
</p>
{ ! isTheme && (
<p className="woocommerce-marketplace__product-card__description">
{ product.description }
</p>
) }
<div className="woocommerce-marketplace__product-card__price">
<span>
<span className="woocommerce-marketplace__product-card__price-label">
{
// '0' is a free product
product.price === 0

View File

@ -8,17 +8,25 @@ import { useQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import NoResultsIcon from '../../assets/images/no-results.svg';
import NoResultsExtensionsIcon from '../../assets/images/no-results-extensions.svg';
import NoResultsThemesIcon from '../../assets/images/no-results-themes.svg';
import { fetchDiscoverPageData, ProductGroup } from '../../utils/functions';
import ProductLoader from '../product-loader/product-loader';
import ProductList from '../product-list/product-list';
import './no-results.scss';
import { ProductType } from '../product-list/types';
export default function NoResults(): JSX.Element {
interface NoResultsProps {
type: ProductType;
}
export default function NoResults( props: NoResultsProps ): JSX.Element {
const [ productGroup, setProductGroup ] = useState< ProductGroup >();
const [ isLoadingProductGroup, setisLoadingProductGroup ] =
useState( false );
const [ noResultsTerm, setNoResultsTerm ] = useState< string >( '' );
const typeLabel =
props.type === ProductType.theme ? 'themes' : 'extensions';
const query = useQuery();
@ -45,8 +53,12 @@ export default function NoResults(): JSX.Element {
fetchDiscoverPageData()
.then( ( products: ProductGroup[] ) => {
const productGroupId =
props.type === ProductType.theme
? 'popular-themes'
: 'most-popular';
const mostPopularGroup = products.find(
( group ) => group.id === 'most-popular'
( group ) => group.id === productGroupId
);
if ( ! mostPopularGroup ) {
@ -74,21 +86,36 @@ export default function NoResults(): JSX.Element {
return <></>;
}
const title = sprintf(
// translators: %s: product type (themes or extensions)
__( 'Most popular %s', 'woocommerce' ),
typeLabel
);
return (
<ProductList
title={ productGroup.title }
title={ title }
products={ productGroup.items }
groupURL={ productGroup.url }
type={ productGroup.itemType }
/>
);
}
function getNoResultsIcon( type: ProductType ) {
if ( type === ProductType.theme ) {
return NoResultsThemesIcon;
}
return NoResultsExtensionsIcon;
}
return (
<div className="woocommerce-marketplace__no-results">
<div className="woocommerce-marketplace__no-results__content">
<img
className="woocommerce-marketplace__no-results__icon"
src={ NoResultsIcon }
src={ getNoResultsIcon( props.type ) }
alt={ __( 'No results.', 'woocommerce' ) }
width="80"
height="80"
@ -96,18 +123,23 @@ export default function NoResults(): JSX.Element {
<div className="woocommerce-marketplace__no-results__description">
<h3 className="woocommerce-marketplace__no-results__description--bold">
{ sprintf(
// translators: %s: search term
// translators: %1$s product type (themes or extensions), %2$s: search term
__(
"We didn't find any results for “%s”",
"We didn't find %1$s for “%2$s”",
'woocommerce'
),
typeLabel,
noResultsTerm
) }
</h3>
<p>
{ __(
'Try searching again using a different term, or take a look at some of our most popular extensions below.',
'woocommerce'
{ sprintf(
// translators: %s product type (themes or extensions)
__(
'Try searching again using a different term, or take a look at some of our most popular %s below.',
'woocommerce'
),
typeLabel
) }
</p>
</div>

View File

@ -3,12 +3,13 @@
*/
import './product-list-content.scss';
import ProductCard from '../product-card/product-card';
import { Product } from '../product-list/types';
import { Product, ProductType } from '../product-list/types';
import { appendURLParams } from '../../utils/functions';
import { getAdminSetting } from '../../../utils/admin-settings';
export default function ProductListContent( props: {
products: Product[];
type: ProductType;
} ): JSX.Element {
const wccomHelperSettings = getAdminSetting( 'wccomHelper', {} );
@ -17,9 +18,11 @@ export default function ProductListContent( props: {
{ props.products.map( ( product ) => (
<ProductCard
key={ product.id }
type="classic"
type={ props.type }
product={ {
title: product.title,
image: product.image,
type: product.type,
icon: product.icon,
vendorName: product.vendorName,
vendorUrl: product.vendorUrl

View File

@ -17,7 +17,8 @@
margin-top: $small-gap;
}
&__product-list-title--extensions {
&__product-list-title--extensions,
&__product-list-title--themes {
margin-bottom: 0;
}

View File

@ -3,21 +3,22 @@
*/
import ProductListContent from '../product-list-content/product-list-content';
import ProductListHeader from '../product-list-header/product-list-header';
import { Product } from './types';
import { Product, ProductType } from './types';
interface ProductListProps {
title: string;
products: Product[];
groupURL: string;
type: ProductType;
}
export default function ProductList( props: ProductListProps ): JSX.Element {
const { title, products, groupURL } = props;
const { title, products, groupURL, type } = props;
return (
<div className="woocommerce-marketplace__product-list">
<ProductListHeader title={ title } groupURL={ groupURL } />
<ProductListContent products={ products } />
<ProductListContent products={ products } type={ type } />
</div>
);
}

View File

@ -1,6 +1,7 @@
export type SearchAPIProductType = {
title: string;
image: string;
type: ProductType;
excerpt: string;
link: string;
demo_url: string;
@ -19,6 +20,8 @@ export type SearchAPIProductType = {
export interface Product {
id?: number;
title: string;
image: string;
type: ProductType;
description: string;
vendorName: string;
vendorUrl: string;
@ -29,3 +32,8 @@ export interface Product {
averageRating?: number | null;
reviewsCount?: number | null;
}
export enum ProductType {
theme = 'theme',
extension = 'extension',
}

View File

@ -0,0 +1,5 @@
.woocommerce-marketplace__search-results {
.woocommerce-marketplace__extensions {
margin-bottom: 48px;
}
}

View File

@ -0,0 +1,34 @@
/**
* Internal dependencies
*/
import './search-results.scss';
import { Product, ProductType } from '../product-list/types';
import Extensions from '../extensions/extensions';
import Themes from '../themes/themes';
import { MARKETPLACE_SEARCH_RESULTS_PER_PAGE } from '../constants';
export interface SearchResultProps {
products: Product[];
}
export default function SearchResults( props: SearchResultProps ): JSX.Element {
const extensions = props.products.filter(
( product ) => product.type === ProductType.extension
);
const themes = props.products.filter(
( product ) => product.type === ProductType.theme
);
return (
<div className="woocommerce-marketplace__search-results">
<Extensions
products={ extensions }
perPage={ MARKETPLACE_SEARCH_RESULTS_PER_PAGE }
/>
<Themes
products={ themes }
perPage={ MARKETPLACE_SEARCH_RESULTS_PER_PAGE }
/>
</div>
);
}

View File

@ -31,12 +31,18 @@ function Search(): JSX.Element {
}
}, [ query.term ] );
useEffect( () => {
if ( query.tab !== 'search' ) {
setSearchTerm( '' );
}
}, [ query.tab ] );
const runSearch = () => {
const term = searchTerm.trim();
// When the search term changes, we reset the category on purpose.
navigateTo( {
url: getNewPath( { term, category: null, tab: 'extensions' } ),
url: getNewPath( { term, category: null, tab: 'search' } ),
} );
return [];

View File

@ -5,6 +5,7 @@
box-sizing: content-box;
display: flex;
gap: 24px;
overflow-y: auto;
}
&__tab-button {
@ -17,6 +18,7 @@
height: 48px;
line-height: 16px;
padding: 0;
white-space: nowrap;
&:focus:not(:disabled) {
box-shadow: none;

View File

@ -2,7 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useContext, useEffect } from '@wordpress/element';
import { useContext, useEffect, useState } from '@wordpress/element';
import { Button } from '@wordpress/components';
import classNames from 'classnames';
import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation';
@ -30,6 +30,10 @@ interface Tabs {
}
const tabs: Tabs = {
search: {
name: 'search',
title: __( 'Search results', 'woocommerce' ),
},
discover: {
name: 'discover',
title: __( 'Discover', 'woocommerce' ),
@ -38,6 +42,10 @@ const tabs: Tabs = {
name: 'extensions',
title: __( 'Browse', 'woocommerce' ),
},
themes: {
name: 'themes',
title: __( 'Themes', 'woocommerce' ),
},
'my-subscriptions': {
name: 'my-subscriptions',
title: __( 'My Subscriptions', 'woocommerce' ),
@ -63,10 +71,25 @@ const setUrlTabParam = ( tabKey: string ) => {
} );
};
const renderTabs = ( contextValue: MarketplaceContextType ) => {
const { selectedTab, setSelectedTab } = contextValue;
const getVisibleTabs = ( selectedTab: string ) => {
if ( selectedTab === '' ) {
return tabs;
}
const currentVisibleTabs = { ...tabs };
if ( selectedTab !== 'search' ) {
delete currentVisibleTabs.search;
}
return currentVisibleTabs;
};
const renderTabs = (
marketplaceContextValue: MarketplaceContextType,
visibleTabs: Tabs
) => {
const { selectedTab, setSelectedTab } = marketplaceContextValue;
const tabContent = [];
for ( const tabKey in tabs ) {
for ( const tabKey in visibleTabs ) {
tabContent.push(
tabs[ tabKey ]?.href ? (
<a
@ -106,18 +129,22 @@ const renderTabs = ( contextValue: MarketplaceContextType ) => {
const Tabs = ( props: TabsProps ): JSX.Element => {
const { additionalClassNames } = props;
const marketplaceContextValue = useContext( MarketplaceContext );
const { setSelectedTab } = marketplaceContextValue;
const { selectedTab, setSelectedTab } = marketplaceContextValue;
const [ visibleTabs, setVisibleTabs ] = useState( getVisibleTabs( '' ) );
const query: Record< string, string > = useQuery();
useEffect( () => {
if ( query?.tab && tabs[ query.tab ] ) {
setSelectedTab( query.tab );
} else {
} else if ( Object.keys( query ).length > 0 ) {
setSelectedTab( DEFAULT_TAB_KEY );
}
}, [ query, setSelectedTab ] );
useEffect( () => {
setVisibleTabs( getVisibleTabs( selectedTab ) );
}, [ selectedTab ] );
return (
<nav
className={ classNames(
@ -125,7 +152,7 @@ const Tabs = ( props: TabsProps ): JSX.Element => {
additionalClassNames || []
) }
>
{ renderTabs( marketplaceContextValue ) }
{ renderTabs( marketplaceContextValue, visibleTabs ) }
</nav>
);
};

View File

@ -0,0 +1,6 @@
.woocommerce-marketplace {
&__themes {
display: flex;
flex-direction: column;
}
}

View File

@ -0,0 +1,71 @@
/**
* External dependencies
*/
import { __, _n, sprintf } from '@wordpress/i18n';
import { useContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import './themes.scss';
import { MarketplaceContext } from '../../contexts/marketplace-context';
import CategorySelector from '../category-selector/category-selector';
import ProductListContent from '../product-list-content/product-list-content';
import ProductLoader from '../product-loader/product-loader';
import NoResults from '../product-list-content/no-results';
import { Product, ProductType } from '../product-list/types';
import { MARKETPLACE_ITEMS_PER_PAGE } from '../constants';
interface ThemeProps {
products?: Product[];
perPage?: number;
}
export default function Themes( props: ThemeProps ): JSX.Element {
const marketplaceContextValue = useContext( MarketplaceContext );
const { isLoading } = marketplaceContextValue;
const products =
props.products?.slice(
0,
props.perPage ?? MARKETPLACE_ITEMS_PER_PAGE
) ?? [];
let title = __( '0 themes found', 'woocommerce' );
if ( products.length > 0 ) {
title = sprintf(
// translators: %s: number of themes
_n( '%s theme', '%s themes', products.length, 'woocommerce' ),
products.length
);
}
function content() {
if ( isLoading ) {
return <ProductLoader />;
}
if ( products.length === 0 ) {
return <NoResults type={ ProductType.theme } />;
}
return (
<>
<CategorySelector type={ ProductType.theme } />
<ProductListContent
products={ products }
type={ ProductType.theme }
/>
</>
);
}
return (
<div className="woocommerce-marketplace__themes">
<h2 className="woocommerce-marketplace__product-list-title woocommerce-marketplace__product-list-title--themes">
{ title }
</h2>
{ content() }
</div>
);
}

View File

@ -6,13 +6,12 @@ import { useState, createContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import { DEFAULT_TAB_KEY } from '../components/constants';
import { MarketplaceContextType } from './types';
export const MarketplaceContext = createContext< MarketplaceContextType >( {
isLoading: false,
setIsLoading: () => {},
selectedTab: DEFAULT_TAB_KEY,
selectedTab: '',
setSelectedTab: () => {},
} );
@ -20,7 +19,7 @@ export function MarketplaceContextProvider( props: {
children: JSX.Element;
} ): JSX.Element {
const [ isLoading, setIsLoading ] = useState( true );
const [ selectedTab, setSelectedTab ] = useState( DEFAULT_TAB_KEY );
const [ selectedTab, setSelectedTab ] = useState( '' );
const contextValue = {
isLoading,

View File

@ -6,7 +6,7 @@ import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { Product } from '../components/product-list/types';
import { Product, ProductType } from '../components/product-list/types';
import {
MARKETPLACE_HOST,
MARKETPLACE_CATEGORY_API_PATH,
@ -19,6 +19,7 @@ interface ProductGroup {
title: string;
items: Product[];
url: string;
itemType: ProductType;
}
// Fetch data for the discover page from the WooCommerce.com API
@ -36,11 +37,17 @@ async function fetchDiscoverPageData(): Promise< ProductGroup[] > {
}
}
function fetchCategories(): Promise< CategoryAPIItem[] > {
let url = MARKETPLACE_HOST + MARKETPLACE_CATEGORY_API_PATH;
function fetchCategories( type: ProductType ): Promise< CategoryAPIItem[] > {
const url = new URL( MARKETPLACE_HOST + MARKETPLACE_CATEGORY_API_PATH );
if ( LOCALE.userLocale ) {
url = `${ url }?locale=${ LOCALE.userLocale }`;
url.searchParams.set( 'locale', LOCALE.userLocale );
}
// We don't define parent for extensions since that is provided by default
// This is to ensure the old marketplace continues to work when this isn't defined
if ( type === ProductType.theme ) {
url.searchParams.set( 'parent', 'themes' );
}
return fetch( url.toString() )
@ -64,6 +71,10 @@ const appendURLParams = (
url: string,
utmParams: Array< [ string, string ] >
): string => {
if ( ! url ) {
return url;
}
const urlObject = new URL( url );
if ( ! urlObject ) {
return url;

View File

@ -0,0 +1,4 @@
Significance: minor
Type: enhancement
Add Themes to the Extensions catalogue for easy download and installation.