[wccom-17942] Only showing feedback snackbar when content of in-app marketplace has finished loading. Making sure snackbar is fixed position, so it's visible wherever you are on the page.

- `ProductListContextProvider` provides `setIsLoading` function as well as `isLoading`.
- `Discover` uses these values from context, instead of keeping a loading state in itself.
- `FeedbackModal` calls `maybSetTimeout` when `isLoading` changes. If `isLoading` isn't truthy, and snackbar hasn't already rendered, it sets a timeout of 5 seconds to show it.

- Removed wrapping <WooFooterItem> from around Footer component, so it's no longer a child of the WooCommerce Admin `.woocommerce-layout__footer` footer.
- Removed the `position: relative` from `.woocommerce-layout__footer`. It needs to be `position: fixed`.
- Added FooterContent component to Footer, to allow the layout we want.

- Changed use of context. This now only has states for the selected tab and loading state.
- We use this context in `Tabs` and `Content` to keep track of which tab is selected, and set the selected tab.
- We also use it in `Discover` and `Extensions`, which both report loading state to the context. This allows us to use it to only render the snackbar when loading is complete.
- Extensions: moved `productList` and `setProductList` and logic for getting product list from the context provider to a state in this component. We don't need to share the list of products in the context.
- Renamed `ProductListContext`, `ProductListContextProvider` and `productListContextValue` to more generic `MarketplaceContext`, `MarketplaceContextProvider` and `marketplaceContextValue`.
- Renamed a constant and created constants for API paths.
- Only shows snackbar after content has loaded, and after a timeout. We set a date `marketplace_redesign_2023_last_shown_date` in local storage to ensure we only show one snackbar.
This commit is contained in:
And Finally 2023-08-23 16:49:36 +01:00
parent 4dc745cc37
commit 3c8f4861e4
15 changed files with 224 additions and 210 deletions

View File

@ -1,3 +1,5 @@
export const DEFAULT_TAB_KEY = 'discover';
export const MARKETPLACE_API_HOST = 'https://woocommerce.com';
export const MARKETPLACE_PATH = '/extensions';
export const MARKETPLACE_URL = 'https://woocommerce.com';
export const MARKETPLACE_SEARCH_API_PATH = '/wp-json/wccom-extensions/1.0/search';
export const MARKETPLACE_CATEGORY_API_PATH = '/wp-json/wccom-extensions/1.0/categories';

View File

