Marketplace: My Subscriptions feature branch (#40249)

This commit is contained in:
And Finally 2023-11-22 15:58:30 +00:00 committed by GitHub
commit 99d063095f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
59 changed files with 3713 additions and 326 deletions

View File

@ -0,0 +1,46 @@
# Marketplace
This folder contains the components used in the Marketplace page found in `WooCommerce > Extensions`.
The page contains two parts, the Woo.com marketplace and a list of products the user purchased.
## Marketplace Tabs
- **Discover**: A curated list of extensions and themes.
- **Browse**: All extensions.
- **Themes**: All themes.
- **Search**: Search results.
### Marketplace API
The data for the Discover section is fetched from the `/wc/v3/marketplace/featured` endpoint. This behaves as a proxy to fetch and cache the content from the `woocommerce.com/wp-json/wccom-extensions` endpoint.
Themes, extensions, and search results are fetched directly from Woo.com.
## My Subscriptions
This tab contains the list of all the extensions and themes the WooCommerce merchant has purchased from the Woo.com Marketplace.
The merchant needs to connect the site to their Woo.com account to view this list and install, update, and enable the products.
If a subscription is expired, the merchant will be prompted to renew it.
### My Subscriptions API
My Subscriptions data uses `/wc/v3/marketplace/subscriptions` API endpoints to list, install, connect, and update products.
You can find a full list of endpoints in the [subscriptions API source code](/plugins/woocommerce/includes/admin/helper/class-wc-helper-subscriptions-api.php).
## Project Structure
The project is structured as follows:
- **components**: The React components used in the Marketplace page.
- **contexts**: React contexts.
- **utils**: Functions used to interact with APIs.
- **stylesheets**: Shared stylesheets.
- **assets**: Images.
## Development
This feature is part of WooCommerce Admin and uses the [same development environment.](/plugins/woocommerce-admin/README.md)

View File

@ -0,0 +1,7 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="alert">
<path id="Vector" d="M12 20C16.4183 20 20 16.4183 20 12C20 7.58172 16.4183 4 12 4C7.58172 4 4 7.58172 4 12C4 16.4183 7.58172 20 12 20Z" stroke="#CC1818" stroke-width="1.5"/>
<path id="Vector_2" d="M13 7H11V13H13V7Z" fill="#CC1818"/>
<path id="Vector_3" d="M13 15H11V17H13V15Z" fill="#CC1818"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 417 B

View File

@ -1,6 +0,0 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M70.8227 17.1683C73.8744 17.1683 77.2598 18.623 80 20.1745V13.4403C80 9.31419 77.664 7 73.5407 7H6.45562C2.33604 6.99628 0 9.31047 0 13.4366V47.3717C0 51.4978 2.33604 53.812 6.45933 53.812H29.5824C28.1214 56.4871 26.8014 59.7091 26.8014 62.626C26.8014 66.6553 28.1214 69.3676 30.7207 71.3916C32.9455 73.1254 35.9898 73.9662 39.9981 73.9662C44.0065 73.9662 47.0508 73.1254 49.2756 71.3916C51.8749 69.3676 53.1949 66.659 53.1949 62.626C53.1949 59.7053 51.8749 56.4871 50.4139 53.812H73.537C77.6603 53.812 79.9963 51.4978 79.9963 47.3717V40.645C77.2561 42.1964 73.8744 43.6512 70.819 43.6512C66.8032 43.6512 64.1001 42.3266 62.083 39.7185C60.355 37.4862 59.517 34.4316 59.517 30.4097C59.517 26.3878 60.355 23.3332 62.083 21.1009C64.1001 18.4928 66.7995 17.1683 70.819 17.1683H70.8227Z" fill="#F0F0F0"/>
<path d="M39.9865 20.4834H6.6145V23.8321H39.9865V20.4834Z" fill="#DDDDDD"/>
<path d="M39.9865 13.7158H6.6145V17.0645H39.9865V13.7158Z" fill="#DDDDDD"/>
<path d="M26.6377 27.1804H6.6145V30.5291H26.6377V27.1804Z" fill="#DDDDDD"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@ -1,17 +0,0 @@
<svg width="80" height="80" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2293_37324)">
<path d="M6.45466 7C2.33301 7 0 9.31262 0 13.4396V73.9767H59.8473L59.9917 13.4396C59.9917 9.31262 57.6587 7 53.537 7L6.45466 7Z" fill="#F0F0F0"/>
<path d="M80.0001 27.0774H43.3274V63.8897H80.0001V27.0774Z" fill="#DDDDDD"/>
<path d="M36.6616 27.0774H6.66577V43.8086H36.6616V27.0774Z" fill="#DDDDDD"/>
<path d="M36.6616 47.5229H6.66943V50.4974H36.6616V47.5229Z" fill="#DDDDDD"/>
<path d="M23.3301 54.219H6.66577V57.1934H23.3301V54.219Z" fill="#DDDDDD"/>
<path d="M60.1989 51.7429L59.3768 49.5232H53.785L52.9629 51.7429H49.8152L54.8404 38.6479H58.3214L63.3466 51.7429H60.1989ZM56.5809 41.4365L54.5479 47.073H58.614L56.5809 41.4365Z" fill="white"/>
<path d="M70.179 51.7429V50.7427C69.5347 51.5272 68.42 51.9771 67.1869 51.9771C65.6834 51.9771 63.9207 50.9584 63.9207 48.8354C63.9207 46.5971 65.6834 45.7754 67.1869 45.7754C68.4608 45.7754 69.5532 46.1881 70.179 46.9317V45.7345C70.179 44.7716 69.3569 44.1432 68.1053 44.1432C67.1091 44.1432 66.1722 44.5373 65.3871 45.2623L64.4095 43.5149C65.5649 42.4738 67.0499 42.0239 68.5348 42.0239C70.7049 42.0239 72.6787 42.8865 72.6787 45.6156V51.7429H70.1753H70.179ZM70.179 49.4637V48.2888C69.768 47.7386 68.9866 47.4448 68.1867 47.4448C67.2091 47.4448 66.4055 47.9765 66.4055 48.88C66.4055 49.7835 67.2091 50.2928 68.1867 50.2928C68.9866 50.2928 69.768 50.0177 70.179 49.4674V49.4637Z" fill="white"/>
<path d="M0 13.4396V16.9829H59.9917V13.4396C59.9917 9.31262 57.6587 7 53.537 7H6.45466C2.33301 7 0 9.31262 0 13.4396Z" fill="#DDDDDD"/>
</g>
<defs>
<clipPath id="clip0_2293_37324">
<rect width="80" height="66.9767" fill="white" transform="translate(0 7)"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.0002 2.25V4C16.4185 4 20.0003 7.58172 20.0003 12C20.0003 15.7277 17.4507 18.8599 14.0003 19.748V19.7479C13.8301 19.7917 13.6577 19.8301 13.4833 19.8628C13.3371 19.8905 13.1906 19.914 13.0442 19.9334L13.0441 19.9325C12.7024 19.977 12.3539 20 12 20C11.646 20 11.2975 19.977 10.9556 19.9324L10.9556 19.9327C10.9148 19.9273 10.874 19.9216 10.8333 19.9155C9.61036 19.7369 8.47722 19.2815 7.49949 18.615C7.37365 18.5295 7.25011 18.4404 7.12904 18.3477L7.12954 18.347C6.57546 17.9212 6.07872 17.4245 5.65291 16.8704L5.65171 16.8713C5.48551 16.6545 5.32941 16.4276 5.18438 16.1911C4.69453 15.3962 4.33985 14.5091 4.15239 13.5619C4.11788 13.3895 4.08925 13.2168 4.06641 13.0441L4.06749 13.0439C4.02297 12.7022 4 12.3538 4 12C4 11.646 4.02299 11.2974 4.06756 10.9555L4.06708 10.9555C4.07694 10.8811 4.08784 10.807 4.09976 10.733C4.281 9.594 4.70266 8.53507 5.31109 7.60992C5.41864 7.44572 5.53236 7.28525 5.6521 7.12891L5.65294 7.12955C5.65535 7.12641 5.65777 7.12327 5.66018 7.12014L6.86037 8.02028C6.74015 8.17531 6.62679 8.33593 6.52074 8.50168C6.06043 9.22439 5.7454 10.0333 5.59639 10.8785C5.53303 11.2428 5.5 11.6176 5.5 12C5.5 12.4751 5.55096 12.9382 5.64772 13.3843C5.72894 13.7544 5.84397 14.1233 5.99475 14.4873C6.11754 14.7837 6.25938 15.0657 6.41813 15.3325C6.91672 16.1658 7.59663 16.8782 8.40335 17.4151C9.17682 17.9275 10.054 18.271 10.9714 18.4191C11.3064 18.4723 11.65 18.5 12 18.5C12.4506 18.5 12.8904 18.4542 13.3151 18.3669C13.5263 18.323 13.7372 18.2681 13.947 18.2019L13.9474 18.2032C16.5859 17.3758 18.5 14.9114 18.5 12C18.5 8.41023 15.59 5.50013 12.0002 5.5V7.25L9.00024 4.75L12.0002 2.25Z" fill="#007CBA"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -6,11 +6,13 @@ import { chevronDown, chevronUp, Icon } from '@wordpress/icons';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { navigateTo, getNewPath } from '@woocommerce/navigation'; import { navigateTo, getNewPath } from '@woocommerce/navigation';
import classNames from 'classnames'; import classNames from 'classnames';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { Category } from './types'; import { Category } from './types';
import { ProductType } from '../product-list/types';
function DropdownContent( props: { function DropdownContent( props: {
readonly categories: Category[]; readonly categories: Category[];
@ -71,16 +73,28 @@ type CategoryDropdownProps = {
contentClassName?: string; contentClassName?: string;
arrowIconSize?: number; arrowIconSize?: number;
selected?: Category; selected?: Category;
type?: ProductType;
}; };
export default function CategoryDropdown( export default function CategoryDropdown(
props: CategoryDropdownProps props: CategoryDropdownProps
): JSX.Element { ): JSX.Element {
function dropDownTracksEvent() {
recordEvent( 'marketplace_category_dropdown_opened', {
type: props.type,
} );
}
return ( return (
<Dropdown <Dropdown
renderToggle={ ( { isOpen, onToggle } ) => ( renderToggle={ ( { isOpen, onToggle } ) => (
<button <button
onClick={ onToggle } onClick={ () => {
if ( ! isOpen ) {
dropDownTracksEvent();
}
onToggle();
} }
className={ props.buttonClassName } className={ props.buttonClassName }
aria-label={ __( aria-label={ __(
'Toggle category dropdown', 'Toggle category dropdown',

View File

@ -145,6 +145,7 @@ export default function CategorySelector(
<li className="woocommerce-marketplace__category-item"> <li className="woocommerce-marketplace__category-item">
{ dropdownItems.length > 0 && ( { dropdownItems.length > 0 && (
<CategoryDropdown <CategoryDropdown
type={ props.type }
label={ __( 'More', 'woocommerce' ) } label={ __( 'More', 'woocommerce' ) }
categories={ dropdownItems } categories={ dropdownItems }
buttonClassName={ classNames( buttonClassName={ classNames(
@ -164,6 +165,7 @@ export default function CategorySelector(
<div className="woocommerce-marketplace__category-selector--full-width"> <div className="woocommerce-marketplace__category-selector--full-width">
<CategoryDropdown <CategoryDropdown
type={ props.type }
label={ mobileCategoryDropdownLabel() } label={ mobileCategoryDropdownLabel() }
categories={ visibleItems.concat( dropdownItems ) } categories={ visibleItems.concat( dropdownItems ) }
buttonClassName="woocommerce-marketplace__category-dropdown-button" buttonClassName="woocommerce-marketplace__category-dropdown-button"

View File

@ -7,3 +7,7 @@ export const MARKETPLACE_CATEGORY_API_PATH =
'/wp-json/wccom-extensions/1.0/categories'; '/wp-json/wccom-extensions/1.0/categories';
export const MARKETPLACE_ITEMS_PER_PAGE = 60; export const MARKETPLACE_ITEMS_PER_PAGE = 60;
export const MARKETPLACE_SEARCH_RESULTS_PER_PAGE = 8; export const MARKETPLACE_SEARCH_RESULTS_PER_PAGE = 8;
export const MARKETPLACE_CART_PATH = MARKETPLACE_HOST + '/cart/';
export const MARKETPLACE_COLLABORATION_PATH =
MARKETPLACE_HOST +
'/document/managing-woocommerce-com-subscriptions/#transfer-a-woocommerce-com-subscription';

View File

@ -5,6 +5,7 @@
margin: auto; margin: auto;
max-width: $content-max-width; max-width: $content-max-width;
padding: $grid-unit-30 $grid-unit-20; padding: $grid-unit-30 $grid-unit-20;
min-height: 500px;
} }
@media screen and (min-width: $breakpoint-medium) { @media screen and (min-width: $breakpoint-medium) {

View File

@ -13,8 +13,10 @@ import { getAdminSetting } from '../../../utils/admin-settings';
import Discover from '../discover/discover'; import Discover from '../discover/discover';
import Products from '../products/products'; import Products from '../products/products';
import SearchResults from '../search-results/search-results'; import SearchResults from '../search-results/search-results';
import MySubscriptions from '../my-subscriptions/my-subscriptions';
import { MarketplaceContext } from '../../contexts/marketplace-context'; import { MarketplaceContext } from '../../contexts/marketplace-context';
import { fetchSearchResults } from '../../utils/functions'; import { fetchSearchResults } from '../../utils/functions';
import { SubscriptionsContextProvider } from '../../contexts/subscriptions-context';
import { import {
recordMarketplaceView, recordMarketplaceView,
recordLegacyTabView, recordLegacyTabView,
@ -118,6 +120,12 @@ export default function Content(): JSX.Element {
); );
case 'discover': case 'discover':
return <Discover />; return <Discover />;
case 'my-subscriptions':
return (
<SubscriptionsContextProvider>
<MySubscriptions />
</SubscriptionsContextProvider>
);
default: default:
return <></>; return <></>;
} }

View File

@ -2,6 +2,7 @@
* External dependencies * External dependencies
*/ */
import { useContext, useEffect, useState } from '@wordpress/element'; import { useContext, useEffect, useState } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
@ -20,6 +21,19 @@ export default function Discover(): JSX.Element | null {
const marketplaceContextValue = useContext( MarketplaceContext ); const marketplaceContextValue = useContext( MarketplaceContext );
const { isLoading, setIsLoading } = marketplaceContextValue; const { isLoading, setIsLoading } = marketplaceContextValue;
function recordTracksEvent( products: ProductGroup[] ) {
const product_ids = products
.flatMap( ( group ) => group.items )
.map( ( product ) => {
return product.id;
} );
recordEvent( 'marketplace_discover_viewed', {
view: 'discover',
product_ids,
} );
}
// Get the content for this screen // Get the content for this screen
useEffect( () => { useEffect( () => {
setIsLoading( true ); setIsLoading( true );
@ -35,6 +49,7 @@ export default function Discover(): JSX.Element | null {
) )
.then( ( products: Array< ProductGroup > ) => { .then( ( products: Array< ProductGroup > ) => {
setProductGroups( products ); setProductGroups( products );
recordTracksEvent( products );
} ) } )
.finally( () => { .finally( () => {
setIsLoading( false ); setIsLoading( false );

View File

@ -31,6 +31,14 @@
} }
} }
.woocommerce-marketplace--my-subscriptions {
@media (width <= $breakpoint-medium) {
.woocommerce-marketplace__search {
display: none;
}
}
}
.woocommerce-marketplace__header-title { .woocommerce-marketplace__header-title {
align-items: center; align-items: center;
align-self: stretch; align-self: stretch;

View File

@ -2,7 +2,7 @@
* External dependencies * External dependencies
*/ */
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import classnames from 'classnames'; import classNames from 'classnames';
/** /**
* Internal dependencies * Internal dependencies
@ -52,7 +52,7 @@ export default function LikertScale( props: LikertScaleProps ): JSX.Element {
}, },
]; ];
const classes = classnames( 'woocommerce-marketplace__likert-scale', { const classes = classNames( 'woocommerce-marketplace__likert-scale', {
'validation-failed': validationFailed, 'validation-failed': validationFailed,
} ); } );

View File

@ -0,0 +1,340 @@
@import '@wordpress/base-styles/_colors.native.scss';
@import '../../stylesheets/_variables.scss';
@mixin content-width {
max-width: calc(100vw - (2 * #{$grid-unit-20}));
@media screen and (min-width: 783px) {
max-width: calc(100vw - (2 * #{$grid-unit-40}) - $admin-menu-width-collapsed);
}
@media screen and (min-width: 960px) {
max-width: calc(100vw - (2 * #{$grid-unit-40}) - $admin-menu-width);
}
}
.woocommerce-marketplace__my-subscriptions__header {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: stretch;
@include content-width;
.woocommerce-marketplace__my-subscriptions__header-content,
.woocommerce-marketplace__my-subscriptions__header-refresh {
width: 100%;
}
.woocommerce-marketplace__my-subscriptions__header-content {
order: 2;
}
.woocommerce-marketplace__my-subscriptions__header-refresh {
display: flex;
justify-content: end;
order: 1;
}
@media screen and (min-width: $breakpoint-medium) {
align-items: center;
justify-content: space-between;
flex-direction: row;
.woocommerce-marketplace__my-subscriptions__header-content,
.woocommerce-marketplace__my-subscriptions__header-refresh {
width: auto;
}
.woocommerce-marketplace__my-subscriptions__header-content {
order: 1;
}
.woocommerce-marketplace__my-subscriptions__header-refresh {
order: 2;
}
}
}
.woocommerce-marketplace__refresh-subscriptions {
text-decoration: none;
color: #007cba;
.woocommerce-marketplace__refresh-subscriptions-icon {
margin-right: $grid-unit-05;
}
}
.woocommerce-marketplace__my-subscriptions__available {
margin-top: $grid-unit-50;
}
.woocommerce-marketplace__my-subscriptions__heading {
font-size: 20px;
color: $gray-900;
font-weight: 400;
margin: 0;
line-height: 28px;
}
.woocommerce-marketplace__notice--error {
&:last-child {
margin-bottom: $grid-unit-50;
}
}
.woocommerce-marketplace__my-subscriptions__table-description {
font-size: 13px;
margin: 1em 0;
line-height: 20px;
color: $gray-700;
a {
display: inline-flex;
align-items: center;
text-decoration: none;
}
svg {
fill: #007cba;
margin-left: 2px;
width: 16px;
height: 16px;
}
}
.woocommerce-marketplace__my-subscriptions__table-wrapper {
position: relative;
overflow: hidden;
}
.woocommerce-marketplace__my-subscriptions__table {
font-size: 13px;
line-height: 20px;
margin-top: $grid-unit-30;
color: $gray-900;
@include content-width;
}
.woocommerce-marketplace__my-subscriptions__product {
$product-icon-size: 40px;
min-width: 400px;
display: flex;
align-items: center;
&-name {
margin-left: $grid-unit-15;
line-height: 18px;
font-weight: 600;
color: $gray-900;
text-decoration: none;
}
&-icon img {
border-radius: 4px;
width: $product-icon-size;
}
&-icon {
width: $product-icon-size;
height: $product-icon-size;
svg {
border-radius: 4px;
padding: $grid-unit-10;
fill: $gray-600;
background-color: $gray-200;
width: $product-icon-size;
height: $product-icon-size;
}
}
}
.woocommerce-table__item {
.woocommerce-marketplace__my-subscriptions__product-name {
&:active,
&:hover,
&:visited {
color: $gray-900;
}
}
}
.woocommerce-marketplace__my-subscriptions__product-status {
display: flex;
align-items: center;
border-radius: 2px;
border: none;
padding: 2px $grid-unit-10;
margin-left: $grid-unit-15;
text-align: left;
white-space: nowrap;
&--error {
color: var(--wp-red-red-70, #8a2424);
background: var(--wp-red-red-0, #fcf0f1);
& > svg {
margin-right: 2px;
fill: var(--wp-red-red-70, #8a2424);
}
}
&--warning {
color: var(--wp-yellow-yellow-70, #614200);
background: var(--wp-yellow-yellow-0, #fcf9e8);
& > svg {
margin-right: 2px;
color: var(--wp-yellow-yellow-70, #614200);
}
}
}
.woocommerce-marketplace__my-subscriptions__popover {
top: -8px !important;
.components-popover__content {
border: none;
width: 300px;
border-radius: 2px;
padding: $grid-unit-15;
color: $gray-900;
a {
text-decoration: none;
}
}
}
.components-base-control.woocommerce-marketplace__my-subscriptions__activation {
margin-bottom: 0;
}
.woocommerce-marketplace__my-subscriptions-version {
padding: 6px 12px;
}
.woocommerce-marketplace__my-subscriptions__table__header--version > span {
display: inline-block;
padding: 0 12px;
}
.woocommerce-marketplace__my-subscriptions {
.woocommerce-table__empty-item,
.woocommerce-table__header,
.woocommerce-table__item {
padding: $grid-unit-10 $grid-unit-30;
align-items: center;
}
.woocommerce-table__table tr:hover {
background: #f8f9fa;
}
.woocommerce-table.is-empty {
background: none;
border: 1px solid var(--gutenberg-gray-100, #f0f0f0);
flex-direction: column;
gap: $grid-unit-15;
margin-top: $grid-unit-30;
}
}
.woocommerce-marketplace__my-subscriptions .components-button.is-link {
text-decoration: none;
padding: 6px 12px;
}
.woocommerce-marketplace__my-subscriptions
.components-button.is-secondary:hover:not(:disabled) {
color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
}
.woocommerce-marketplace__my-subscriptions--connect {
display: flex;
flex-direction: column;
align-items: center;
max-width: 495px;
padding: $grid-unit-80 $grid-unit-40;
margin: 0 auto;
.woocommerce-marketplace__my-subscriptions__icon {
width: 80px;
height: 80px;
background-image: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iODAiIGhlaWdodD0iODAiIHZpZXdCb3g9IjAgMCA4MCA4MCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KPGcgY2xpcC1wYXRoPSJ1cmwoI2NsaXAwXzI5NzBfMTA0MzcpIj4KPHJlY3Qgd2lkdGg9IjgwIiBoZWlnaHQ9IjgwIiBmaWxsPSJ3aGl0ZSIvPgo8cGF0aCBkPSJNNzQuMTMyNSAwTDY4LjQ0ODEgMy4yOTQ4NEw2Mi43NTc5IDBMNTcuMDczNCAzLjI5NDg0TDUxLjM4MzIgMEw0NS42OTMxIDMuMjk0ODRMNDAuMDA4NiAwTDM0LjMxODQgMy4yOTQ4NEwyOC42MzM5IDBMMjIuOTQzNyAzLjI5NDg0TDE3LjI1MzYgMEwxMS41NjM0IDMuMjk0ODRMNS44NzMxOCAwTDAgMy40MDIwNlY3Ni41OTc5TDUuODY3NDcgODBMMTEuNTU3NiA3Ni43MDFMMTcuMjQyMSA4MEwyMi45MzIzIDc2LjcwMUwyOC42MTY4IDgwTDM0LjMwNjkgNzYuNzAxTDM5Ljk5NzEgODBMNDUuNjgxNiA3Ni43MDFMNTEuMzcxOCA4MEw1Ny4wNTYzIDc2LjcwMUw2Mi43NDY1IDgwTDY4LjQzNjYgNzYuNzAxTDc0LjEzMjUgODBMODAgNzYuNTk3OVYzLjQwMjA2TDc0LjEzMjUgMFoiIGZpbGw9IiNGMEYwRjAiLz4KPHBhdGggZD0iTTcxLjE5OTIgMTQuNzk5Nkg4LjM5OTE3VjE4Ljc5OTZINzEuMTk5MlYxNC43OTk2WiIgZmlsbD0iI0RERERERCIvPgo8cGF0aCBkPSJNNzEuMTk5MiAyNS4xOTk1SDguMzk5MTdWMjkuMTk5NUg3MS4xOTkyVjI1LjE5OTVaIiBmaWxsPSIjREREREREIi8+CjxwYXRoIGQ9Ik03MS4xOTkyIDM1LjU5OTZIOC4zOTkxN1YzOS41OTk2SDcxLjE5OTJWMzUuNTk5NloiIGZpbGw9IiNEREREREQiLz4KPHBhdGggZD0iTTY4IDUzLjYwMDFINDkuMkM0Ny40MzI3IDUzLjYwMDEgNDYgNTUuMDMyOCA0NiA1Ni44MDAxVjYwLjgwMDFDNDYgNjIuNTY3NCA0Ny40MzI3IDY0LjAwMDEgNDkuMiA2NC4wMDAxSDY4QzY5Ljc2NzMgNjQuMDAwMSA3MS4yIDYyLjU2NzQgNzEuMiA2MC44MDAxVjU2LjgwMDFDNzEuMiA1NS4wMzI4IDY5Ljc2NzMgNTMuNjAwMSA2OCA1My42MDAxWiIgZmlsbD0iI0RERERERCIvPgo8L2c+CjxkZWZzPgo8Y2xpcFBhdGggaWQ9ImNsaXAwXzI5NzBfMTA0MzciPgo8cmVjdCB3aWR0aD0iODAiIGhlaWdodD0iODAiIGZpbGw9IndoaXRlIi8+CjwvY2xpcFBhdGg+CjwvZGVmcz4KPC9zdmc+Cg==);
}
.woocommerce-marketplace__my-subscriptions__header {
margin-top: $grid-unit-20;
margin-bottom: $grid-unit-10;
font-size: $default-font-size;
}
.woocommerce-marketplace__my-subscriptions__description {
text-align: center;
font-size: $default-font-size;
line-height: 20px;
font-weight: 400;
color: var(--gutenberg-gray-700);
margin-top: 0;
margin-bottom: $grid-unit-30;
}
}
.woocommerce-marketplace__my-subscriptions__table__header--actions {
text-align: right;
justify-content: flex-end;
}
.woocommerce-marketplace__my-subscriptions__actions {
display: flex;
justify-content: end;
.components-button {
margin-right: $grid-unit-10;
}
a:hover {
color: #007cba;
}
}
.woocommerce-marketplace__my-subscriptions__notices {
.components-notice {
margin-left: 0;
margin-right: 0;
background-color: #fff;
box-shadow: 0 2px 6px 0 rgba($gray-100, 0.05);
border: 1px solid var(--gutenberg-gray-100, #f0f0f0);
padding-right: $grid-unit-15;
&::before {
content: '';
display: block;
width: 4px;
height: 100%;
background-color: var(--wp-admin-theme-color, #007cba);
position: absolute;
left: 0;
top: 0;
bottom: 0;
}
&.is-error::before {
background-color: $alert-red;
}
.components-notice__content {
display: flex;
align-items: center;
gap: $grid-unit-15;
}
.components-notice__dismiss.has-icon {
width: 24px;
min-width: 24px;
height: 24px;
align-self: center;
padding: $grid-unit-05;
> svg {
fill: $gray-900;
}
}
}
.components-notice__action.components-button.is-link {
margin: 0;
padding: 0;
}
}

View File

@ -0,0 +1,129 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { createInterpolateElement, useContext } from '@wordpress/element';
import { Icon, external } from '@wordpress/icons';
/**
* Internal dependencies
*/
import { getAdminSetting } from '../../../utils/admin-settings';
import { SubscriptionsContext } from '../../contexts/subscriptions-context';
import './my-subscriptions.scss';
import {
AvailableSubscriptionsTable,
InstalledSubscriptionsTable,
} from './table/table';
import {
availableSubscriptionRow,
installedSubscriptionRow,
} from './table/table-rows';
import { Subscription } from './types';
import { RefreshButton } from './table/actions/refresh-button';
import Notices from './notices';
export default function MySubscriptions(): JSX.Element {
const { subscriptions, isLoading } = useContext( SubscriptionsContext );
const wccomSettings = getAdminSetting( 'wccomHelper', {} );
const installedTableDescription = createInterpolateElement(
__(
'Woo.com extensions and themes installed on this store. To see all your subscriptions go to <a>your account<custom_icon /></a> on Woo.com.',
'woocommerce'
),
{
a: (
<a
href="https://woo.com/my-account/my-subscriptions"
target="_blank"
rel="nofollow noopener noreferrer"
>
your account
</a>
),
custom_icon: <Icon icon={ external } size={ 12 } />,
}
);
const subscriptionsInstalled: Array< Subscription > = subscriptions.filter(
( subscription: Subscription ) => subscription.subscription_installed
);
const subscriptionsAvailable: Array< Subscription > = subscriptions.filter(
( subscription: Subscription ) => ! subscription.subscription_installed
);
if ( ! wccomSettings?.isConnected ) {
return (
<div className="woocommerce-marketplace__my-subscriptions--connect">
<div className="woocommerce-marketplace__my-subscriptions__icon" />
<h2 className="woocommerce-marketplace__my-subscriptions__header">
{ __( 'Manage your subscriptions', 'woocommerce' ) }
</h2>
<p className="woocommerce-marketplace__my-subscriptions__description">
{ __(
'Connect your account to get updates, manage your subscriptions, and get seamless support. Once connected, your Woo.com subscriptions will appear here.',
'woocommerce'
) }
</p>
<Button href={ wccomSettings?.connectURL } variant="primary">
{ __( 'Connect Account', 'woocommerce' ) }
</Button>
</div>
);
}
return (
<div className="woocommerce-marketplace__my-subscriptions">
<section className="woocommerce-marketplace__my-subscriptions__notices">
<Notices />
</section>
<section className="woocommerce-marketplace__my-subscriptions-section woocommerce-marketplace__my-subscriptions__installed">
<header className="woocommerce-marketplace__my-subscriptions__header">
<div className="woocommerce-marketplace__my-subscriptions__header-content">
<h2 className="woocommerce-marketplace__my-subscriptions__heading">
{ __( 'Installed on this store', 'woocommerce' ) }
</h2>
<p className="woocommerce-marketplace__my-subscriptions__table-description">
{ installedTableDescription }
</p>
</div>
<div className="woocommerce-marketplace__my-subscriptions__header-refresh">
<RefreshButton />
</div>
</header>
<div className="woocommerce-marketplace__my-subscriptions__table-wrapper">
<InstalledSubscriptionsTable
isLoading={ isLoading }
rows={ subscriptionsInstalled.map( ( item ) => {
return installedSubscriptionRow( item );
} ) }
/>
</div>
</section>
{ subscriptionsAvailable.length > 0 && (
<section className="woocommerce-marketplace__my-subscriptions-section woocommerce-marketplace__my-subscriptions__available">
<h2 className="woocommerce-marketplace__my-subscriptions__heading">
{ __( 'Available to use', 'woocommerce' ) }
</h2>
<p className="woocommerce-marketplace__my-subscriptions__table-description">
{ __(
"Woo.com subscriptions you haven't used yet.",
'woocommerce'
) }
</p>
<div className="woocommerce-marketplace__my-subscriptions__table-wrapper">
<AvailableSubscriptionsTable
isLoading={ isLoading }
rows={ subscriptionsAvailable.map( ( item ) => {
return availableSubscriptionRow( item );
} ) }
/>
</div>
</section>
) }
</div>
);
}

View File

@ -0,0 +1,50 @@
/**
* External dependencies
*/
import { Notice } from '@wordpress/components';
import { useSelect } from '@wordpress/data';
/**
* Internal dependencies
*/
import Alert from '../../assets/images/alert.svg';
import { Notice as NoticeType } from '../../contexts/types';
import { noticeStore } from '../../contexts/notice-store';
import { removeNotice } from '../../utils/functions';
export default function Notices() {
const notices: NoticeType[] = useSelect(
( select ) => select( noticeStore ).notices(),
[]
);
const actions = ( notice: NoticeType ) => {
if ( ! notice.options?.actions ) {
return [];
}
return notice.options?.actions.map( ( action ) => {
return {
...action,
variant: 'link',
className: 'is-link',
};
} );
};
const errorNotices = [];
for ( const notice of notices ) {
errorNotices.push(
<Notice
className="woocommerce-marketplace__notice--error"
status={ notice.status }
onRemove={ () => removeNotice( notice.productKey ) }
key={ notice.productKey }
actions={ actions( notice ) }
>
<img src={ Alert } alt="" width={ 24 } height={ 24 } />
{ notice.message }
</Notice>
);
}
return <>{ errorNotices }</>;
}

View File

@ -0,0 +1,91 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { useContext, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { SubscriptionsContext } from '../../../../contexts/subscriptions-context';
import {
addNotice,
connectProduct,
removeNotice,
} from '../../../../utils/functions';
import { Subscription } from '../../types';
import { NoticeStatus } from '../../../../contexts/types';
interface ConnectProps {
subscription: Subscription;
onClose?: () => void;
variant?: Button.ButtonVariant;
}
export default function ConnectButton( props: ConnectProps ) {
const [ isConnecting, setIsConnecting ] = useState( false );
const { loadSubscriptions } = useContext( SubscriptionsContext );
const connect = () => {
recordEvent( 'marketplace_product_connect_button_clicked', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
} );
setIsConnecting( true );
removeNotice( props.subscription.product_key );
connectProduct( props.subscription )
.then( () => {
loadSubscriptions( false ).then( () => {
addNotice(
props.subscription.product_key,
sprintf(
// translators: %s is the product name.
__( '%s successfully connected.', 'woocommerce' ),
props.subscription.product_name
),
NoticeStatus.Success
);
setIsConnecting( false );
if ( props.onClose ) {
props.onClose();
}
} );
} )
.catch( () => {
addNotice(
props.subscription.product_key,
sprintf(
// translators: %s is the product name.
__( '%s couldnt be connected.', 'woocommerce' ),
props.subscription.product_name
),
NoticeStatus.Error,
{
actions: [
{
label: __( 'Try again', 'woocommerce' ),
onClick: connect,
},
],
}
);
setIsConnecting( false );
if ( props.onClose ) {
props.onClose();
}
} );
};
return (
<Button
onClick={ connect }
variant={ props.variant ?? 'secondary' }
isBusy={ isConnecting }
disabled={ isConnecting }
>
{ __( 'Connect', 'woocommerce' ) }
</Button>
);
}

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { Button, ButtonGroup, Modal } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { Subscription } from '../../types';
import ConnectButton from './connect-button';
interface ConnectProps {
subscription: Subscription;
onClose: () => void;
}
export default function ConnectModal( props: ConnectProps ) {
return (
<Modal
title={ __( 'Connect to update', 'woocommerce' ) }
onRequestClose={ props.onClose }
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">
{ sprintf(
// translators: %s is the product version number (e.g. 1.0.2).
__(
'Version %s is available. To enable this update you need to connect your subscription to this store.',
'woocommerce'
),
props.subscription.version
) }
</p>
<ButtonGroup className="woocommerce-marketplace__header-account-modal-button-group">
<Button
variant="tertiary"
onClick={ props.onClose }
className="woocommerce-marketplace__header-account-modal-button"
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<ConnectButton
subscription={ props.subscription }
onClose={ props.onClose }
variant="primary"
/>
</ButtonGroup>
</Modal>
);
}

View File

@ -0,0 +1,124 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { dispatch, useSelect } from '@wordpress/data';
import { useContext } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { SubscriptionsContext } from '../../../../contexts/subscriptions-context';
import {
addNotice,
installProduct,
removeNotice,
} from '../../../../utils/functions';
import { Subscription } from '../../types';
import { installingStore } from '../../../../contexts/install-store';
import { NoticeStatus } from '../../../../contexts/types';
interface InstallProps {
subscription: Subscription;
}
export default function Install( props: InstallProps ) {
const { loadSubscriptions } = useContext( SubscriptionsContext );
const loading: boolean = useSelect(
( select ) => {
return select( installingStore ).isInstalling(
props.subscription.product_key
);
},
[ props.subscription.product_key ]
);
const startInstall = () => {
dispatch( installingStore ).startInstalling(
props.subscription.product_key
);
};
const stopInstall = () => {
dispatch( installingStore ).stopInstalling(
props.subscription.product_key
);
};
const install = () => {
recordEvent( 'marketplace_product_install_button_clicked', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_current_version: props.subscription.version,
} );
startInstall();
removeNotice( props.subscription.product_key );
installProduct( props.subscription )
.then( () => {
loadSubscriptions( false ).then( () => {
addNotice(
props.subscription.product_key,
sprintf(
// translators: %s is the product name.
__( '%s successfully installed.', 'woocommerce' ),
props.subscription.product_name
),
NoticeStatus.Success
);
stopInstall();
} );
recordEvent( 'marketplace_product_installed', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_current_version: props.subscription.version,
} );
} )
.catch( ( error ) => {
loadSubscriptions( false ).then( () => {
let errorMessage = sprintf(
// translators: %s is the product name.
__( '%s couldnt be installed.', 'woocommerce' ),
props.subscription.product_name
);
if ( error?.success === false && error?.data.message ) {
errorMessage += ' ' + error.data.message;
}
addNotice(
props.subscription.product_key,
errorMessage,
NoticeStatus.Error,
{
actions: [
{
label: __( 'Try again', 'woocommerce' ),
onClick: install,
},
],
}
);
stopInstall();
} );
recordEvent( 'marketplace_product_install_failed', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_current_version: props.subscription.version,
} );
} );
};
return (
<Button
variant="link"
isBusy={ loading }
disabled={ loading }
onClick={ install }
>
{ __( 'Install', 'woocommerce' ) }
</Button>
);
}

View File

@ -0,0 +1,79 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
import { useContext, useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import RefreshIcon from '../../../../assets/images/refresh.svg';
import { SubscriptionsContext } from '../../../../contexts/subscriptions-context';
import { addNotice, removeNotice } from '../../../../utils/functions';
import { NoticeStatus } from '../../../../contexts/types';
const NOTICE_ID = 'woocommerce-marketplace-refresh-subscriptions';
export function RefreshButton() {
const { refreshSubscriptions } = useContext( SubscriptionsContext );
const [ isLoading, setIsLoading ] = useState( false );
const refresh = () => {
if ( isLoading ) {
return;
}
removeNotice( NOTICE_ID );
setIsLoading( true );
refreshSubscriptions()
.then( () => {
addNotice(
NOTICE_ID,
__( 'Subscriptions refreshed.', 'woocommerce' ),
NoticeStatus.Success
);
} )
.catch( ( error ) => {
addNotice(
NOTICE_ID,
sprintf(
// translators: %s is the error message.
__(
'Error refreshing subscriptions: %s',
'woocommerce'
),
error.message
),
NoticeStatus.Error,
{
actions: [
{
label: __( 'Try again', 'woocommerce' ),
onClick: refresh,
},
],
}
);
} )
.finally( () => {
setIsLoading( false );
} );
};
return (
<Button
className="woocommerce-marketplace__refresh-subscriptions"
onClick={ refresh }
isBusy={ isLoading }
>
<img
src={ RefreshIcon }
alt={ __( 'Refresh subscriptions', 'woocommerce' ) }
className="woocommerce-marketplace__refresh-subscriptions-icon"
/>
{ __( 'Refresh', 'woocommerce' ) }
</Button>
);
}

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { queueRecordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { renewUrl } from '../../../../utils/functions';
import { Subscription } from '../../types';
interface RenewProps {
subscription: Subscription;
variant?: Button.ButtonVariant;
}
export default function RenewButton( props: RenewProps ) {
function recordTracksEvent() {
queueRecordEvent( 'marketplace_renew_button_clicked', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
} );
}
return (
<Button
href={ renewUrl( props.subscription ) }
variant={ props.variant ?? 'secondary' }
onClick={ recordTracksEvent }
>
{ __( 'Renew', 'woocommerce' ) }
</Button>
);
}

View File

@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { Button, ButtonGroup, Modal } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { Subscription } from '../../types';
import RenewButton from './renew-button';
interface RenewProps {
subscription: Subscription;
onClose: () => void;
}
export default function RenewModal( props: RenewProps ) {
return (
<Modal
title={ __( 'Renew to update', 'woocommerce' ) }
onRequestClose={ props.onClose }
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">
{ sprintf(
// translators: %s is the product version number (e.g. 1.0.2).
__(
'Version %s is available. To enable this update you need to renew your subscription.',
'woocommerce'
),
props.subscription.version
) }
</p>
<ButtonGroup className="woocommerce-marketplace__header-account-modal-button-group">
<Button
variant="tertiary"
onClick={ props.onClose }
className="woocommerce-marketplace__header-account-modal-button"
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<RenewButton
subscription={ props.subscription }
variant="primary"
/>
</ButtonGroup>
</Modal>
);
}

View File

@ -0,0 +1,36 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
import { queueRecordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { subscribeUrl } from '../../../../utils/functions';
import { Subscription } from '../../types';
interface SubscribeProps {
subscription: Subscription;
variant?: Button.ButtonVariant;
}
export default function SubscribeButton( props: SubscribeProps ) {
function recordTracksEvent() {
queueRecordEvent( 'marketplace_subscribe_button_clicked', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
} );
}
return (
<Button
href={ subscribeUrl( props.subscription ) }
variant={ props.variant ?? 'secondary' }
onClick={ recordTracksEvent }
>
{ __( 'Subscribe', 'woocommerce' ) }
</Button>
);
}

View File

@ -0,0 +1,53 @@
/**
* External dependencies
*/
import { Button, ButtonGroup, Modal } from '@wordpress/components';
import { __, sprintf } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { Subscription } from '../../types';
import SubscribeButton from './subscribe-button';
interface SubscribeProps {
subscription: Subscription;
onClose: () => void;
}
export default function SubscribeModal( props: SubscribeProps ) {
return (
<Modal
title={ __( 'Subscribe to update', 'woocommerce' ) }
onRequestClose={ props.onClose }
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">
{ sprintf(
// translators: %s is the product version number (e.g. 1.0.2).
__(
'Version %s is available. To enable this update you need to purchase a subscription.',
'woocommerce'
),
props.subscription.version
) }
</p>
<ButtonGroup className="woocommerce-marketplace__header-account-modal-button-group">
<Button
variant="tertiary"
onClick={ props.onClose }
className="woocommerce-marketplace__header-account-modal-button"
>
{ __( 'Cancel', 'woocommerce' ) }
</Button>
<SubscribeButton
subscription={ props.subscription }
variant="primary"
/>
</ButtonGroup>
</Modal>
);
}

View File

@ -0,0 +1,187 @@
/**
* External dependencies
*/
import { Button } from '@wordpress/components';
import { useContext, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import { SubscriptionsContext } from '../../../../contexts/subscriptions-context';
import { Subscription } from '../../types';
import ConnectModal from './connect-modal';
import RenewModal from './renew-modal';
import SubscribeModal from './subscribe-modal';
import {
addNotice,
removeNotice,
updateProduct,
} from '../../../../utils/functions';
import { NoticeStatus } from '../../../../contexts/types';
interface UpdateProps {
subscription: Subscription;
}
export default function Update( props: UpdateProps ) {
const [ showModal, setShowModal ] = useState( false );
const [ isUpdating, setIsUpdating ] = useState( false );
const { loadSubscriptions } = useContext( SubscriptionsContext );
const canUpdate =
props.subscription.active &&
props.subscription.local &&
props.subscription.local.slug &&
props.subscription.local.path;
function update() {
recordEvent( 'marketplace_product_update_button_clicked', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_installed_version: props.subscription.local.installed,
product_current_version: props.subscription.version,
} );
if ( ! canUpdate ) {
setShowModal( true );
return;
}
removeNotice( props.subscription.product_key );
if ( ! window.wp.updates ) {
addNotice(
props.subscription.product_key,
sprintf(
// translators: %s is the product name.
__( '%s couldnt be updated.', 'woocommerce' ),
props.subscription.product_name
),
NoticeStatus.Error,
{
actions: [
{
label: __(
'Reload page and try again',
'woocommerce'
),
onClick: () => {
window.location.reload();
},
},
],
}
);
return;
}
setIsUpdating( true );
updateProduct( props.subscription )
.then( () => {
loadSubscriptions( false ).then( () => {
addNotice(
props.subscription.product_key,
sprintf(
// translators: %s is the product name.
__( '%s updated successfully.', 'woocommerce' ),
props.subscription.product_name
),
NoticeStatus.Success
);
setIsUpdating( false );
} );
recordEvent( 'marketplace_product_updated', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_installed_version:
props.subscription.local.installed,
product_current_version: props.subscription.version,
} );
} )
.catch( () => {
addNotice(
props.subscription.product_key,
sprintf(
// translators: %s is the product name.
__( '%s couldnt be updated.', 'woocommerce' ),
props.subscription.product_name
),
NoticeStatus.Error,
{
actions: [
{
label: __( 'Try again', 'woocommerce' ),
onClick: update,
},
],
}
);
setIsUpdating( false );
recordEvent( 'marketplace_product_update_failed', {
product_zip_slug: props.subscription.zip_slug,
product_id: props.subscription.product_id,
product_installed_version:
props.subscription.local.installed,
product_current_version: props.subscription.version,
} );
} );
}
const modal = () => {
if ( ! showModal ) {
return null;
}
if ( props.subscription.product_key === '' ) {
return (
<SubscribeModal
onClose={ () => setShowModal( false ) }
subscription={ props.subscription }
/>
);
} else if ( props.subscription.expired ) {
return (
<RenewModal
subscription={ props.subscription }
onClose={ () => setShowModal( false ) }
/>
);
} else if ( ! props.subscription.active ) {
return (
<ConnectModal
subscription={ props.subscription }
onClose={ () => setShowModal( false ) }
/>
);
}
return null;
};
return (
<>
{ modal() }
<Button
variant="link"
className="woocommerce-marketplace__my-subscriptions-update"
onClick={ update }
isBusy={ isUpdating }
disabled={ isUpdating }
label={ sprintf(
// translators: %s is the product version.
__( 'Update to %s', 'woocommerce' ),
props.subscription.version
) }
showTooltip={ true }
tooltipPosition="top center"
>
{ isUpdating
? __( 'Updating', 'woocommerce' )
: __( 'Update', 'woocommerce' ) }
</Button>
</>
);
}

View File

@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { DropdownMenu } from '@wordpress/components';
import { moreVertical } from '@wordpress/icons';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { Subscription } from '../../types';
import { ADMIN_URL } from '../../../../../utils/admin-settings';
export default function ActionsDropdownMenu( props: {
subscription: Subscription;
} ) {
const controls = [
{
title: __( 'Manage on Woo.com', 'woocommerce' ),
icon: <></>,
onClick: () => {
window.open(
'https://woo.com/my-account/my-subscriptions',
'_blank'
);
},
},
{
title: __( 'Manage in Plugins', 'woocommerce' ),
icon: <></>,
onClick: () => {
window.location.href = ADMIN_URL + 'plugins.php';
},
},
];
if ( props.subscription.documentation_url ) {
controls.unshift( {
title: __( 'View documentation', 'woocommerce' ),
icon: <></>,
onClick: () => {
window.open( props.subscription.documentation_url, '_blank' );
},
} );
}
return (
<DropdownMenu
icon={ moreVertical }
label={ __( 'Actions', 'woocommerce' ) }
controls={ controls }
/>
);
}

View File

@ -0,0 +1,291 @@
/**
* External dependencies
*/
import { TableRow } from '@woocommerce/components/build-types/table/types';
import { gmdateI18n } from '@wordpress/date';
import { __, sprintf } from '@wordpress/i18n';
import { Icon, plugins } from '@wordpress/icons';
import { createInterpolateElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import { StatusLevel, Subscription } from '../../types';
import ConnectButton from '../actions/connect-button';
import Install from '../actions/install';
import RenewButton from '../actions/renew-button';
import SubscribeButton from '../actions/subscribe-button';
import Update from '../actions/update';
import StatusPopover from './status-popover';
import ActionsDropdownMenu from './actions-dropdown-menu';
import Version from './version';
import {
appendURLParams,
renewUrl,
subscribeUrl,
} from '../../../../utils/functions';
import { MARKETPLACE_COLLABORATION_PATH } from '../../../constants';
type StatusBadge = {
text: string;
level: StatusLevel;
explanation?: string | JSX.Element;
};
function getStatusBadge( subscription: Subscription ): StatusBadge | false {
if ( subscription.product_key === '' ) {
/**
* If there is no subscription, we don't need to check for the expiry.
*/
return {
text: __( 'No subscription', 'woocommerce' ),
level: StatusLevel.Error,
explanation: createInterpolateElement(
__(
'To receive updates and support, please <purchase>purchase</purchase> a subscription or use a subscription from another account by <sharing>sharing</sharing> or <transferring>transferring</transferring>.',
'woocommerce'
),
{
purchase: (
<a
href={ subscribeUrl( subscription ) }
rel="nofollow noopener noreferrer"
>
renew
</a>
),
sharing: (
<a
href={ MARKETPLACE_COLLABORATION_PATH }
rel="nofollow noopener noreferrer"
>
sharing
</a>
),
transferring: (
<a
href={ MARKETPLACE_COLLABORATION_PATH }
rel="nofollow noopener noreferrer"
>
sharing
</a>
),
}
),
};
}
if ( subscription.local.installed && ! subscription.active ) {
return {
text: __( 'Not connected', 'woocommerce' ),
level: StatusLevel.Warning,
explanation: __(
'To receive updates and support, please connect your subscription to this store.',
'woocommerce'
),
};
}
if ( subscription.expired ) {
return {
text: __( 'Expired', 'woocommerce' ),
level: StatusLevel.Error,
explanation: createInterpolateElement(
__(
'To receive updates and support, please <renew>renew</renew> this subscription or use a subscription from another account by <sharing>sharing</sharing> or <transferring>transferring</transferring>.',
'woocommerce'
),
{
renew: (
<a
href={ renewUrl( subscription ) }
rel="nofollow noopener noreferrer"
>
renew
</a>
),
sharing: (
<a
href={ MARKETPLACE_COLLABORATION_PATH }
rel="nofollow noopener noreferrer"
>
sharing
</a>
),
transferring: (
<a
href={ MARKETPLACE_COLLABORATION_PATH }
rel="nofollow noopener noreferrer"
>
sharing
</a>
),
}
),
};
}
return false;
}
function getVersion( subscription: Subscription ): string | JSX.Element {
if ( subscription.local.version === subscription.version ) {
return <Version span={ subscription.local.version } />;
}
if ( subscription.local.version && subscription.version ) {
return <Update subscription={ subscription } />;
}
if ( subscription.version ) {
return <Version span={ subscription.version } />;
}
if ( subscription.local.version ) {
return <Version span={ subscription.local.version } />;
}
return '';
}
function appendUTMParams( url: string ) {
return appendURLParams( url, [
[ 'utm_source', 'subscriptionsscreen' ],
[ 'utm_medium', 'product' ],
[ 'utm_campaign', 'wcaddons' ],
[ 'utm_content', 'product-name' ],
] );
}
export function nameAndStatus( subscription: Subscription ): TableRow {
// This is the fallback icon element with products without
let iconElement = <Icon icon={ plugins } size={ 40 } />;
// If the product has an icon, use that instead.
if ( subscription.product_icon ) {
iconElement = (
<img
src={ subscription.product_icon }
alt={ sprintf(
/* translators: %s is the product name. */
__( '%s icon', 'woocommerce' ),
subscription.product_name
) }
/>
);
}
const statusBadge = getStatusBadge( subscription );
const displayElement = (
<div className="woocommerce-marketplace__my-subscriptions__product">
<a
href={ appendUTMParams( subscription.product_url ) }
target="_blank"
rel="noreferrer"
>
<span className="woocommerce-marketplace__my-subscriptions__product-icon">
{ iconElement }
</span>
</a>
<a
href={ appendUTMParams( subscription.product_url ) }
className="woocommerce-marketplace__my-subscriptions__product-name"
target="_blank"
rel="noreferrer"
>
{ subscription.product_name }
</a>
<span className="woocommerce-marketplace__my-subscriptions__product-statuses">
{ statusBadge && (
<StatusPopover
text={ statusBadge.text }
level={ statusBadge.level }
explanation={ statusBadge.explanation ?? '' }
/>
) }
</span>
</div>
);
return {
display: displayElement,
value: subscription.product_name,
};
}
export function expiry( subscription: Subscription ): TableRow {
const expiryDate = subscription.expires;
if (
subscription.local.installed === true &&
subscription.product_key === ''
) {
return {
display: '',
value: '',
};
}
let expiryDateElement = __( 'Never expires', 'woocommerce' );
if ( expiryDate ) {
expiryDateElement = gmdateI18n(
'j M, Y',
new Date( expiryDate * 1000 )
);
}
const displayElement = (
<span className="woocommerce-marketplace__my-subscriptions__expiry-date">
{ expiryDateElement }
</span>
);
return {
display: displayElement,
value: expiryDate,
};
}
export function autoRenew( subscription: Subscription ): TableRow {
return {
display: subscription.autorenew
? __( 'On', 'woocommerce' )
: __( 'Off', 'woocommerce' ),
value: subscription.autorenew,
};
}
export function version( subscription: Subscription ): TableRow {
return {
display: getVersion( subscription ),
};
}
export function actions( subscription: Subscription ): TableRow {
let actionButton = null;
if ( subscription.product_key === '' ) {
actionButton = <SubscribeButton subscription={ subscription } />;
} else if ( subscription.expired ) {
actionButton = <RenewButton subscription={ subscription } />;
} else if (
subscription.local.installed === false &&
subscription.subscription_installed === false
) {
actionButton = <Install subscription={ subscription } />;
} else if (
subscription.active === false &&
subscription.subscription_available === true
) {
actionButton = (
<ConnectButton subscription={ subscription } variant="link" />
);
}
return {
display: (
<div className="woocommerce-marketplace__my-subscriptions__actions">
{ actionButton }
<ActionsDropdownMenu subscription={ subscription } />
</div>
),
};
}

View File

@ -0,0 +1,51 @@
/**
* External dependencies
*/
import { Popover } from '@wordpress/components';
import { Icon, info } from '@wordpress/icons';
import { useState } from '@wordpress/element';
/**
* Internal dependencies
*/
import { StatusLevel } from '../../types';
export default function StatusPopover( props: {
text: string;
level: StatusLevel;
explanation: string | JSX.Element;
} ) {
const [ isVisible, setIsVisible ] = useState( false );
function shouldShowExplanation() {
if ( props.explanation === '' ) {
return false;
}
return isVisible;
}
return (
<button
onClick={ () => {
setIsVisible( ! isVisible );
} }
className={
'woocommerce-marketplace__my-subscriptions__product-status' +
' ' +
`woocommerce-marketplace__my-subscriptions__product-status--${ props.level }`
}
>
<Icon icon={ info } size={ 16 } />
{ props.text }
{ shouldShowExplanation() && (
<Popover
className="woocommerce-marketplace__my-subscriptions__popover"
position="top center"
>
{ props.explanation }
</Popover>
) }
</button>
);
}

View File

@ -0,0 +1,11 @@
interface VersionProps {
span: string;
}
export default function Version( props: VersionProps ) {
return (
<span className="woocommerce-marketplace__my-subscriptions-version">
{ props.span }
</span>
);
}

View File

@ -0,0 +1,35 @@
/**
* External dependencies
*/
import { TableRow } from '@woocommerce/components/build-types/table/types';
/**
* Internal dependencies
*/
import { Subscription } from '../types';
import {
actions,
autoRenew,
expiry,
nameAndStatus,
version,
} from './rows/functions';
export function availableSubscriptionRow( item: Subscription ): TableRow[] {
return [
nameAndStatus( item ),
expiry( item ),
autoRenew( item ),
version( item ),
actions( item ),
];
}
export function installedSubscriptionRow( item: Subscription ): TableRow[] {
return [
nameAndStatus( item ),
expiry( item ),
autoRenew( item ),
version( item ),
actions( item ),
];
}

View File

@ -0,0 +1,129 @@
/**
* External dependencies
*/
import { EmptyTable, Table, TablePlaceholder } from '@woocommerce/components';
import {
TableHeader,
TableRow,
} from '@woocommerce/components/build-types/table/types';
import { getNewPath } from '@woocommerce/navigation';
import { createInterpolateElement } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/**
* Internal dependencies
*/
import { MARKETPLACE_PATH } from '../../constants';
const tableHeadersDefault = [
{
key: 'name',
label: __( 'Name', 'woocommerce' ),
},
{
key: 'expiry',
label: __( 'Expiry/Renewal date', 'woocommerce' ),
},
{
key: 'autoRenew',
label: __( 'Auto-renew', 'woocommerce' ),
},
{
key: 'version',
label: __( 'Version', 'woocommerce' ),
},
];
function SubscriptionsTable( props: {
rows?: TableRow[][];
headers: TableHeader[];
isLoading: boolean;
} ) {
if ( props.isLoading ) {
return (
<TablePlaceholder
caption={ __( 'Loading your subscriptions', 'woocommerce' ) }
headers={ props.headers }
/>
);
}
const headersWithClasses = props.headers.map( ( header ) => {
return {
...header,
cellClassName:
'woocommerce-marketplace__my-subscriptions__table__header--' +
header.key,
};
} );
return (
<Table
className="woocommerce-marketplace__my-subscriptions__table"
headers={ headersWithClasses }
rows={ props.rows }
/>
);
}
export function InstalledSubscriptionsTable( props: {
rows?: TableRow[][];
isLoading: boolean;
} ) {
const headers = [
...tableHeadersDefault,
{
key: 'actions',
label: __( 'Actions', 'woocommerce' ),
},
];
if ( ! props.isLoading && ( ! props.rows || props.rows.length === 0 ) ) {
const marketplaceBrowseURL = getNewPath( {}, MARKETPLACE_PATH, {} );
const noInstalledSubscriptionsHTML = createInterpolateElement(
__(
'No extensions or themes installed. <a>Browse the Marketplace</a>',
'woocommerce'
),
{
// eslint-disable-next-line jsx-a11y/anchor-has-content
a: <a href={ marketplaceBrowseURL } />,
}
);
return (
<EmptyTable numberOfRows={ 4 }>
{ noInstalledSubscriptionsHTML }
</EmptyTable>
);
}
return (
<SubscriptionsTable
rows={ props.rows }
isLoading={ props.isLoading }
headers={ headers }
/>
);
}
export function AvailableSubscriptionsTable( props: {
rows?: TableRow[][];
isLoading: boolean;
} ) {
const headers = [
...tableHeadersDefault,
{
key: 'actions',
label: __( 'Actions', 'woocommerce' ),
},
];
return (
<SubscriptionsTable
rows={ props.rows }
isLoading={ props.isLoading }
headers={ headers }
/>
);
}

View File

@ -0,0 +1,57 @@
export type Subscription = {
product_key: string;
product_id: number;
product_name: string;
product_url: string;
product_icon: string;
product_slug: string;
product_type: string;
documentation_url: string;
zip_slug: string;
key_type: string;
key_type_label: string;
autorenew: boolean;
connections: string[];
legacy_connections: string[];
shares: SubscriptionShare[];
lifetime: boolean;
expires: number;
expired: boolean;
expiring: boolean;
sites_max: number;
sites_active: number;
maxed: boolean;
order_id: number;
product_keys_all: string[];
product_status: string;
active: boolean;
local: SubscriptionLocal;
has_updates: boolean;
version: string;
subscription_installed: boolean;
subscription_available: boolean;
};
export interface SubscriptionLocal {
installed: boolean;
installable: boolean;
active: boolean;
version: string;
type: string;
slug: string;
path: string;
}
export interface SubscriptionShare {
share_id: string;
product_key: string;
user_id: string;
subscription_item_id: string;
status: string;
created: string;
}
export enum StatusLevel {
Warning = 'warning',
Error = 'error',
}

View File

@ -4,21 +4,26 @@
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { Card } from '@wordpress/components'; import { Card } from '@wordpress/components';
import classnames from 'classnames'; import classnames from 'classnames';
import { ExtraProperties, queueRecordEvent } from '@woocommerce/tracks';
import { useQuery } from '@woocommerce/navigation';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import './product-card.scss'; import './product-card.scss';
import { Product, ProductType } from '../product-list/types'; import { Product, ProductTracksData, ProductType } from '../product-list/types';
import { appendURLParams } from '../../utils/functions';
export interface ProductCardProps { export interface ProductCardProps {
type?: string; type?: string;
product?: Product; product?: Product;
isLoading?: boolean; isLoading?: boolean;
tracksData: ProductTracksData;
} }
function ProductCard( props: ProductCardProps ): JSX.Element { function ProductCard( props: ProductCardProps ): JSX.Element {
const { isLoading, type } = props; const { isLoading, type } = props;
const query = useQuery();
// Get the product if provided; if not provided, render a skeleton loader // Get the product if provided; if not provided, render a skeleton loader
const product = props.product ?? { const product = props.product ?? {
title: '', title: '',
@ -34,16 +39,64 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
// We hardcode this for now while we only display prices in USD. // We hardcode this for now while we only display prices in USD.
const currencySymbol = '$'; const currencySymbol = '$';
function recordTracksEvent( event: string, data: ExtraProperties ) {
const tracksData = props.tracksData;
if ( tracksData.position ) {
data.position = tracksData.position;
}
if ( tracksData.label ) {
data.label = tracksData.label;
}
if ( tracksData.group ) {
data.group = tracksData.group;
}
if ( tracksData.searchTerm ) {
data.search_term = tracksData.searchTerm;
}
if ( tracksData.category ) {
data.category = tracksData.category;
}
queueRecordEvent( event, data );
}
const isTheme = type === ProductType.theme; const isTheme = type === ProductType.theme;
let productVendor: string | JSX.Element | null = product?.vendorName; let productVendor: string | JSX.Element | null = product?.vendorName;
if ( product?.vendorName && product?.vendorUrl ) { if ( product?.vendorName && product?.vendorUrl ) {
productVendor = ( productVendor = (
<a href={ product.vendorUrl } rel="noopener noreferrer"> <a
href={ product.vendorUrl }
rel="noopener noreferrer"
onClick={ () => {
recordTracksEvent(
'marketplace_product_card_vendor_clicked',
{
product: product.title,
vendor: product.vendorName,
product_type: type,
}
);
} }
>
{ product.vendorName } { product.vendorName }
</a> </a>
); );
} }
const productUrl = () => {
if ( query.ref ) {
return appendURLParams( product.url, [
[ 'utm_content', query.ref ],
] );
}
return product.url;
};
const classNames = classnames( const classNames = classnames(
'woocommerce-marketplace__product-card', 'woocommerce-marketplace__product-card',
`woocommerce-marketplace__product-card--${ type }`, `woocommerce-marketplace__product-card--${ type }`,
@ -86,8 +139,18 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
<h2 className="woocommerce-marketplace__product-card__title"> <h2 className="woocommerce-marketplace__product-card__title">
<a <a
className="woocommerce-marketplace__product-card__link" className="woocommerce-marketplace__product-card__link"
href={ product.url } href={ productUrl() }
rel="noopener noreferrer" rel="noopener noreferrer"
onClick={ () => {
recordTracksEvent(
'marketplace_product_card_clicked',
{
product: product.title,
vendor: product.vendorName,
product_type: type,
}
);
} }
> >
{ isLoading ? ' ' : product.title } { isLoading ? ' ' : product.title }
</a> </a>

View File

@ -1,38 +1,23 @@
@import '../../stylesheets/_variables.scss'; @import '../../stylesheets/_variables.scss';
.woocommerce-marketplace__no-results__content { .woocommerce-marketplace__no-results__heading {
border: 1px solid $gutenberg-gray-100; margin: 0;
padding: $grid-unit-80 $grid-unit-40; font-size: 20px;
display: flex; font-weight: 400;
flex-direction: column;
align-items: center;
margin-top: $grid-unit-30;
}
.woocommerce-marketplace__no-results__product-group {
margin-top: $grid-unit-60;
.woocommerce-marketplace__product-list-title {
font-size: 16px;
font-weight: 600;
}
}
.woocommerce-marketplace__no-results__icon {
height: 80px;
} }
.woocommerce-marketplace__no-results__description { .woocommerce-marketplace__no-results__description {
text-align: center; margin: $grid-unit-10 0 0 0;
font-size: 13px; color: $gray-700;
max-width: 52ch; line-height: 20px;
p {
color: $gutenberg-gray-700;
}
} }
.woocommerce-marketplace__no-results__description--bold { .woocommerce-marketplace__no-results__product-groups {
font-weight: 600; margin-top: $grid-unit-20;
}
.woocommerce-marketplace__no-results .woocommerce-marketplace__product-list-title {
font-size: 16px; font-size: 16px;
font-weight: 400;
margin: $grid-unit-40 0 0 0;
} }

View File

@ -1,159 +1,150 @@
/** /**
* External dependencies * External dependencies
*/ */
import { __, sprintf } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import { useEffect, useState } from '@wordpress/element'; import { useEffect, useState } from '@wordpress/element';
import { useQuery } from '@woocommerce/navigation'; import { useQuery } from '@woocommerce/navigation';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import NoResultsExtensionsIcon from '../../assets/images/no-results-extensions.svg';
import NoResultsThemesIcon from '../../assets/images/no-results-themes.svg';
import { fetchDiscoverPageData, ProductGroup } from '../../utils/functions'; import { fetchDiscoverPageData, ProductGroup } from '../../utils/functions';
import ProductLoader from '../product-loader/product-loader'; import ProductLoader from '../product-loader/product-loader';
import ProductList from '../product-list/product-list'; import ProductList from '../product-list/product-list';
import './no-results.scss'; import { ProductType, SearchResultType } from '../product-list/types';
import { ProductType } from '../product-list/types';
import CategorySelector from '../category-selector/category-selector'; import CategorySelector from '../category-selector/category-selector';
import './no-results.scss';
interface NoResultsProps { export default function NoResults( props: {
type: ProductType; type: SearchResultType;
} showHeading?: boolean;
heading?: string;
export default function NoResults( props: NoResultsProps ): JSX.Element { } ): JSX.Element {
const [ productGroup, setProductGroup ] = useState< ProductGroup >(); const [ productGroups, setProductGroups ] = useState< ProductGroup[] >();
const [ isLoadingProductGroup, setisLoadingProductGroup ] = const [ isLoading, setIsLoading ] = useState( false );
useState( false );
const [ noResultsTerm, setNoResultsTerm ] = useState< string >( '' );
const typeLabel =
props.type === ProductType.theme ? 'themes' : 'extensions';
const query = useQuery(); const query = useQuery();
const showCategorySelector = query.tab === 'search' && query.section; const showCategorySelector = query.tab === 'search' && query.section;
const productGroupsForSearchType = {
[ SearchResultType.all ]: [ 'most-popular', 'popular-themes' ],
[ SearchResultType.theme ]: [ 'popular-themes' ],
[ SearchResultType.extension ]: [ 'most-popular' ],
};
useEffect( () => { useEffect( () => {
if ( query.term ) { setIsLoading( true );
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() fetchDiscoverPageData()
.then( ( products: ProductGroup[] ) => { .then( ( products: ProductGroup[] ) => {
const productGroupId = const productGroupIds =
props.type === ProductType.theme productGroupsForSearchType[ props.type ];
? 'popular-themes'
: 'most-popular';
const mostPopularGroup = products.find(
( group ) => group.id === productGroupId
);
if ( ! mostPopularGroup ) { if ( ! productGroupIds ) {
return; return;
} }
mostPopularGroup.items = mostPopularGroup.items.slice( 0, 9 ); const productGroupsToDisplay = products.filter( ( group ) => {
return productGroupIds.includes( group.id );
} );
setProductGroup( mostPopularGroup ); if ( ! productGroupsToDisplay ) {
return;
}
// Limit productGroup.items to 4 items.
productGroupsToDisplay.forEach( ( group ) => {
group.items = group.items.slice( 0, 4 );
} );
setProductGroups( productGroupsToDisplay );
} ) } )
.catch( () => { .catch( () => {
setProductGroup( undefined ); setProductGroups( undefined );
} ) } )
.finally( () => { .finally( () => {
setisLoadingProductGroup( false ); setIsLoading( false );
} ); } );
}, [] ); }, [] );
function productListTitle( type: ProductType ) { function productListTitle( groupId: string ) {
if ( type === ProductType.theme ) { if ( groupId === 'popular-themes' ) {
return __( 'Our favorite themes', 'woocommerce' ); return __( 'Our favorite themes', 'woocommerce' );
} }
return __( 'Most popular extensions', 'woocommerce' ); return __( 'Most popular extensions', 'woocommerce' );
} }
function renderProductGroup() { function renderProductGroups() {
if ( isLoadingProductGroup ) { if ( isLoading ) {
return ( return (
<ProductLoader <>
type={ productGroup?.itemType || ProductType.extension } <ProductLoader
/> type={ ProductType.extension }
placeholderCount={ 4 }
/>
<ProductLoader
type={ ProductType.theme }
placeholderCount={ 4 }
/>
</>
); );
} }
if ( ! productGroup ) { if ( ! productGroups || productGroups.length === 0 ) {
return <></>; return <></>;
} }
return ( return (
<ProductList <>
title={ productListTitle( props.type ) } { productGroups.map( ( productGroup ) => {
products={ productGroup.items } return (
groupURL={ productGroup.url } <ProductList
type={ productGroup.itemType } title={ productListTitle( productGroup.id ) }
/> products={ productGroup.items }
groupURL={ productGroup.url }
type={ productGroup.itemType }
key={ productGroup.id }
/>
);
} ) }
</>
); );
} }
function getNoResultsIcon( type: ProductType ) { function categorySelector() {
if ( type === ProductType.theme ) { if ( ! showCategorySelector ) {
return NoResultsThemesIcon; return <></>;
} }
return NoResultsExtensionsIcon; if ( props.type === SearchResultType.all ) {
return <></>;
}
let categorySelectorType = ProductType.extension;
if ( props.type === SearchResultType.theme ) {
categorySelectorType = ProductType.theme;
}
return <CategorySelector type={ categorySelectorType } />;
} }
return ( return (
<div className="woocommerce-marketplace__no-results"> <div className="woocommerce-marketplace__no-results">
{ showCategorySelector && <CategorySelector type={ props.type } /> } { categorySelector() }
<div className="woocommerce-marketplace__no-results__content"> <div className="woocommerce-marketplace__no-results__content">
<img <h2 className="woocommerce-marketplace__no-results__heading">
className="woocommerce-marketplace__no-results__icon" { props.showHeading ? props.heading : '' }
src={ getNoResultsIcon( props.type ) } </h2>
alt={ __( 'No results.', 'woocommerce' ) } <p className="woocommerce-marketplace__no-results__description">
width="80" { __(
height="80" 'Try searching again using a different term, or take a look at' +
/> ' our recommendations below.',
<div className="woocommerce-marketplace__no-results__description"> 'woocommerce'
<h3 className="woocommerce-marketplace__no-results__description--bold"> ) }
{ sprintf( </p>
// translators: %1$s product type (themes or extensions), %2$s: search term
__(
"We didn't find %1$s for “%2$s”",
'woocommerce'
),
typeLabel,
noResultsTerm
) }
</h3>
<p>
{ sprintf(
// translators: %s product type (themes or extensions)
__(
'Try searching again using a different term, or take a look at some of our most popular %s below.',
'woocommerce'
),
typeLabel
) }
</p>
</div>
</div> </div>
<div className="woocommerce-marketplace__no-results__product-group"> <div className="woocommerce-marketplace__no-results__product-groups">
{ renderProductGroup() } { renderProductGroups() }
</div> </div>
</div> </div>
); );

View File

@ -20,9 +20,11 @@
gap: $large-gap; gap: $large-gap;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
// Hide third and above product cards on Discover page due to API result count
// These are progressively displayed at larger screen sizes. // Hide third and above product cards on Discover and suggestions on "no results" search results page
&__discover .woocommerce-marketplace__product-card:nth-child(n+3) { // due to item count. We display progressively more on larger screen sizes.
&__discover .woocommerce-marketplace__product-card:nth-child(n+3),
&__no-results .woocommerce-marketplace__product-card:nth-child(n+3) {
display: none; display: none;
} }
} }
@ -34,7 +36,9 @@
gap: $large-gap; gap: $large-gap;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
} }
&__discover .woocommerce-marketplace__product-card:nth-child(3) {
&__discover .woocommerce-marketplace__product-card:nth-child(3),
&__no-results .woocommerce-marketplace__product-card:nth-child(3) {
display: block; display: block;
} }
} }
@ -45,7 +49,9 @@
&__product-list-content { &__product-list-content {
grid-template-columns: repeat(4, 1fr); grid-template-columns: repeat(4, 1fr);
} }
&__discover .woocommerce-marketplace__product-card:nth-child(4) {
&__discover .woocommerce-marketplace__product-card:nth-child(4),
&__no-results .woocommerce-marketplace__product-card:nth-child(4) {
display: block; display: block;
} }
} }

View File

@ -14,8 +14,11 @@ import { getAdminSetting } from '../../../utils/admin-settings';
export default function ProductListContent( props: { export default function ProductListContent( props: {
products: Product[]; products: Product[];
group?: string;
type: ProductType; type: ProductType;
className?: string; className?: string;
searchTerm?: string;
category?: string;
} ): JSX.Element { } ): JSX.Element {
const wccomHelperSettings = getAdminSetting( 'wccomHelper', {} ); const wccomHelperSettings = getAdminSetting( 'wccomHelper', {} );
@ -26,7 +29,7 @@ export default function ProductListContent( props: {
return ( return (
<div className={ classes }> <div className={ classes }>
{ props.products.map( ( product ) => ( { props.products.map( ( product, index ) => (
<ProductCard <ProductCard
key={ product.id } key={ product.id }
type={ props.type } type={ props.type }
@ -53,6 +56,15 @@ export default function ProductListContent( props: {
), ),
description: product.description, description: product.description,
} } } }
tracksData={ {
position: index + 1,
...( product.label && { label: product.label } ),
...( props.group && { group: props.group } ),
...( props.searchTerm && {
searchTerm: props.searchTerm,
} ),
...( props.category && { category: props.category } ),
} }
/> />
) ) } ) ) }
</div> </div>

View File

@ -12,7 +12,7 @@
flex: 1 0 0; flex: 1 0 0;
font-size: 20px; font-size: 20px;
font-style: normal; font-style: normal;
font-weight: 500; font-weight: 400;
margin-bottom: $medium-gap; margin-bottom: $medium-gap;
margin-top: $small-gap; margin-top: $small-gap;

View File

@ -4,6 +4,7 @@
import { Link } from '@woocommerce/components'; import { Link } from '@woocommerce/components';
import { __ } from '@wordpress/i18n'; import { __ } from '@wordpress/i18n';
import classnames from 'classnames'; import classnames from 'classnames';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
@ -35,7 +36,16 @@ export default function ProductListHeader(
</h2> </h2>
{ groupURL !== null && ( { groupURL !== null && (
<span className="woocommerce-marketplace__product-list-link"> <span className="woocommerce-marketplace__product-list-link">
<Link href={ groupURL } target="_blank"> <Link
href={ groupURL }
target="_blank"
onClick={ () => {
recordEvent( 'marketplace_see_more_clicked', {
group_title: title,
group_url: groupURL,
} );
} }
>
{ __( 'See more', 'woocommerce' ) } { __( 'See more', 'woocommerce' ) }
</Link> </Link>
</span> </span>

View File

@ -18,7 +18,11 @@ export default function ProductList( props: ProductListProps ): JSX.Element {
return ( return (
<div className="woocommerce-marketplace__product-list"> <div className="woocommerce-marketplace__product-list">
<ProductListHeader title={ title } groupURL={ groupURL } /> <ProductListHeader title={ title } groupURL={ groupURL } />
<ProductListContent products={ products } type={ type } /> <ProductListContent
group={ title }
products={ products }
type={ type }
/>
</div> </div>
); );
} }

View File

@ -23,6 +23,7 @@ export type SearchAPIProductType = {
export interface Product { export interface Product {
id?: number; id?: number;
position?: number;
title: string; title: string;
image: string; image: string;
type: ProductType; type: ProductType;
@ -35,6 +36,18 @@ export interface Product {
productType?: string; productType?: string;
averageRating?: number | null; averageRating?: number | null;
reviewsCount?: number | null; reviewsCount?: number | null;
label?: string;
group?: string;
searchTerm?: string;
category?: string;
}
export interface ProductTracksData {
position?: number;
label?: string;
group?: string;
searchTerm?: string;
category?: string;
} }
export enum ProductType { export enum ProductType {

View File

@ -27,7 +27,12 @@ export default function ProductLoader(
) } ) }
<div className="woocommerce-marketplace__product-list-content"> <div className="woocommerce-marketplace__product-list-content">
{ [ ...Array( placeholderCount ) ].map( ( element, i ) => ( { [ ...Array( placeholderCount ) ].map( ( element, i ) => (
<ProductCard key={ i } isLoading={ true } type={ type } /> <ProductCard
key={ i }
isLoading={ true }
type={ type }
tracksData={ {} }
/>
) ) } ) ) }
</div> </div>
</div> </div>

View File

@ -16,17 +16,16 @@ import CategorySelector from '../category-selector/category-selector';
import ProductListContent from '../product-list-content/product-list-content'; import ProductListContent from '../product-list-content/product-list-content';
import ProductLoader from '../product-loader/product-loader'; import ProductLoader from '../product-loader/product-loader';
import NoResults from '../product-list-content/no-results'; import NoResults from '../product-list-content/no-results';
import { Product, ProductType } from '../product-list/types'; import { Product, ProductType, SearchResultType } from '../product-list/types';
import { import { MARKETPLACE_ITEMS_PER_PAGE } from '../constants';
MARKETPLACE_ITEMS_PER_PAGE,
MARKETPLACE_SEARCH_RESULTS_PER_PAGE,
} from '../constants';
interface ProductsProps { interface ProductsProps {
categorySelector?: boolean; categorySelector?: boolean;
products?: Product[]; products?: Product[];
perPage?: number; perPage?: number;
type: ProductType; type: ProductType;
searchTerm?: string;
showAllButton?: boolean;
} }
const LABELS = { const LABELS = {
@ -46,15 +45,12 @@ export default function Products( props: ProductsProps ): JSX.Element {
const label = LABELS[ props.type ].label; const label = LABELS[ props.type ].label;
const singularLabel = LABELS[ props.type ].singularLabel; const singularLabel = LABELS[ props.type ].singularLabel;
const query = useQuery(); const query = useQuery();
const category = query?.category;
const perPage = const perPage = props.perPage ?? MARKETPLACE_ITEMS_PER_PAGE;
// Limit results when on search tab, and not showing only one section.
query?.tab === 'search' && ! query?.section
? MARKETPLACE_SEARCH_RESULTS_PER_PAGE
: MARKETPLACE_ITEMS_PER_PAGE;
// Only show the "View all" button when on search but not showing a specific section of results. // Only show the "View all" button when on search but not showing a specific section of results.
const showAllButton = query.tab === 'search' && ! query.section; const showAllButton = props.showAllButton ?? false;
function showSection( section: ProductType ) { function showSection( section: ProductType ) {
navigateTo( { navigateTo( {
@ -109,7 +105,12 @@ export default function Products( props: ProductsProps ): JSX.Element {
} }
if ( products.length === 0 ) { if ( products.length === 0 ) {
return <NoResults type={ props.type } />; const type =
props.type === ProductType.extension
? SearchResultType.extension
: SearchResultType.theme;
return <NoResults type={ type } showHeading={ false } />;
} }
const productListClass = classnames( const productListClass = classnames(
@ -127,6 +128,8 @@ export default function Products( props: ProductsProps ): JSX.Element {
products={ products } products={ products }
type={ props.type } type={ props.type }
className={ productListClass } className={ productListClass }
searchTerm={ props.searchTerm }
category={ category }
/> />
{ showAllButton && ( { showAllButton && (
<Button <Button

View File

@ -2,6 +2,8 @@
* External dependencies * External dependencies
*/ */
import { useQuery } from '@woocommerce/navigation'; import { useQuery } from '@woocommerce/navigation';
import { useContext } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
/** /**
* Internal dependencies * Internal dependencies
@ -9,6 +11,12 @@ import { useQuery } from '@woocommerce/navigation';
import './search-results.scss'; import './search-results.scss';
import { Product, ProductType, SearchResultType } from '../product-list/types'; import { Product, ProductType, SearchResultType } from '../product-list/types';
import Products from '../products/products'; import Products from '../products/products';
import NoResults from '../product-list-content/no-results';
import { MarketplaceContext } from '../../contexts/marketplace-context';
import {
MARKETPLACE_ITEMS_PER_PAGE,
MARKETPLACE_SEARCH_RESULTS_PER_PAGE,
} from '../../../marketplace/components/constants';
export interface SearchResultProps { export interface SearchResultProps {
products: Product[]; products: Product[];
@ -22,47 +30,112 @@ export default function SearchResults( props: SearchResultProps ): JSX.Element {
const themeList = props.products.filter( const themeList = props.products.filter(
( product ) => product.type === ProductType.theme ( product ) => product.type === ProductType.theme
); );
const marketplaceContextValue = useContext( MarketplaceContext );
const { isLoading } = marketplaceContextValue;
const query = useQuery(); const query = useQuery();
const showCategorySelector = query.section ? true : false; const showCategorySelector = query.section ? true : false;
const searchTerm = query.term ? query.term : '';
const extensionComponent = ( type Overrides = {
<Products categorySelector?: boolean;
products={ extensionList } showAllButton?: boolean;
type={ ProductType.extension } perPage?: number;
categorySelector={ showCategorySelector } };
/>
);
const themeComponent = ( function productsComponent(
<Products products: Product[],
products={ themeList } type: ProductType,
type={ ProductType.theme } overrides: Overrides = {}
categorySelector={ showCategorySelector } ) {
/> return (
); <Products
products={ products }
type={ type }
categorySelector={
overrides.categorySelector ?? showCategorySelector
}
searchTerm={ searchTerm }
showAllButton={ overrides.showAllButton ?? true }
perPage={ overrides.perPage ?? MARKETPLACE_ITEMS_PER_PAGE }
/>
);
}
function extensionsComponent( overrides: Overrides = {} ) {
return productsComponent(
extensionList,
ProductType.extension,
overrides
);
}
function themesComponent( overrides: Overrides = {} ) {
return productsComponent( themeList, ProductType.theme, overrides );
}
const content = () => { const content = () => {
if ( query?.section === SearchResultType.theme ) {
return themeComponent;
}
if ( query?.section === SearchResultType.extension ) { if ( query?.section === SearchResultType.extension ) {
return extensionComponent; return extensionsComponent( { showAllButton: false } );
} }
if ( extensionList.length === 0 && themeList.length > 0 ) { if ( query?.section === SearchResultType.theme ) {
return themesComponent( { showAllButton: false } );
}
// Components can handle their isLoading state. So we can put them both on the page.
if ( isLoading ) {
return ( return (
<> <>
{ themeComponent } { extensionsComponent() }
{ extensionComponent } { themesComponent() }
</> </>
); );
} }
// If we did finish loading items, and there are no results, show the no results component.
if (
! isLoading &&
extensionList.length === 0 &&
themeList.length === 0
) {
return (
<NoResults
type={ SearchResultType.all }
showHeading={ true }
heading={ __(
'No extensions or themes found…',
'woocommerce'
) }
/>
);
}
if ( themeList.length === 0 && extensionList.length > 0 ) {
return extensionsComponent( {
categorySelector: true,
showAllButton: false,
perPage: MARKETPLACE_ITEMS_PER_PAGE,
} );
}
if ( extensionList.length === 0 && themeList.length > 0 ) {
return themesComponent( {
categorySelector: true,
showAllButton: false,
perPage: MARKETPLACE_ITEMS_PER_PAGE,
} );
}
// If we're done loading, we can put these components on the page.
return ( return (
<> <>
{ extensionComponent } { extensionsComponent( {
{ themeComponent } perPage: MARKETPLACE_SEARCH_RESULTS_PER_PAGE,
} ) }
{ themesComponent( {
perPage: MARKETPLACE_SEARCH_RESULTS_PER_PAGE,
} ) }
</> </>
); );
}; };

View File

@ -48,14 +48,7 @@ const tabs: Tabs = {
}, },
'my-subscriptions': { 'my-subscriptions': {
name: 'my-subscriptions', name: 'my-subscriptions',
title: __( 'My Subscriptions', 'woocommerce' ), title: __( 'My subscriptions', 'woocommerce' ),
href: getNewPath(
{
page: 'wc-addons',
section: 'helper',
},
''
),
}, },
}; };

View File

@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
/**
* Internal dependencies
*/
import { InstallingState } from './types';
const INSTALLING_STORE_NAME = 'woocommerce-admin/installing';
export interface InstallingStateErrorAction {
label: string;
onClick: () => void;
}
const DEFAULT_STATE: InstallingState = {
installingProducts: [],
};
const store = createReduxStore( INSTALLING_STORE_NAME, {
reducer( state: InstallingState | undefined = DEFAULT_STATE, action ) {
switch ( action.type ) {
case 'START_INSTALLING':
return {
...state,
installingProducts: [
...state.installingProducts,
action.productKey,
],
};
case 'STOP_INSTALLING':
return {
...state,
installingProducts: [
...state.installingProducts.filter(
( productKey ) => productKey !== action.productKey
),
],
};
}
return state;
},
actions: {
startInstalling( productKey: string ) {
return {
type: 'START_INSTALLING',
productKey,
};
},
stopInstalling( productKey: string ) {
return {
type: 'STOP_INSTALLING',
productKey,
};
},
},
selectors: {
isInstalling(
state: InstallingState | undefined,
productKey: string
): boolean {
if ( ! state ) {
return false;
}
return state.installingProducts.includes( productKey );
},
},
} );
register( store );
export { store as installingStore, INSTALLING_STORE_NAME };

View File

@ -0,0 +1,90 @@
/**
* External dependencies
*/
import { createReduxStore, register } from '@wordpress/data';
import { Options } from '@wordpress/notices';
/**
* Internal dependencies
*/
import { NoticeState, Notice, NoticeStatus } from './types';
const NOTICE_STORE_NAME = 'woocommerce-admin/subscription-notices';
const DEFAULT_STATE: NoticeState = {
notices: {},
};
const store = createReduxStore( NOTICE_STORE_NAME, {
reducer( state: NoticeState | undefined = DEFAULT_STATE, action ) {
switch ( action.type ) {
case 'ADD_NOTICE':
return {
...state,
notices: {
...state.notices,
[ action.productKey ]: {
productKey: action.productKey,
message: action.message,
status: action.status,
options: action.options,
},
},
};
case 'REMOVE_NOTICE':
const notices = { ...state.notices };
if ( notices[ action.productKey ] ) {
delete notices[ action.productKey ];
}
return {
...state,
notices,
};
}
return state;
},
actions: {
addNotice(
productKey: string,
message: string,
status: NoticeStatus,
options?: Partial< Options >
) {
return {
type: 'ADD_NOTICE',
productKey,
message,
status,
options,
};
},
removeNotice( productKey: string ) {
return {
type: 'REMOVE_NOTICE',
productKey,
};
},
},
selectors: {
notices( state: NoticeState | undefined ): Notice[] {
if ( ! state ) {
return [];
}
return Object.values( state.notices );
},
getNotice(
state: NoticeState | undefined,
productKey: string
): Notice | undefined {
if ( ! state ) {
return;
}
return state.notices[ productKey ];
},
},
} );
register( store );
export { store as noticeStore, NOTICE_STORE_NAME };

View File

@ -0,0 +1,75 @@
/**
* External dependencies
*/
import { useState, createContext, useEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
import { SubscriptionsContextType } from './types';
import { Subscription } from '../components/my-subscriptions/types';
import {
fetchSubscriptions,
refreshSubscriptions as fetchSubscriptionsFromWooCom,
} from '../utils/functions';
export const SubscriptionsContext = createContext< SubscriptionsContextType >( {
subscriptions: [],
setSubscriptions: () => {},
loadSubscriptions: () => new Promise( () => {} ),
refreshSubscriptions: () => new Promise( () => {} ),
isLoading: true,
setIsLoading: () => {},
} );
export function SubscriptionsContextProvider( props: {
children: JSX.Element;
} ): JSX.Element {
const [ subscriptions, setSubscriptions ] = useState<
Array< Subscription >
>( [] );
const [ isLoading, setIsLoading ] = useState( true );
const loadSubscriptions = ( toggleLoading?: boolean ) => {
if ( toggleLoading === true ) {
setIsLoading( true );
}
return fetchSubscriptions()
.then( ( subscriptionResponse ) => {
setSubscriptions( subscriptionResponse );
} )
.finally( () => {
if ( toggleLoading ) {
setIsLoading( false );
}
} );
};
const refreshSubscriptions = () => {
return fetchSubscriptionsFromWooCom().then(
( subscriptionResponse ) => {
setSubscriptions( subscriptionResponse );
}
);
};
useEffect( () => {
loadSubscriptions( true );
}, [] );
const contextValue = {
subscriptions,
setSubscriptions,
loadSubscriptions,
refreshSubscriptions,
isLoading,
setIsLoading,
};
return (
<SubscriptionsContext.Provider value={ contextValue }>
{ props.children }
</SubscriptionsContext.Provider>
);
}

View File

@ -1,6 +1,47 @@
/**
* External dependencies
*/
import { Options } from '@wordpress/notices';
/**
* Internal dependencies
*/
import { Subscription } from '../components/my-subscriptions/types';
export type MarketplaceContextType = { export type MarketplaceContextType = {
isLoading: boolean; isLoading: boolean;
setIsLoading: ( isLoading: boolean ) => void; setIsLoading: ( isLoading: boolean ) => void;
selectedTab: string; selectedTab: string;
setSelectedTab: ( tab: string ) => void; setSelectedTab: ( tab: string ) => void;
}; };
export type SubscriptionsContextType = {
subscriptions: Subscription[];
setSubscriptions: ( subscriptions: Subscription[] ) => void;
loadSubscriptions: ( toggleLoading?: boolean ) => Promise< void >;
refreshSubscriptions: () => Promise< void >;
isLoading: boolean;
setIsLoading: ( isLoading: boolean ) => void;
};
export enum NoticeStatus {
Success = 'success',
Error = 'error',
}
export interface Notice {
productKey: string;
message: string;
status: NoticeStatus;
options?: Partial< Options > | undefined;
}
export interface NoticeState {
notices: {
[ key: string ]: Notice;
};
}
export interface InstallingState {
installingProducts: string[];
}

View File

@ -1,22 +1,42 @@
/**
* External dependencies
*/
import { useContext } from '@wordpress/element';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import './marketplace.scss'; import './marketplace.scss';
import { MarketplaceContextProvider } from './contexts/marketplace-context'; import {
MarketplaceContextProvider,
MarketplaceContext,
} from './contexts/marketplace-context';
import Header from './components/header/header'; import Header from './components/header/header';
import Content from './components/content/content'; import Content from './components/content/content';
import Footer from './components/footer/footer'; import Footer from './components/footer/footer';
import FeedbackModal from './components/feedback-modal/feedback-modal'; import FeedbackModal from './components/feedback-modal/feedback-modal';
function MarketplaceComponents() {
const { selectedTab } = useContext( MarketplaceContext );
const classNames =
'woocommerce-marketplace' +
( selectedTab ? ' woocommerce-marketplace--' + selectedTab : '' );
return (
<div className={ classNames }>
<Header />
<Content />
<FeedbackModal />
<Footer />
</div>
);
}
export default function Marketplace() { export default function Marketplace() {
return ( return (
<MarketplaceContextProvider> <MarketplaceContextProvider>
<div className="woocommerce-marketplace"> <MarketplaceComponents />
<Header />
<Content />
<FeedbackModal />
<Footer />
</div>
</MarketplaceContextProvider> </MarketplaceContextProvider>
); );
} }

View File

@ -2,23 +2,31 @@
* External dependencies * External dependencies
*/ */
import apiFetch from '@wordpress/api-fetch'; import apiFetch from '@wordpress/api-fetch';
import { __, sprintf } from '@wordpress/i18n';
import { dispatch } from '@wordpress/data';
import { Options } from '@wordpress/notices';
import { Icon } from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { LOCALE } from '../../utils/admin-settings';
import { CategoryAPIItem } from '../components/category-selector/types';
import {
MARKETPLACE_CART_PATH,
MARKETPLACE_CATEGORY_API_PATH,
MARKETPLACE_HOST,
MARKETPLACE_SEARCH_API_PATH,
} from '../components/constants';
import { Subscription } from '../components/my-subscriptions/types';
import { import {
Product, Product,
ProductType, ProductType,
SearchAPIProductType,
SearchAPIJSONType, SearchAPIJSONType,
SearchAPIProductType,
} from '../components/product-list/types'; } from '../components/product-list/types';
import { import { NoticeStatus } from '../contexts/types';
MARKETPLACE_HOST, import { noticeStore } from '../contexts/notice-store';
MARKETPLACE_CATEGORY_API_PATH,
MARKETPLACE_SEARCH_API_PATH,
} from '../components/constants';
import { CategoryAPIItem } from '../components/category-selector/types';
import { LOCALE } from '../../utils/admin-settings';
interface ProductGroup { interface ProductGroup {
id: string; id: string;
@ -179,6 +187,183 @@ function fetchCategories( type: ProductType ): Promise< CategoryAPIItem[] > {
} ); } );
} }
async function fetchSubscriptions(): Promise< Array< Subscription > > {
const url = '/wc/v3/marketplace/subscriptions';
return await apiFetch( { path: url.toString() } );
}
async function refreshSubscriptions(): Promise< Array< Subscription > > {
const url = '/wc/v3/marketplace/refresh';
return await apiFetch( {
path: url.toString(),
method: 'POST',
} );
}
function connectProduct( subscription: Subscription ): Promise< void > {
if ( subscription.active === true ) {
return Promise.resolve();
}
const url = '/wc/v3/marketplace/subscriptions/connect';
const data = new URLSearchParams();
data.append( 'product_key', subscription.product_key );
return apiFetch( {
path: url.toString(),
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
} );
}
function disconnectProduct( subscription: Subscription ): Promise< void > {
if ( subscription.active === false ) {
return Promise.resolve();
}
const url = '/wc/v3/marketplace/subscriptions/disconnect';
const data = new URLSearchParams();
data.append( 'product_key', subscription.product_key );
return apiFetch( {
path: url.toString(),
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
} );
}
function wpAjax(
action: string,
data: {
slug?: string;
plugin?: string;
theme?: string;
success?: boolean;
}
): Promise< void > {
return new Promise( ( resolve, reject ) => {
if ( ! window.wp.updates ) {
reject( __( 'Please reload and try again', 'woocommerce' ) );
return;
}
window.wp.updates.ajax( action, {
...data,
success: ( response: {
success?: boolean;
errorMessage?: string;
} ) => {
if ( response.success === false ) {
reject( {
success: false,
data: {
message: response.errorMessage,
},
} );
}
resolve();
},
error: ( error: { errorMessage: string } ) => {
reject( {
success: false,
data: {
message: error.errorMessage,
},
} );
},
} );
} );
}
function activateProduct( subscription: Subscription ): Promise< void > {
if ( subscription.local.active === true ) {
return Promise.resolve();
}
const url = '/wc/v3/marketplace/subscriptions/activate';
const data = new URLSearchParams();
data.append( 'product_key', subscription.product_key );
return apiFetch( {
path: url.toString(),
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: data,
} )
.then( () => Promise.resolve() )
.catch( () =>
Promise.reject( {
success: false,
data: {
message: sprintf(
// translators: %s is the product name.
__(
'%s could not be activated. Please activate it manually.',
'woocommerce'
),
subscription.product_name
),
},
} )
);
}
function installProduct( subscription: Subscription ): Promise< void > {
return connectProduct( subscription ).then( () => {
return wpAjax( 'install-' + subscription.product_type, {
// The slug prefix is required for the install to use WCCOM install filters.
slug: 'woocommerce-com-' + subscription.product_slug,
} )
.then( () => {
return activateProduct( subscription );
} )
.catch( ( error ) => {
// If install fails disconnect the product
return disconnectProduct( subscription ).finally( () =>
Promise.reject( error )
);
} );
} );
}
function updateProduct( subscription: Subscription ): Promise< void > {
return wpAjax( 'update-' + subscription.product_type, {
slug: subscription.local.slug,
[ subscription.product_type ]: subscription.local.path,
} );
}
function addNotice(
productKey: string,
message: string,
status?: NoticeStatus,
options?: Partial< Options >
) {
if ( status === NoticeStatus.Error ) {
dispatch( noticeStore ).addNotice(
productKey,
message,
status,
options
);
} else {
if ( ! options?.icon ) {
options = {
...options,
icon: <Icon icon="saved" />,
};
}
dispatch( 'core/notices' ).createSuccessNotice( message, options );
}
}
const removeNotice = ( productKey: string ) => {
dispatch( noticeStore ).removeNotice( productKey );
};
// Append UTM parameters to a URL, being aware of existing query parameters // Append UTM parameters to a URL, being aware of existing query parameters
const appendURLParams = ( const appendURLParams = (
url: string, url: string,
@ -198,10 +383,33 @@ const appendURLParams = (
return urlObject.toString(); return urlObject.toString();
}; };
const renewUrl = ( subscription: Subscription ): string => {
return appendURLParams( MARKETPLACE_CART_PATH, [
[ 'renew_product', subscription.product_id.toString() ],
[ 'product_key', subscription.product_key ],
[ 'order_id', subscription.order_id.toString() ],
] );
};
const subscribeUrl = ( subscription: Subscription ): string => {
return appendURLParams( MARKETPLACE_CART_PATH, [
[ 'add-to-cart', subscription.product_id.toString() ],
] );
};
export { export {
fetchSearchResults,
fetchDiscoverPageData,
fetchCategories,
ProductGroup, ProductGroup,
appendURLParams, appendURLParams,
connectProduct,
fetchCategories,
fetchDiscoverPageData,
fetchSearchResults,
fetchSubscriptions,
refreshSubscriptions,
installProduct,
updateProduct,
addNotice,
removeNotice,
renewUrl,
subscribeUrl,
}; };

View File

@ -37,6 +37,15 @@ declare global {
'shipping-setting-tour': boolean; 'shipping-setting-tour': boolean;
}; };
wp: { wp: {
updates?: {
ajax: ( action, data: {
slug?: string;
plugin?: string;
theme?: string;
success?: function;
error?: function;
} ) => JQuery.Promise;
};
autosave?: { autosave?: {
server: { server: {
postChanged: () => boolean; postChanged: () => boolean;

View File

@ -0,0 +1,4 @@
Significance: major
Type: update
The WooCommerce > Extensions section now includes a rebuilt "My Subscriptions" page with improvements to how subscriptions can be managed and installed.

View File

@ -58,32 +58,30 @@ class WC_Helper_Admin {
* @return string * @return string
*/ */
public static function get_connection_url() { public static function get_connection_url() {
// No active connection. global $current_screen;
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_args = array(
} 'page' => 'wc-addons',
'section' => 'helper',
$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; // No active connection.
if ( WC_Helper::is_site_connected() ) {
$connect_url_args['wc-helper-disconnect'] = 1;
$connect_url_args['wc-helper-nonce'] = wp_create_nonce( 'disconnect' );
} else {
$connect_url_args['wc-helper-connect'] = 1;
$connect_url_args['wc-helper-nonce'] = wp_create_nonce( 'connect' );
}
if ( isset( $current_screen->id ) && 'woocommerce_page_wc-admin' === $current_screen->id ) {
$connect_url_args['redirect-to-wc-admin'] = 1;
}
return add_query_arg(
$connect_url_args,
admin_url( 'admin.php' )
);
} }
/** /**
@ -112,7 +110,7 @@ class WC_Helper_Admin {
} }
/** /**
* Fetch featured procucts from Woo.com and serve them * Fetch featured products from Woo.com and serve them
* as JSON. * as JSON.
*/ */
public static function get_featured() { public static function get_featured() {
@ -124,7 +122,6 @@ class WC_Helper_Admin {
wp_send_json( $featured ); wp_send_json( $featured );
} }
} }
WC_Helper_Admin::load(); WC_Helper_Admin::load();

View File

@ -22,6 +22,7 @@ class WC_Helper_Plugin_Info {
*/ */
public static function load() { public static function load() {
add_filter( 'plugins_api', array( __CLASS__, 'plugins_api' ), 20, 3 ); add_filter( 'plugins_api', array( __CLASS__, 'plugins_api' ), 20, 3 );
add_filter( 'themes_api', array( __CLASS__, 'themes_api' ), 20, 3 );
} }
/** /**
@ -37,7 +38,31 @@ class WC_Helper_Plugin_Info {
if ( 'plugin_information' !== $action ) { if ( 'plugin_information' !== $action ) {
return $response; return $response;
} }
return self::maybe_override_products_api( $response, $action, $args );
}
/**
* Theme information callback for Woo themes.
*
* @param object $response The response core needs to display the modal.
* @param string $action The requested themes_api() action.
* @param object $args Arguments passed to themes_api().
*/
public static function themes_api( $response, $action, $args ) {
if ( 'theme_information' !== $action ) {
return $response;
}
return self::maybe_override_products_api( $response, $action, $args );
}
/**
* Override the products API to fetch data from the Helper API if it's a Woo product.
*
* @param object $response The response core needs to display the modal.
* @param string $action The requested action.
* @param object $args Arguments passed to the API.
*/
public static function maybe_override_products_api( $response, $action, $args ) {
if ( empty( $args->slug ) ) { if ( empty( $args->slug ) ) {
return $response; return $response;
} }
@ -74,6 +99,11 @@ class WC_Helper_Plugin_Info {
$results = json_decode( wp_remote_retrieve_body( $request ), true ); $results = json_decode( wp_remote_retrieve_body( $request ), true );
if ( ! empty( $results ) ) { if ( ! empty( $results ) ) {
$response = (object) $results; $response = (object) $results;
$product = array_shift( $products );
if ( isset( $product['package'] ) ) {
$response->download_link = $product['package'];
}
} }
return $response; return $response;

View File

@ -0,0 +1,262 @@
<?php
/**
* WooCommerce Admin Helper - React admin interface
*
* @package WooCommerce\Admin\Helper
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
/**
* WC_Helper_Subscriptions_API
*
* The main entry-point for all things related to the Marketplace Subscriptions API.
* The Subscriptions API manages WooCommerce.com Subscriptions.
*/
class WC_Helper_Subscriptions_API {
/**
* Loads the class, runs on init
*
* @return void
*/
public static function load() {
add_filter( 'rest_api_init', array( __CLASS__, 'register_rest_routes' ) );
}
/**
* Registers the REST routes for the Marketplace Subscriptions API.
* These endpoints are used by the Marketplace Subscriptions React UI.
*/
public static function register_rest_routes() {
register_rest_route(
'wc/v3',
'/marketplace/refresh',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'refresh' ),
'permission_callback' => array( __CLASS__, 'get_permission' ),
)
);
register_rest_route(
'wc/v3',
'/marketplace/subscriptions',
array(
'methods' => 'GET',
'callback' => array( __CLASS__, 'get_subscriptions' ),
'permission_callback' => array( __CLASS__, 'get_permission' ),
)
);
register_rest_route(
'wc/v3',
'/marketplace/subscriptions/connect',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'connect' ),
'permission_callback' => array( __CLASS__, 'get_permission' ),
'args' => array(
'product_key' => array(
'required' => true,
'type' => 'string',
),
),
)
);
register_rest_route(
'wc/v3',
'/marketplace/subscriptions/disconnect',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'disconnect' ),
'permission_callback' => array( __CLASS__, 'get_permission' ),
'args' => array(
'product_key' => array(
'required' => true,
'type' => 'string',
),
),
)
);
register_rest_route(
'wc/v3',
'/marketplace/subscriptions/activate',
array(
'methods' => 'POST',
'callback' => array( __CLASS__, 'activate' ),
'permission_callback' => array( __CLASS__, 'get_permission' ),
'args' => array(
'product_key' => array(
'required' => true,
'type' => 'string',
),
),
)
);
}
/**
* 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 subscriptions from WooCommerce.com and serve them
* as JSON.
*/
public static function get_subscriptions() {
$subscriptions = WC_Helper::get_subscription_list_data();
wp_send_json(
array_values(
$subscriptions
)
);
}
/**
* Refresh account and subscriptions from WooCommerce.com and serve subscriptions
* as JSON.
*/
public static function refresh() {
WC_Helper::refresh_helper_subscriptions();
self::get_subscriptions();
}
/**
* Connect a WooCommerce.com subscription.
*
* @param WP_REST_Request $request Request object.
*/
public static function connect( $request ) {
$product_key = $request->get_param( 'product_key' );
try {
$success = WC_Helper::activate_helper_subscription( $product_key );
} catch ( Exception $e ) {
wp_send_json_error(
array(
'message' => $e->getMessage(),
),
400
);
}
if ( $success ) {
wp_send_json_success(
array(
'message' => __( 'Your subscription has been connected.', 'woocommerce' ),
)
);
} else {
wp_send_json_error(
array(
'message' => __( 'There was an error connecting your subscription. Please try again.', 'woocommerce' ),
),
400
);
}
}
/**
* Disconnect a WooCommerce.com subscription.
*
* @param WP_REST_Request $request Request object.
*/
public static function disconnect( $request ) {
$product_key = $request->get_param( 'product_key' );
try {
$success = WC_Helper::deactivate_helper_subscription( $product_key );
} catch ( Exception $e ) {
wp_send_json_error(
array(
'message' => $e->getMessage(),
),
400
);
}
if ( $success ) {
wp_send_json_success(
array(
'message' => __( 'Your subscription has been disconnected.', 'woocommerce' ),
)
);
} else {
wp_send_json_error(
array(
'message' => __( 'There was an error disconnecting your subscription. Please try again.', 'woocommerce' ),
),
400
);
}
}
/**
* Activate a WooCommerce.com product.
* This activates the plugin/theme on the site.
*
* @param WP_REST_Request $request Request object.
*/
public static function activate( $request ) {
$product_key = $request->get_param( 'product_key' );
$subscription = WC_Helper::get_subscription( $product_key );
if ( ! $subscription ) {
wp_send_json_error(
array(
'message' => __( 'We couldn\'t find a subscription for this product.', 'woocommerce' ),
),
400
);
}
if ( true !== $subscription['local']['installed'] || ! isset( $subscription['local']['active'] ) ) {
wp_send_json_error(
array(
'message' => __( 'This product is not installed.', 'woocommerce' ),
),
400
);
}
if ( true === $subscription['local']['active'] ) {
wp_send_json_success(
array(
'message' => __( 'This product is already active.', 'woocommerce' ),
),
);
}
if ( 'plugin' === $subscription['product_type'] ) {
$success = activate_plugin( $subscription['local']['path'] );
if ( is_wp_error( $success ) ) {
wp_send_json_error(
array(
'message' => __( 'There was an error activating this plugin.', 'woocommerce' ),
),
400
);
}
} elseif ( 'theme' === $subscription['product_type'] ) {
switch_theme( $subscription['local']['slug'] );
$theme = wp_get_theme();
if ( $subscription['local']['slug'] !== $theme->get_stylesheet() ) {
wp_send_json_error(
array(
'message' => __( 'There was an error activating this theme.', 'woocommerce' ),
),
400
);
}
}
wp_send_json_success(
array(
'message' => __( 'This product has been activated.', 'woocommerce' ),
),
);
}
}
WC_Helper_Subscriptions_API::load();

View File

@ -59,6 +59,7 @@ class WC_Helper {
include_once dirname( __FILE__ ) . '/class-wc-helper-plugin-info.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-compat.php';
include_once dirname( __FILE__ ) . '/class-wc-helper-admin.php'; include_once dirname( __FILE__ ) . '/class-wc-helper-admin.php';
include_once dirname( __FILE__ ) . '/class-wc-helper-subscriptions-api.php';
} }
/** /**
@ -112,14 +113,17 @@ class WC_Helper {
$woo_plugins = self::get_local_woo_plugins(); $woo_plugins = self::get_local_woo_plugins();
$woo_themes = self::get_local_woo_themes(); $woo_themes = self::get_local_woo_themes();
$site_id = absint( $auth['site_id'] ); $subscriptions_list_data = self::get_subscription_list_data();
$subscriptions = self::get_subscriptions(); $subscriptions = array_filter(
$subscriptions_list_data,
function( $subscription ) {
return ! empty( $subscription['product_key'] );
}
);
$updates = WC_Helper_Updater::get_update_data(); $updates = WC_Helper_Updater::get_update_data();
$subscriptions_product_ids = wp_list_pluck( $subscriptions, 'product_id' ); $subscriptions_product_ids = wp_list_pluck( $subscriptions, 'product_id' );
foreach ( $subscriptions as &$subscription ) { foreach ( $subscriptions as &$subscription ) {
$subscription['active'] = in_array( $site_id, $subscription['connections'] );
$subscription['activate_url'] = add_query_arg( $subscription['activate_url'] = add_query_arg(
array( array(
'page' => 'wc-addons', 'page' => 'wc-addons',
@ -146,46 +150,22 @@ class WC_Helper {
admin_url( 'admin.php' ) admin_url( 'admin.php' )
); );
$subscription['local'] = array(
'installed' => false,
'active' => false,
'version' => null,
);
$subscription['update_url'] = admin_url( 'update-core.php' ); $subscription['update_url'] = admin_url( 'update-core.php' );
$local = wp_list_filter( array_merge( $woo_plugins, $woo_themes ), array( '_product_id' => $subscription['product_id'] ) ); $local = wp_list_filter( array_merge( $woo_plugins, $woo_themes ), array( '_product_id' => $subscription['product_id'] ) );
if ( ! empty( $local ) ) { if ( ! empty( $local ) ) {
$local = array_shift( $local ); $local = array_shift( $local );
$subscription['local']['installed'] = true; if ( 'plugin' === $local['_type'] ) {
$subscription['local']['version'] = $local['Version'];
if ( 'plugin' == $local['_type'] ) {
if ( is_plugin_active( $local['_filename'] ) ) {
$subscription['local']['active'] = true;
} elseif ( is_multisite() && is_plugin_active_for_network( $local['_filename'] ) ) {
$subscription['local']['active'] = true;
}
// A magic update_url. // A magic update_url.
$subscription['update_url'] = wp_nonce_url( self_admin_url( 'update.php?action=upgrade-plugin&plugin=' ) . $local['_filename'], 'upgrade-plugin_' . $local['_filename'] ); $subscription['update_url'] = wp_nonce_url( self_admin_url( 'update.php?action=upgrade-plugin&plugin=' ) . $local['_filename'], 'upgrade-plugin_' . $local['_filename'] );
} elseif ( 'theme' == $local['_type'] ) { } elseif ( 'theme' === $local['_type'] ) {
if ( in_array( $local['_stylesheet'], array( get_stylesheet(), get_template() ) ) ) {
$subscription['local']['active'] = true;
}
// Another magic update_url. // Another magic update_url.
$subscription['update_url'] = wp_nonce_url( self_admin_url( 'update.php?action=upgrade-theme&theme=' . $local['_stylesheet'] ), 'upgrade-theme_' . $local['_stylesheet'] ); $subscription['update_url'] = wp_nonce_url( self_admin_url( 'update.php?action=upgrade-theme&theme=' . $local['_stylesheet'] ), 'upgrade-theme_' . $local['_stylesheet'] );
} }
} }
$subscription['has_update'] = false;
if ( $subscription['local']['installed'] && ! empty( $updates[ $subscription['product_id'] ] ) ) {
$subscription['has_update'] = version_compare( $updates[ $subscription['product_id'] ]['version'], $subscription['local']['version'], '>' );
}
$subscription['download_primary'] = true; $subscription['download_primary'] = true;
$subscription['download_url'] = 'https://woo.com/my-account/downloads/'; $subscription['download_url'] = 'https://woo.com/my-account/downloads/';
if ( ! $subscription['local']['installed'] && ! empty( $updates[ $subscription['product_id'] ] ) ) { if ( ! $subscription['local']['installed'] && ! empty( $updates[ $subscription['product_id'] ] ) ) {
@ -693,7 +673,7 @@ class WC_Helper {
} }
if ( ! empty( $_GET['wc-helper-deactivate'] ) ) { if ( ! empty( $_GET['wc-helper-deactivate'] ) ) {
return self::_helper_subscription_deactivate(); return self::helper_subscription_deactivate();
} }
if ( ! empty( $_GET['wc-helper-deactivate-plugin'] ) ) { if ( ! empty( $_GET['wc-helper-deactivate-plugin'] ) ) {
@ -701,6 +681,32 @@ class WC_Helper {
} }
} }
/**
* Get helper redirect URL.
*
* @param array $args Query args.
* @param bool $redirect_to_wc_admin Whether to redirect to WC Admin.
* @return string
*/
private static function get_helper_redirect_url( $args = array(), $redirect_to_wc_admin = false ) {
global $current_screen;
if ( true === $redirect_to_wc_admin && 'woocommerce_page_wc-addons' === $current_screen->id ) {
return add_query_arg(
array(
'page' => 'wc-admin',
'tab' => 'my-subscriptions',
'path' => rawurlencode( '/extensions' ),
),
admin_url( 'admin.php' )
);
}
return add_query_arg(
$args,
admin_url( 'admin.php' )
);
}
/** /**
* Initiate a new OAuth connection. * Initiate a new OAuth connection.
*/ */
@ -710,13 +716,19 @@ class WC_Helper {
wp_die( 'Could not verify nonce' ); wp_die( 'Could not verify nonce' );
} }
$redirect_url_args = array(
'page' => 'wc-addons',
'section' => 'helper',
'wc-helper-return' => 1,
'wc-helper-nonce' => wp_create_nonce( 'connect' ),
);
if ( isset( $_GET['redirect-to-wc-admin'] ) ) {
$redirect_url_args['redirect-to-wc-admin'] = 1;
}
$redirect_uri = add_query_arg( $redirect_uri = add_query_arg(
array( $redirect_url_args,
'page' => 'wc-addons',
'section' => 'helper',
'wc-helper-return' => 1,
'wc-helper-nonce' => wp_create_nonce( 'connect' ),
),
admin_url( 'admin.php' ) admin_url( 'admin.php' )
); );
@ -776,7 +788,16 @@ class WC_Helper {
* Fires when the Helper connection process is denied/cancelled. * Fires when the Helper connection process is denied/cancelled.
*/ */
do_action( 'woocommerce_helper_denied' ); do_action( 'woocommerce_helper_denied' );
wp_safe_redirect( admin_url( 'admin.php?page=wc-addons&section=helper' ) );
wp_safe_redirect(
self::get_helper_redirect_url(
array(
'page' => 'wc-addons',
'section' => 'helper',
),
isset( $_GET['redirect-to-wc-admin'] )
)
);
die(); die();
} }
@ -831,13 +852,13 @@ class WC_Helper {
} }
wp_safe_redirect( wp_safe_redirect(
add_query_arg( self::get_helper_redirect_url(
array( array(
'page' => 'wc-addons', 'page' => 'wc-addons',
'section' => 'helper', 'section' => 'helper',
'wc-helper-status' => 'helper-connected', 'wc-helper-status' => 'helper-connected',
), ),
admin_url( 'admin.php' ) isset( $_GET['redirect-to-wc-admin'] )
) )
); );
die(); die();
@ -857,13 +878,13 @@ class WC_Helper {
*/ */
do_action( 'woocommerce_helper_disconnected' ); do_action( 'woocommerce_helper_disconnected' );
$redirect_uri = add_query_arg( $redirect_uri = self::get_helper_redirect_url(
array( array(
'page' => 'wc-addons', 'page' => 'wc-addons',
'section' => 'helper', 'section' => 'helper',
'wc-helper-status' => 'helper-disconnected', 'wc-helper-status' => 'helper-disconnected',
), ),
admin_url( 'admin.php' ) isset( $_GET['redirect-to-wc-admin'] )
); );
self::disconnect(); self::disconnect();
@ -881,27 +902,36 @@ class WC_Helper {
wp_die( 'Could not verify nonce' ); wp_die( 'Could not verify nonce' );
} }
/** self::refresh_helper_subscriptions();
* Fires when Helper subscriptions are refreshed.
*/
do_action( 'woocommerce_helper_subscriptions_refresh' );
$redirect_uri = add_query_arg( $redirect_uri = self::get_helper_redirect_url(
array( array(
'page' => 'wc-addons', 'page' => 'wc-addons',
'section' => 'helper', 'section' => 'helper',
'filter' => self::get_current_filter(), 'filter' => self::get_current_filter(),
'wc-helper-status' => 'helper-refreshed', 'wc-helper-status' => 'helper-refreshed',
), ),
admin_url( 'admin.php' ) isset( $_GET['redirect-to-wc-admin'] )
); );
wp_safe_redirect( $redirect_uri );
die();
}
/**
* Flush helper authentication cache.
*/
public static function refresh_helper_subscriptions() {
/**
* Fires when Helper subscriptions are refreshed.
*
* @since 8.3.0
*/
do_action( 'woocommerce_helper_subscriptions_refresh' );
self::_flush_authentication_cache(); self::_flush_authentication_cache();
self::_flush_subscriptions_cache(); self::_flush_subscriptions_cache();
self::_flush_updates_cache(); self::_flush_updates_cache();
wp_safe_redirect( $redirect_uri );
die();
} }
/** /**
@ -916,6 +946,41 @@ class WC_Helper {
wp_die( 'Could not verify nonce' ); wp_die( 'Could not verify nonce' );
} }
try {
$activated = self::activate_helper_subscription( $product_key, $product_id );
} catch ( Exception $e ) {
$activated = false;
}
$redirect_uri = add_query_arg(
array(
'page' => 'wc-addons',
'section' => 'helper',
'filter' => self::get_current_filter(),
'wc-helper-status' => $activated ? 'activate-success' : 'activate-error',
'wc-helper-product-id' => $product_id,
),
admin_url( 'admin.php' )
);
wp_safe_redirect( $redirect_uri );
die();
}
/**
* Activate helper subscription.
*
* @throws Exception If the subscription could not be activated or found.
* @param string $product_key Subscription product key.
* @return bool True if activated, false otherwise.
*/
public static function activate_helper_subscription( $product_key ) {
$subscription = self::get_subscription( $product_key );
if ( ! $subscription ) {
throw new Exception( __( 'Subscription not found', 'woocommerce' ) );
}
$product_id = $subscription['product_id'];
// Activate subscription. // Activate subscription.
$activation_response = WC_Helper_API::post( $activation_response = WC_Helper_API::post(
'activate', 'activate',
@ -954,6 +1019,7 @@ class WC_Helper {
* @param array $activation_response The response object from wp_safe_remote_request(). * @param array $activation_response The response object from wp_safe_remote_request().
*/ */
do_action( 'woocommerce_helper_subscription_activate_error', $product_id, $product_key, $activation_response ); do_action( 'woocommerce_helper_subscription_activate_error', $product_id, $product_key, $activation_response );
throw new Exception( $body['message'] ?? __( 'Unknown error', 'woocommerce' ) );
} }
// Attempt to activate this plugin. // Attempt to activate this plugin.
@ -965,12 +1031,33 @@ class WC_Helper {
self::_flush_subscriptions_cache(); self::_flush_subscriptions_cache();
self::_flush_updates_cache(); self::_flush_updates_cache();
return $activated;
}
/**
* Deactivate a product subscription.
*/
private static function helper_subscription_deactivate() {
$product_key = isset( $_GET['wc-helper-product-key'] ) ? wc_clean( wp_unslash( $_GET['wc-helper-product-key'] ) ) : '';
$product_id = isset( $_GET['wc-helper-product-id'] ) ? absint( $_GET['wc-helper-product-id'] ) : 0;
if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( wp_unslash( $_GET['wc-helper-nonce'] ), 'deactivate:' . $product_key ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
self::log( 'Could not verify nonce in helper_subscription_deactivate' );
wp_die( 'Could not verify nonce' );
}
try {
$deactivated = self::deactivate_helper_subscription( $product_key );
} catch ( Exception $e ) {
$deactivated = false;
}
$redirect_uri = add_query_arg( $redirect_uri = add_query_arg(
array( array(
'page' => 'wc-addons', 'page' => 'wc-addons',
'section' => 'helper', 'section' => 'helper',
'filter' => self::get_current_filter(), 'filter' => self::get_current_filter(),
'wc-helper-status' => $activated ? 'activate-success' : 'activate-error', 'wc-helper-status' => $deactivated ? 'deactivate-success' : 'deactivate-error',
'wc-helper-product-id' => $product_id, 'wc-helper-product-id' => $product_id,
), ),
admin_url( 'admin.php' ) admin_url( 'admin.php' )
@ -982,15 +1069,17 @@ class WC_Helper {
/** /**
* Deactivate a product subscription. * Deactivate a product subscription.
*
* @throws Exception If the subscription could not be deactivated or found.
* @param string $product_key Subscription product key.
* @return bool True if deactivated, false otherwise.
*/ */
private static function _helper_subscription_deactivate() { public static function deactivate_helper_subscription( $product_key ) {
$product_key = isset( $_GET['wc-helper-product-key'] ) ? wc_clean( wp_unslash( $_GET['wc-helper-product-key'] ) ) : ''; $subscription = self::get_subscription( $product_key );
$product_id = isset( $_GET['wc-helper-product-id'] ) ? absint( $_GET['wc-helper-product-id'] ) : 0; if ( ! $subscription ) {
throw new Exception( __( 'Subscription not found', 'woocommerce' ) );
if ( empty( $_GET['wc-helper-nonce'] ) || ! wp_verify_nonce( wp_unslash( $_GET['wc-helper-nonce'] ), 'deactivate:' . $product_key ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
self::log( 'Could not verify nonce in _helper_subscription_deactivate' );
wp_die( 'Could not verify nonce' );
} }
$product_id = $subscription['product_id'];
$deactivation_response = WC_Helper_API::post( $deactivation_response = WC_Helper_API::post(
'deactivate', 'deactivate',
@ -1027,23 +1116,14 @@ class WC_Helper {
* @param array $deactivation_response The response object from wp_safe_remote_request(). * @param array $deactivation_response The response object from wp_safe_remote_request().
*/ */
do_action( 'woocommerce_helper_subscription_deactivate_error', $product_id, $product_key, $deactivation_response ); do_action( 'woocommerce_helper_subscription_deactivate_error', $product_id, $product_key, $deactivation_response );
$body = json_decode( wp_remote_retrieve_body( $deactivation_response ), true );
throw new Exception( $body['message'] ?? __( 'Unknown error', 'woocommerce' ) );
} }
self::_flush_subscriptions_cache(); self::_flush_subscriptions_cache();
$redirect_uri = add_query_arg( return $deactivated;
array(
'page' => 'wc-addons',
'section' => 'helper',
'filter' => self::get_current_filter(),
'wc-helper-status' => $deactivated ? 'deactivate-success' : 'deactivate-error',
'wc-helper-product-id' => $product_id,
),
admin_url( 'admin.php' )
);
wp_safe_redirect( $redirect_uri );
die();
} }
/** /**
@ -1148,10 +1228,63 @@ class WC_Helper {
if ( ! empty( $subscriptions ) ) { if ( ! empty( $subscriptions ) ) {
return $single ? array_shift( $subscriptions ) : $subscriptions; return $single ? array_shift( $subscriptions ) : $subscriptions;
} }
return false; return false;
} }
/**
* Get locally installed plugins
*
* @return array
*/
public static function get_local_plugins() {
if ( ! function_exists( 'get_plugins' ) ) {
require_once ABSPATH . 'wp-admin/includes/plugin.php';
}
$plugins = get_plugins();
$output_plugins = array();
foreach ( $plugins as $filename => $data ) {
array_push(
$output_plugins,
array(
'_filename' => $filename,
'_type' => 'plugin',
'slug' => dirname( $filename ),
'Version' => $data['Version'],
)
);
}
return $output_plugins;
}
/**
* Get locally installed themes.
*
* @return array
*/
public static function get_local_themes() {
if ( ! function_exists( 'wp_get_themes' ) ) {
require_once ABSPATH . 'wp-admin/includes/theme.php';
}
$themes = wp_get_themes();
$output_themes = array();
foreach ( $themes as $theme ) {
array_push(
$output_themes,
array(
'_filename' => $theme->get_stylesheet() . '/style.css',
'_stylesheet' => $theme->get_stylesheet(),
'_type' => 'theme',
'slug' => $theme->get_stylesheet(),
'Version' => $theme->get( 'Version' ),
)
);
}
return $output_themes;
}
/** /**
* Obtain a list of data about locally installed Woo extensions. * Obtain a list of data about locally installed Woo extensions.
*/ */
@ -1256,6 +1389,7 @@ class WC_Helper {
'_product_id' => absint( $product_id ), '_product_id' => absint( $product_id ),
'_file_id' => $file_id, '_file_id' => $file_id,
'_type' => 'theme', '_type' => 'theme',
'slug' => dirname( $theme->get_stylesheet() ),
); );
$woo_themes[ $data['_filename'] ] = $data; $woo_themes[ $data['_filename'] ] = $data;
@ -1313,6 +1447,256 @@ class WC_Helper {
return $data; return $data;
} }
/**
* Get subscription data for a given product key.
*
* @param string $product_key Subscription product key.
* @return array|bool The array containing sub data or false.
*/
public static function get_subscription( $product_key ) {
$subscriptions = wp_list_filter(
self::get_subscriptions(),
array( 'product_key' => $product_key )
);
if ( empty( $subscriptions ) ) {
return false;
}
$subscription = array_shift( $subscriptions );
$subscription['local'] = self::get_subscription_local_data( $subscription );
return $subscription;
}
/**
* Get the connected user's subscription list data.
* This is used by the My Subscriptions page.
*
* @return array
*/
public static function get_subscription_list_data() {
$subscriptions = self::get_subscriptions();
// Installed plugins and themes, with or without an active subscription.
$woo_plugins = self::get_local_woo_plugins();
$woo_themes = self::get_local_woo_themes();
$subscriptions_product_ids = wp_list_pluck( $subscriptions, 'product_id' );
$auth = WC_Helper_Options::get( 'auth' );
$site_id = isset( $auth['site_id'] ) ? absint( $auth['site_id'] ) : 0;
// Installed products without a subscription.
foreach ( array_merge( $woo_plugins, $woo_themes ) as $filename => $data ) {
if ( in_array( $data['_product_id'], $subscriptions_product_ids, true ) ) {
continue;
}
$subscriptions[] = array(
'product_key' => '',
'product_id' => $data['_product_id'],
'product_name' => $data['Name'],
'product_url' => $data['PluginURI'] ?? '',
'zip_slug' => $data['slug'],
'documentation_url' => '',
'key_type' => '',
'key_type_label' => '',
'lifetime' => false,
'product_status' => 'publish',
'connections' => array(),
'expires' => 0,
'expired' => true,
'expiring' => false,
'sites_max' => 0,
'sites_active' => 0,
'autorenew' => false,
'maxed' => false,
);
}
foreach ( $subscriptions as &$subscription ) {
$subscription['active'] = in_array( $site_id, $subscription['connections'], true );
$updates = WC_Helper_Updater::get_update_data();
$subscription['local'] = self::get_subscription_local_data( $subscription );
$subscription['has_update'] = false;
if ( $subscription['local']['installed'] && ! empty( $updates[ $subscription['product_id'] ] ) ) {
$subscription['has_update'] = version_compare( $updates[ $subscription['product_id'] ]['version'], $subscription['local']['version'], '>' );
}
if ( ! empty( $updates[ $subscription['product_id'] ] ) ) {
$subscription['version'] = $updates[ $subscription['product_id'] ]['version'];
}
}
// Sort subscriptions by name and expiration date.
usort(
$subscriptions,
function( $a, $b ) {
$compare_value = strcasecmp( $a['product_name'], $b['product_name'] );
if ( 0 === $compare_value ) {
return strcasecmp( $a['expires'], $b['expires'] );
}
return $compare_value;
}
);
// Add subscription install flags after the active and local data is set.
foreach ( $subscriptions as &$subscription ) {
$subscription['subscription_available'] = self::is_subscription_available( $subscription, $subscriptions );
$subscription['subscription_installed'] = self::is_subscription_installed( $subscription, $subscriptions );
}
// Break the by-ref.
unset( $subscription );
return $subscriptions;
}
/**
* Check if a subscription is available to use.
* That is, is not already active and hasn't expired, and there are no other subscriptions
* for this product already active on this site.
*
* @param array $subscription The subscription we're checking.
* @param array $subscriptions The list of all the user's subscriptions.
* @return bool True if multiple licenses exist, false otherwise.
*/
public static function is_subscription_available( $subscription, $subscriptions ) {
if ( true === $subscription['active'] ) {
return false;
}
if ( true === $subscription['expired'] ) {
return false;
}
$product_subscriptions = wp_list_filter(
$subscriptions,
array(
'product_id' => $subscription['product_id'],
'active' => true,
)
);
// If there are no subscriptions for this product already active on this site, then it's available.
if ( empty( $product_subscriptions ) ) {
return true;
}
return false;
}
/**
* Check if product relating to a subscription is installed.
* This method will return true if the product is installed, but will exclude subscriptions for the same product that are not in use.
* If a product is installed and inactive, this will ensure that one subscription is marked as installed.
*
* @param array $subscription The subscription we're checking.
* @param array $subscriptions The list of all the user's subscriptions.
* @return bool True if installed, false otherwise.
*/
public static function is_subscription_installed( $subscription, $subscriptions ) {
if ( false === $subscription['local']['installed'] ) {
return false;
}
// If the subscription is active, then it's installed.
if ( true === $subscription['active'] ) {
return true;
}
$product_subscriptions = wp_list_filter(
$subscriptions,
array(
'product_id' => $subscription['product_id'],
)
);
if ( empty( $product_subscriptions ) ) {
return false;
}
// If there are no other subscriptions for this product, then it's installed.
if ( 1 === count( $product_subscriptions ) ) {
return true;
}
$active_subscription = wp_list_filter(
$product_subscriptions,
array(
'active' => true,
)
);
// If there is another active subscription, this subscription is not installed.
// If the current subscription is active, it would already return true above.
if ( ! empty( $active_subscription ) ) {
return false;
}
// If there are multiple subscriptions, but no active subscriptions, then mark the first one as installed.
$product_subscription = array_shift( $product_subscriptions );
if ( $product_subscription['product_key'] === $subscription['product_key'] ) {
return true;
}
return false;
}
/**
* Add local data to a subscription.
*
* @param array $subscription The subscription data.
* @return array The subscription data with local data added.
*/
public static function get_subscription_local_data( array $subscription ) {
$local_plugins = self::get_local_plugins();
$local_themes = self::get_local_themes();
$installed_product = wp_list_filter(
array_merge( $local_plugins, $local_themes ),
array( 'slug' => $subscription['zip_slug'] )
);
$installed_product = array_shift( $installed_product );
if ( empty( $installed_product ) ) {
return array(
'installed' => false,
'active' => false,
'version' => null,
'type' => null,
'slug' => null,
'path' => null,
);
}
$local_data = array(
'installed' => true,
'active' => false,
'version' => $installed_product['Version'],
'type' => $installed_product['_type'],
'slug' => null,
'path' => $installed_product['_filename'],
);
if ( 'plugin' === $installed_product['_type'] ) {
$local_data['slug'] = $installed_product['slug'];
if ( is_plugin_active( $installed_product['_filename'] ) ) {
$local_data['active'] = true;
} elseif ( is_multisite() && is_plugin_active_for_network( $installed_product['_filename'] ) ) {
$local_data['active'] = true;
}
} elseif ( 'theme' === $installed_product['_type'] ) {
$local_data['slug'] = $installed_product['_stylesheet'];
if ( in_array( $installed_product['_stylesheet'], array( get_stylesheet(), get_template() ), true ) ) {
$local_data['active'] = true;
}
}
return $local_data;
}
/** /**
* Runs when any plugin is activated. * Runs when any plugin is activated.
* *

View File

@ -12,12 +12,20 @@ use Automattic\WooCommerce\Utilities\FeaturesUtil;
*/ */
class Marketplace { class Marketplace {
const MARKETPLACE_TAB_SLUG = 'woo';
/** /**
* Class initialization, to be executed when the class is resolved by the container. * Class initialization, to be executed when the class is resolved by the container.
*/ */
final public function init() { final public function init() {
if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) { if ( FeaturesUtil::feature_is_enabled( 'marketplace' ) ) {
add_action( 'admin_menu', array( $this, 'register_pages' ), 70 ); add_action( 'admin_menu', array( $this, 'register_pages' ), 70 );
add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
// Add a Woo Marketplace link to the plugin install action links.
add_filter( 'install_plugins_tabs', array( $this, 'add_woo_plugin_install_action_link' ) );
add_action( 'install_plugins_pre_woo', array( $this, 'maybe_open_woo_tab' ) );
add_action( 'admin_print_styles-plugin-install.php', array( $this, 'add_plugins_page_styles' ) );
} }
} }
@ -57,4 +65,82 @@ class Marketplace {
*/ */
return apply_filters( 'woocommerce_marketplace_menu_items', $marketplace_pages ); return apply_filters( 'woocommerce_marketplace_menu_items', $marketplace_pages );
} }
/**
* Enqueue update script.
*
* @param string $hook_suffix The current admin page.
*/
public function enqueue_scripts( $hook_suffix ) {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( 'woocommerce_page_wc-admin' !== $hook_suffix ) {
return;
};
if ( ! isset( $_GET['path'] ) || '/extensions' !== $_GET['path'] ) {
return;
}
// Enqueue WordPress updates script to enable plugin and theme installs and updates.
wp_enqueue_script( 'updates' );
// phpcs:enable WordPress.Security.NonceVerification.Recommended
}
/**
* Add a Woo Marketplace link to the plugin install action links.
*
* @param array $tabs Plugins list tabs.
* @return array
*/
public function add_woo_plugin_install_action_link( $tabs ) {
$tabs[ self::MARKETPLACE_TAB_SLUG ] = 'Woo';
return $tabs;
}
/**
* Open the Woo tab when the user clicks on the Woo link in the plugin installer.
*/
public function maybe_open_woo_tab() {
// phpcs:disable WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET['tab'] ) || self::MARKETPLACE_TAB_SLUG !== $_GET['tab'] ) {
return;
}
// phpcs:enable WordPress.Security.NonceVerification.Recommended
$woo_url = add_query_arg(
array(
'page' => 'wc-admin',
'path' => '/extensions',
'tab' => 'extensions',
'ref' => 'plugins',
),
admin_url( 'admin.php' )
);
wp_safe_redirect( $woo_url );
exit;
}
/**
* Add styles to the plugin install page.
*/
public function add_plugins_page_styles() {
?>
<style>
.plugin-install-woo > a::after {
content: "";
display: inline-block;
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.33321 3H12.9999V7.66667H11.9999V4.70711L8.02009 8.68689L7.31299 7.97978L11.2928 4H8.33321V3Z' fill='%23646970'/%3E%3Cpath d='M6.33333 4.1665H4.33333C3.8731 4.1665 3.5 4.5396 3.5 4.99984V11.6665C3.5 12.1267 3.8731 12.4998 4.33333 12.4998H11C11.4602 12.4998 11.8333 12.1267 11.8333 11.6665V9.6665' stroke='%23646970'/%3E%3C/svg%3E%0A");
width: 16px;
height: 16px;
background-repeat: no-repeat;
vertical-align: text-top;
margin-left: 2px;
}
.plugin-install-woo:hover > a::after {
background-image: url("data:image/svg+xml,%3Csvg width='16' height='16' viewBox='0 0 16 16' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M8.33321 3H12.9999V7.66667H11.9999V4.70711L8.02009 8.68689L7.31299 7.97978L11.2928 4H8.33321V3Z' fill='%23135E96'/%3E%3Cpath d='M6.33333 4.1665H4.33333C3.8731 4.1665 3.5 4.5396 3.5 4.99984V11.6665C3.5 12.1267 3.8731 12.4998 4.33333 12.4998H11C11.4602 12.4998 11.8333 12.1267 11.8333 11.6665V9.6665' stroke='%23135E96'/%3E%3C/svg%3E%0A");
}
</style>
<?php
}
} }

View File

@ -232,6 +232,9 @@ class Settings {
$settings['allowMarketplaceSuggestions'] = WC_Marketplace_Suggestions::allow_suggestions(); $settings['allowMarketplaceSuggestions'] = WC_Marketplace_Suggestions::allow_suggestions();
$settings['connectNonce'] = wp_create_nonce( 'connect' ); $settings['connectNonce'] = wp_create_nonce( 'connect' );
$settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' ); $settings['wcpay_welcome_page_connect_nonce'] = wp_create_nonce( 'wcpay-connect' );
$settings['wc_helper_nonces'] = array(
'refresh' => wp_create_nonce( 'refresh' ),
);
$settings['features'] = $this->get_features(); $settings['features'] = $this->get_features();