In-app Marketplace React rebuild: feature branch (#39121)

This commit is contained in:
nigeljamesstevenson 2023-08-22 17:09:14 +01:00 committed by GitHub
commit c0d6a75731
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
72 changed files with 3315 additions and 58 deletions

View File

@ -30,7 +30,7 @@ export const getTourConfig = ( {
spotlight: {
interactivity: {
enabled: true,
rootElementSelector: '.woocommerce.wc-addons-wrap',
rootElementSelector: '.woocommerce-marketplace',
},
},
autoScroll: {
@ -39,21 +39,6 @@ export const getTourConfig = ( {
},
},
popperModifiers: [
{
name: 'arrow',
options: {
padding: ( {
popper,
}: {
popper: { width: number };
} ) => {
return {
// Align the arrow to the left of the popper.
right: popper.width - 34,
};
},
},
},
{
name: 'offset',
options: {

View File

@ -10,13 +10,15 @@ export const getSteps = (): TourKitTypes.WooStep[] => {
return [
{
referenceElements: {
desktop: '#adminmenu a[href="admin.php?page=wc-addons"]',
desktop:
'#adminmenu a[href="admin.php?page=wc-admin&path=%2Fextensions"]',
},
focusElement: {
desktop: '#adminmenu a[href="admin.php?page=wc-addons"]',
desktop:
'#adminmenu a[href="admin.php?page=wc-admin&path=%2Fextensions"]',
},
meta: {
name: 'wc-addons-menu-item',
name: 'wc-extensions-menu-item',
heading: __(
'Welcome to the WooCommerce Marketplace',
'woocommerce'
@ -24,7 +26,7 @@ export const getSteps = (): TourKitTypes.WooStep[] => {
descriptions: {
desktop: createInterpolateElement(
__(
'Power up your store by adding extra functionality using extensions, find a fresh new look with themes, or integrate your store with other software and services.<br/><br/>The WooCommerce Marketplace is your go-to for all of the above, and the only place youll find products that have been reviewed and approved by the WooCommerce team.<br/><br/>Whether youre looking to improve your store or grow your business, you can find a solution here. There are hundreds of options available, and new products are added regularly.<br/><br/>The WooCommerce Marketplace is also available at WooCommerce.com.',
"Power up your store by adding extra functionality with extensions or integrate your store with other software and services.<br/><br/>Here you'll find hundreds of trusted solutions for your store — all reviewed and approved by the Woo team.<br/><br/>You can also browse the Woo Marketplace at WooCommerce.com.",
'woocommerce'
),
{
@ -36,17 +38,17 @@ export const getSteps = (): TourKitTypes.WooStep[] => {
},
{
referenceElements: {
desktop: '.marketplace-header__search-form',
desktop: '.woocommerce-marketplace__search',
},
focusElement: {
desktop: '.marketplace-header__search-form',
desktop: '.woocommerce-marketplace__search',
},
meta: {
name: 'wc-addons-search',
name: 'wc-extensions-search',
heading: __( 'Find exactly what you need', 'woocommerce' ),
descriptions: {
desktop: __(
'Use the search box to find specific products or solutions.',
'Use the search box to find specific extensions or solutions.',
'woocommerce'
),
},
@ -54,10 +56,10 @@ export const getSteps = (): TourKitTypes.WooStep[] => {
},
{
referenceElements: {
desktop: '#marketplace-current-section-dropdown',
desktop: '.woocommerce-marketplace__tab-browse',
},
focusElement: {
desktop: '#marketplace-current-section-dropdown',
desktop: '.woocommerce-marketplace__tab-browse',
},
meta: {
name: 'wc-addons-categories',
@ -65,7 +67,7 @@ export const getSteps = (): TourKitTypes.WooStep[] => {
descriptions: {
desktop: createInterpolateElement(
__(
'Or browse all available products by category.',
"Or if you're not sure exactly what you need, you can browse all available extensions by category.",
'woocommerce'
),
{
@ -77,18 +79,18 @@ export const getSteps = (): TourKitTypes.WooStep[] => {
},
{
referenceElements: {
desktop: '.addon-product-group:first-child',
desktop: '.woocommerce-marketplace__discover:first-child',
},
focusElement: {
desktop: '.addon-product-group:first-child',
desktop: '.woocommerce-marketplace__discover:first-child',
},
meta: {
name: 'wc-addons-featured',
heading: __( 'Learn more about products', 'woocommerce' ),
heading: __( 'Learn more about each product', 'woocommerce' ),
descriptions: {
desktop: createInterpolateElement(
__(
'Scroll down to see all available products for a search or selected category.<br/><br/>Click on any product to see more information about it, including features, requirements, and available documentation.',
'Scroll down to see all of the relevant extensions and solutions.<br/><br/>Click on any solution to learn more about its features, any installation requirements, and available documentation.',
'woocommerce'
),
{
@ -100,10 +102,10 @@ export const getSteps = (): TourKitTypes.WooStep[] => {
},
{
referenceElements: {
desktop: '.marketplace-header__tab-link_helper',
desktop: '.woocommerce-marketplace__header-meta',
},
focusElement: {
desktop: '.marketplace-header__tab-link_helper',
desktop: '.woocommerce-marketplace__header-meta',
},
meta: {
name: 'wc-addons-my-subscriptions',
@ -111,7 +113,7 @@ export const getSteps = (): TourKitTypes.WooStep[] => {
descriptions: {
desktop: createInterpolateElement(
__(
"Products purchased from the WooCommerce Marketplace can be managed in My Subscriptions, either here or on WooCommerce.com.<br/><br/>Every purchase is backed by our <a1>30-day money-back guarantee</a1>, and includes <a2>email and live chat support</a2>.<br/><br/>That's it! We hope the WooCommerce Marketplace helps you build the business of your dreams.",
"All of your Woo Marketplace purchases can be found here, or on WooCommerce.com.<br/><br/>Every purchase is backed by our <a1>30-day money-back guarantee</a1>, and includes <a2>email and live chat support</a2>.<br/><br/>That's it! You're now ready to power up your store.",
'woocommerce'
),
{

View File

@ -16,7 +16,7 @@ import { scrollPopperToVisibleAreaIfNeeded } from './utils';
import { getSteps } from './get-steps';
const WCAddonsTour = () => {
const [ showTour, setShowTour ] = useState( false );
const [ showTour, setShowTour ] = useState( true );
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
@ -62,7 +62,7 @@ const WCAddonsTour = () => {
const timeoutId = setTimeout( showPopper, 500 );
const intervalId = observePositionChange(
'.wc-addons-wrap',
'.woocommerce-marketplace',
showPopper,
150
);

View File

@ -57,6 +57,9 @@ const MarketingOverviewMultichannel = lazy( () =>
/* webpackChunkName: "multichannel-marketing" */ '../marketing/overview-multichannel'
)
);
const Marketplace = lazy( () =>
import( /* webpackChunkName: "marketplace" */ '../marketplace' )
);
const ProfileWizard = lazy( () =>
import( /* webpackChunkName: "profile-wizard" */ '../profile-wizard' )
);
@ -177,6 +180,25 @@ export const getPages = () => {
} );
}
if ( isFeatureEnabled( 'marketplace' ) ) {
pages.push( {
container: Marketplace,
layout: {
header: false,
},
path: '/extensions',
breadcrumbs: [
[ '/extensions', __( 'Extensions', 'woocommerce' ) ],
__( 'Extensions', 'woocommerce' ),
],
wpOpenMenu: 'toplevel_page_woocommerce',
capability: 'manage_woocommerce',
navArgs: {
id: 'woocommerce-marketplace',
},
} );
}
if ( isFeatureEnabled( 'product_block_editor' ) ) {
const productPage = {
container: ProductPage,

View File

@ -0,0 +1,12 @@
<svg width="74" height="100" viewBox="0 0 74 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M67.7896 0L62.6619 4.11855L57.5289 0L52.4011 4.11855L47.2682 0L42.1353 4.11855L37.0075 0L31.8746 4.11855L26.7468 0L21.6138 4.11855L16.4809 0L11.348 4.11855L6.21501 0L0.916992 4.25258V95.7474L6.20986 100L11.3428 95.8763L16.4706 100L21.6035 95.8763L26.7313 100L31.8642 95.8763L36.9972 100L42.125 95.8763L47.2579 100L52.3857 95.8763L57.5186 100L62.6515 95.8763L67.7896 100L73.0825 95.7474V4.25258L67.7896 0Z" fill="#E0E0E0"/>
<path d="M49.4449 18.7216H7.10547V22.5103H49.4449V18.7216Z" fill="#757575"/>
<path d="M49.4449 30.2784H7.10547V34.067H49.4449V30.2784Z" fill="#757575"/>
<path d="M49.4449 41.8351H7.10547V45.6237H49.4449V41.8351Z" fill="#757575"/>
<path d="M66.8991 18.7216H56.9102V22.5103H66.8991V18.7216Z" fill="white"/>
<path d="M66.8991 30.2783H56.9102V34.067H66.8991V30.2783Z" fill="white"/>
<path d="M66.8991 41.835H56.9102V45.6237H66.8991V41.835Z" fill="white"/>
<path d="M66.8993 63.9176H50.4043V71.1341H66.8993V63.9176Z" fill="#757575"/>
<path d="M7.13379 55.5258H66.8714" stroke="#271B3D" stroke-width="0.510311" stroke-miterlimit="10"/>
<path d="M51.2154 89.8917C50.6639 89.8917 50.1845 89.7731 49.8082 89.4999C48.0556 88.2473 47.8597 85.768 47.6845 83.5772C47.4886 81.0618 47.2876 79.5205 45.736 79.5205C44.437 79.5205 43.5659 81.7731 42.7205 83.9484C41.705 86.5669 40.6535 89.2731 38.6122 89.2731C36.4473 89.2731 36.1895 87.1752 35.937 85.1494C35.6741 83.0205 35.4266 81.0051 33.2256 81.0051V80.4948C35.8802 80.4948 36.1792 82.9329 36.4473 85.0875C36.7308 87.3762 37.1586 88.4071 38.6071 88.4535C39.8648 88.4896 41.2875 86.2267 42.2411 83.768C43.1896 81.3247 44.2978 78.2886 45.9473 78.2886C48.0504 78.2886 48.2721 80.3762 48.4061 82.8195C48.5401 85.2628 48.5401 87.9741 50.102 89.0927C52.8546 91.0618 62.4011 83.2473 66.2259 79.4174L67.0558 80.3711C66.5609 80.804 56.0093 89.8968 51.2154 89.8968V89.8917Z" fill="#CCCCCC"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,14 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="W" clip-path="url(#clip0_1256_184463)">
<rect width="16" height="16" fill="#646970"/>
<rect id="Rectangle 1" width="16" height="16" rx="2" fill="#646970"/>
<g id="Speech bubble">
<path id="Vector" d="M3.04855 3.86047C3.2036 3.67312 3.42971 3.55683 3.6752 3.55037C4.17911 3.51807 4.46983 3.75711 4.54735 4.26747C4.85745 6.34771 5.19339 8.11786 5.54871 9.57144L7.73878 5.41096C7.93905 5.03626 8.18454 4.83599 8.48818 4.81661C8.92749 4.7843 9.19882 5.0621 9.30865 5.65645C9.51538 6.81932 9.83194 7.96281 10.2519 9.06753C10.5167 6.53506 10.956 4.70032 11.5698 3.56975C11.6925 3.31134 11.9445 3.14337 12.2287 3.13045C12.4549 3.11107 12.681 3.18213 12.8554 3.33072C13.0363 3.46639 13.1461 3.67958 13.159 3.90569C13.1719 4.07366 13.1396 4.24163 13.0621 4.38376C12.6745 5.10732 12.3515 6.30895 12.0995 7.98865C11.854 9.6102 11.7571 10.8829 11.8217 11.7938C11.8476 12.0199 11.8024 12.246 11.7054 12.4463C11.6085 12.653 11.4018 12.7952 11.1757 12.8081C10.9108 12.8275 10.6524 12.7047 10.3875 12.4398C9.45724 11.4902 8.72076 10.0753 8.17808 8.19538C7.53851 9.47453 7.05398 10.4371 6.73742 11.0702C6.14953 12.2008 5.64562 12.7758 5.23215 12.8081C4.96082 12.8275 4.72825 12.6014 4.5409 12.1233C4.03053 10.8183 3.4814 8.29229 2.8935 4.54527C2.84182 4.29978 2.89996 4.05428 3.04855 3.86047Z" fill="white"/>
</g>
</g>
<defs>
<clipPath id="clip0_1256_184463">
<rect width="16" height="16" fill="white"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,108 @@
/**
* External dependencies
*/
import { Dropdown } from '@wordpress/components';
import { chevronDown, chevronUp, Icon } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
import { navigateTo, getNewPath } from '@woocommerce/navigation';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import { Category } from './types';
function DropdownContent( props: {
readonly categories: Category[];
readonly selected?: Category;
readonly onClick: () => void;
} ): 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 (
<ul className="woocommerce-marketplace__category-dropdown-list">
{ props.categories.map( ( category ) => (
<li
className="woocommerce-marketplace__category-dropdown-item"
key={ category.slug }
>
<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 }
</button>
</li>
) ) }
</ul>
);
}
type CategoryDropdownProps = {
label: string;
categories: Category[];
className?: string;
buttonClassName?: string;
contentClassName?: string;
arrowIconSize?: number;
selected?: Category;
};
export default function CategoryDropdown(
props: CategoryDropdownProps
): JSX.Element {
return (
<Dropdown
renderToggle={ ( { isOpen, onToggle } ) => (
<button
onClick={ onToggle }
className={ props.buttonClassName }
aria-label={ __(
'Toggle category dropdown',
'woocommerce'
) }
>
{ props.label }
<Icon
icon={ isOpen ? chevronUp : chevronDown }
size={ props.arrowIconSize }
/>
</button>
) }
className={ props.className }
renderContent={ ( { onToggle } ) => (
<DropdownContent
categories={ props.categories }
selected={ props.selected }
onClick={ onToggle }
/>
) }
contentClassName={ props.contentClassName }
/>
);
}

View File

@ -0,0 +1,44 @@
/**
* External dependencies
*/
import classNames from 'classnames';
import { navigateTo, getNewPath } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import { Category } from './types';
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(
'woocommerce-marketplace__category-item-button',
{
'woocommerce-marketplace__category-item-button--selected':
props.selected,
}
);
return (
<button
className={ classes }
onClick={ updateCategorySelection }
value={ props.slug }
>
{ props.label }
</button>
);
}

View File

@ -0,0 +1,126 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace__category-selector {
display: flex;
align-items: stretch;
margin: $grid-unit-20 0 0 0;
}
.woocommerce-marketplace__category-item {
cursor: pointer;
.components-dropdown {
height: 100%;
}
}
.woocommerce-marketplace__category-item-button {
display: flex;
align-items: center;
cursor: pointer;
border: none;
border-radius: 2px;
color: $wp-gray-60;
background-color: $wp-gray-0;
padding: 6px $grid-unit-10;
margin-right: $grid-unit-10;
line-height: 20px;
height: 100%;
&--selected {
color: $white;
background-color: $gray-900;
fill: $white;
}
}
.woocommerce-marketplace__category-item-content {
.components-popover__content {
min-width: 200px;
}
}
.woocommerce-marketplace__category-selector--full-width {
display: none;
margin-top: $grid-unit-15;
}
@media screen and (max-width: $break-medium) {
.woocommerce-marketplace__category-selector--full-width {
display: flex;
}
.woocommerce-marketplace__category-selector {
display: none;
}
}
.woocommerce-marketplace__category-dropdown {
width: 100%;
}
.woocommerce-marketplace__category-dropdown-button {
display: flex;
align-items: center;
justify-content: space-between;
cursor: pointer;
border: 1px solid $gray-600;
border-radius: 2px;
background-color: $white;
width: 100%;
font-size: 13px;
line-height: 20px;
padding: $grid-unit-15 $grid-unit-10;
text-align: left;
}
.woocommerce-marketplace__category-dropdown-content {
background-color: $white;
color: $gray-900;
font-size: 13px;
min-width: 280px;
width: calc(100% - 32px);
.components-popover__content {
width: 100%;
}
}
.woocommerce-marketplace__category-dropdown-list {
margin: 0;
line-height: 20px;
}
.woocommerce-marketplace__category-dropdown-item {
border-radius: 2px;
&:hover {
background-color: $gutenberg-gray-100;
}
}
.woocommerce-marketplace__category-dropdown-item-button {
border: none;
cursor: pointer;
background-color: inherit;
color: $gray-900;
text-align: left;
padding: 6px $grid-unit-10;
line-height: 20px;
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;
}
}

View File

@ -0,0 +1,160 @@
/**
* External dependencies
*/
import { useState, useEffect } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { Spinner } from '@wordpress/components';
import { useQuery } from '@woocommerce/navigation';
import classNames from 'classnames';
/**
* Internal dependencies
*/
import CategoryLink from './category-link';
import CategoryDropdown from './category-dropdown';
import { Category, CategoryAPIItem } from './types';
import { fetchCategories } from '../../utils/functions';
import './category-selector.scss';
const ALL_CATEGORIES_SLUG = '_all';
export default function CategorySelector(): JSX.Element {
const [ visibleItems, setVisibleItems ] = useState< Category[] >( [] );
const [ dropdownItems, setDropdownItems ] = useState< Category[] >( [] );
const [ selected, setSelected ] = useState< Category >();
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( () => {
setIsLoading( true );
fetchCategories()
.then( ( categoriesFromAPI: CategoryAPIItem[] ) => {
const categories: Category[] = categoriesFromAPI
.map( ( categoryAPIItem: CategoryAPIItem ): Category => {
return {
...categoryAPIItem,
selected: false,
};
} )
.filter( ( category: Category ): boolean => {
// The "featured" category is returned from the API for legacy reasons, but we don't need it:
return category.slug !== '_featured';
} );
// Split array into two from 7th item
const visibleCategoryItems = categories.slice( 0, 7 );
const dropdownCategoryItems = categories.slice( 7 );
setVisibleItems( visibleCategoryItems );
setDropdownItems( dropdownCategoryItems );
} )
.catch( () => {
setVisibleItems( [] );
setDropdownItems( [] );
} )
.finally( () => {
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 ) {
return (
<div className="woocommerce-marketplace__category-selector-loading">
<p>{ __( 'Loading categories…', 'woocommerce' ) }</p>
<Spinner />
</div>
);
}
return (
<>
<ul className="woocommerce-marketplace__category-selector">
{ visibleItems.map( ( category ) => (
<li
className="woocommerce-marketplace__category-item"
key={ category.slug }
>
<CategoryLink
{ ...category }
selected={ category.slug === selected?.slug }
/>
</li>
) ) }
<li className="woocommerce-marketplace__category-item">
{ dropdownItems.length > 0 && (
<CategoryDropdown
label={ __( 'More', 'woocommerce' ) }
categories={ dropdownItems }
buttonClassName={ classNames(
'woocommerce-marketplace__category-item-button',
{
'woocommerce-marketplace__category-item-button--selected':
isSelectedInDropdown(),
}
) }
contentClassName="woocommerce-marketplace__category-item-content"
arrowIconSize={ 20 }
selected={ selected }
/>
) }
</li>
</ul>
<div className="woocommerce-marketplace__category-selector--full-width">
<CategoryDropdown
label={ mobileCategoryDropdownLabel() }
categories={ visibleItems.concat( dropdownItems ) }
buttonClassName="woocommerce-marketplace__category-dropdown-button"
className="woocommerce-marketplace__category-dropdown"
contentClassName="woocommerce-marketplace__category-dropdown-content"
selected={ selected }
/>
</div>
</>
);
}

View File

@ -0,0 +1,10 @@
export type Category = {
readonly slug: string;
readonly label: string;
selected: boolean;
};
export type CategoryAPIItem = {
readonly slug: string;
readonly label: string;
};

View File

@ -0,0 +1,3 @@
export const DEFAULT_TAB_KEY = 'discover';
export const MARKETPLACE_PATH = '/extensions';
export const MARKETPLACE_URL = 'https://woocommerce.com';

View File

@ -0,0 +1,14 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace__content {
box-sizing: content-box;
margin: auto;
max-width: $content-max-width;
padding: $header-height-mobile $content-spacing-small $content-spacing-small;
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace__content {
padding: $header-height-desktop $content-spacing-large $content-spacing-large;
}
}

View File

@ -0,0 +1,38 @@
/**
* External dependencies
*/
/**
* Internal dependencies
*/
import './content.scss';
import Discover from '../discover/discover';
import Extensions from '../extensions/extensions';
import Footer from '../footer/footer';
import FeedbackModal from '../feedback-modal/feedback-modal';
export interface ContentProps {
selectedTab?: string | undefined;
}
const renderContent = ( selectedTab?: string ): JSX.Element => {
switch ( selectedTab ) {
case 'extensions':
return <Extensions />;
default:
return <Discover />;
}
};
export default function Content( props: ContentProps ): JSX.Element {
const { selectedTab } = props;
return (
<>
<div className="woocommerce-marketplace__content">
{ renderContent( selectedTab ) }
</div>
<Footer />
<FeedbackModal />
</>
);
}

View File

@ -0,0 +1,10 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__discover {
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
}
}

View File

@ -0,0 +1,49 @@
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import ProductList from '../product-list/product-list';
import { fetchDiscoverPageData, ProductGroup } from '../../utils/functions';
import ProductLoader from '../product-loader/product-loader';
import './discover.scss';
export default function Discover(): JSX.Element | null {
const [ productGroups, setProductGroups ] = useState<
Array< ProductGroup >
>( [] );
const [ isLoading, setIsLoading ] = useState( false );
useEffect( () => {
setIsLoading( true );
fetchDiscoverPageData()
.then( ( products: Array< ProductGroup > ) => {
setProductGroups( products );
} )
.finally( () => {
setIsLoading( false );
} );
}, [] );
if ( isLoading ) {
return <ProductLoader />;
}
const groupsList = productGroups.flatMap( ( group ) => group );
return (
<div className="woocommerce-marketplace__discover">
{ groupsList.map( ( groups ) => (
<ProductList
key={ groups.id }
title={ groups.title }
products={ groups.items }
groupURL={ groups.url }
/>
) ) }
</div>
);
}

View File

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

View File

@ -0,0 +1,59 @@
/**
* External dependencies
*/
import { useContext } from '@wordpress/element';
import { __, _n, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import './extensions.scss';
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';
import NoResults from '../product-list-content/no-results';
export default function Extensions(): JSX.Element {
const productListContextValue = useContext( ProductListContext );
const { productList, isLoading } = productListContextValue;
const products = productList.slice( 0, 60 );
let title = __( '0 extensions found', 'woocommerce' );
if ( products.length > 0 ) {
title = sprintf(
// translators: %s: number of extensions
_n(
'%s extension',
'%s extensions',
products.length,
'woocommerce'
),
products.length
);
}
function content() {
if ( isLoading ) {
return <ProductLoader />;
}
if ( products.length === 0 ) {
return <NoResults />;
}
return <ProductListContent products={ products } />;
}
return (
<div className="woocommerce-marketplace__extensions">
<h2 className="woocommerce-marketplace__product-list-title woocommerce-marketplace__product-list-title--extensions">
{ title }
</h2>
<CategorySelector />
{ content() }
</div>
);
}

View File

@ -0,0 +1,9 @@
.woocommerce-marketplace__feedback-modal {
max-width: 666px;
}
.woocommerce-marketplace__feedback-modal-buttons {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@ -0,0 +1,243 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Modal, Button, TextareaControl } from '@wordpress/components';
import { useDispatch } from '@wordpress/data';
import { useState, useEffect } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import './feedback-modal.scss';
import LikertScale from '../likert-scale/likert-scale';
export default function FeedbackModal(): JSX.Element {
const CUSTOMER_EFFORT_SCORE_ACTION = 'marketplace_redesign_2023';
const LOCALSTORAGE_KEY_DISMISSAL_COUNT =
'marketplace_redesign_2023_dismissals'; // ensure we don't ask for feedback if the user's already given feedback or declined to multiple times
const LOCALSTORAGE_KEY_LAST_REQUESTED_DATE =
'marketplace_redesign_2023_last_shown_date'; // ensure we don't ask for feedback more than once per day
const SUPPRESS_IF_DISMISSED_X_TIMES = 1; // if the user dismisses the snackbar this many times, stop asking for feedback
const SUPPRESS_IF_AFTER_DATE = '2024-01-01'; // if this date is reached, stop asking for feedback
// Save that we dismissed the dialog or snackbar TODAY so we don't show it again until tomorrow (if ever)
const dismissToday = () =>
localStorage.setItem(
LOCALSTORAGE_KEY_LAST_REQUESTED_DATE,
new Date().toDateString()
);
// Returns the number of times that the request for feedback has been dismissed
const dismissedTimes = () =>
parseInt(
localStorage.getItem( LOCALSTORAGE_KEY_DISMISSAL_COUNT ) || '0',
10
);
// Increment the number of times that the request for feedback has been dismissed
const incrementDismissedTimes = () => {
dismissToday();
localStorage.setItem(
LOCALSTORAGE_KEY_DISMISSAL_COUNT,
`${ dismissedTimes() + 1 }`
);
};
// Dismiss forever (by incrementing the number of dismissals to a high number), e.g. when feedback is provided
const dismissForever = () => {
dismissToday();
localStorage.setItem(
LOCALSTORAGE_KEY_DISMISSAL_COUNT,
`${ SUPPRESS_IF_DISMISSED_X_TIMES }`
);
};
// Returns true if dismissed forever (either by dismissing at least SUPPRESS_IF_DISMISSED_X_TIMES times, or by submitting feedback)
const isDismissedForever = () =>
dismissedTimes() >= SUPPRESS_IF_DISMISSED_X_TIMES;
const [ isOpen, setOpen ] = useState( false );
const [ thoughts, setThoughts ] = useState( '' );
const [ easyToFind, setEasyToFind ] = useState( 0 );
const [ easyToFindValidiationFailed, setEasyToFindValidiationFailed ] =
useState( false );
const [ meetsMyNeeds, setMeetsMyNeeds ] = useState( 0 );
const [ meetsMyNeedsValidiationFailed, setMeetsMyNeedsValidiationFailed ] =
useState( false );
const openModal = () => setOpen( true );
const closeModal = () => {
incrementDismissedTimes();
setOpen( false );
};
const { createNotice } = useDispatch( 'core/notices' );
function maybeShowSnackbar() {
// don't show if the user has already given feedback or otherwise suppressed:
if ( isDismissedForever() ) {
return;
}
// don't show if the user has already declined to provide feedback today:
const today = new Date().toDateString();
if (
today ===
localStorage.getItem( LOCALSTORAGE_KEY_LAST_REQUESTED_DATE )
) {
return;
}
createNotice(
'success',
__( 'How easy is it to find an extension?', 'woocommerce' ),
{
type: 'snackbar',
icon: (
<>
<svg
color="#fff"
strokeWidth="1.5"
viewBox="0 0 28.873 8.9823"
style={ { height: '8px', marginLeft: '-7px' } }
>
<path
className="l"
d="m4.1223 1.1216 19.12-0.014142 4.3982 3.38-4.3982 3.38-19.12-0.014142a3.34 3.34 0 0 1-2.39-0.97581 3.37 3.37 0 0 1 0.00707-4.773 3.34 3.34 0 0 1 2.383-0.98288z"
stroke="#fff"
/>
<line
className="l"
x1="6.7669"
x2="6.7669"
y1="7.8533"
y2="1.1216"
stroke="#fff"
/>
<path
className="l"
d="m23.235 1.1146 4.4053 3.3729-4.3982 3.38a6.59 6.59 0 0 1-0.89096-3.3517 6.59 6.59 0 0 1 0.88388-3.4012z"
stroke="#fff"
/>
<line
className="l"
x1="6.7669"
x2="22.323"
y1="4.4875"
y2="4.4875"
stroke="#fff"
/>
</svg>
</>
),
explicitDismiss: true,
onDismiss: incrementDismissedTimes,
actions: [
{
onClick: openModal,
label: 'Give feedback',
},
],
}
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- [] => we only want this effect to run once, on first render
useEffect( maybeShowSnackbar, [] );
// We don't want the "How easy was it to find an extension?" dialog to appear forever:
const FEEDBACK_DIALOG_CAN_APPEAR =
new Date( SUPPRESS_IF_AFTER_DATE ) > new Date();
if ( ! FEEDBACK_DIALOG_CAN_APPEAR ) {
return <></>;
}
function easyToFindChanged( value: number ) {
setEasyToFindValidiationFailed( false );
setEasyToFind( value );
}
function meetsMyNeedsChanged( value: number ) {
setMeetsMyNeedsValidiationFailed( false );
setMeetsMyNeeds( value );
}
function submit() {
// Validate:
if ( easyToFind === 0 || meetsMyNeeds === 0 ) {
if ( easyToFind === 0 ) setEasyToFindValidiationFailed( true );
if ( meetsMyNeeds === 0 ) setMeetsMyNeedsValidiationFailed( true );
return;
}
// Send event to CES:
recordEvent( 'ces_feedback', {
action: CUSTOMER_EFFORT_SCORE_ACTION,
score: easyToFind,
score_second_question: meetsMyNeeds,
score_combined: easyToFind + meetsMyNeeds,
thoughts,
} );
// Close the modal:
setOpen( false );
// Ensure we don't ask for feedback again:
dismissForever();
}
return (
<>
{ isOpen && (
<Modal
title={ __(
'How easy was it to find an extension?',
'woocommerce'
) }
onRequestClose={ closeModal }
className="woocommerce-marketplace__feedback-modal"
>
<p>
{ __(
'Your feedback will help us create a better experience for people like you! Please tell us to what extent you agree or disagree with the statements below.',
'woocommerce'
) }
</p>
<LikertScale
fieldName="extension_screen_easy_to_find"
title={ __(
'It was easy to find an extension',
'woocommerce'
) }
onValueChange={ easyToFindChanged }
validationFailed={ easyToFindValidiationFailed }
/>
<LikertScale
fieldName="extension_screen_meets_my_needs"
title={ __(
'The Extensions screens functionality meets my needs',
'woocommerce'
) }
onValueChange={ meetsMyNeedsChanged }
validationFailed={ meetsMyNeedsValidiationFailed }
/>
<TextareaControl
label={ __( 'Additional thoughts', 'woocommerce' ) }
value={ thoughts }
onChange={ ( value: string ) => setThoughts( value ) }
/>
<p className="woocommerce-marketplace__feedback-modal-buttons">
<Button
variant="tertiary"
onClick={ closeModal }
text={ __( 'Cancel', 'woocommerce' ) }
/>
<Button
variant="primary"
onClick={ submit }
text={ __( 'Send', 'woocommerce' ) }
/>
</p>
</Modal>
) }
</>
);
}

View File

@ -0,0 +1,55 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-admin-page__extensions .woocommerce-layout__footer {
background: #f6f7f7;
// Undo default fixed footer style used in WC Admin
position: relative;
width: 100%;
}
.woocommerce-marketplace__footer {
box-sizing: content-box;
max-width: $content-max-width;
margin: auto;
padding: $content-spacing-xlarge $content-spacing-small;
&-title {
color: $gutenberg-gray-900;
font-size: 20px;
font-style: normal;
font-weight: 500;
line-height: 28px;
max-width: 389px;
margin: 0 0 $content-spacing-large;
}
a {
text-decoration: none;
}
&-columns {
display: flex;
flex-direction: column;
gap: $large-gap;
}
&-logo {
color: $wp-gray-50;
display: flex;
font-size: 14px;
font-weight: 600;
line-height: 20px;
gap: $small-gap;
margin: 48px 0 0;
}
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace__footer {
padding: $content-spacing-xlarge $content-spacing-large;
}
.woocommerce-marketplace__footer-columns {
flex-direction: row;
}
}

View File

@ -0,0 +1,90 @@
/**
* External dependencies
*/
import { WooFooterItem } from '@woocommerce/admin-layout';
import { __ } from '@wordpress/i18n';
import { check, commentContent, shield } from '@wordpress/icons';
import { createInterpolateElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import './footer.scss';
import IconWithText from '../icon-with-text/icon-with-text';
import WooIcon from '../../assets/images/woo-icon.svg';
import { MARKETPLACE_URL } from '../constants';
const refundPolicyTitle = createInterpolateElement(
__( '30 day <a>money back guarantee</a>', 'woocommerce' ),
{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href={ MARKETPLACE_URL + '/refund-policy/' } />,
}
);
const supportTitle = createInterpolateElement(
__( '<a>Get help</a> when you need it', 'woocommerce' ),
{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href={ MARKETPLACE_URL + '/docs/' } />,
}
);
const paymentTitle = createInterpolateElement(
__( '<a>Products</a> you can trust', 'woocommerce' ),
{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href={ MARKETPLACE_URL + '/products/' } />,
}
);
function FooterContent(): JSX.Element {
return (
<div className="woocommerce-marketplace__footer">
<h2 className="woocommerce-marketplace__footer-title">
{ __(
'Hundreds of vetted products and services. Unlimited potential.',
'woocommerce'
) }
</h2>
<div className="woocommerce-marketplace__footer-columns">
<IconWithText
icon={ check }
title={ refundPolicyTitle }
description={ __(
"If you change your mind within 30 days of your purchase, we'll give you a full refund — hassle-free.",
'woocommerce'
) }
/>
<IconWithText
icon={ commentContent }
title={ supportTitle }
description={ __(
'With detailed documentation and a global support team, help is always available if you need it.',
'woocommerce'
) }
/>
<IconWithText
icon={ shield }
title={ paymentTitle }
description={ __(
'Everything in the Marketplace has been built by our own team or by our trusted partners, so you can be sure of its quality.',
'woocommerce'
) }
/>
</div>
<div className="woocommerce-marketplace__footer-logo">
<img src={ WooIcon } alt="Woo Logo" aria-hidden="true" />
<span>{ __( 'Woo Marketplace', 'woocommerce' ) }</span>
</div>
</div>
);
}
export default function Footer(): JSX.Element {
return (
<WooFooterItem>
<FooterContent />
</WooFooterItem>
);
}

View File

@ -0,0 +1,61 @@
/**
* External dependencies
*/
import { useState } from '@wordpress/element';
import { Button, ButtonGroup, Modal } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
export interface HeaderAccountModalProps {
setIsModalOpen: ( value: boolean ) => void;
disconnectURL: string;
}
export default function HeaderAccountModal(
props: HeaderAccountModalProps
): JSX.Element {
const { setIsModalOpen, disconnectURL } = props;
const [ isBusy, setIsBusy ] = useState( false );
const toggleIsBusy = () => setIsBusy( ! isBusy );
const closeModal = () => setIsModalOpen( false );
return (
<Modal
title={ __( 'Are you sure?', 'woocommerce' ) }
onRequestClose={ closeModal }
focusOnMount={ true }
className="woocommerce-marketplace__header-account-modal"
style={ { borderRadius: 4 } }
overlayClassName="woocommerce-marketplace__header-account-modal-overlay"
>
<p className="woocommerce-marketplace__header-account-modal-text">
{ __(
'Keep your your account connected to manage your subscriptions, get updates and support for your extensions and themes.',
'woocommerce'
) }
</p>
<ButtonGroup className="woocommerce-marketplace__header-account-modal-button-group">
<Button
variant="tertiary"
href={ disconnectURL }
onClick={ toggleIsBusy }
isBusy={ isBusy }
isDestructive={ true }
className="woocommerce-marketplace__header-account-modal-button"
>
{ __( 'Disconnect account', 'woocommerce' ) }
</Button>
<Button
variant="primary"
onClick={ closeModal }
className="woocommerce-marketplace__header-account-modal-button"
>
{ __( 'Keep connected', 'woocommerce' ) }
</Button>
</ButtonGroup>
</Modal>
);
}

View File

@ -0,0 +1,62 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__menu-item span {
white-space: normal;
}
&__menu-avatar-image {
border-radius: 50%;
height: $grid-unit-30;
width: $grid-unit-30;
}
&__menu-icon {
flex-shrink: 0;
margin-right: $grid-unit-10;
}
&__menu-text {
display: flex;
flex-direction: column;
}
&__sub-text {
color: $gray-700;
font-size: 12px;
}
&__header-account-modal {
&__header-account-modal-text {
margin-bottom: $grid-unit-10;
}
}
&__header-account-modal-overlay {
// This is to ensure the modal is above the user menu popover.
z-index: 1000000;
}
&__header-account-modal-button-group {
display: inline-flex;
gap: $grid-unit-10;
justify-content: flex-end;
margin-top: $grid-unit-30;
width: 100%;
.woocommerce-marketplace__header-account-modal-button,
.woocommerce-marketplace__header-account-modal-button.is-primary {
border-radius: 2px;
box-shadow: none;
}
}
}
@media screen and (min-width: $break-small) {
.woocommerce-marketplace {
&__header-account-modal {
max-width: 350px;
}
}
}

View File

@ -0,0 +1,144 @@
/**
* External dependencies
*/
import { useState } from '@wordpress/element';
import { DropdownMenu, MenuGroup, MenuItem } from '@wordpress/components';
import { Icon, commentAuthorAvatar, external, linkOff } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import './header-account.scss';
import { getAdminSetting } from '../../../utils/admin-settings';
import HeaderAccountModal from './header-account-modal';
import { MARKETPLACE_URL } from '../constants';
export default function HeaderAccount(): JSX.Element {
const [ isModalOpen, setIsModalOpen ] = useState( false );
const openModal = () => setIsModalOpen( true );
const wccomSettings = getAdminSetting( 'wccomHelper', {} );
const isConnected = wccomSettings?.isConnected ?? false;
const connectionURL = wccomSettings?.connectURL ?? '';
const userEmail = wccomSettings?.userEmail;
const avatarURL = wccomSettings?.userAvatar ?? commentAuthorAvatar;
// This is a hack to prevent TypeScript errors. The MenuItem component passes these as an href prop to the underlying button
// component. That component is either an anchor with href if provided or a button that won't accept an href if no href is provided.
// Due to early erroring of TypeScript, it only takes the button version into account which doesn't accept href.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const accountURL: any = MARKETPLACE_URL + '/my-dashboard/';
const accountOrConnect = isConnected ? accountURL : connectionURL;
const avatar = () => {
if ( ! isConnected ) {
return commentAuthorAvatar;
}
return (
<img
src={ avatarURL }
alt=""
className="woocommerce-marketplace__menu-avatar-image"
/>
);
};
const connectionStatusText = isConnected
? __( 'Connected', 'woocommerce' )
: __( 'Not Connected', 'woocommerce' );
const connectionDetails = () => {
if ( isConnected ) {
return (
<>
<Icon
icon={ commentAuthorAvatar }
size={ 24 }
className="woocommerce-marketplace__menu-icon"
/>
<span className="woocommerce-marketplace__main-text">
{ userEmail }
</span>
</>
);
}
return (
<>
<Icon
icon={ commentAuthorAvatar }
size={ 24 }
className="woocommerce-marketplace__menu-icon"
/>
<div className="woocommerce-marketplace__menu-text">
{ __( 'Connect account', 'woocommerce' ) }
<span className="woocommerce-marketplace__sub-text">
{ __(
'Manage your subscriptions, get updates and support for your extensions and themes.',
'woocommerce'
) }
</span>
</div>
</>
);
};
return (
<>
<DropdownMenu
className="woocommerce-marketplace__user-menu"
icon={ avatar() }
label={ __( 'User options', 'woocommerce' ) }
>
{ () => (
<>
<MenuGroup
className="woocommerce-layout__homescreen-display-options"
label={ connectionStatusText }
>
<MenuItem
className="woocommerce-marketplace__menu-item"
href={ accountOrConnect }
>
{ connectionDetails() }
</MenuItem>
<MenuItem href={ accountURL }>
<Icon
icon={ external }
size={ 24 }
className="woocommerce-marketplace__menu-icon"
/>
{ __(
'WooCommerce.com account',
'woocommerce'
) }
</MenuItem>
</MenuGroup>
{ isConnected && (
<MenuGroup className="woocommerce-layout__homescreen-display-options">
<MenuItem onClick={ openModal }>
<Icon
icon={ linkOff }
size={ 24 }
className="woocommerce-marketplace__menu-icon"
/>
{ __(
'Disconnect account',
'woocommerce'
) }
</MenuItem>
</MenuGroup>
) }
</>
) }
</DropdownMenu>
{ isModalOpen && (
<HeaderAccountModal
setIsModalOpen={ setIsModalOpen }
disconnectURL={ connectionURL }
/>
) }
</>
);
}

View File

@ -0,0 +1,33 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
export default function HeaderSearchButton() {
return (
<button className="woocommerce-marketplace__header-search-button">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
id="Union"
fillRule="evenodd"
clipRule="evenodd"
d="M19.0001 11C19.0001 14.3137 16.3138 17 13.0001 17C11.6135 17 10.3369 16.5297 9.32086 15.7399L5.53039 19.5304L4.46973 18.4697L8.26019 14.6793C7.47038 13.6632 7.00006 12.3865 7.00006 11C7.00006 7.68629 9.68635 5 13.0001 5C16.3138 5 19.0001 7.68629 19.0001 11ZM17.5001 11C17.5001 13.4853 15.4853 15.5 13.0001 15.5C10.5148 15.5 8.50006 13.4853 8.50006 11C8.50006 8.51472 10.5148 6.5 13.0001 6.5C15.4853 6.5 17.5001 8.51472 17.5001 11Z"
fill="#1E1E1E"
/>
</svg>
<span className="screen-reader-text">
{ __( 'Search', 'woocommerce' ) }
</span>
</button>
);
}

View File

@ -0,0 +1,39 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
export default function HeaderSearch() {
return (
<form className="woocommerce-marketplace__header-search">
<input
type="search"
className="woocommerce-marketplace__header-search-field"
placeholder={ __(
'Search extensions and themes',
'woocommerce'
) }
/>
<button className="woocommerce-marketplace__header-search-button">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
id="Union"
fillRule="evenodd"
clipRule="evenodd"
d="M19.0001 11C19.0001 14.3137 16.3138 17 13.0001 17C11.6135 17 10.3369 16.5297 9.32086 15.7399L5.53039 19.5304L4.46973 18.4697L8.26019 14.6793C7.47038 13.6632 7.00006 12.3865 7.00006 11C7.00006 7.68629 9.68635 5 13.0001 5C16.3138 5 19.0001 7.68629 19.0001 11ZM17.5001 11C17.5001 13.4853 15.4853 15.5 13.0001 15.5C10.5148 15.5 8.50006 13.4853 8.50006 11C8.50006 8.51472 10.5148 6.5 13.0001 6.5C15.4853 6.5 17.5001 8.51472 17.5001 11Z"
fill="#1E1E1E"
/>
</svg>
<span className="screen-reader-text">
{ __( 'Search', 'woocommerce' ) }
</span>
</button>
</form>
);
}

View File

@ -0,0 +1,16 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
export default function HeaderTitle() {
return (
<h1 className="woocommerce-marketplace__header-title">
{ __( 'Extensions', 'woocommerce' ) }
</h1>
);
}

View File

@ -0,0 +1,81 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace__header {
align-items: center;
background: #fff;
border-bottom: 1px solid $gutenberg-gray-300;
display: grid;
grid-template: 'mktpl-title mktpl-search mktpl-meta' 60px
'mktpl-tabs mktpl-tabs mktpl-tabs' auto / 1fr 320px 36px;
left: 0;
padding: 0 $content-spacing-large;
position: absolute;
top: -60px;
width: 100%;
/* On narrow screens, "stack" header items and hide the bottom border */
@media (width <= $breakpoint-medium) {
border-bottom: 0;
grid-template: 'mktpl-title mktpl-meta' 60px
'mktpl-tabs mktpl-tabs' 48px
'mktpl-search mktpl-search' auto / auto 48px;
padding: 0;
}
.woocommerce-marketplace__header-title {
font-size: 14px;
font-weight: 600;
margin: 0;
padding: 10px 0 0;
@media (width <= $breakpoint-medium) {
padding-left: var(--large-gap);
}
}
}
.woocommerce-marketplace__header-title {
align-items: center;
align-self: stretch;
display: flex;
grid-area: mktpl-title;
line-height: 18px;
@media (width <= $breakpoint-medium) {
padding: 0 $content-spacing-small;
}
}
.woocommerce-marketplace__header-meta {
grid-area: mktpl-meta;
justify-self: end;
padding-top: 10px;
@media (width <= $breakpoint-medium) {
margin-right: $content-spacing-small;
padding: 0;
}
}
.woocommerce-marketplace__header-tabs {
align-self: end;
grid-area: mktpl-tabs;
@media (width <= $breakpoint-medium) {
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
*/
/**
* Internal dependencies
*/
import './header.scss';
import HeaderTitle from '../header-title/header-title';
import HeaderAccount from '../header-account/header-account';
import Tabs from '../tabs/tabs';
import Search from '../search/search';
export interface HeaderProps {
selectedTab?: string | undefined;
setSelectedTab: ( value: string ) => void;
}
export default function Header( props: HeaderProps ) {
const { selectedTab, setSelectedTab } = props;
return (
<header className="woocommerce-marketplace__header">
<HeaderTitle />
<Tabs
additionalClassNames={ [
'woocommerce-marketplace__header-tabs',
] }
selectedTab={ selectedTab }
setSelectedTab={ setSelectedTab }
/>
<Search />
<div className="woocommerce-marketplace__header-meta">
<HeaderAccount />
</div>
</header>
);
}

View File

@ -0,0 +1,29 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__icon-group {
flex: 1;
max-width: 382px;
&-headline {
display: flex;
gap: $small-gap;
}
&-title {
color: #101517;
font-size: 14px;
font-weight: 600;
line-height: 20px;
margin: 0 0 8px;
}
&-description {
color: $wp-gray-50;
font-size: 13px;
font-weight: 400;
line-height: 20px;
margin: 0;
}
}
}

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { Icon } from '@wordpress/icons';
import { ReactElement } from 'react';
/**
* Internal dependencies
*/
import './icon-with-text.scss';
export interface IconWithTextProps {
icon: JSX.Element;
title: ReactElement;
description: string;
}
export default function IconWithText( props: IconWithTextProps ): JSX.Element {
const { icon, title, description } = props;
return (
<div className="woocommerce-marketplace__icon-group">
<div className="woocommerce-marketplace__icon-group-headline">
<Icon
icon={ icon }
size={ 20 }
className="woocommerce-marketplace__icon-group-icon"
/>
<h3 className="woocommerce-marketplace__icon-group-title">
{ title }
</h3>
</div>
<p className="woocommerce-marketplace__icon-group-description">
{ description }
</p>
</div>
);
}

View File

@ -0,0 +1,52 @@
.woocommerce-marketplace__likert-scale {
display: flex;
list-style: none;
margin: 0;
border: 2px solid transparent;
&.validation-failed {
border-color: $alert-red;
}
}
.woocommerce-marketplace__likert-scale-item {
margin: 0;
width: 100%;
font-size: 11px;
label {
align-items: center;
aspect-ratio: 91.6 / 48;
border: 1.5px solid transparent;
box-sizing: border-box;
display: flex;
flex-direction: column;
gap: 8px;
justify-content: center;
padding: 16px 8px;
text-align: center;
}
input:checked + label {
border-color: var(--wp-admin-theme-color-darker-10);
background: #e6f1f5;
}
// Improve a11y (especially keyboard navigation) by transferring the outline from the (now hidden) checkbox to its label
input:focus {
margin-top: -99px;
& + label {
outline: 2px solid var(--wp-admin-theme-color);
outline-offset: 2px;
}
}
}
.woocommerce-marketplace__likert-scale-icon {
font-size: 24px;
}
.woocommerce-marketplace__likert-scale-text {
font-size: 11px;
}

View File

@ -0,0 +1,96 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import classnames from 'classnames';
/**
* Internal dependencies
*/
import './likert-scale.scss';
export interface LikertScaleProps {
title: string;
fieldName: string;
onValueChange: ( value: number ) => void;
validationFailed?: boolean;
}
export interface LikertChangeEvent {
target: {
value: number;
};
}
export default function LikertScale( props: LikertScaleProps ): JSX.Element {
const { title, fieldName, onValueChange, validationFailed } = props;
const scaleOptions = [
{
value: 1,
emoji: '😔',
label: __( 'Strongly disagree', 'woocommerce' ),
},
{
value: 2,
emoji: '🙁',
label: __( 'Disagree', 'woocommerce' ),
},
{
value: 3,
emoji: '😐',
label: __( 'Neutral', 'woocommerce' ),
},
{
value: 4,
emoji: '🙂',
label: __( 'Agree', 'woocommerce' ),
},
{
value: 5,
emoji: '😍',
label: __( 'Strongly agree', 'woocommerce' ),
},
];
const classes = classnames( 'woocommerce-marketplace__likert-scale', {
'validation-failed': validationFailed,
} );
function valueChanged( e: React.ChangeEvent< HTMLInputElement > ) {
onValueChange( parseInt( e.target.value, 10 ) );
}
return (
<>
<h2>{ title }</h2>
<ol className={ classes }>
{ scaleOptions.map( ( option ) => {
const key = `${ fieldName }_${ option.value }`;
return (
<li
key={ key }
className="woocommerce-marketplace__likert-scale-item"
>
<input
type="radio"
name={ fieldName }
value={ option.value }
id={ key }
onChange={ valueChanged }
className="screen-reader-text"
/>
<label htmlFor={ key }>
<div className="woocommerce-marketplace__likert-scale-icon">
{ option.emoji }
</div>
<div className="woocommerce-marketplace__likert-scale-text">
{ option.label }
</div>
</label>
</li>
);
} ) }
</ol>
</>
);
}

View File

@ -0,0 +1,133 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__product-card {
padding: $large-gap;
border-radius: $grid-unit-05 !important;
&:hover {
outline: 1.5px solid var(--wp-admin-theme-color);
}
&__content {
display: grid;
align-items: flex-start;
gap: $medium-gap;
justify-content: space-between;
height: 100%;
grid-template-rows: auto 1fr 20px;
}
&__header {
align-self: stretch;
}
&__icon {
width: $grid-unit-60;
height: $grid-unit-60;
flex-shrink: 0;
border-radius: $grid-unit;
}
&__details {
display: flex;
justify-content: flex-start;
align-items: flex-start;
gap: $medium-gap;
background: $white;
}
&__meta {
display: flex;
flex-direction: column;
gap: 2px;
color: $gray-900;
}
&__title {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
color: $gray-900;
font-size: $editor-font-size;
font-style: normal;
font-weight: 600;
line-height: $large-gap;
margin: -4px 0 0;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
}
&__link {
&,
&:hover,
&:active {
color: $gray-900;
text-decoration: none;
}
/* Use the ::after trick to make the whole card clickable: */
&::after {
bottom: 0;
content: '';
left: 0;
position: absolute;
right: 0;
top: 0;
}
}
&__vendor {
display: flex;
gap: $grid-unit-05;
margin: 0;
padding: 0;
/* Allow vendor link to "punch through" the "whole card clickable" trick: */
position: relative;
}
&__vendor a {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
text-decoration: none;
}
&__description {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
}
&__price {
align-items: flex-end;
gap: $grid-unit-05;
align-self: stretch;
text-decoration: none !important;
color: $gray-900 !important;
font-style: normal;
font-weight: 500;
line-height: $medium-gap;
}
&__price-billing {
color: $gray-600;
font-size: $default-font-size;
font-style: normal;
font-weight: 400;
line-height: $medium-gap;
}
}
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace {
&__product-card {
margin-top: 0;
}
}
}

View File

@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Card } from '@wordpress/components';
/**
* Internal dependencies
*/
import './product-card.scss';
import { Product } from '../product-list/types';
import { appendUTMParams } from '../../utils/functions';
export interface ProductCardProps {
type?: string;
product: Product;
}
function ProductCard( props: ProductCardProps ): JSX.Element {
const { product } = props;
// We hardcode this for now while we only display prices in USD.
const currencySymbol = '$';
// Append UTM parameters to the vendor URL
let vendorUrl = '';
if ( product.vendorUrl ) {
vendorUrl = appendUTMParams( product.vendorUrl, [
[ 'utm_source', 'extensionsscreen' ],
[ 'utm_medium', 'product' ],
[ 'utm_campaign', 'wcaddons' ],
[ 'utm_content', 'devpartner' ],
] );
}
let productVendor: string | JSX.Element | null = product?.vendorName;
if ( product?.vendorName && product?.vendorUrl ) {
productVendor = (
<a href={ vendorUrl } target="_blank" rel="noopener noreferrer">
{ product.vendorName }
</a>
);
}
return (
<Card className="woocommerce-marketplace__product-card">
<div className="woocommerce-marketplace__product-card__content">
<div className="woocommerce-marketplace__product-card__header">
<div className="woocommerce-marketplace__product-card__details">
{ product.icon && (
<img
className="woocommerce-marketplace__product-card__icon"
src={ product.icon }
alt={ product.title }
/>
) }
<div className="woocommerce-marketplace__product-card__meta">
<h2 className="woocommerce-marketplace__product-card__title">
<a
className="woocommerce-marketplace__product-card__link"
href={ product.url }
target="_blank"
rel="noopener noreferrer"
>
{ product.title }
</a>
</h2>
{ productVendor && (
<p className="woocommerce-marketplace__product-card__vendor">
<span>{ __( 'By ', 'woocommerce' ) }</span>
{ productVendor }
</p>
) }
</div>
</div>
</div>
<p className="woocommerce-marketplace__product-card__description">
{ product.description }
</p>
<div className="woocommerce-marketplace__product-card__price">
<span>
{
// '0' is a free product
product.price === 0
? __( 'Free download', 'woocommerce' )
: currencySymbol + product.price
}
</span>
<span className="woocommerce-marketplace__product-card__price-billing">
{ product.price === 0
? ''
: __( ' annually', 'woocommerce' ) }
</span>
</div>
</div>
</Card>
);
}
export default ProductCard;

View File

@ -0,0 +1,31 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace__no-results__content {
border: 1px solid $gutenberg-gray-100;
padding: $grid-unit-80 $grid-unit-40;
display: flex;
flex-direction: column;
margin-top: $grid-unit-30;
}
.woocommerce-marketplace__no-results__product-group {
margin-top: $grid-unit-60;
}
.woocommerce-marketplace__no-results__icon {
height: 100px;
}
.woocommerce-marketplace__no-results__description {
text-align: center;
font-size: 13px;
p {
color: $gutenberg-gray-700;
}
}
.woocommerce-marketplace__no-results__description--bold {
font-weight: 600;
font-size: 16px;
}

View File

@ -0,0 +1,118 @@
/**
* External dependencies
*/
import { __, sprintf } from '@wordpress/i18n';
import { useEffect, useState } from '@wordpress/element';
import { useQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import NoResultsIcon from '../../assets/images/no-results.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';
export default function NoResults(): JSX.Element {
const [ productGroup, setProductGroup ] = useState< ProductGroup >();
const [ isLoadingProductGroup, setisLoadingProductGroup ] =
useState( false );
const [ noResultsTerm, setNoResultsTerm ] = useState< string >( '' );
const query = useQuery();
useEffect( () => {
if ( query.term ) {
setNoResultsTerm( query.term );
return;
}
if ( query.category ) {
/**
* Trim understore from start and end of a category. Some categories have underscores at the start and end
* and we don't want to show them for the no results term
*/
const categoryTerm = query.category.replace( /^_+|_+$/g, '' );
setNoResultsTerm( categoryTerm );
}
}, [ query ] );
useEffect( () => {
setisLoadingProductGroup( true );
fetchDiscoverPageData()
.then( ( products: ProductGroup[] ) => {
const mostPopularGroup = products.find(
( group ) => group.id === 'most-popular'
);
if ( ! mostPopularGroup ) {
return;
}
mostPopularGroup.items = mostPopularGroup.items.slice( 0, 9 );
setProductGroup( mostPopularGroup );
} )
.catch( () => {
setProductGroup( undefined );
} )
.finally( () => {
setisLoadingProductGroup( false );
} );
}, [] );
function renderProductGroup() {
if ( isLoadingProductGroup ) {
return <ProductLoader />;
}
if ( ! productGroup ) {
return <></>;
}
return (
<ProductList
title={ productGroup.title }
products={ productGroup.items }
groupURL={ productGroup.url }
/>
);
}
return (
<div className="woocommerce-marketplace__no-results">
<div className="woocommerce-marketplace__no-results__content">
<img
className="woocommerce-marketplace__no-results__icon"
src={ NoResultsIcon }
alt={ __( 'No results.', 'woocommerce' ) }
/>
<div className="woocommerce-marketplace__no-results__description">
<h3 className="woocommerce-marketplace__no-results__description--bold">
{ sprintf(
// translators: %s: search term
__(
'We didn\'t find any results for "%s"',
'woocommerce'
),
noResultsTerm
) }
</h3>
<p>
{ __(
'Try searching again using a different term, or take a look at some of our most popular extensions below.',
'woocommerce'
) }
</p>
</div>
</div>
<div className="woocommerce-marketplace__no-results__product-group">
{ renderProductGroup() }
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__product-list-content {
display: grid;
gap: $medium-gap;
margin-top: $grid-unit-20;
}
&__extension-card {
background-color: #3c3c3c;
color: $white;
height: 270px;
}
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace {
&__product-list-content {
gap: $large-gap;
grid-template-columns: repeat(2, 1fr);
}
}
}
@media screen and (min-width: $breakpoint-large) {
.woocommerce-marketplace {
&__product-list-content {
gap: $large-gap;
grid-template-columns: repeat(3, 1fr);
}
}
}
@media screen and (min-width: $breakpoint-huge) {
.woocommerce-marketplace {
&__product-list-content {
grid-template-columns: repeat(4, 1fr);
}
}
}

View File

@ -0,0 +1,31 @@
/**
* Internal dependencies
*/
import './product-list-content.scss';
import ProductCard from '../product-card/product-card';
import { Product } from '../product-list/types';
export default function ProductListContent( props: {
products: Product[];
} ): JSX.Element {
const { products } = props;
return (
<div className="woocommerce-marketplace__product-list-content">
{ products.map( ( product ) => (
<ProductCard
key={ product.id }
type="classic"
product={ {
title: product.title,
icon: product.icon,
vendorName: product.vendorName,
vendorUrl: product.vendorUrl,
price: product.price,
url: product.url,
description: product.description,
} }
/>
) ) }
</div>
);
}

View File

@ -0,0 +1,38 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__product-list-header {
display: flex;
justify-content: center;
gap: $medium-gap;
align-self: stretch;
}
&__product-list-title {
flex: 1 0 0;
font-size: 20px;
font-style: normal;
font-weight: 500;
margin-bottom: $medium-gap;
margin-top: $small-gap;
}
&__product-list-title--extensions {
margin-bottom: 0;
}
&__product-list-link {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
}
&__product-list-link a {
display: flex;
justify-content: center;
align-items: center;
text-decoration: none;
}
}

View File

@ -0,0 +1,34 @@
/**
* External dependencies
*/
import { Link } from '@woocommerce/components';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import './product-list-header.scss';
interface ProductListHeaderProps {
title: string;
groupURL: string;
}
export default function ProductListHeader(
props: ProductListHeaderProps
): JSX.Element {
const { title, groupURL } = props;
return (
<div className="woocommerce-marketplace__product-list-header">
<h2 className="woocommerce-marketplace__product-list-title">
{ title }
</h2>
{ groupURL !== null && (
<span className="woocommerce-marketplace__product-list-link">
<Link href={ groupURL } target="_blank">
{ __( 'See more', 'woocommerce' ) }
</Link>
</span>
) }
</div>
);
}

View File

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

View File

@ -0,0 +1,31 @@
export type SearchAPIProductType = {
title: string;
image: string;
excerpt: string;
link: string;
demo_url: string;
price: string;
raw_price: number;
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: number;
productType?: string;
averageRating?: number | null;
reviewsCount?: number | null;
}

View File

@ -0,0 +1,72 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__product-loader {
margin-top: $grid-unit-20;
}
&__product-loader-cards {
display: grid;
background: linear-gradient(to right, $gray-0 40%, $gray-5 60%, $gray-0 80%);
background-color: $gray-0;
background-size: 500% 200%;
animation: GradientSlide 4s linear infinite;
height: 270px;
}
&__product-loader-divider {
background: #fff;
width: 24px;
display: none;
}
.divider-1 {
grid-column-start: 2;
}
}
@media screen and (min-width: $breakpoint-medium) {
.woocommerce-marketplace {
&__product-loader-cards {
grid-template-columns: repeat(2, 1fr);
}
.divider-1 {
display: block;
}
}
}
@media screen and (min-width: $breakpoint-large) {
.woocommerce-marketplace {
&__product-loader-cards {
grid-template-columns: repeat(3, 1fr);
}
.divider-2 {
display: block;
}
}
}
@media screen and (min-width: $breakpoint-xlarge) {
.woocommerce-marketplace {
&__product-loader-cards {
grid-template-columns: repeat(4, 1fr);
}
.divider-3 {
display: block;
}
}
}
@keyframes GradientSlide {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}

View File

@ -0,0 +1,20 @@
/**
* External dependencies
*/
/**
* Internal dependencies
*/
import './product-loader.scss';
export default function ProductLoader(): JSX.Element {
return (
<div className="woocommerce-marketplace__product-loader">
<div className="woocommerce-marketplace__product-loader-cards">
<div className="woocommerce-marketplace__product-loader-divider divider-1"></div>
<div className="woocommerce-marketplace__product-loader-divider divider-2"></div>
<div className="woocommerce-marketplace__product-loader-divider divider-3"></div>
</div>
</div>
);
}

View File

@ -0,0 +1,29 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace__search {
grid-area: mktpl-search;
background: $gutenberg-gray-100;
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: 1.5px solid var(--wp-admin-theme-color, #3858e9);
}
@media (width <= $breakpoint-medium) {
margin: $content-spacing-small;
}
}
.woocommerce-marketplace__search-button {
all: unset;
cursor: pointer;
}

View File

@ -0,0 +1,89 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { Icon, search } from '@wordpress/icons';
import { useEffect, useState } from '@wordpress/element';
import { navigateTo, getNewPath, useQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import './search.scss';
const searchPlaceholder = __( 'Search for extensions', 'woocommerce' );
/**
* Search component.
*
* @return {JSX.Element} Search component.
*/
function Search(): JSX.Element {
const [ searchTerm, setSearchTerm ] = useState( '' );
const query = useQuery();
useEffect( () => {
if ( query.term ) {
setSearchTerm( query.term );
}
}, [ query.term ] );
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' } ),
} );
return [];
};
const handleInputChange = (
event: React.ChangeEvent< HTMLInputElement >
) => {
setSearchTerm( event.target.value );
};
const handleKeyUp = ( event: { key: string } ) => {
if ( event.key === 'Enter' ) {
runSearch();
}
if ( event.key === 'Escape' ) {
setSearchTerm( '' );
}
};
return (
<div className="woocommerce-marketplace__search">
<label
className="screen-reader-text"
htmlFor="woocommerce-marketplace-search-query"
>
{ searchPlaceholder }
</label>
<input
id="woocommerce-marketplace-search-query"
value={ searchTerm }
className="woocommerce-marketplace__search-input"
type="search"
name="woocommerce-marketplace-search-query"
placeholder={ searchPlaceholder }
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>
);
}
export default Search;

View File

@ -0,0 +1,35 @@
@import '../../stylesheets/_variables.scss';
.woocommerce-marketplace {
&__tabs {
box-sizing: content-box;
display: flex;
gap: 24px;
}
&__tab-button {
border-bottom: 1.5px solid transparent;
border-radius: 0;
color: $mauve-light-12;
font-size: 13px;
font-style: normal;
font-weight: 600;
height: 48px;
line-height: 16px;
padding: 0;
&:focus:not(:disabled) {
box-shadow: none;
}
&.is-active {
border-color: var(--wp-admin-theme-color);
}
}
}
@media (width <= $breakpoint-medium) {
.woocommerce-marketplace__tabs {
border-bottom: 1px solid $gutenberg-gray-300;
}
}

View File

@ -0,0 +1,136 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEffect } from '@wordpress/element';
import { Button } from '@wordpress/components';
import classNames from 'classnames';
import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation';
/**
* Internal dependencies
*/
import './tabs.scss';
import { DEFAULT_TAB_KEY, MARKETPLACE_PATH } from '../constants';
export interface TabsProps {
selectedTab?: string | undefined;
setSelectedTab: ( value: string ) => void;
additionalClassNames?: Array< string > | undefined;
}
interface Tab {
name: string;
title: string;
href?: string;
}
interface Tabs {
[ key: string ]: Tab;
}
const tabs: Tabs = {
discover: {
name: 'discover',
title: __( 'Discover', 'woocommerce' ),
},
extensions: {
name: 'extensions',
title: __( 'Browse', 'woocommerce' ),
},
'my-subscriptions': {
name: 'my-subscriptions',
title: __( 'My Subscriptions', 'woocommerce' ),
href: getNewPath(
{
page: 'wc-addons',
section: 'helper',
},
''
),
},
};
const setUrlTabParam = ( tabKey: string ) => {
if ( tabKey === DEFAULT_TAB_KEY ) {
navigateTo( {
url: getNewPath( {}, MARKETPLACE_PATH, {} ),
} );
return;
}
navigateTo( {
url: getNewPath( { tab: tabKey } ),
} );
};
const renderTabs = ( props: TabsProps ) => {
const { selectedTab, setSelectedTab } = props;
const tabContent = [];
for ( const tabKey in tabs ) {
tabContent.push(
tabs[ tabKey ]?.href ? (
<a
className={ classNames(
'woocommerce-marketplace__tab-button',
'components-button',
`woocommerce-marketplace__tab-${ tabKey }`
) }
href={ tabs[ tabKey ]?.href }
key={ tabKey }
>
{ tabs[ tabKey ]?.title }
</a>
) : (
<Button
className={ classNames(
'woocommerce-marketplace__tab-button',
`woocommerce-marketplace__tab-${ tabKey }`,
{
'is-active': tabKey === selectedTab,
}
) }
onClick={ () => {
setSelectedTab( tabKey );
setUrlTabParam( tabKey );
} }
key={ tabKey }
>
{ tabs[ tabKey ]?.title }
</Button>
)
);
}
return tabContent;
};
const Tabs = ( props: TabsProps ): JSX.Element => {
const { setSelectedTab, additionalClassNames } = props;
interface Query {
path?: string;
tab?: string;
}
const query: Query = useQuery();
useEffect( () => {
if ( query?.tab && tabs[ query.tab ] ) {
setSelectedTab( query.tab );
} else {
setSelectedTab( DEFAULT_TAB_KEY );
}
}, [ query, setSelectedTab ] );
return (
<nav
className={ classNames(
'woocommerce-marketplace__tabs',
additionalClassNames || []
) }
>
{ renderTabs( props ) }
</nav>
);
};
export default Tabs;

View File

@ -0,0 +1,98 @@
/**
* 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,
} );
export function ProductListContextProvider( props: {
children: JSX.Element;
} ): 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();
if ( query.term ) {
params.append( 'term', query.term );
}
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,
// 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 ] );
return (
<ProductListContext.Provider value={ contextValue }>
{ props.children }
</ProductListContext.Provider>
);
}

View File

@ -0,0 +1,29 @@
/**
* External dependencies
*/
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import './marketplace.scss';
import { DEFAULT_TAB_KEY } from './components/constants';
import Header from './components/header/header';
import Content from './components/content/content';
import { ProductListContextProvider } from './contexts/product-list-context';
export default function Marketplace() {
const [ selectedTab, setSelectedTab ] = useState( DEFAULT_TAB_KEY );
return (
<ProductListContextProvider>
<div className="woocommerce-marketplace">
<Header
selectedTab={ selectedTab }
setSelectedTab={ setSelectedTab }
/>
<Content selectedTab={ selectedTab } />
</div>
</ProductListContextProvider>
);
}

View File

@ -0,0 +1,13 @@
@import './stylesheets/_variables.scss';
.woocommerce-admin-page__extensions {
background: #fff;
.woocommerce-layout__primary {
margin: 0;
}
.woocommerce-layout__main {
padding: 0;
}
}

View File

@ -0,0 +1,38 @@
@import '@wordpress/base-styles/_colors.native.scss';
// Spacings
// Taken from base style system
// @wordpress/base-styles/_variables.scss
$small-gap: $grid-unit-10; // 8px
$medium-gap: $grid-unit-20; // 16px
$large-gap: $grid-unit-30; // 24px
$xlarge-gap: $grid-unit-40; // 32px
// Layout
$content-spacing-small: $grid-unit-20;
$content-spacing-large: $grid-unit-40;
$content-spacing-small: $medium-gap;
$content-spacing-large: $xlarge-gap;
$content-spacing-xlarge: $grid-unit-60;
$content-max-width: 1600px;
// Breakpoints
$breakpoint-medium: 769px;
$breakpoint-large: 1024px;
$breakpoint-xlarge: 1500px;
$breakpoint-huge: 1920px;
// Header
$header-height-desktop: 96px;
$header-height-mobile: 129px;
// Colours
$gutenberg-gray-100: $gray-0; // replaced with closest colour from _colors.native.scss
$gutenberg-gray-300: $gray-300; // anything above gray-100 is from the default _colors.scss
$gutenberg-gray-700: $gray-700;
$gutenberg-gray-900: $gray-900;
$mauve-light-12: $gray-900;
$woo-purple-50: #7f54b3;
$wp-gray-0: $gray-0;
$wp-gray-50: $gray-50;
$wp-gray-60: $gray-60;

View File

@ -0,0 +1,79 @@
/**
* External dependencies
*/
import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
import { Product } from '../components/product-list/types';
import { MARKETPLACE_URL } from '../components/constants';
import { CategoryAPIItem } from '../components/category-selector/types';
import { LOCALE } from '../../utils/admin-settings';
interface ProductGroup {
id: string;
title: string;
items: Product[];
url: string;
}
// Fetch data for the discover page from the WooCommerce.com API
async function fetchDiscoverPageData(): Promise< ProductGroup[] > {
let url = '/wc/v3/marketplace/featured';
if ( LOCALE.userLocale ) {
url = `${ url }?locale=${ LOCALE.userLocale }`;
}
try {
return await apiFetch( { path: url.toString() } );
} catch ( error ) {
return [];
}
}
function fetchCategories(): Promise< CategoryAPIItem[] > {
let url = MARKETPLACE_URL + '/wp-json/wccom-extensions/1.0/categories';
if ( LOCALE.userLocale ) {
url = `${ url }?locale=${ LOCALE.userLocale }`;
}
return fetch( url.toString() )
.then( ( response ) => {
if ( ! response.ok ) {
throw new Error( response.statusText );
}
return response.json();
} )
.then( ( json ) => {
return json;
} )
.catch( () => {
return [];
} );
}
// Append UTM parameters to a URL, being aware of existing query parameters
const appendUTMParams = (
url: string,
utmParams: Array< [ string, string ] >
): string => {
const urlObject = new URL( url );
if ( ! urlObject ) {
return url;
}
utmParams.forEach( ( [ key, value ] ) => {
urlObject.searchParams.set( key, value );
} );
return urlObject.toString();
};
export {
fetchDiscoverPageData,
fetchCategories,
ProductGroup,
appendUTMParams,
};

View File

@ -33,6 +33,9 @@ declare global {
cta_label: string;
tc_url: string;
};
currency?: {
symbol: string;
};
};
};
}

View File

@ -0,0 +1,4 @@
Significance: major
Type: update
We have completely redesigned the In-app Marketplace.

View File

@ -0,0 +1,4 @@
Significance: minor
Type: update
Added Marketplace class as basis for Reactified marketplace.

View File

@ -0,0 +1,16 @@
document.addEventListener( 'DOMContentLoaded', () => {
const addonsSubMenuLinks = document.querySelectorAll(
'#adminmenu' +
' #toplevel_page_woocommerce' +
' .wp-submenu' +
' li a[href="admin.php?page=wc-addons"]'
);
addonsSubMenuLinks.forEach( ( el, index ) => {
if ( ! el.innerHTML ) {
el.parentNode.remove();
}
} );
} );

View File

@ -64,8 +64,24 @@ class WC_Admin_Addons {
* @return void
*/
public static function render_featured() {
$featured = self::fetch_featured();
if ( is_wp_error( $featured ) ) {
self::output_empty( $featured->get_error_message() );
}
self::output_featured( $featured );
}
/**
* Fetch featured products from WCCOM's the Featured 2.0 Endpoint and cache the data for a day.
*
* @return array|WP_Error
*/
public static function fetch_featured() {
$locale = get_user_locale();
$featured = self::get_locale_data_from_transient( 'wc_addons_featured', $locale );
if ( false === $featured ) {
$headers = array();
$auth = WC_Helper_Options::get( 'auth' );
@ -96,9 +112,7 @@ class WC_Admin_Addons {
? __( 'We encountered an SSL error. Please ensure your site supports TLS version 1.2 or above.', 'woocommerce' )
: $raw_featured->get_error_message();
self::output_empty( $message );
return;
return new WP_Error( 'wc-addons-connection-error', $message );
}
$response_code = (int) wp_remote_retrieve_response_code( $raw_featured );
@ -117,18 +131,15 @@ class WC_Admin_Addons {
$response_code
);
self::output_empty( $message );
return;
return new WP_Error( 'wc-addons-connection-error', $message );
}
$featured = json_decode( wp_remote_retrieve_body( $raw_featured ) );
if ( empty( $featured ) || ! is_array( $featured ) ) {
do_action( 'woocommerce_page_wc-addons_connection_error', 'Empty or malformed response' );
$message = __( 'Our request to the featured API got a malformed response.', 'woocommerce' );
self::output_empty( $message );
return;
return new WP_Error( 'wc-addons-connection-error', $message );
}
if ( $featured ) {
@ -136,7 +147,7 @@ class WC_Admin_Addons {
}
}
self::output_featured( $featured );
return $featured;
}
/**

View File

@ -8,6 +8,7 @@
use Automattic\Jetpack\Constants;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
if ( ! defined( 'ABSPATH' ) ) {
@ -550,6 +551,11 @@ if ( ! class_exists( 'WC_Admin_Assets', false ) ) :
wp_enqueue_script( 'marketplace-suggestions' );
}
// Temporarily hide empty addons submenu item
if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
wp_enqueue_script( 'wc-admin-menu', WC()->plugin_url() . '/assets/js/admin/wc-admin-menu' . $suffix . '.js', null, $version );
}
}
/**

View File

@ -6,10 +6,14 @@
* @version 2.5.0
*/
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\PageController;
use Automattic\WooCommerce\Internal\Admin\Marketplace;
use Automattic\WooCommerce\Internal\Admin\Orders\COTRedirectionController;
use Automattic\WooCommerce\Internal\Admin\Orders\PageController as Custom_Orders_PageController;
use Automattic\WooCommerce\Internal\Admin\WCAdminAssets;
use Automattic\WooCommerce\Internal\DataStores\Orders\CustomOrdersTableController;
use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
defined( 'ABSPATH' ) || exit;
@ -36,7 +40,12 @@ class WC_Admin_Menus {
add_action( 'admin_menu', array( $this, 'settings_menu' ), 50 );
add_action( 'admin_menu', array( $this, 'status_menu' ), 60 );
if ( apply_filters( 'woocommerce_show_addons_page', true ) ) {
if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
$container = wc_get_container();
$container->get( Marketplace::class );
add_action( 'admin_menu', array( $this, 'addons_my_subscriptions' ), 70 );
} else {
add_action( 'admin_menu', array( $this, 'addons_menu' ), 70 );
}
@ -171,6 +180,16 @@ class WC_Admin_Menus {
add_submenu_page( 'woocommerce', __( 'WooCommerce extensions', 'woocommerce' ), $menu_title, 'manage_woocommerce', 'wc-addons', array( $this, 'addons_page' ) );
}
/**
* Registers the wc-addons page within the WooCommerce menu.
* Temporary measure till we convert the whole page to React.
*
* @return void
*/
public function addons_my_subscriptions() {
add_submenu_page( 'woocommerce', __( 'WooCommerce extensions', 'woocommerce' ), null, 'manage_woocommerce', 'wc-addons', array( $this, 'addons_page' ) );
}
/**
* Highlights the correct top level admin menu item for post type add screens.
*/
@ -426,7 +445,7 @@ class WC_Admin_Menus {
* Maybe add new management product experience.
*/
public function maybe_add_new_product_management_experience() {
if ( Features::is_enabled( 'new-product-management-experience' ) || \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) {
if ( Features::is_enabled( 'new-product-management-experience' ) || FeaturesUtil::feature_is_enabled( 'product_block_editor' ) ) {
global $submenu;
if ( isset( $submenu['edit.php?post_type=product'][10] ) ) {
// Disable phpcs since we need to override submenu classes.

View File

@ -0,0 +1,128 @@
<?php
/**
* WooCommerce Admin Helper - React admin interface
*
* @package WooCommerce\Admin\Helper
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WC_Helper Class
*
* The main entry-point for all things related to the Helper.
* The Helper manages the connection between the store and
* an account on WooCommerce.com.
*/
class WC_Helper_Admin {
/**
* Loads the class, runs on init
*
* @return void
*/
public static function load() {
add_filter( 'woocommerce_admin_shared_settings', array( __CLASS__, 'add_marketplace_settings' ) );
add_filter( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
}
/**
* Pushes settings onto the WooCommerce Admin global settings object (wcSettings).
*
* @param mixed $settings The settings object we're amending.
*
* @return mixed $settings
*/
public static function add_marketplace_settings( $settings ) {
$auth_user_data = WC_Helper_Options::get( 'auth_user_data', array() );
$auth_user_email = isset( $auth_user_data['email'] ) ? $auth_user_data['email'] : '';
$settings['wccomHelper'] = array(
'isConnected' => WC_Helper::is_site_connected(),
'connectURL' => self::get_connection_url(),
'userEmail' => $auth_user_email,
'userAvatar' => get_avatar_url( $auth_user_email, array( 'size' => '48' ) ),
);
return $settings;
}
/**
* Generates the URL for connecting or disconnecting the store to/from WooCommerce.com.
* Approach taken from existing helper code that isn't exposed.
*
* @return string
*/
public static function get_connection_url() {
// No active connection.
if ( ! WC_Helper::is_site_connected() ) {
$connect_url = add_query_arg(
array(
'page' => 'wc-addons',
'section' => 'helper',
'wc-helper-connect' => 1,
'wc-helper-nonce' => wp_create_nonce( 'connect' ),
),
admin_url( 'admin.php' )
);
return $connect_url;
}
$connect_url = add_query_arg(
array(
'page' => 'wc-addons',
'section' => 'helper',
'wc-helper-disconnect' => 1,
'wc-helper-nonce' => wp_create_nonce( 'disconnect' ),
),
admin_url( 'admin.php' )
);
return $connect_url;
}
/**
* Registers the REST routes for the featured products endpoint.
* This endpoint is used by the WooCommerce > Extensions > Discover
* page.
*/
public static function register_rest_routes() {
register_rest_route(
'wc/v3',
'/marketplace/featured',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_featured' ),
'permission_callback' => array( __CLASS__, 'get_permission' ),
)
);
}
/**
* The Extensions page can only be accessed by users with the manage_woocommerce
* capability. So the API mimics that behavior.
*/
public static function get_permission() {
return current_user_can( 'manage_woocommerce' );
}
/**
* Fetch featured procucts from WooCommerce.com and serve them
* as JSON.
*/
public static function get_featured() {
$featured = WC_Admin_Addons::fetch_featured();
if ( is_wp_error( $featured ) ) {
wp_send_json_error( array( 'message' => $featured->get_error_message() ) );
}
wp_send_json( $featured );
}
}
WC_Helper_Admin::load();

View File

@ -58,6 +58,7 @@ class WC_Helper {
include_once dirname( __FILE__ ) . '/class-wc-helper-updater.php';
include_once dirname( __FILE__ ) . '/class-wc-helper-plugin-info.php';
include_once dirname( __FILE__ ) . '/class-wc-helper-compat.php';
include_once dirname( __FILE__ ) . '/class-wc-helper-admin.php';
}
/**

View File

@ -7,10 +7,17 @@
* @deprecated 5.7.0
*/
use Automattic\WooCommerce\Utilities\FeaturesUtil;
$addons_url = admin_url( 'admin.php?page=wc-addons' );
if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
$addons_url = admin_url( 'admin.php?page=wc-admin&path=/extensions&tab=extensions' );
}
defined( 'ABSPATH' ) || exit(); ?>
<nav class="nav-tab-wrapper woo-nav-tab-wrapper">
<a href="<?php echo esc_url( admin_url( 'admin.php?page=wc-addons' ) ); ?>" class="nav-tab"><?php esc_html_e( 'Browse Extensions', 'woocommerce' ); ?></a>
<a href="<?php echo esc_url( $addons_url ); ?>" class="nav-tab"><?php esc_html_e( 'Browse Extensions', 'woocommerce' ); ?></a>
<?php
$count_html = WC_Helper_Updater::get_updates_count_html();

View File

@ -19,6 +19,7 @@ use Automattic\WooCommerce\Internal\ProductDownloads\ApprovedDirectories\Registe
use Automattic\WooCommerce\Internal\RestockRefundedItemsAdjuster;
use Automattic\WooCommerce\Internal\Settings\OptionSanitizer;
use Automattic\WooCommerce\Internal\Utilities\WebhookUtil;
use Automattic\WooCommerce\Internal\Admin\Marketplace;
use Automattic\WooCommerce\Proxies\LegacyProxy;
/**
@ -259,6 +260,7 @@ final class WooCommerce {
$container->get( BatchProcessingController::class );
$container->get( FeaturesController::class );
$container->get( WebhookUtil::class );
$container->get( Marketplace::class );
}
/**

View File

@ -9,7 +9,6 @@ use Automattic\WooCommerce\Admin\Features\Features;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\DeprecatedExtendedTask;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Task;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\ReviewShippingOptions;
use Automattic\WooCommerce\Admin\Features\OnboardingTasks\Tasks\TourInAppMarketplace;
/**
* Task Lists class.
*/
@ -56,7 +55,6 @@ class TaskLists {
'AdditionalPayments',
'ReviewShippingOptions',
'GetMobileApp',
'TourInAppMarketplace',
);
/**
@ -197,11 +195,6 @@ class TaskLists {
);
}
if ( ! wp_is_mobile() ) { // Permit In-App Marketplace Tour on desktops only.
$tour_task = new TourInAppMarketplace();
self::add_task( 'extended', $tour_task );
}
if ( has_filter( 'woocommerce_admin_experimental_onboarding_tasklists' ) ) {
/**
* Filter to override default task lists.

View File

@ -24,7 +24,7 @@ class TourInAppMarketplace extends Task {
*/
public function get_title() {
return __(
'Discover where to find powerful store add-ons and integrations, with a WooCommerce Marketplace tour',
'Discover ways of extending your store with a tour of the Woo Marketplace',
'woocommerce'
);
}
@ -62,7 +62,7 @@ class TourInAppMarketplace extends Task {
* @return string
*/
public function get_action_url() {
return admin_url( 'admin.php?page=wc-addons&tutorial=true' );
return admin_url( 'admin.php?page=wc-admin&path=%2Fextensions&tutorial=true' );
}
/**

View File

@ -11,6 +11,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\Downlo
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\AssignDefaultCategoryServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\FeaturesServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\MarketingServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\MarketplaceServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrdersControllersServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderAdminServiceProvider;
use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\OrderMetaBoxServiceProvider;
@ -68,6 +69,7 @@ final class Container {
OrderAdminServiceProvider::class,
FeaturesServiceProvider::class,
MarketingServiceProvider::class,
MarketplaceServiceProvider::class,
BlockTemplatesServiceProvider::class,
);

View File

@ -0,0 +1,56 @@
<?php
/**
* WooCommerce Marketplace.
*/
namespace Automattic\WooCommerce\Internal\Admin;
use Automattic\WooCommerce\Utilities\FeaturesUtil;
/**
* Contains backend logic for the Marketplace feature.
*/
class Marketplace {
/**
* Class initialization, to be executed when the class is resolved by the container.
*/
final public function init() {
if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
add_action( 'admin_menu', array( $this, 'register_pages' ), 70 );
}
}
/**
* Registers report pages.
*/
public function register_pages() {
$marketplace_pages = self::get_marketplace_pages();
foreach ( $marketplace_pages as $marketplace_page ) {
if ( ! is_null( $marketplace_page ) ) {
wc_admin_register_page( $marketplace_page );
}
}
}
/**
* Get report pages.
*/
public static function get_marketplace_pages() {
$marketplace_pages = array(
array(
'id' => 'woocommerce-marketplace',
'parent' => 'woocommerce',
'title' => __( 'Extensions', 'woocommerce' ),
'path' => '/extensions',
),
);
/**
* The marketplace items used in the menu.
*
* @since 8.0
*/
return apply_filters( 'woocommerce_marketplace_menu_items', $marketplace_pages );
}
}

View File

@ -0,0 +1,31 @@
<?php
/**
* MarketplaceServiceProvider class file.
*/
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
use Automattic\WooCommerce\Internal\Admin\Marketplace;
/**
* Service provider for the Marketplace namespace.
*/
class MarketplaceServiceProvider extends AbstractServiceProvider {
/**
* The classes/interfaces that are serviced by this service provider.
*
* @var array
*/
protected $provides = array(
Marketplace::class,
);
/**
* Register the classes.
*/
public function register() {
$this->share( Marketplace::class );
}
}

View File

@ -133,12 +133,23 @@ class FeaturesController {
'is_experimental' => false,
'disable_ui' => true,
),
'marketplace' => array(
'name' => __( 'Marketplace', 'woocommerce' ),
'description' => __(
'New, faster way to find extensions and themes for your WooCommerce store',
'woocommerce'
),
'is_experimental' => false,
'enabled_by_default' => true,
'disable_ui' => false,
),
);
$this->legacy_feature_ids = array(
'analytics',
'new_navigation',
'product_block_editor',
'marketplace',
// Compatibility for COT is determined by `custom_order_tables'.
CustomOrdersTableController::CUSTOM_ORDERS_TABLE_USAGE_ENABLED_OPTION,
DataSynchronizer::ORDERS_DATA_SYNC_ENABLED_OPTION,