@ -1,6 +1,7 @@
/**
* External dependencies
*/
import { useContext } from '@wordpress/element';
/**
* Internal dependencies
@ -8,12 +9,7 @@
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;
}
import { MarketplaceContext } from '../../contexts/marketplace-context';
const renderContent = ( selectedTab?: string ): JSX.Element => {
switch ( selectedTab ) {
@ -24,15 +20,12 @@ const renderContent = ( selectedTab?: string ): JSX.Element => {
}
};
export default function Content( props: ContentProps ): JSX.Element {
const { selectedTab } = props;
export default function Content(): JSX.Element {
const marketplaceContextValue = useContext( MarketplaceContext );
const { selectedTab } = marketplaceContextValue;
return (
<>
<div className="woocommerce-marketplace__content">
{ renderContent( selectedTab ) }
</div>
<Footer />
<FeedbackModal />
</>
);
}

View File

@ -1,7 +1,7 @@
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import { useContext, useEffect, useState } from '@wordpress/element';
/**
* Internal dependencies
@ -9,14 +9,17 @@ import { useEffect, useState } from '@wordpress/element';
import ProductList from '../product-list/product-list';
import { fetchDiscoverPageData, ProductGroup } from '../../utils/functions';
import ProductLoader from '../product-loader/product-loader';
import { MarketplaceContext } from '../../contexts/marketplace-context';
import './discover.scss';
export default function Discover(): JSX.Element | null {
const [ productGroups, setProductGroups ] = useState<
Array< ProductGroup >
>( [] );
const [ isLoading, setIsLoading ] = useState( false );
const marketplaceContextValue = useContext( MarketplaceContext );
const { isLoading, setIsLoading } = marketplaceContextValue;
// Get the content for this screen
useEffect( () => {
setIsLoading( true );

View File

@ -1,7 +1,8 @@
/**
* External dependencies
*/
import { useContext } from '@wordpress/element';
import { useContext, useEffect, useState } from '@wordpress/element';
import { useQuery } from '@woocommerce/navigation';
import { __, _n, sprintf } from '@wordpress/i18n';
/**
@ -9,15 +10,78 @@ import { __, _n, sprintf } from '@wordpress/i18n';
*/
import './extensions.scss';
import CategorySelector from '../category-selector/category-selector';
import { ProductListContext } from '../../contexts/product-list-context';
import { MarketplaceContext } from '../../contexts/marketplace-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';
import {
Product,
SearchAPIProductType,
} from '../product-list/types';
import { MARKETPLACE_SEARCH_API_PATH, MARKETPLACE_API_HOST } from '../constants';
export default function Extensions(): JSX.Element {
const productListContextValue = useContext( ProductListContext );
const [ productList, setProductList ] = useState<Product[]>( [] );
const marketplaceContextValue = useContext( MarketplaceContext );
const { isLoading, setIsLoading } = marketplaceContextValue;
const query = useQuery();
// Get the content for this screen
useEffect( () => {
setIsLoading( true );
const params = new URLSearchParams();
if ( query.term ) {
params.append( 'term', query.term );
}
if ( query.category ) {
params.append( 'category', query.category );
}
const wccomSearchEndpoint =
MARKETPLACE_API_HOST +
MARKETPLACE_SEARCH_API_PATH + '?' +
params.toString();
// Fetch data from WCCOM API
fetch( wccomSearchEndpoint )
.then( ( response ) => response.json() )
.then( ( response ) => {
/**
* Product card component expects a Product type.
* So we build that object from the API response.
*/
const products = response.products.map(
( product: SearchAPIProductType ): Product => {
return {
id: product.id,
title: product.title,
description: product.excerpt,
vendorName: product.vendor_name,
vendorUrl: product.vendor_url,
icon: product.icon,
url: product.link,
// Due to backwards compatibility, raw_price is from search API, price is from featured API
price: product.raw_price ?? product.price,
averageRating: product.rating ?? 0,
reviewsCount: product.reviews_count ?? 0,
};
}
);
setProductList( products );
} )
.catch( () => {
setProductList( [] );
} )
.finally( () => {
setIsLoading( false );
} );
}, [ query ] );
const { productList, isLoading } = productListContextValue;
const products = productList.slice( 0, 60 );
let title = __( '0 extensions found', 'woocommerce' );

View File

@ -4,7 +4,7 @@
import { __ } from '@wordpress/i18n';
import { Modal, Button, TextareaControl } from '@wordpress/components';
import { useDispatch } from '@wordpress/data';
import { useState, useEffect } from '@wordpress/element';
import { useContext, useEffect, useState } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
/**
@ -12,15 +12,24 @@ import { recordEvent } from '@woocommerce/tracks';
*/
import './feedback-modal.scss';
import LikertScale from '../likert-scale/likert-scale';
import { MarketplaceContext } from '../../contexts/marketplace-context';
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
'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
'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
const SNACKBAR_TIMEOUT = 5000; // How long we wait before asking for feedback
const marketplaceContextValue = useContext( MarketplaceContext );
const { isLoading } = marketplaceContextValue;
// Save that we dismissed the dialog or snackbar TODAY so we don't show it again until tomorrow (if ever)
const dismissToday = () =>
@ -74,12 +83,19 @@ export default function FeedbackModal(): JSX.Element {
const { createNotice } = useDispatch( 'core/notices' );
function maybeShowSnackbar() {
// don't show if the user has already given feedback or otherwise suppressed:
let timer: ReturnType<typeof setTimeout> | undefined;
// Don't show if we're still loading content
if ( isLoading ) {
return;
}
// 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:
// Don't show if we've already shown today or user has declined today
const today = new Date().toDateString();
if (
today ===
@ -88,6 +104,15 @@ export default function FeedbackModal(): JSX.Element {
return;
}
timer = setTimeout( showSnackbar, SNACKBAR_TIMEOUT );
// Without this, navigating between screens will create a series of snackbars
dismissToday();
return () => { clearTimeout( timer ); }
}
function showSnackbar() {
createNotice(
'success',
__( 'How easy is it to find an extension?', 'woocommerce' ),
@ -142,8 +167,7 @@ export default function FeedbackModal(): JSX.Element {
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- [] => we only want this effect to run once, on first render
useEffect( maybeShowSnackbar, [] );
useEffect( maybeShowSnackbar, [ isLoading ] );
// We don't want the "How easy was it to find an extension?" dialog to appear forever:
const FEEDBACK_DIALOG_CAN_APPEAR =

View File

@ -1,19 +1,24 @@
@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;
background: $gray-0;
border-top: 1px solid $gray-200;
margin: auto;
padding: $content-spacing-xlarge $content-spacing-small;
&-title {
a {
text-decoration: none;
}
}
.woocommerce-marketplace__footer-content {
box-sizing: content-box;
max-width: $content-max-width;
margin: auto;
width: 100%;
}
.woocommerce-marketplace__footer-title {
color: $gutenberg-gray-900;
font-size: 20px;
font-style: normal;
@ -21,19 +26,15 @@
line-height: 28px;
max-width: 389px;
margin: 0 0 $content-spacing-large;
}
}
a {
text-decoration: none;
}
&-columns {
.woocommerce-marketplace__footer-columns {
display: flex;
flex-direction: column;
gap: $large-gap;
}
}
&-logo {
.woocommerce-marketplace__footer-logo {
color: $wp-gray-50;
display: flex;
font-size: 14px;
@ -41,7 +42,6 @@
line-height: 20px;
gap: $small-gap;
margin: 48px 0 0;
}
}
@media screen and (min-width: $breakpoint-medium) {

View File

@ -12,13 +12,13 @@ import { createInterpolateElement } from '@wordpress/element';
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';
import { MARKETPLACE_API_HOST } 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/' } />,
a: <a href={ MARKETPLACE_API_HOST + '/refund-policy/' } />,
}
);
@ -26,7 +26,7 @@ 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/' } />,
a: <a href={ MARKETPLACE_API_HOST + '/docs/' } />,
}
);
@ -34,13 +34,13 @@ 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/' } />,
a: <a href={ MARKETPLACE_API_HOST + '/products/' } />,
}
);
function FooterContent(): JSX.Element {
return (
<div className="woocommerce-marketplace__footer">
<div className="woocommerce-marketplace__footer-content">
<h2 className="woocommerce-marketplace__footer-title">
{ __(
'Hundreds of vetted products and services. Unlimited potential.',
@ -83,8 +83,8 @@ function FooterContent(): JSX.Element {
export default function Footer(): JSX.Element {
return (
<WooFooterItem>
<div className="woocommerce-marketplace__footer">
<FooterContent />
</WooFooterItem>
</div>
);
}

View File

@ -12,7 +12,7 @@ import { __ } from '@wordpress/i18n';
import './header-account.scss';
import { getAdminSetting } from '../../../utils/admin-settings';
import HeaderAccountModal from './header-account-modal';
import { MARKETPLACE_URL } from '../constants';
import { MARKETPLACE_API_HOST } from '../constants';
export default function HeaderAccount(): JSX.Element {
const [ isModalOpen, setIsModalOpen ] = useState( false );
@ -28,7 +28,7 @@ export default function HeaderAccount(): JSX.Element {
// 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 accountURL: any = MARKETPLACE_API_HOST + '/my-dashboard/';
const accountOrConnect = isConnected ? accountURL : connectionURL;
const avatar = () => {

View File

@ -1,7 +1,3 @@
/**
* External dependencies
*/
/**
* Internal dependencies
*/
@ -11,13 +7,7 @@ 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;
export default function Header() {
return (
<header className="woocommerce-marketplace__header">
<HeaderTitle />
@ -25,8 +15,6 @@ export default function Header( props: HeaderProps ) {
additionalClassNames={ [
'woocommerce-marketplace__header-tabs',
] }
selectedTab={ selectedTab }
setSelectedTab={ setSelectedTab }
/>
<Search />
<div className="woocommerce-marketplace__header-meta">

View File

@ -2,7 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { useEffect } from '@wordpress/element';
import { useContext, useEffect } from '@wordpress/element';
import { Button } from '@wordpress/components';
import classNames from 'classnames';
import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation';
@ -12,10 +12,10 @@ import { getNewPath, navigateTo, useQuery } from '@woocommerce/navigation';
*/
import './tabs.scss';
import { DEFAULT_TAB_KEY, MARKETPLACE_PATH } from '../constants';
import { MarketplaceContext } from '../../contexts/marketplace-context';
import { MarketplaceContextType } from '../../contexts/types';
export interface TabsProps {
selectedTab?: string | undefined;
setSelectedTab: ( value: string ) => void;
additionalClassNames?: Array< string > | undefined;
}
@ -63,8 +63,8 @@ const setUrlTabParam = ( tabKey: string ) => {
} );
};
const renderTabs = ( props: TabsProps ) => {
const { selectedTab, setSelectedTab } = props;
const renderTabs = ( contextValue: MarketplaceContextType ) => {
const { selectedTab, setSelectedTab } = contextValue;
const tabContent = [];
for ( const tabKey in tabs ) {
tabContent.push(
@ -104,14 +104,16 @@ const renderTabs = ( props: TabsProps ) => {
};
const Tabs = ( props: TabsProps ): JSX.Element => {
const { setSelectedTab, additionalClassNames } = props;
const { additionalClassNames } = props;
const marketplaceContextValue = useContext( MarketplaceContext );
const { setSelectedTab } = marketplaceContextValue;
interface Query {
path?: string;
tab?: string;
}
const query: Query = useQuery();
const query: Record<string, string> = useQuery();
useEffect( () => {
if ( query?.tab && tabs[ query.tab ] ) {
@ -128,7 +130,7 @@ const Tabs = ( props: TabsProps ): JSX.Element => {
additionalClassNames || []
) }
>
{ renderTabs( props ) }
{ renderTabs( marketplaceContextValue ) }
</nav>
);
};

View File

@ -0,0 +1,37 @@
/**
* External dependencies
*/
import { useState, createContext } from '@wordpress/element';
/**
* Internal dependencies
*/
import { DEFAULT_TAB_KEY } from '../components/constants';
import { MarketplaceContextType } from './types';
export const MarketplaceContext = createContext< MarketplaceContextType >( {
isLoading: false,
setIsLoading: () => {},
selectedTab: DEFAULT_TAB_KEY,
setSelectedTab: () => {},
} );
export function MarketplaceContextProvider( props: {
children: JSX.Element;
} ): JSX.Element {
const [ isLoading, setIsLoading ] = useState( true );
const [ selectedTab, setSelectedTab ] = useState( DEFAULT_TAB_KEY );
const contextValue = {
isLoading,
setIsLoading,
selectedTab,
setSelectedTab
};
return (
<MarketplaceContext.Provider value={ contextValue }>
{ props.children }
</MarketplaceContext.Provider>
);
}

View File

@ -1,98 +0,0 @@
/**
* 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,6 @@
export type MarketplaceContextType = {
isLoading: boolean;
setIsLoading: ( isLoading: boolean ) => void;
selectedTab: string;
setSelectedTab: ( tab: string ) => void;
};

View File

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

View File

@ -7,7 +7,7 @@ import apiFetch from '@wordpress/api-fetch';
* Internal dependencies
*/
import { Product } from '../components/product-list/types';
import { MARKETPLACE_URL } from '../components/constants';
import { MARKETPLACE_API_HOST, MARKETPLACE_CATEGORY_API_PATH } from '../components/constants';
import { CategoryAPIItem } from '../components/category-selector/types';
import { LOCALE } from '../../utils/admin-settings';
@ -34,7 +34,7 @@ async function fetchDiscoverPageData(): Promise< ProductGroup[] > {
}
function fetchCategories(): Promise< CategoryAPIItem[] > {
let url = MARKETPLACE_URL + '/wp-json/wccom-extensions/1.0/categories';
let url = MARKETPLACE_API_HOST + MARKETPLACE_CATEGORY_API_PATH;
if ( LOCALE.userLocale ) {
url = `${ url }?locale=${ LOCALE.userLocale }`;