* Marketplace: Add "Add to Store" button for free and WordPress.org products (#43616) * Marketplace: Install free .org plugins with Add to Store button * Marketplace: addressed feedback for the the new install free products flow - Moved notices to the top of the modal - Updated notice styles slightly - Updated the CreateOrderSuccessResponse to reflect API changes * Marketplace: Require the Helper orders API file * Marketplace: fix linter errors * Marketplace: form encode when submitting the request The body is encoded anyways by the WordPress core. However, if I don't do it here, I can't create a valid signature to be verified by Woo.com. I could have just submitted a JSON too, but this seamed easier since the body is parsed on Woo.com automatically when it's in this form. * Add changefile(s) from automation for the following project(s): woocommerce * Marketplace: remove "~" character in imports and use relative paths * Marketplace: fix margins in the product with the Add to Store button * Marketplace: Add conditions to hide the button We hide it if: - the product is already installed - user doesn't have the right capability - if the product is just installed using our flow and there is no page refresh * Marketplace: don't show Add to Store button on Themes and on Discover * Marketplace: fix linting * Marketplace: hide ratings from the product if "is-small" class exists * Marketplace: fix linting errors --------- Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
parent
27f079e9b1
commit
48ebe7b84c
|
@ -21,6 +21,7 @@ import {
|
|||
recordMarketplaceView,
|
||||
recordLegacyTabView,
|
||||
} from '../../utils/tracking';
|
||||
import InstallNewProductModal from '../install-flow/install-new-product-modal';
|
||||
|
||||
export default function Content(): JSX.Element {
|
||||
const marketplaceContextValue = useContext( MarketplaceContext );
|
||||
|
@ -133,6 +134,7 @@ export default function Content(): JSX.Element {
|
|||
|
||||
return (
|
||||
<div className="woocommerce-marketplace__content">
|
||||
<InstallNewProductModal products={ products } />
|
||||
{ renderContent() }
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import apiFetch from '@wordpress/api-fetch';
|
||||
|
||||
type CreateOrderSuccessResponse = {
|
||||
success: true;
|
||||
data: {
|
||||
product_id: number;
|
||||
zip_slug: string;
|
||||
product_type: string;
|
||||
documentation_url: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CreateOrderErrorResponse = {
|
||||
success: false;
|
||||
data: {
|
||||
code: string;
|
||||
message: string;
|
||||
redirect_location?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type CreateOrderResponse =
|
||||
| CreateOrderSuccessResponse
|
||||
| CreateOrderErrorResponse;
|
||||
|
||||
function createOrder( productId: number ): Promise< CreateOrderResponse > {
|
||||
return apiFetch( {
|
||||
path: '/wc/v3/marketplace/create-order',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify( { product_id: productId } ),
|
||||
} );
|
||||
}
|
||||
|
||||
export {
|
||||
createOrder,
|
||||
CreateOrderResponse,
|
||||
CreateOrderSuccessResponse,
|
||||
CreateOrderErrorResponse,
|
||||
};
|
|
@ -0,0 +1,404 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { ButtonGroup, Button, Modal, Notice } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { dispatch } from '@wordpress/data';
|
||||
import { useState, useEffect, useContext } from '@wordpress/element';
|
||||
import { navigateTo, getNewPath, useQuery } from '@woocommerce/navigation';
|
||||
import { recordEvent } from '@woocommerce/tracks';
|
||||
import { Status } from '@wordpress/notices';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import ProductCard from '../product-card/product-card';
|
||||
import { Product } from '../product-list/types';
|
||||
import ConnectAccountButton from '../my-subscriptions/table/actions/connect-account-button';
|
||||
import { installingStore } from '../../contexts/install-store';
|
||||
import { downloadProduct } from '../../utils/functions';
|
||||
import { createOrder } from './create-order';
|
||||
import { MARKETPLACE_PATH, WP_ADMIN_PLUGIN_LIST_URL } from '../constants';
|
||||
import { getAdminSetting } from '../../../utils/admin-settings';
|
||||
import { MarketplaceContext } from '../../contexts/marketplace-context';
|
||||
|
||||
enum InstallFlowStatus {
|
||||
'notConnected',
|
||||
'notInstalled',
|
||||
'installing',
|
||||
'installedCanActivate',
|
||||
'installedCannotActivate',
|
||||
'installFailed',
|
||||
'activating',
|
||||
'activated',
|
||||
'activationFailed',
|
||||
}
|
||||
|
||||
function InstallNewProductModal( props: { products: Product[] } ) {
|
||||
const [ installStatus, setInstallStatus ] = useState< InstallFlowStatus >(
|
||||
InstallFlowStatus.notInstalled
|
||||
);
|
||||
const [ product, setProduct ] = useState< Product >();
|
||||
const [ installedProducts, setInstalledProducts ] = useState< string[] >();
|
||||
const [ activateUrl, setActivateUrl ] = useState< string >();
|
||||
const [ documentationUrl, setDocumentationUrl ] = useState< string >();
|
||||
const [ showModal, setShowModal ] = useState< boolean >( false );
|
||||
const [ notice, setNotice ] = useState< {
|
||||
message: string;
|
||||
status: Status;
|
||||
} >();
|
||||
const { addInstalledProduct } = useContext( MarketplaceContext );
|
||||
|
||||
const query = useQuery();
|
||||
|
||||
// Check if the store is connected to Woo.com. This is run once, when the component is mounted.
|
||||
useEffect( () => {
|
||||
const wccomSettings = getAdminSetting( 'wccomHelper', {} );
|
||||
const isStoreConnected = wccomSettings?.isConnected;
|
||||
|
||||
setInstalledProducts( wccomSettings?.installedProducts );
|
||||
|
||||
if ( isStoreConnected === false ) {
|
||||
setInstallStatus( InstallFlowStatus.notConnected );
|
||||
setNotice( {
|
||||
status: 'warning',
|
||||
message: __(
|
||||
'In order to install a product, you need to first connect your account.',
|
||||
'woocommerce'
|
||||
),
|
||||
} );
|
||||
} else {
|
||||
setInstallStatus( InstallFlowStatus.notInstalled );
|
||||
}
|
||||
}, [] );
|
||||
|
||||
/**
|
||||
* Listen for changes in the query, and show the modal if the installProduct query param is set.
|
||||
* If it's set, try to find the product in the products prop. We need it to be able to
|
||||
* display title, icon and send product ID to Woo.com to create an order.
|
||||
*/
|
||||
useEffect( () => {
|
||||
setShowModal( false );
|
||||
if ( ! query.installProduct ) {
|
||||
return;
|
||||
}
|
||||
|
||||
const productId = parseInt( query.installProduct, 10 );
|
||||
|
||||
/**
|
||||
* Try to find the product in the search results. We need to product to be able to
|
||||
* show the title and the icon.
|
||||
*/
|
||||
const productToInstall = props.products.find(
|
||||
( item ) => item.id === productId
|
||||
);
|
||||
|
||||
if ( ! productToInstall ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( installedProducts ) {
|
||||
const isInstalled = !! installedProducts.find(
|
||||
( item ) => item === productToInstall.slug
|
||||
);
|
||||
|
||||
if ( isInstalled ) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setShowModal( true );
|
||||
setProduct( productToInstall );
|
||||
}, [ query, props.products, installedProducts ] );
|
||||
|
||||
function activateClick() {
|
||||
if ( ! activateUrl ) {
|
||||
return;
|
||||
}
|
||||
|
||||
setInstallStatus( InstallFlowStatus.activating );
|
||||
|
||||
recordEvent( 'marketplace_activate_new_product_clicked', {
|
||||
product_id: product ? product.id : 0,
|
||||
} );
|
||||
|
||||
fetch( activateUrl )
|
||||
.then( () => {
|
||||
setInstallStatus( InstallFlowStatus.activated );
|
||||
} )
|
||||
.catch( () => {
|
||||
setInstallStatus( InstallFlowStatus.activationFailed );
|
||||
setNotice( {
|
||||
status: 'error',
|
||||
message: __(
|
||||
'Activation failed. Please try again from the plugins page.',
|
||||
'woocommerce'
|
||||
),
|
||||
} );
|
||||
} );
|
||||
}
|
||||
|
||||
function orderAndInstall() {
|
||||
if ( ! product || ! product.id ) {
|
||||
return;
|
||||
}
|
||||
|
||||
recordEvent( 'marketplace_install_new_product_clicked', {
|
||||
product_id: product.id,
|
||||
} );
|
||||
|
||||
setInstallStatus( InstallFlowStatus.installing );
|
||||
|
||||
createOrder( product.id )
|
||||
.then( ( response ) => {
|
||||
// This narrows the CreateOrderResponse type to CreateOrderSuccessResponse
|
||||
if ( ! response.success ) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
dispatch( installingStore ).startInstalling( product.id );
|
||||
setDocumentationUrl( response.data.documentation_url );
|
||||
|
||||
if ( product.slug ) {
|
||||
addInstalledProduct( product.slug ?? '' );
|
||||
}
|
||||
|
||||
return downloadProduct(
|
||||
response.data.product_type,
|
||||
response.data.zip_slug
|
||||
).then( ( downloadResponse ) => {
|
||||
dispatch( installingStore ).stopInstalling( product.id );
|
||||
|
||||
if ( downloadResponse.data.activateUrl ) {
|
||||
setActivateUrl( downloadResponse.data.activateUrl );
|
||||
|
||||
setInstallStatus(
|
||||
InstallFlowStatus.installedCanActivate
|
||||
);
|
||||
} else {
|
||||
setInstallStatus(
|
||||
InstallFlowStatus.installedCannotActivate
|
||||
);
|
||||
}
|
||||
} );
|
||||
} )
|
||||
.catch( ( error ) => {
|
||||
/**
|
||||
* apiFetch doesn't return the error code in the error condition.
|
||||
* We'll rely on the data returned by the server.
|
||||
*/
|
||||
if ( error.data.redirect_location ) {
|
||||
setNotice( {
|
||||
status: 'warning',
|
||||
message: __(
|
||||
'We need your address to complete installing this product. We will redirect you to Woo.com checkout. Afterwards, you will be able to install the product.',
|
||||
'woocommerce'
|
||||
),
|
||||
} );
|
||||
|
||||
setTimeout( () => {
|
||||
window.location.href = error.data.redirect_location;
|
||||
}, 5000 );
|
||||
} else {
|
||||
setInstallStatus( InstallFlowStatus.installFailed );
|
||||
setNotice( {
|
||||
status: 'error',
|
||||
message:
|
||||
error.data.message ??
|
||||
__(
|
||||
'An error ocurred. Please try again later.',
|
||||
'woocommerce'
|
||||
),
|
||||
} );
|
||||
}
|
||||
} );
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
setInstallStatus( InstallFlowStatus.notInstalled );
|
||||
setNotice( undefined );
|
||||
|
||||
navigateTo( {
|
||||
url: getNewPath(
|
||||
{
|
||||
...query,
|
||||
install: undefined,
|
||||
installProduct: undefined,
|
||||
},
|
||||
MARKETPLACE_PATH,
|
||||
{}
|
||||
),
|
||||
} );
|
||||
}
|
||||
|
||||
function getTitle(): string {
|
||||
if ( installStatus === InstallFlowStatus.activated ) {
|
||||
return __( 'You are ready to go!', 'woocommerce' );
|
||||
}
|
||||
|
||||
return __( 'Add to Store', 'woocommerce' );
|
||||
}
|
||||
|
||||
function getDescription(): string {
|
||||
if ( installStatus === InstallFlowStatus.notConnected ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (
|
||||
installStatus === InstallFlowStatus.installedCanActivate ||
|
||||
installStatus === InstallFlowStatus.activating
|
||||
) {
|
||||
return __(
|
||||
'Extension successfully installed. Would you like to activate it?',
|
||||
'woocommerce'
|
||||
);
|
||||
}
|
||||
|
||||
if ( installStatus === InstallFlowStatus.installedCannotActivate ) {
|
||||
return __(
|
||||
"Extension successfully installed but we can't activate it at the moment. Please visit the plugins page to see more.",
|
||||
'woocommerce'
|
||||
);
|
||||
}
|
||||
|
||||
if ( installStatus === InstallFlowStatus.activated ) {
|
||||
return __(
|
||||
'Keep the momentum going and start setting up your extension.',
|
||||
'woocommerce'
|
||||
);
|
||||
}
|
||||
|
||||
return __( 'Would you like to install this extension?', 'woocommerce' );
|
||||
}
|
||||
|
||||
function secondaryButton(): React.ReactElement {
|
||||
if ( installStatus === InstallFlowStatus.activated ) {
|
||||
if ( documentationUrl ) {
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
href={ documentationUrl }
|
||||
className="woocommerce-marketplace__header-account-modal-button"
|
||||
key={ 'docs' }
|
||||
>
|
||||
{ __( 'View Docs', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="tertiary"
|
||||
onClick={ onClose }
|
||||
className="woocommerce-marketplace__header-account-modal-button"
|
||||
key={ 'cancel' }
|
||||
>
|
||||
{ __( 'Cancel', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function primaryButton(): React.ReactElement {
|
||||
if ( installStatus === InstallFlowStatus.notConnected ) {
|
||||
return <ConnectAccountButton variant="primary" key={ 'connect' } />;
|
||||
}
|
||||
|
||||
if (
|
||||
installStatus === InstallFlowStatus.installedCanActivate ||
|
||||
installStatus === InstallFlowStatus.activating
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={ activateClick }
|
||||
key={ 'activate' }
|
||||
isBusy={ installStatus === InstallFlowStatus.activating }
|
||||
disabled={ installStatus === InstallFlowStatus.activating }
|
||||
>
|
||||
{ __( 'Activate', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
installStatus === InstallFlowStatus.activated ||
|
||||
installStatus === InstallFlowStatus.installedCannotActivate ||
|
||||
installStatus === InstallFlowStatus.activationFailed
|
||||
) {
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
href={ WP_ADMIN_PLUGIN_LIST_URL }
|
||||
className="woocommerce-marketplace__header-account-modal-button"
|
||||
key={ 'plugin-list' }
|
||||
>
|
||||
{ __( 'View in Plugins', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={ orderAndInstall }
|
||||
key={ 'install' }
|
||||
isBusy={ installStatus === InstallFlowStatus.installing }
|
||||
disabled={
|
||||
installStatus === InstallFlowStatus.installing ||
|
||||
installStatus === InstallFlowStatus.installFailed
|
||||
}
|
||||
>
|
||||
{ __( 'Install', 'woocommerce' ) }
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Actually, just checking showModal is enough here. However, checking
|
||||
* for the product narrows the type from "Product | undefined"
|
||||
* to "Product".
|
||||
*/
|
||||
if ( ! product || ! showModal ) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={ getTitle() }
|
||||
onRequestClose={ onClose }
|
||||
focusOnMount={ true }
|
||||
className="woocommerce-marketplace__header-account-modal has-size-medium"
|
||||
style={ { borderRadius: 4 } }
|
||||
overlayClassName="woocommerce-marketplace__header-account-modal-overlay"
|
||||
>
|
||||
{ notice && (
|
||||
<Notice status={ notice.status } isDismissible={ false }>
|
||||
{ notice.message }
|
||||
</Notice>
|
||||
) }
|
||||
<p className="woocommerce-marketplace__header-account-modal-text">
|
||||
{ getDescription() }
|
||||
</p>
|
||||
{ product && (
|
||||
<ProductCard
|
||||
product={ product }
|
||||
small={ true }
|
||||
tracksData={ {
|
||||
position: 1,
|
||||
group: 'install-flow',
|
||||
label: 'install',
|
||||
} }
|
||||
/>
|
||||
) }
|
||||
<ButtonGroup className="woocommerce-marketplace__header-account-modal-button-group">
|
||||
{ secondaryButton() }
|
||||
{ primaryButton() }
|
||||
</ButtonGroup>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default InstallNewProductModal;
|
|
@ -354,26 +354,36 @@
|
|||
}
|
||||
}
|
||||
.components-notice {
|
||||
color: $gray-900;
|
||||
padding: $grid-unit-15 $grid-unit-20;
|
||||
border-left: none;
|
||||
margin: $grid-unit-20 0;
|
||||
|
||||
&.is-error {
|
||||
background-color: var(--wp-red-red-0, #fcf0f1);
|
||||
}
|
||||
|
||||
&.is-warning {
|
||||
background-color: var(--wp-yellow-yellow-0, #fcf9e8);
|
||||
}
|
||||
|
||||
&.is-error,
|
||||
&.is-warning, {
|
||||
align-items: start;
|
||||
|
||||
.components-notice__content {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
/* stylelint-disable-next-line function-url-quotes */
|
||||
background-image: url("data:image/svg+xml,%3Csvg width='24' height='24' viewBox='0 0 24 24' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath 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='%23614200' stroke-width='1.5'/%3E%3Cpath d='M13 7H11V13H13V7Z' fill='%23614200'/%3E%3Cpath d='M13 15H11V17H13V15Z' fill='%23614200'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
margin-right: $grid-unit-15;
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.components-notice__content {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,46 +1,111 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { Icon } from '@wordpress/components';
|
||||
import { Button, Icon } from '@wordpress/components';
|
||||
import { __ } from '@wordpress/i18n';
|
||||
import { useContext } from '@wordpress/element';
|
||||
import { recordEvent } from '@woocommerce/tracks';
|
||||
import { navigateTo, getNewPath } from '@woocommerce/navigation';
|
||||
import { useUser } from '@woocommerce/data';
|
||||
|
||||
export interface ProductCardFooterProps {
|
||||
price: number | null | undefined;
|
||||
currencySymbol: string;
|
||||
averageRating: number | null | undefined;
|
||||
reviewsCount: number | null | undefined;
|
||||
}
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { Product } from '../product-list/types';
|
||||
import { MarketplaceContext } from '../../contexts/marketplace-context';
|
||||
|
||||
function ProductCardFooter( props: { product: Product } ) {
|
||||
const { product } = props;
|
||||
const { user, currentUserCan } = useUser();
|
||||
const { selectedTab, isProductInstalled } =
|
||||
useContext( MarketplaceContext );
|
||||
|
||||
function openInstallModal() {
|
||||
recordEvent( 'marketplace_add_to_store_clicked', {
|
||||
product_id: product.id,
|
||||
} );
|
||||
|
||||
navigateTo( {
|
||||
url: getNewPath( {
|
||||
installProduct: product.id,
|
||||
} ),
|
||||
} );
|
||||
}
|
||||
|
||||
function shouldShowAddToStore( productToCheck: Product ) {
|
||||
if ( ! user || ! productToCheck ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! currentUserCan( 'install_plugins' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! productToCheck.isInstallable ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( productToCheck.type === 'theme' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( selectedTab === 'discover' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
productToCheck.slug &&
|
||||
isProductInstalled( productToCheck.slug )
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// We hardcode this for now while we only display prices in USD.
|
||||
const currencySymbol = '$';
|
||||
|
||||
if ( shouldShowAddToStore( product ) ) {
|
||||
return (
|
||||
<>
|
||||
<span className="woocommerce-marketplace__product-card__add-to-store">
|
||||
<Button variant="secondary" onClick={ openInstallModal }>
|
||||
{ __( 'Add to Store', 'woocommerce' ) }
|
||||
</Button>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductCardFooter( props: ProductCardFooterProps ) {
|
||||
const { price, currencySymbol, averageRating, reviewsCount } = props;
|
||||
return (
|
||||
<>
|
||||
<div className="woocommerce-marketplace__product-card__price">
|
||||
<span className="woocommerce-marketplace__product-card__price-label">
|
||||
{
|
||||
// '0' is a free product
|
||||
price === 0
|
||||
product.price === 0
|
||||
? __( 'Free download', 'woocommerce' )
|
||||
: currencySymbol + price
|
||||
: currencySymbol + product.price
|
||||
}
|
||||
</span>
|
||||
<span className="woocommerce-marketplace__product-card__price-billing">
|
||||
{ props.price === 0
|
||||
{ product.price === 0
|
||||
? ''
|
||||
: __( ' annually', 'woocommerce' ) }
|
||||
</span>
|
||||
</div>
|
||||
<div className="woocommerce-marketplace__product-card__rating">
|
||||
{ averageRating !== null && (
|
||||
{ product.averageRating !== null && (
|
||||
<>
|
||||
<span className="woocommerce-marketplace__product-card__rating-icon">
|
||||
<Icon icon={ 'star-filled' } size={ 16 } />
|
||||
</span>
|
||||
<span className="woocommerce-marketplace__product-card__rating-average">
|
||||
{ averageRating }
|
||||
{ product.averageRating }
|
||||
</span>
|
||||
<span className="woocommerce-marketplace__product-card__rating-count">
|
||||
({ reviewsCount })
|
||||
({ product.reviewsCount })
|
||||
</span>
|
||||
</>
|
||||
) }
|
||||
|
|
|
@ -63,6 +63,8 @@
|
|||
padding: $medium-gap;
|
||||
|
||||
.woocommerce-marketplace__product-card__description,
|
||||
.woocommerce-marketplace__product-card__add-to-store,
|
||||
.woocommerce-marketplace__product-card__rating,
|
||||
.woocommerce-marketplace__product-card__price {
|
||||
display: none;
|
||||
}
|
||||
|
@ -95,7 +97,7 @@
|
|||
display: grid;
|
||||
gap: $medium-gap;
|
||||
height: 100%;
|
||||
grid-template-rows: auto 1fr 20px;
|
||||
grid-template-rows: auto 1fr 36px;
|
||||
}
|
||||
|
||||
&__header {
|
||||
|
@ -158,6 +160,11 @@
|
|||
}
|
||||
}
|
||||
|
||||
/* Allow the "add to store" button to "punch through" the "whole card clickable" trick: */
|
||||
&__add-to-store {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__vendor {
|
||||
display: flex;
|
||||
gap: $grid-unit-05;
|
||||
|
@ -187,7 +194,6 @@
|
|||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: $grid-unit-05;
|
||||
align-self: stretch;
|
||||
text-decoration: none !important;
|
||||
color: $gray-900 !important;
|
||||
font-style: normal;
|
||||
|
|
|
@ -40,9 +40,6 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
|
|||
reviewsCount: null,
|
||||
};
|
||||
|
||||
// We hardcode this for now while we only display prices in USD.
|
||||
const currencySymbol = '$';
|
||||
|
||||
function recordTracksEvent( event: string, data: ExtraProperties ) {
|
||||
const tracksData = props.tracksData;
|
||||
|
||||
|
@ -181,13 +178,8 @@ function ProductCard( props: ProductCardProps ): JSX.Element {
|
|||
{ isLoading && (
|
||||
<div className="woocommerce-marketplace__product-card__price" />
|
||||
) }
|
||||
{ ! isLoading && (
|
||||
<ProductCardFooter
|
||||
currencySymbol={ currencySymbol }
|
||||
price={ product.price }
|
||||
averageRating={ product.averageRating }
|
||||
reviewsCount={ product.reviewsCount }
|
||||
/>
|
||||
{ ! isLoading && props.product && (
|
||||
<ProductCardFooter product={ props.product } />
|
||||
) }
|
||||
</footer>
|
||||
</div>
|
||||
|
|
|
@ -35,6 +35,8 @@ export default function ProductListContent( props: {
|
|||
key={ product.id }
|
||||
type={ props.type }
|
||||
product={ {
|
||||
id: product.id,
|
||||
slug: product.slug,
|
||||
title: product.title,
|
||||
image: product.image,
|
||||
type: product.type,
|
||||
|
@ -61,6 +63,7 @@ export default function ProductListContent( props: {
|
|||
averageRating: product.averageRating,
|
||||
reviewsCount: product.reviewsCount,
|
||||
description: product.description,
|
||||
isInstallable: product.isInstallable,
|
||||
} }
|
||||
tracksData={ {
|
||||
position: index + 1,
|
||||
|
|
|
@ -19,10 +19,12 @@ export type SearchAPIProductType = {
|
|||
vendor_name: string;
|
||||
vendor_url: string;
|
||||
icon: string;
|
||||
is_installable: boolean;
|
||||
};
|
||||
|
||||
export interface Product {
|
||||
id?: number;
|
||||
slug?: string;
|
||||
position?: number;
|
||||
title: string;
|
||||
image: string;
|
||||
|
@ -40,6 +42,7 @@ export interface Product {
|
|||
group?: string;
|
||||
searchTerm?: string;
|
||||
category?: string;
|
||||
isInstallable: boolean;
|
||||
}
|
||||
|
||||
export interface ProductTracksData {
|
||||
|
|
|
@ -1,18 +1,21 @@
|
|||
/**
|
||||
* External dependencies
|
||||
*/
|
||||
import { useState, createContext } from '@wordpress/element';
|
||||
import { useState, useEffect, createContext } from '@wordpress/element';
|
||||
|
||||
/**
|
||||
* Internal dependencies
|
||||
*/
|
||||
import { MarketplaceContextType } from './types';
|
||||
import { getAdminSetting } from '../../utils/admin-settings';
|
||||
|
||||
export const MarketplaceContext = createContext< MarketplaceContextType >( {
|
||||
isLoading: false,
|
||||
setIsLoading: () => {},
|
||||
selectedTab: '',
|
||||
setSelectedTab: () => {},
|
||||
isProductInstalled: () => false,
|
||||
addInstalledProduct: () => {},
|
||||
} );
|
||||
|
||||
export function MarketplaceContextProvider( props: {
|
||||
|
@ -20,12 +23,33 @@ export function MarketplaceContextProvider( props: {
|
|||
} ): JSX.Element {
|
||||
const [ isLoading, setIsLoading ] = useState( true );
|
||||
const [ selectedTab, setSelectedTab ] = useState( '' );
|
||||
const [ installedPlugins, setInstalledPlugins ] = useState< string[] >(
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect( () => {
|
||||
const wccomSettings = getAdminSetting( 'wccomHelper', {} );
|
||||
const installedProductSlugs: string[] =
|
||||
wccomSettings?.installedProducts;
|
||||
|
||||
setInstalledPlugins( installedProductSlugs );
|
||||
}, [] );
|
||||
|
||||
function isProductInstalled( slug: string ): boolean {
|
||||
return installedPlugins.includes( slug );
|
||||
}
|
||||
|
||||
function addInstalledProduct( slug: string ) {
|
||||
setInstalledPlugins( [ ...installedPlugins, slug ] );
|
||||
}
|
||||
|
||||
const contextValue = {
|
||||
isLoading,
|
||||
setIsLoading,
|
||||
selectedTab,
|
||||
setSelectedTab,
|
||||
isProductInstalled,
|
||||
addInstalledProduct,
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
@ -13,6 +13,8 @@ export type MarketplaceContextType = {
|
|||
setIsLoading: ( isLoading: boolean ) => void;
|
||||
selectedTab: string;
|
||||
setSelectedTab: ( tab: string ) => void;
|
||||
isProductInstalled: ( slug: string ) => boolean;
|
||||
addInstalledProduct: ( slug: string ) => void;
|
||||
};
|
||||
|
||||
export type SubscriptionsContextType = {
|
||||
|
|
|
@ -125,6 +125,7 @@ async function fetchSearchResults(
|
|||
( product: SearchAPIProductType ): Product => {
|
||||
return {
|
||||
id: product.id,
|
||||
slug: product.slug,
|
||||
title: product.title,
|
||||
image: product.image,
|
||||
type: product.type,
|
||||
|
@ -137,6 +138,7 @@ async function fetchSearchResults(
|
|||
price: product.raw_price ?? product.price,
|
||||
averageRating: product.rating ?? null,
|
||||
reviewsCount: product.reviews_count ?? null,
|
||||
isInstallable: product.is_installable,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
@ -234,6 +236,16 @@ function disconnectProduct( subscription: Subscription ): Promise< void > {
|
|||
} );
|
||||
}
|
||||
|
||||
type WpAjaxReponse = {
|
||||
success: boolean;
|
||||
data: WpAjaxResponseData;
|
||||
};
|
||||
|
||||
type WpAjaxResponseData = {
|
||||
errorMessage?: string;
|
||||
activateUrl?: string;
|
||||
};
|
||||
|
||||
function wpAjax(
|
||||
action: string,
|
||||
data: {
|
||||
|
@ -242,7 +254,7 @@ function wpAjax(
|
|||
theme?: string;
|
||||
success?: boolean;
|
||||
}
|
||||
): Promise< void > {
|
||||
): Promise< WpAjaxReponse > {
|
||||
return new Promise( ( resolve, reject ) => {
|
||||
if ( ! window.wp.updates ) {
|
||||
reject( __( 'Please reload and try again', 'woocommerce' ) );
|
||||
|
@ -251,21 +263,13 @@ function wpAjax(
|
|||
|
||||
window.wp.updates.ajax( action, {
|
||||
...data,
|
||||
success: ( response: {
|
||||
success?: boolean;
|
||||
errorMessage?: string;
|
||||
} ) => {
|
||||
if ( response.success === false ) {
|
||||
reject( {
|
||||
success: false,
|
||||
data: {
|
||||
message: response.errorMessage,
|
||||
},
|
||||
} );
|
||||
}
|
||||
resolve();
|
||||
success: ( response: WpAjaxResponseData ) => {
|
||||
resolve( {
|
||||
success: true,
|
||||
data: response,
|
||||
} );
|
||||
},
|
||||
error: ( error: { errorMessage: string } ) => {
|
||||
error: ( error: WpAjaxResponseData ) => {
|
||||
reject( {
|
||||
success: false,
|
||||
data: {
|
||||
|
@ -320,12 +324,19 @@ function getInstallUrl( subscription: Subscription ): Promise< string > {
|
|||
} );
|
||||
}
|
||||
|
||||
function downloadProduct( productType: string, zipSlug: string ) {
|
||||
return wpAjax( 'install-' + productType, {
|
||||
// The slug prefix is required for the install to use WCCOM install filters.
|
||||
slug: zipSlug,
|
||||
} );
|
||||
}
|
||||
|
||||
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,
|
||||
} )
|
||||
return downloadProduct(
|
||||
subscription.product_type,
|
||||
subscription.zip_slug
|
||||
)
|
||||
.then( () => {
|
||||
return activateProduct( subscription );
|
||||
} )
|
||||
|
@ -338,7 +349,7 @@ function installProduct( subscription: Subscription ): Promise< void > {
|
|||
} );
|
||||
}
|
||||
|
||||
function updateProduct( subscription: Subscription ): Promise< void > {
|
||||
function updateProduct( subscription: Subscription ): Promise< WpAjaxReponse > {
|
||||
return wpAjax( 'update-' + subscription.product_type, {
|
||||
slug: subscription.local.slug,
|
||||
[ subscription.product_type ]: subscription.local.path,
|
||||
|
@ -386,8 +397,9 @@ const subscriptionToProduct = ( subscription: Subscription ): Product => {
|
|||
icon: subscription.product_icon,
|
||||
url: subscription.product_url,
|
||||
price: -1,
|
||||
averageRating: 0,
|
||||
reviewsCount: 0,
|
||||
averageRating: null,
|
||||
reviewsCount: null,
|
||||
isInstallable: false,
|
||||
};
|
||||
};
|
||||
|
||||
|
@ -446,6 +458,8 @@ export {
|
|||
fetchSubscriptions,
|
||||
refreshSubscriptions,
|
||||
getInstallUrl,
|
||||
downloadProduct,
|
||||
activateProduct,
|
||||
installProduct,
|
||||
updateProduct,
|
||||
addNotice,
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
Significance: minor
|
||||
Type: add
|
||||
|
||||
Adds a faster way to install products from the In-App Marketplace
|
|
@ -39,13 +39,23 @@ class WC_Helper_Admin {
|
|||
$auth_user_data = WC_Helper_Options::get( 'auth_user_data', array() );
|
||||
$auth_user_email = isset( $auth_user_data['email'] ) ? $auth_user_data['email'] : '';
|
||||
|
||||
// Get the all installed themes and plugins. Knowing this will help us decide to show Add to Store button on product cards.
|
||||
$installed_products = array_merge( WC_Helper::get_local_plugins(), WC_Helper::get_local_themes() );
|
||||
$installed_products = array_map(
|
||||
function ( $product ) {
|
||||
return $product['slug'];
|
||||
},
|
||||
$installed_products
|
||||
);
|
||||
|
||||
$settings['wccomHelper'] = array(
|
||||
'isConnected' => WC_Helper::is_site_connected(),
|
||||
'connectURL' => self::get_connection_url(),
|
||||
'userEmail' => $auth_user_email,
|
||||
'userAvatar' => get_avatar_url( $auth_user_email, array( 'size' => '48' ) ),
|
||||
'storeCountry' => wc_get_base_location()['country'],
|
||||
'isConnected' => WC_Helper::is_site_connected(),
|
||||
'connectURL' => self::get_connection_url(),
|
||||
'userEmail' => $auth_user_email,
|
||||
'userAvatar' => get_avatar_url( $auth_user_email, array( 'size' => '48' ) ),
|
||||
'storeCountry' => wc_get_base_location()['country'],
|
||||
'inAppPurchaseURLParams' => WC_Admin_Addons::get_in_app_purchase_url_params(),
|
||||
'installedProducts' => $installed_products,
|
||||
);
|
||||
|
||||
return $settings;
|
||||
|
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
/**
|
||||
* WooCommerce Admin Helper - React admin interface
|
||||
*
|
||||
* @package WooCommerce\Admin\Helper
|
||||
*/
|
||||
|
||||
if ( ! defined( 'ABSPATH' ) ) {
|
||||
exit;
|
||||
}
|
||||
|
||||
/**
|
||||
* WC_Helper_Orders_API
|
||||
*
|
||||
* Pings Woo.com to create an order and pull in the necessary data to start the installation process.
|
||||
*/
|
||||
class WC_Helper_Orders_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 Orders API.
|
||||
* These endpoints are used by the Marketplace Subscriptions React UI.
|
||||
*/
|
||||
public static function register_rest_routes() {
|
||||
register_rest_route(
|
||||
'wc/v3',
|
||||
'/marketplace/create-order',
|
||||
array(
|
||||
'methods' => 'POST',
|
||||
'callback' => array( __CLASS__, 'create_order' ),
|
||||
'permission_callback' => array( __CLASS__, 'get_permission' ),
|
||||
'args' => array(
|
||||
'product_id' => array(
|
||||
'required' => true,
|
||||
'validate_callback' => function( $argument ) {
|
||||
return is_int( $argument );
|
||||
},
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The Extensions page can only be accessed by users with the manage_woocommerce
|
||||
* capability. So the API mimics that behavior.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function get_permission() {
|
||||
return WC_Helper_Subscriptions_API::get_permission();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core function to create an order on Woo.com. Pings the API and catches the exceptions if any.
|
||||
*
|
||||
* @param WP_REST_Request $request Request object.
|
||||
*
|
||||
* @return WP_REST_Response
|
||||
*/
|
||||
public static function create_order( $request ) {
|
||||
if ( ! current_user_can( 'install_plugins' ) ) {
|
||||
return new \WP_REST_Response(
|
||||
array(
|
||||
'message' => __( 'You do not have permission to install plugins.', 'woocommerce' ),
|
||||
),
|
||||
403
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
$response = WC_Helper_API::post(
|
||||
'create-order',
|
||||
array(
|
||||
'authenticated' => true,
|
||||
'body' => http_build_query(
|
||||
array(
|
||||
'product_id' => $request['product_id'],
|
||||
),
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
return new \WP_REST_Response(
|
||||
json_decode( wp_remote_retrieve_body( $response ), true ),
|
||||
wp_remote_retrieve_response_code( $response )
|
||||
);
|
||||
} catch ( Exception $e ) {
|
||||
return new \WP_REST_Response(
|
||||
array(
|
||||
'message' => __( 'Could not start the installation process. Reason: ', 'woocommerce' ) . $e->getMessage(),
|
||||
'code' => 'could-not-install',
|
||||
),
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
WC_Helper_Orders_API::load();
|
|
@ -61,6 +61,7 @@ class WC_Helper {
|
|||
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-subscriptions-api.php';
|
||||
include_once dirname( __FILE__ ) . '/class-wc-helper-orders-api.php';
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue