Commit 3b85e257922 for woocommerce
commit 3b85e2579228c8eb5630255607c7683f533e0fa7
Author: Michal Iwanow <4765119+mcliwanow@users.noreply.github.com>
Date: Wed Aug 12 14:49:23 2026 +0200
Show the WooCommerce.com quality badge in the in-app marketplace (#67457)
* Show the WooCommerce.com quality badge in the in-app marketplace
Product cards render a badge chip at the bottom of the card (below the
price) when the WooCommerce.com API marks the product with the quality
badge, and the Extensions listing gains a "Show only <badge>" toggle.
Everything is driven by the API: the per-product flag comes with the
search/featured payloads, and the badge name, tooltip copy and enablement
come from the iam-settings endpoint, so the whole feature stays dormant
until WooCommerce.com turns it on. Filtering happens server-side via the
new quality_badge search param, carried in the URL.
The iam-settings localStorage cache is now keyed to the locale it was
fetched for, since the payload carries translated copy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Match the badge filter styling to the Figma spec
40px control with a 2px radius and gray-5 border, 18px icon, 12px label
with the badge name in medium weight, and the design's spacing between
label, info icon and toggle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Type the locale-keyed iam-settings storage wrapper
tsc --build only surfaced the missing generic on a later full rebuild.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Add a Learn more link to the badge filter info popover
The info button now opens a Popover (not a hover tooltip) containing the
badge explanation and, when the WooCommerce.com API provides a docs URL,
a Learn more link. A popover so the link is reachable by pointer and
keyboard; Escape closes it and returns focus to the button.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Open the badge explanation popover from the card chip too
The stock Tooltip on the card chip rendered as a cramped unstyled bar and
could not hold the Learn more link. The chip is now a button that opens
the same explanation popover as the filter's info button, extracted into
a shared QualityBadgePopover. Chips render as inert labels when the API
sends no tooltip copy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keep compact card footers content-hugging
The 36px footer min-height exists to preserve the regular card's old
fixed footer row; compact (Discover) cards always had auto-height price
rows, so scope it out of the compact variant.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Scope the footer min-height to extension cards only
Theme card footers and the install-modal small variant were always
content-height on trunk; the 36px min-height belongs only to the regular
extension card whose footer previously had a fixed 36px grid row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Keep categories beside the badge filter in the empty state
The empty state rendered the toggle in the sub-header while NoResults
rendered its own category selector below it, stacking the two rows.
Categories now stay in the sub-header in every state and NoResults
accepts showCategorySelector={false} to skip its duplicate.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Give the badge filter select-like sizing on mobile
Below the category-dropdown breakpoint the sub-header stacks: the filter
goes full width with the dropdown's sizing and the toggle at the right
edge, the category dropdown sits below at full width, and the label
ellipsizes so the control can never exceed the viewport.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Accept only absolute https docs URLs in the badge popover
The API sanitizes badge_docs_url server-side with esc_url_raw already;
validating on the client too keeps a javascript: href out of the Learn
more link even if the payload is tampered with.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Announce the badge popover and stop trapping focus in it
Screen readers did not read the popover content, and constrainTabbing
(implied by focusOnMount) trapped Tab inside it. The content is now
announced with speak() when the popover opens, tabbing is no longer
constrained (tabbing out closes it and returns focus to the trigger),
and without a docs link focus stays on the trigger, which handles
Escape itself.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Close the badge popover when focus leaves it
Tabbing away did not close the popover: without a docs link focus never
enters it, and with one, Tab exits through the portal to the end of the
document, so Popover's focus-outside detection never fires. A focusin
listener now closes it when focus lands outside the popover and its
trigger (without stealing focus), and Tab from the docs link hands focus
back to the trigger so the browser continues the tab order from there.
Escape keeps returning focus to the trigger.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Top-align compact card icons with the title
Compact cards centered the icon against the content, so the badge row
pulled it away from the title on badged cards while unbadged neighbours
kept it higher. Anchor it to the title like regular cards do, so the
icon sits identically with or without the badge row.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* Optically balance compact card text against the icon
With flex-start, the title's negative focus-ring margin pinned the text
to the icon's top edge on unbadged cards. A 4px offset on the meta block
restores the balanced look while badged cards stay anchored to the top.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Herman <KokkieH@users.noreply.github.com>
diff --git a/plugins/woocommerce/changelog/wccom-2808-expand-quality-badge-to-the-in-app-marketplace b/plugins/woocommerce/changelog/wccom-2808-expand-quality-badge-to-the-in-app-marketplace
new file mode 100644
index 00000000000..f5cd5c51442
--- /dev/null
+++ b/plugins/woocommerce/changelog/wccom-2808-expand-quality-badge-to-the-in-app-marketplace
@@ -0,0 +1,4 @@
+Significance: minor
+Type: add
+
+Show the WooCommerce.com quality badge on in-app marketplace product cards and add a "show only" filter toggle, both driven by the WooCommerce.com API.
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/content/content.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/content/content.tsx
index 5405723cd29..05428dacad1 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/content/content.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/content/content.tsx
@@ -92,6 +92,10 @@ export default function Content(): React.JSX.Element {
params.append( 'term', query.term );
}
+ if ( query.quality_badge === '1' && query.tab === 'extensions' ) {
+ params.append( 'quality_badge', '1' );
+ }
+
const wccomSettings = getAdminSetting( 'wccomHelper', false );
if ( wccomSettings.storeCountry ) {
params.append( 'country', wccomSettings.storeCountry );
@@ -146,6 +150,7 @@ export default function Content(): React.JSX.Element {
query.category,
query.term,
query.tab,
+ query.quality_badge,
setIsLoadingMore,
] );
@@ -200,6 +205,10 @@ export default function Content(): React.JSX.Element {
params.append( 'term', query.term );
}
+ if ( query.quality_badge === '1' && query.tab === 'extensions' ) {
+ params.append( 'quality_badge', '1' );
+ }
+
const wccomSettings = getAdminSetting( 'wccomHelper', false );
if ( wccomSettings.storeCountry ) {
params.append( 'country', wccomSettings.storeCountry );
@@ -234,6 +243,13 @@ export default function Content(): React.JSX.Element {
if ( query.term ) {
params.append( 'term', query.term );
}
+ // The badge filter only applies to the extensions results.
+ if (
+ category === 'extensions' &&
+ query.quality_badge === '1'
+ ) {
+ params.append( 'quality_badge', '1' );
+ }
const wccomSettings = getAdminSetting(
'wccomHelper',
@@ -326,6 +342,7 @@ export default function Content(): React.JSX.Element {
query.tab,
query.term,
query.category,
+ query.quality_badge,
setIsLoading,
setSearchResultsCount,
currentPage,
@@ -368,11 +385,11 @@ export default function Content(): React.JSX.Element {
recordLegacyTabView( marketplaceViewProps );
}, [ query?.tab, query?.term, query?.section, query?.category ] );
- // Reset current page when tab, term, or category changes
+ // Reset current page when tab, term, category or badge filter changes
useEffect( () => {
setCurrentPage( 1 );
setFirstNewProductId( 0 );
- }, [ selectedTab, query?.category, query?.term ] );
+ }, [ selectedTab, query?.category, query?.term, query?.quality_badge ] );
// Maintain product focus for accessibility
useEffect( () => {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.scss b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.scss
index 3bc3ec51c86..a771ef1b69a 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.scss
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.scss
@@ -66,10 +66,16 @@
.woocommerce-marketplace__product-card__description,
.woocommerce-marketplace__product-card__add-to-store,
.woocommerce-marketplace__product-card__rating,
- .woocommerce-marketplace__product-card__price {
+ .woocommerce-marketplace__product-card__price,
+ .woocommerce-marketplace__quality-badge {
display: none;
}
+ /* Nearly all footer content is hidden here; collapse unless "Installed" shows. */
+ .woocommerce-marketplace__product-card__footer {
+ min-height: 0;
+ }
+
.woocommerce-marketplace__product-card__content {
display: block;
}
@@ -86,8 +92,12 @@
&__footer {
display: flex;
+ /* The quality badge, when present, wraps onto its own row below the price: */
+ flex-wrap: wrap;
justify-content: space-between;
align-items: center;
+ row-gap: $grid-unit-10;
+ min-height: 36px;
}
&:hover {
@@ -98,7 +108,7 @@
display: grid;
gap: $medium-gap;
height: 100%;
- grid-template-rows: auto 1fr 36px;
+ grid-template-rows: auto 1fr auto;
}
&__header {
@@ -297,6 +307,8 @@
}
.woocommerce-marketplace__product-card__footer {
padding: 0 $large-gap;
+ /* Theme footers were always content-height; 36px is the extension-card row. */
+ min-height: 0;
}
.woocommerce-marketplace__product-card__price {
margin-right: $medium-gap;
@@ -404,10 +416,20 @@
.woocommerce-marketplace__product-card__footer {
flex-wrap: wrap;
gap: 4px;
+ /* Compact footers hug their content; 36px is the regular-card row height. */
+ min-height: 0;
}
.woocommerce-marketplace__product-card__details {
- align-items: center;
+ /* Top-align the icon with the title, like regular cards, so it
+ sits identically whether or not the badge row is present. */
+ align-items: flex-start;
+ }
+
+ .woocommerce-marketplace__product-card__meta {
+ /* Optical compensation: the title's negative focus-ring margin
+ would otherwise pin the text to the icon's top edge. */
+ margin-top: $grid-unit-05;
}
.woocommerce-marketplace__product-card__link {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
index b0e9f8dd081..557200f83a4 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-card/product-card.tsx
@@ -14,6 +14,7 @@ import { useState, useContext, useRef } from '@wordpress/element';
*/
import './product-card.scss';
import ProductCardFooter from './product-card-footer';
+import QualityBadge from '../quality-badge/quality-badge';
import {
Product,
ProductCardType,
@@ -299,7 +300,10 @@ function ProductCard( props: ProductCardProps ): React.JSX.Element {
<div className="woocommerce-marketplace__product-card__price" />
) }
{ ! isLoading && props.product && (
- <ProductCardFooter product={ props.product } />
+ <>
+ <ProductCardFooter product={ props.product } />
+ <QualityBadge product={ props.product } />
+ </>
) }
</footer>
) : null;
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/no-results.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/no-results.tsx
index 48241c133c3..1bae4e0b0b8 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/no-results.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/no-results.tsx
@@ -18,6 +18,8 @@ export default function NoResults( props: {
type: SearchResultType;
showHeading?: boolean;
heading?: string;
+ // Set to false when the parent already renders a category selector.
+ showCategorySelector?: boolean;
} ): React.JSX.Element {
const [ productGroups, setProductGroups ] = useState< ProductGroup[] >();
const [ isLoading, setIsLoading ] = useState( false );
@@ -121,7 +123,10 @@ export default function NoResults( props: {
}
function categorySelector() {
- if ( props.type === SearchResultType.all ) {
+ if (
+ props.showCategorySelector === false ||
+ props.type === SearchResultType.all
+ ) {
return <></>;
}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/product-list-content.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/product-list-content.tsx
index 35cb9d2277f..d9f33a1feac 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/product-list-content.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-list-content/product-list-content.tsx
@@ -159,6 +159,7 @@ export default function ProductListContent( props: {
currency: product.currency,
isOnSale: product.isOnSale,
regularPrice: product.regularPrice,
+ hasQualityBadge: product.hasQualityBadge,
} }
tracksData={ {
position: index + 1,
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/product-list/types.ts b/plugins/woocommerce/client/admin/client/marketplace/components/product-list/types.ts
index 935c14a9d30..cc10014ce3d 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/product-list/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/product-list/types.ts
@@ -31,6 +31,7 @@ export type SearchAPIProductType = {
currency: string;
is_on_sale: boolean;
regular_price: number;
+ has_quality_badge?: boolean;
};
export interface Product {
@@ -64,6 +65,7 @@ export interface Product {
currency: string;
isOnSale: boolean;
regularPrice: number;
+ hasQualityBadge?: boolean;
}
export interface ProductTracksData {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/products/products.scss b/plugins/woocommerce/client/admin/client/marketplace/components/products/products.scss
index 4fb5f89218c..43b42a21261 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/products/products.scss
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/products/products.scss
@@ -14,7 +14,18 @@
display: flex;
align-items: center;
justify-content: space-between;
- gap: 32px;
+ flex-wrap: wrap;
+ gap: 16px 32px;
+}
+
+/* Below this width the category selector becomes a full-width dropdown;
+ stack the badge filter above it at full width to match. */
+@media screen and (max-width: $break-medium) {
+ .woocommerce-marketplace__sub-header {
+ align-items: stretch;
+ flex-direction: column;
+ gap: $grid-unit-15;
+ }
}
.woocommerce-marketplace__sub-header__categories {
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/products/products.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/products/products.tsx
index 15114ac75eb..cf524b5f1d5 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/components/products/products.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/products/products.tsx
@@ -15,6 +15,7 @@ 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 QualityBadgeFilter from '../quality-badge/quality-badge-filter';
import NoResults from '../product-list-content/no-results';
import { Product, ProductType, SearchResultType } from '../product-list/types';
import { ADMIN_URL } from '~/utils/admin-settings';
@@ -73,33 +74,37 @@ export default function Products( props: ProductsProps ) {
baseContainerClass + 'button-' + labelForClassName
);
- if ( isLoading ) {
- return (
- <>
- { props.categorySelector && (
+ // The quality badge filter only applies to extensions; the component also
+ // renders nothing unless the WooCommerce.com API has the badge enabled.
+ const showQualityBadgeFilter = props.type === ProductType.extension;
+ const hasNoResults = ! isLoading && products.length === 0;
+ const showCategorySelector = Boolean( props.categorySelector );
+
+ // The sub-header stays mounted across the loading/empty/loaded states so
+ // the filter toggle keeps keyboard focus while toggling triggers a refetch.
+ const subHeader = ( showQualityBadgeFilter || showCategorySelector ) && (
+ <nav className="woocommerce-marketplace__sub-header">
+ { showQualityBadgeFilter && <QualityBadgeFilter /> }
+ <div className="woocommerce-marketplace__sub-header__categories">
+ { showCategorySelector && (
<CategorySelector type={ props.type } />
) }
- <ProductLoader hasTitle={ false } type={ props.type } />
- </>
- );
- }
+ </div>
+ </nav>
+ );
- if ( products.length === 0 ) {
- let type = SearchResultType.all;
-
- switch ( props.type ) {
- case ProductType.extension:
- type = SearchResultType.extension;
- break;
- case ProductType.theme:
- type = SearchResultType.theme;
- break;
- case ProductType.businessService:
- type = SearchResultType.businessService;
- break;
- }
-
- return <NoResults type={ type } showHeading={ false } />;
+ let noResultsType = SearchResultType.all;
+
+ switch ( props.type ) {
+ case ProductType.extension:
+ noResultsType = SearchResultType.extension;
+ break;
+ case ProductType.theme:
+ noResultsType = SearchResultType.theme;
+ break;
+ case ProductType.businessService:
+ noResultsType = SearchResultType.businessService;
+ break;
}
const productListClass = clsx(
@@ -110,21 +115,27 @@ export default function Products( props: ProductsProps ) {
return (
<div className={ containerClassName }>
- <nav className="woocommerce-marketplace__sub-header">
- <div className="woocommerce-marketplace__sub-header__categories">
- { props.categorySelector && (
- <CategorySelector type={ props.type } />
- ) }
- </div>
- </nav>
- <ProductListContent
- products={ products }
- type={ props.type }
- className={ productListClass }
- searchTerm={ props.searchTerm }
- category={ category }
- />
- { props.type === 'theme' && (
+ { subHeader }
+ { isLoading && (
+ <ProductLoader hasTitle={ false } type={ props.type } />
+ ) }
+ { hasNoResults && (
+ <NoResults
+ type={ noResultsType }
+ showHeading={ false }
+ showCategorySelector={ ! showCategorySelector }
+ />
+ ) }
+ { ! isLoading && ! hasNoResults && (
+ <ProductListContent
+ products={ products }
+ type={ props.type }
+ className={ productListClass }
+ searchTerm={ props.searchTerm }
+ category={ category }
+ />
+ ) }
+ { ! isLoading && ! hasNoResults && props.type === 'theme' && (
<div
className={
'woocommerce-marketplace__browse-wp-theme-directory'
@@ -155,7 +166,7 @@ export default function Products( props: ProductsProps ) {
</span>
</div>
) }
- { showAllButton && (
+ { ! isLoading && ! hasNoResults && showAllButton && (
<Button
className={ viewAllButonClassName }
variant="secondary"
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge-filter.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge-filter.tsx
new file mode 100644
index 00000000000..759060eddc2
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge-filter.tsx
@@ -0,0 +1,135 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import {
+ createInterpolateElement,
+ useContext,
+ useState,
+} from '@wordpress/element';
+import { FormToggle, Icon } from '@wordpress/components';
+import { info } from '@wordpress/icons';
+import { useInstanceId } from '@wordpress/compose';
+import { recordEvent } from '@woocommerce/tracks';
+import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation';
+
+/**
+ * Internal dependencies
+ */
+import './quality-badge.scss';
+import { QualityBadgeIcon, QualityBadgePopover } from './quality-badge';
+import { MarketplaceContext } from '../../contexts/marketplace-context';
+
+/**
+ * Info button next to the filter label; opens the shared badge explanation
+ * popover.
+ */
+function QualityBadgeInfo( props: {
+ label: string;
+ tooltip: string;
+ docsUrl?: string;
+} ) {
+ const [ isOpen, setIsOpen ] = useState( false );
+ const [ anchor, setAnchor ] = useState< HTMLButtonElement | null >( null );
+
+ return (
+ <>
+ <button
+ ref={ setAnchor }
+ type="button"
+ className="woocommerce-marketplace__quality-badge-filter__info"
+ aria-expanded={ isOpen }
+ aria-label={ sprintf(
+ // translators: %s: name of the quality badge, supplied by the WooCommerce.com API (e.g. "Excellence Verified").
+ __( 'About %s', 'woocommerce' ),
+ props.label
+ ) }
+ onClick={ () => setIsOpen( ! isOpen ) }
+ onKeyDown={ ( event ) => {
+ // Focus stays on the trigger when the popover has no link.
+ if ( event.key === 'Escape' && isOpen ) {
+ setIsOpen( false );
+ }
+ } }
+ >
+ <Icon icon={ info } size={ 16 } />
+ </button>
+ { isOpen && (
+ <QualityBadgePopover
+ label={ props.label }
+ tooltip={ props.tooltip }
+ docsUrl={ props.docsUrl }
+ anchor={ anchor }
+ source="filter"
+ onClose={ () => setIsOpen( false ) }
+ />
+ ) }
+ </>
+ );
+}
+
+/**
+ * "Show only <badge>" toggle for product listings. Renders nothing unless the
+ * WooCommerce.com API reports the quality badge as enabled. The filter state
+ * lives in the `quality_badge` URL query parameter, which is forwarded to the
+ * search API.
+ */
+export default function QualityBadgeFilter() {
+ const { iamSettings } = useContext( MarketplaceContext );
+ const query = useQuery();
+ const toggleId = useInstanceId(
+ QualityBadgeFilter,
+ 'woocommerce-marketplace-quality-badge-filter'
+ ) as string;
+
+ const badge = iamSettings?.quality_badge;
+ if ( ! badge?.enabled || ! badge.label ) {
+ return null;
+ }
+
+ const isActive = query.quality_badge === '1';
+
+ const onChange = () => {
+ recordEvent( 'marketplace_quality_badge_filter_toggled', {
+ state: isActive ? 'off' : 'on',
+ } );
+ navigateTo( {
+ url: getNewPath( { quality_badge: isActive ? undefined : '1' } ),
+ } );
+ };
+
+ return (
+ <div className="woocommerce-marketplace__quality-badge-filter">
+ <QualityBadgeIcon size={ 18 } />
+ <label htmlFor={ toggleId }>
+ { createInterpolateElement(
+ sprintf(
+ // translators: %s: name of the quality badge, supplied by the WooCommerce.com API (e.g. "Excellence Verified").
+ __(
+ 'Show only <badgeName>%s</badgeName>',
+ 'woocommerce'
+ ),
+ badge.label
+ ),
+ {
+ badgeName: (
+ <span className="woocommerce-marketplace__quality-badge-filter__name" />
+ ),
+ }
+ ) }
+ </label>
+ { badge.tooltip && (
+ <QualityBadgeInfo
+ label={ badge.label }
+ tooltip={ badge.tooltip }
+ docsUrl={ badge.docs_url }
+ />
+ ) }
+ <FormToggle
+ id={ toggleId }
+ checked={ isActive }
+ onChange={ onChange }
+ />
+ </div>
+ );
+}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.scss b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.scss
new file mode 100644
index 00000000000..3d0b0d68af8
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.scss
@@ -0,0 +1,105 @@
+@import "../../stylesheets/_variables.scss";
+
+.woocommerce-marketplace {
+ // Chip on product cards. Sits on its own row at the bottom of the card
+ // footer, below the price, to avoid crowding the title on narrow cards.
+ &__quality-badge {
+ width: 100%;
+
+ &__chip {
+ align-items: center;
+ background: $studio-gray-0;
+ border: 0;
+ border-radius: $grid-unit-05;
+ color: $gray-900;
+ cursor: pointer;
+ display: inline-flex;
+ font-family: inherit;
+ font-size: 12px;
+ font-weight: 500;
+ gap: $grid-unit-05;
+ line-height: 20px;
+ padding: 0 $grid-unit-10 0 $grid-unit-05;
+ /* Punch through the "whole card clickable" trick so the popover opens: */
+ position: relative;
+ }
+
+ /* The tooltip-less variant is an inert label, not a control: */
+ span#{&}__chip {
+ cursor: default;
+ }
+ }
+
+ &__quality-badge-icon {
+ flex-shrink: 0;
+ }
+
+ // "Show only <badge>" toggle in the listing sub-header.
+ &__quality-badge-filter {
+ align-items: center;
+ border: 1px solid $studio-gray-5;
+ border-radius: 2px;
+ display: flex;
+ flex-shrink: 0;
+ height: 40px;
+ padding: 0 $grid-unit-15;
+
+ label {
+ cursor: pointer;
+ font-size: 12px;
+ line-height: 16px;
+ margin: 0 $grid-unit-05 0 $grid-unit-10;
+ white-space: nowrap;
+ }
+
+ &__name {
+ font-weight: 500;
+ }
+
+ &__info {
+ align-items: center;
+ background: none;
+ border: 0;
+ color: $gray-700;
+ cursor: pointer;
+ display: flex;
+ padding: 0;
+ }
+
+ .components-form-toggle {
+ margin-left: $grid-unit-20;
+ }
+
+ /* On mobile the filter sits above the category dropdown; mirror the
+ dropdown's select-like sizing and never exceed the viewport. */
+ @media screen and (max-width: $break-medium) {
+ border-color: $gray-600;
+ height: auto;
+ min-width: 0;
+ padding: $grid-unit-15 $grid-unit-10;
+
+ label {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .components-form-toggle {
+ margin-left: auto;
+ }
+ }
+ }
+
+ // Shared explanation popover (card chip + filter info button).
+ &__quality-badge-popover .components-popover__content {
+ font-size: 12px;
+ line-height: 16px;
+ max-width: 260px;
+ padding: $grid-unit-15;
+ width: max-content;
+
+ p {
+ margin: 0 0 $grid-unit-05;
+ }
+ }
+}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.tsx
new file mode 100644
index 00000000000..9a13da0e5de
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/quality-badge.tsx
@@ -0,0 +1,218 @@
+/**
+ * External dependencies
+ */
+import { __, sprintf } from '@wordpress/i18n';
+import { useContext, useEffect, useState } from '@wordpress/element';
+import { Popover } from '@wordpress/components';
+import { speak } from '@wordpress/a11y';
+import { recordEvent } from '@woocommerce/tracks';
+
+/**
+ * Internal dependencies
+ */
+import './quality-badge.scss';
+import { Product } from '../product-list/types';
+import { MarketplaceContext } from '../../contexts/marketplace-context';
+
+/**
+ * Seal-with-checkmark icon, kept identical to the badge icon on WooCommerce.com.
+ */
+export function QualityBadgeIcon( { size = 12 }: { size?: number } ) {
+ return (
+ <svg
+ className="woocommerce-marketplace__quality-badge-icon"
+ fill="none"
+ height={ size }
+ viewBox="0 0 16 16"
+ width={ size }
+ xmlns="http://www.w3.org/2000/svg"
+ aria-hidden="true"
+ >
+ <path
+ d="M8.63342 0.528086L9.86941 0.105681C10.8022 -0.212938 11.8262 0.211045 12.2606 1.09624L12.836 2.26913C13.0272 2.65841 13.342 2.97356 13.7316 3.16473L14.9045 3.74014C15.7894 4.17422 16.2137 5.19853 15.8951 6.13135L15.4727 7.36734C15.3323 7.77776 15.3323 8.22319 15.4727 8.63361L15.8951 9.8696C16.2137 10.8024 15.7897 11.8264 14.9045 12.2608L13.7316 12.8362C13.3423 13.0274 13.0272 13.3422 12.836 13.7318L12.2606 14.9047C11.8265 15.7896 10.8022 16.2139 9.86941 15.8953L8.63342 15.4729C8.223 15.3325 7.77757 15.3325 7.36715 15.4729L6.13116 15.8953C5.19834 16.2139 4.17434 15.7899 3.73995 14.9047L3.16454 13.7318C2.97337 13.3425 2.65854 13.0274 2.26894 12.8362L1.09605 12.2608C0.211172 11.8267 -0.213126 10.8024 0.105492 9.8696L0.527898 8.63361C0.668279 8.22319 0.668279 7.77776 0.527898 7.36734L0.105492 6.13135C-0.213126 5.19853 0.210857 4.17453 1.09605 3.74014L2.26894 3.16473C2.65822 2.97356 2.97337 2.65873 3.16454 2.26913L3.73995 1.09624C4.17371 0.211045 5.19802 -0.213253 6.13085 0.105681L7.36683 0.528086C7.77725 0.668468 8.22269 0.668468 8.6331 0.528086H8.63342Z"
+ fill="#1d2327"
+ />
+ <path
+ d="M7.31385 11.7143L4 7.98002L4.28348 7.14289L7.31385 8.81639L11.4267 4.95924L12.237 5.54286L7.31385 11.7143Z"
+ fill="#fff"
+ />
+ </svg>
+ );
+}
+
+/**
+ * The docs URL comes from the WooCommerce.com API, which sanitizes it
+ * server-side; accept only absolute https URLs anyway before rendering it
+ * as a link target.
+ */
+function getSafeDocsUrl( value?: string ): string | undefined {
+ try {
+ const url = new URL( value ?? '' );
+ return url.protocol === 'https:' ? url.href : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/**
+ * Popover with the badge explanation and, when the WooCommerce.com API
+ * provides a docs URL, a "Learn more" link. Shared by the card chip and the
+ * filter's info button; a popover (not a tooltip) so the link stays reachable
+ * by pointer and keyboard.
+ */
+export function QualityBadgePopover( props: {
+ label: string;
+ tooltip: string;
+ docsUrl?: string;
+ anchor: Element | null;
+ source: 'product_card' | 'filter';
+ onClose: () => void;
+} ) {
+ const docsUrl = getSafeDocsUrl( props.docsUrl );
+ const { tooltip, anchor, onClose } = props;
+
+ // Screen readers do not announce the popover content on their own.
+ useEffect( () => {
+ speak( tooltip );
+ }, [ tooltip ] );
+
+ // Close when focus lands outside the popover and its trigger, without
+ // pulling focus back, so tabbing away flows naturally. The Popover's own
+ // focus-outside detection does not cover this: without a link, focus
+ // never enters the popover at all.
+ useEffect( () => {
+ const onFocusin = ( event: FocusEvent ) => {
+ const target = event.target as Element | null;
+ if (
+ ! target ||
+ target.closest(
+ '.woocommerce-marketplace__quality-badge-popover'
+ ) ||
+ anchor?.contains( target )
+ ) {
+ return;
+ }
+ onClose();
+ };
+
+ document.addEventListener( 'focusin', onFocusin );
+ return () => document.removeEventListener( 'focusin', onFocusin );
+ }, [ anchor, onClose ] );
+
+ return (
+ <Popover
+ className="woocommerce-marketplace__quality-badge-popover"
+ anchor={ props.anchor }
+ placement="bottom"
+ // Focus the link when there is one; without it, focus stays on the
+ // trigger and the content is announced via speak() above.
+ focusOnMount={ docsUrl ? 'firstElement' : false }
+ // Keep the popover in the page tab order; tabbing out closes it
+ // via the focusin listener above.
+ constrainTabbing={ false }
+ onClose={ onClose }
+ >
+ <p>{ props.tooltip }</p>
+ { docsUrl && (
+ <a
+ href={ docsUrl }
+ target="_blank"
+ rel="noreferrer"
+ aria-label={ sprintf(
+ // translators: %s: name of the quality badge, supplied by the WooCommerce.com API (e.g. "Excellence Verified").
+ __( 'Learn more about the %s badge', 'woocommerce' ),
+ props.label
+ ) }
+ onClick={ () =>
+ recordEvent(
+ 'marketplace_quality_badge_learn_more_clicked',
+ { source: props.source }
+ )
+ }
+ onKeyDown={ ( event ) => {
+ // Escape returns focus to the trigger. Tab would leave
+ // through the portal to the end of the document, so
+ // hand focus back to the trigger and let the browser
+ // continue the tab order from there.
+ if ( event.key === 'Escape' || event.key === 'Tab' ) {
+ onClose();
+ ( anchor as HTMLElement | null )?.focus();
+ }
+ } }
+ >
+ { __( 'Learn more', 'woocommerce' ) }
+ </a>
+ ) }
+ </Popover>
+ );
+}
+
+/**
+ * Quality badge chip shown on product cards. Whether it renders and with what
+ * copy is fully driven by the WooCommerce.com API: the per-product flag comes
+ * with the product data, the label/tooltip/docs URL from the IAM settings
+ * endpoint. Clicking the chip opens the explanation popover.
+ */
+export default function QualityBadge( props: { product: Product } ) {
+ const { iamSettings } = useContext( MarketplaceContext );
+ const [ isOpen, setIsOpen ] = useState( false );
+ const [ anchor, setAnchor ] = useState< HTMLButtonElement | null >( null );
+
+ const badge = iamSettings?.quality_badge;
+
+ if (
+ ! badge?.enabled ||
+ ! badge.label ||
+ ! props.product.hasQualityBadge
+ ) {
+ return null;
+ }
+
+ const chipContent = (
+ <>
+ <QualityBadgeIcon />
+ { badge.label }
+ </>
+ );
+
+ // Without explanation copy the chip is a plain, inert label.
+ if ( ! badge.tooltip ) {
+ return (
+ <div className="woocommerce-marketplace__quality-badge">
+ <span className="woocommerce-marketplace__quality-badge__chip">
+ { chipContent }
+ </span>
+ </div>
+ );
+ }
+
+ return (
+ <div className="woocommerce-marketplace__quality-badge">
+ <button
+ ref={ setAnchor }
+ type="button"
+ className="woocommerce-marketplace__quality-badge__chip"
+ aria-expanded={ isOpen }
+ onClick={ () => setIsOpen( ! isOpen ) }
+ onKeyDown={ ( event ) => {
+ // Focus stays on the trigger when the popover has no link.
+ if ( event.key === 'Escape' && isOpen ) {
+ setIsOpen( false );
+ }
+ } }
+ >
+ { chipContent }
+ </button>
+ { isOpen && (
+ <QualityBadgePopover
+ label={ badge.label }
+ tooltip={ badge.tooltip }
+ docsUrl={ badge.docs_url }
+ anchor={ anchor }
+ source="product_card"
+ onClose={ () => setIsOpen( false ) }
+ />
+ ) }
+ </div>
+ );
+}
diff --git a/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/test/quality-badge.test.tsx b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/test/quality-badge.test.tsx
new file mode 100644
index 00000000000..0eb27ab4940
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/marketplace/components/quality-badge/test/quality-badge.test.tsx
@@ -0,0 +1,344 @@
+/**
+ * External dependencies
+ */
+import { render, screen, fireEvent } from '@testing-library/react';
+import React from 'react';
+
+jest.mock( '@woocommerce/navigation', () => ( {
+ getNewPath: jest.fn( () => '/new-path' ),
+ navigateTo: jest.fn(),
+ useQuery: jest.fn( () => ( {} ) ),
+} ) );
+
+jest.mock( '@woocommerce/tracks', () => ( {
+ recordEvent: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/a11y', () => ( {
+ speak: jest.fn(),
+} ) );
+
+/**
+ * Internal dependencies
+ */
+import { speak } from '@wordpress/a11y';
+import { navigateTo, useQuery } from '@woocommerce/navigation';
+import QualityBadge from '../quality-badge';
+import QualityBadgeFilter from '../quality-badge-filter';
+import { MarketplaceContext } from '../../../contexts/marketplace-context';
+import { MarketplaceContextType } from '../../../contexts/types';
+import { Product, ProductType } from '../../product-list/types';
+
+const contextWithBadge = {
+ iamSettings: {
+ quality_badge: {
+ enabled: true,
+ label: 'Excellence Verified',
+ tooltip: 'Verified against WooCommerce standards.',
+ },
+ },
+} as MarketplaceContextType;
+
+const contextWithBadgeDisabled = {
+ iamSettings: {
+ quality_badge: {
+ enabled: false,
+ label: 'Excellence Verified',
+ tooltip: '',
+ },
+ },
+} as MarketplaceContextType;
+
+const contextWithoutBadgeSettings = {
+ iamSettings: {},
+} as MarketplaceContextType;
+
+const product: Product = {
+ id: 1,
+ title: 'Test extension',
+ image: '',
+ type: ProductType.extension,
+ description: '',
+ vendorName: '',
+ vendorUrl: '',
+ icon: '',
+ url: '',
+ price: 0,
+ isInstallable: false,
+ currency: 'USD',
+ isOnSale: false,
+ regularPrice: 0,
+ hasQualityBadge: true,
+};
+
+function renderWithContext(
+ ui: React.ReactElement,
+ context: MarketplaceContextType
+) {
+ return render(
+ <MarketplaceContext.Provider value={ context }>
+ { ui }
+ </MarketplaceContext.Provider>
+ );
+}
+
+beforeEach( () => {
+ ( useQuery as jest.Mock ).mockReturnValue( {} );
+} );
+
+describe( 'QualityBadge', () => {
+ it( 'renders the badge label when enabled and the product has the badge', () => {
+ renderWithContext(
+ <QualityBadge product={ product } />,
+ contextWithBadge
+ );
+
+ expect( screen.getByText( 'Excellence Verified' ) ).toBeInTheDocument();
+ } );
+
+ it( 'renders nothing when the API reports the badge as disabled', () => {
+ const { container } = renderWithContext(
+ <QualityBadge product={ product } />,
+ contextWithBadgeDisabled
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'renders nothing when the API sends no badge settings', () => {
+ const { container } = renderWithContext(
+ <QualityBadge product={ product } />,
+ contextWithoutBadgeSettings
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'renders nothing when the product does not have the badge', () => {
+ const { container } = renderWithContext(
+ <QualityBadge product={ { ...product, hasQualityBadge: false } } />,
+ contextWithBadge
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'opens a popover with the explanation when the chip is clicked', () => {
+ renderWithContext(
+ <QualityBadge product={ product } />,
+ contextWithBadge
+ );
+
+ fireEvent.click( screen.getByText( 'Excellence Verified' ) );
+
+ expect(
+ screen.getByText( 'Verified against WooCommerce standards.' )
+ ).toBeInTheDocument();
+ expect( screen.queryByText( 'Learn more' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'links to the docs from the chip popover when the API sends a docs URL', () => {
+ const context = {
+ iamSettings: {
+ quality_badge: {
+ ...contextWithBadge.iamSettings.quality_badge,
+ docs_url: 'https://woocommerce.com/document/excellence/',
+ },
+ },
+ } as MarketplaceContextType;
+
+ renderWithContext( <QualityBadge product={ product } />, context );
+
+ fireEvent.click( screen.getByText( 'Excellence Verified' ) );
+
+ expect(
+ screen.getByText( 'Learn more' ).closest( 'a' )
+ ).toHaveAttribute(
+ 'href',
+ 'https://woocommerce.com/document/excellence/'
+ );
+ } );
+
+ it( 'drops non-https docs URLs from the popover', () => {
+ const context = {
+ iamSettings: {
+ quality_badge: {
+ ...contextWithBadge.iamSettings.quality_badge,
+ docs_url: 'javascript:alert(1)',
+ },
+ },
+ } as MarketplaceContextType;
+
+ renderWithContext( <QualityBadge product={ product } />, context );
+
+ fireEvent.click( screen.getByText( 'Excellence Verified' ) );
+
+ expect(
+ screen.getByText( 'Verified against WooCommerce standards.' )
+ ).toBeInTheDocument();
+ expect( screen.queryByText( 'Learn more' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'announces the explanation when the popover opens', () => {
+ renderWithContext(
+ <QualityBadge product={ product } />,
+ contextWithBadge
+ );
+
+ fireEvent.click( screen.getByText( 'Excellence Verified' ) );
+
+ expect( speak ).toHaveBeenCalledWith(
+ 'Verified against WooCommerce standards.'
+ );
+ } );
+
+ it( 'closes with Escape while focus is on the chip', () => {
+ renderWithContext(
+ <QualityBadge product={ product } />,
+ contextWithBadge
+ );
+
+ const chip = screen.getByText( 'Excellence Verified' );
+ fireEvent.click( chip );
+ expect(
+ screen.getByText( 'Verified against WooCommerce standards.' )
+ ).toBeInTheDocument();
+
+ fireEvent.keyDown( chip, { key: 'Escape' } );
+
+ expect(
+ screen.queryByText( 'Verified against WooCommerce standards.' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'closes on Tab from the docs link and returns focus to the trigger', () => {
+ const context = {
+ iamSettings: {
+ quality_badge: {
+ ...contextWithBadge.iamSettings.quality_badge,
+ docs_url: 'https://woocommerce.com/document/excellence/',
+ },
+ },
+ } as MarketplaceContextType;
+
+ renderWithContext( <QualityBadge product={ product } />, context );
+
+ const chip = screen.getByText( 'Excellence Verified' );
+ fireEvent.click( chip );
+
+ fireEvent.keyDown( screen.getByText( 'Learn more' ), { key: 'Tab' } );
+
+ expect(
+ screen.queryByText( 'Verified against WooCommerce standards.' )
+ ).not.toBeInTheDocument();
+ expect( chip.closest( 'button' ) ).toHaveFocus();
+ } );
+
+ it( 'closes when focus moves outside the popover and its trigger', () => {
+ renderWithContext(
+ <QualityBadge product={ product } />,
+ contextWithBadge
+ );
+
+ fireEvent.click( screen.getByText( 'Excellence Verified' ) );
+ expect(
+ screen.getByText( 'Verified against WooCommerce standards.' )
+ ).toBeInTheDocument();
+
+ fireEvent.focusIn( document.body );
+
+ expect(
+ screen.queryByText( 'Verified against WooCommerce standards.' )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'renders an inert chip when there is no tooltip copy', () => {
+ const context = {
+ iamSettings: {
+ quality_badge: {
+ enabled: true,
+ label: 'Excellence Verified',
+ tooltip: '',
+ },
+ },
+ } as MarketplaceContextType;
+
+ renderWithContext( <QualityBadge product={ product } />, context );
+
+ expect(
+ screen.getByText( 'Excellence Verified' ).closest( 'button' )
+ ).toBeNull();
+ } );
+} );
+
+describe( 'QualityBadgeFilter', () => {
+ it( 'renders the toggle with the label from the API', () => {
+ renderWithContext( <QualityBadgeFilter />, contextWithBadge );
+
+ expect(
+ screen.getByLabelText( 'Show only Excellence Verified' )
+ ).toBeInTheDocument();
+ } );
+
+ it( 'renders nothing when the API reports the badge as disabled', () => {
+ const { container } = renderWithContext(
+ <QualityBadgeFilter />,
+ contextWithBadgeDisabled
+ );
+
+ expect( container ).toBeEmptyDOMElement();
+ } );
+
+ it( 'navigates with the quality_badge param when toggled on', () => {
+ renderWithContext( <QualityBadgeFilter />, contextWithBadge );
+
+ fireEvent.click(
+ screen.getByLabelText( 'Show only Excellence Verified' )
+ );
+
+ expect( navigateTo ).toHaveBeenCalledWith( { url: '/new-path' } );
+ } );
+
+ it( 'is checked when the quality_badge query param is set', () => {
+ ( useQuery as jest.Mock ).mockReturnValue( { quality_badge: '1' } );
+
+ renderWithContext( <QualityBadgeFilter />, contextWithBadge );
+
+ expect(
+ screen.getByLabelText( 'Show only Excellence Verified' )
+ ).toBeChecked();
+ } );
+
+ it( 'shows the tooltip copy in a popover without a link when no docs URL is set', () => {
+ renderWithContext( <QualityBadgeFilter />, contextWithBadge );
+
+ fireEvent.click( screen.getByLabelText( 'About Excellence Verified' ) );
+
+ expect(
+ screen.getByText( 'Verified against WooCommerce standards.' )
+ ).toBeInTheDocument();
+ expect( screen.queryByText( 'Learn more' ) ).not.toBeInTheDocument();
+ } );
+
+ it( 'shows a "Learn more" link in the popover when the API sends a docs URL', () => {
+ const context = {
+ iamSettings: {
+ quality_badge: {
+ ...contextWithBadge.iamSettings.quality_badge,
+ docs_url: 'https://woocommerce.com/document/excellence/',
+ },
+ },
+ } as MarketplaceContextType;
+
+ renderWithContext( <QualityBadgeFilter />, context );
+
+ fireEvent.click( screen.getByLabelText( 'About Excellence Verified' ) );
+
+ expect(
+ screen.getByText( 'Learn more' ).closest( 'a' )
+ ).toHaveAttribute(
+ 'href',
+ 'https://woocommerce.com/document/excellence/'
+ );
+ } );
+} );
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx b/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
index fcd18ba3161..9865c0f9de3 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/marketplace-context.tsx
@@ -12,7 +12,7 @@ import {
* Internal dependencies
*/
import { SearchResultsCountType, MarketplaceContextType } from './types';
-import { getAdminSetting } from '../../utils/admin-settings';
+import { LOCALE, getAdminSetting } from '../../utils/admin-settings';
import { createStorageUtils } from '../../utils/localStorage';
import {
MARKETPLACE_HOST,
@@ -20,10 +20,10 @@ import {
} from '../components/constants';
// Create storage utils with 24h expiration
-const iamSettingsStorage = createStorageUtils(
- 'wc_iam_settings',
- 24 * 60 * 60
-);
+const iamSettingsStorage = createStorageUtils< {
+ locale: string | null | undefined;
+ settings: MarketplaceContextType[ 'iamSettings' ];
+} >( 'wc_iam_settings', 24 * 60 * 60 );
export const MarketplaceContext = createContext< MarketplaceContextType >( {
isLoading: false,
@@ -68,16 +68,21 @@ export function MarketplaceContextProvider( props: {
);
/**
- * Load IAM settings from localStorage or WCCOM.
+ * Load IAM settings from localStorage or WCCOM. The response contains
+ * translated copy (e.g. the quality badge tooltip), so the cache is keyed
+ * to the locale it was fetched for.
*/
useEffect( () => {
- const cachedSettings = iamSettingsStorage.getWithExpiry();
- if ( cachedSettings ) {
- setIamSettings( cachedSettings );
+ const cached = iamSettingsStorage.getWithExpiry();
+ if ( cached?.settings && cached.locale === LOCALE.userLocale ) {
+ setIamSettings( cached.settings );
return;
}
- const url = `${ MARKETPLACE_HOST }${ MARKETPLACE_IAM_SETTINGS_API_PATH }`;
+ let url = `${ MARKETPLACE_HOST }${ MARKETPLACE_IAM_SETTINGS_API_PATH }`;
+ if ( LOCALE.userLocale ) {
+ url += `?locale=${ LOCALE.userLocale }`;
+ }
fetch( url )
.then( ( response ) => {
if ( ! response.ok ) {
@@ -89,7 +94,10 @@ export function MarketplaceContextProvider( props: {
} )
.then( ( data ) => {
setIamSettings( data );
- iamSettingsStorage.setWithExpiry( data );
+ iamSettingsStorage.setWithExpiry( {
+ locale: LOCALE.userLocale,
+ settings: data,
+ } );
} )
.catch( ( error ) => {
// eslint-disable-next-line no-console
diff --git a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
index dd3fec7bf19..de49756a10e 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
+++ b/plugins/woocommerce/client/admin/client/marketplace/contexts/types.ts
@@ -25,6 +25,12 @@ export type MarketplaceContextType = {
) => void;
iamSettings: {
product_previews?: 'modal' | 'none';
+ quality_badge?: {
+ enabled?: boolean;
+ label?: string;
+ tooltip?: string;
+ docs_url?: string;
+ };
};
};
diff --git a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
index df7ddd8ce2c..7afbc9e0d67 100644
--- a/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
+++ b/plugins/woocommerce/client/admin/client/marketplace/utils/functions.tsx
@@ -176,6 +176,7 @@ async function fetchSearchResults(
billingPeriodInterval:
product.billing_period_interval,
currency: product.currency,
+ hasQualityBadge: product.has_quality_badge ?? false,
};
}
);