From 48ebe7b84c588e2884f9a493457367a9d6a7fefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cem=20=C3=9Cnalan?= Date: Wed, 24 Jan 2024 23:02:20 +0300 Subject: [PATCH] Marketplace: Add "Add to Store" button for free and WordPress.org products #43616 (#43983) * 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 --- .../components/content/content.tsx | 2 + .../components/install-flow/create-order.tsx | 45 ++ .../install-new-product-modal.tsx | 404 ++++++++++++++++++ .../my-subscriptions/my-subscriptions.scss | 20 +- .../product-card/product-card-footer.tsx | 95 +++- .../components/product-card/product-card.scss | 10 +- .../components/product-card/product-card.tsx | 12 +- .../product-list-content.tsx | 3 + .../components/product-list/types.ts | 3 + .../contexts/marketplace-context.tsx | 26 +- .../client/marketplace/contexts/types.ts | 2 + .../client/marketplace/utils/functions.tsx | 58 ++- .../43983-feature-marketplace-faster-installs | 4 + .../admin/helper/class-wc-helper-admin.php | 20 +- .../helper/class-wc-helper-orders-api.php | 107 +++++ .../includes/admin/helper/class-wc-helper.php | 1 + 16 files changed, 752 insertions(+), 60 deletions(-) create mode 100644 plugins/woocommerce-admin/client/marketplace/components/install-flow/create-order.tsx create mode 100644 plugins/woocommerce-admin/client/marketplace/components/install-flow/install-new-product-modal.tsx create mode 100644 plugins/woocommerce/changelog/43983-feature-marketplace-faster-installs create mode 100644 plugins/woocommerce/includes/admin/helper/class-wc-helper-orders-api.php diff --git a/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx b/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx index fa9d010be15..720327cf2e9 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/content/content.tsx @@ -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 (
+ { renderContent() }
); diff --git a/plugins/woocommerce-admin/client/marketplace/components/install-flow/create-order.tsx b/plugins/woocommerce-admin/client/marketplace/components/install-flow/create-order.tsx new file mode 100644 index 00000000000..ec52148e66b --- /dev/null +++ b/plugins/woocommerce-admin/client/marketplace/components/install-flow/create-order.tsx @@ -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, +}; diff --git a/plugins/woocommerce-admin/client/marketplace/components/install-flow/install-new-product-modal.tsx b/plugins/woocommerce-admin/client/marketplace/components/install-flow/install-new-product-modal.tsx new file mode 100644 index 00000000000..7887f90942a --- /dev/null +++ b/plugins/woocommerce-admin/client/marketplace/components/install-flow/install-new-product-modal.tsx @@ -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 ( + + ); + } + + return <>; + } + + return ( + + ); + } + + function primaryButton(): React.ReactElement { + if ( installStatus === InstallFlowStatus.notConnected ) { + return ; + } + + if ( + installStatus === InstallFlowStatus.installedCanActivate || + installStatus === InstallFlowStatus.activating + ) { + return ( + + ); + } + + if ( + installStatus === InstallFlowStatus.activated || + installStatus === InstallFlowStatus.installedCannotActivate || + installStatus === InstallFlowStatus.activationFailed + ) { + return ( + + ); + } + + return ( + + ); + } + + /** + * 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 ( + + { notice && ( + + { notice.message } + + ) } +

+ { getDescription() } +

+ { product && ( + + ) } + + { secondaryButton() } + { primaryButton() } + +
+ ); +} + +export default InstallNewProductModal; diff --git a/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss b/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss index 5119c8c6d14..48ec92b6574 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/my-subscriptions/my-subscriptions.scss @@ -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; - } } } diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card-footer.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card-footer.tsx index 7210315051c..6701a458e2e 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card-footer.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card-footer.tsx @@ -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 ( + <> + + + + + ); + } -function ProductCardFooter( props: ProductCardFooterProps ) { - const { price, currencySymbol, averageRating, reviewsCount } = props; return ( <>
{ // '0' is a free product - price === 0 + product.price === 0 ? __( 'Free download', 'woocommerce' ) - : currencySymbol + price + : currencySymbol + product.price } - { props.price === 0 + { product.price === 0 ? '' : __( ' annually', 'woocommerce' ) }
- { averageRating !== null && ( + { product.averageRating !== null && ( <> - { averageRating } + { product.averageRating } - ({ reviewsCount }) + ({ product.reviewsCount }) ) } diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.scss b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.scss index 1805c052b19..9b4e5dd520b 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.scss +++ b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.scss @@ -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; diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx index 3ed5b37d1ba..3d740c1375f 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-card/product-card.tsx @@ -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 && (
) } - { ! isLoading && ( - + { ! isLoading && props.product && ( + ) }
diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/product-list-content.tsx b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/product-list-content.tsx index 17ebb2d9211..aef9831625a 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list-content/product-list-content.tsx +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list-content/product-list-content.tsx @@ -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, diff --git a/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts b/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts index 6cc5463f245..dcf675cd49f 100644 --- a/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts +++ b/plugins/woocommerce-admin/client/marketplace/components/product-list/types.ts @@ -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 { diff --git a/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx b/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx index f3d262e3726..c28c3a5c218 100644 --- a/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx +++ b/plugins/woocommerce-admin/client/marketplace/contexts/marketplace-context.tsx @@ -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 ( diff --git a/plugins/woocommerce-admin/client/marketplace/contexts/types.ts b/plugins/woocommerce-admin/client/marketplace/contexts/types.ts index 14930e5edd5..42f06fd32ca 100644 --- a/plugins/woocommerce-admin/client/marketplace/contexts/types.ts +++ b/plugins/woocommerce-admin/client/marketplace/contexts/types.ts @@ -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 = { diff --git a/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx b/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx index 711e514ab86..5233303c2a7 100644 --- a/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx +++ b/plugins/woocommerce-admin/client/marketplace/utils/functions.tsx @@ -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, diff --git a/plugins/woocommerce/changelog/43983-feature-marketplace-faster-installs b/plugins/woocommerce/changelog/43983-feature-marketplace-faster-installs new file mode 100644 index 00000000000..976f81bd8e3 --- /dev/null +++ b/plugins/woocommerce/changelog/43983-feature-marketplace-faster-installs @@ -0,0 +1,4 @@ +Significance: minor +Type: add + +Adds a faster way to install products from the In-App Marketplace \ No newline at end of file diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php index 153beeb88c1..6d58b6eea51 100644 --- a/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php +++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-admin.php @@ -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; diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper-orders-api.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper-orders-api.php new file mode 100644 index 00000000000..71097adde77 --- /dev/null +++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper-orders-api.php @@ -0,0 +1,107 @@ + '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(); diff --git a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php index 36395a9b662..f93ab209cd7 100644 --- a/plugins/woocommerce/includes/admin/helper/class-wc-helper.php +++ b/plugins/woocommerce/includes/admin/helper/class-wc-helper.php @@ -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'; } /**