From b71bc861fb25031740722ce537f7eae3cdc457cd Mon Sep 17 00:00:00 2001 From: Fernando Espinosa Date: Fri, 27 Mar 2020 21:42:58 +0100 Subject: [PATCH] WooCommerce Shipping order page prompt (https://github.com/woocommerce/woocommerce-admin/pull/3955) Co-authored-by: David Stone Co-authored-by: Chris Shultz Co-authored-by: Harris Wong --- plugins/woocommerce-admin/bin/make-zip.sh | 4 +- .../dismiss-modal/index.js | 81 +++ .../dismiss-modal/test/index.js | 141 +++++ .../print-shipping-label-banner/index.js | 16 + .../setup-notice/index.js | 50 ++ .../setup-notice/test/index.js | 43 ++ .../shipping-banner/index.js | 556 ++++++++++++++++++ .../shipping-banner/test/index.js | 459 +++++++++++++++ .../print-shipping-label-banner/style.scss | 153 +++++ .../print-shipping-label-banner/wcs-api.js | 21 + plugins/woocommerce-admin/config/core.json | 3 +- .../woocommerce-admin/config/development.json | 3 +- plugins/woocommerce-admin/config/plugin.json | 3 +- .../images/shippingillustration.svg | 1 + plugins/woocommerce-admin/src/API/Init.php | 8 + .../src/Features/ShippingLabelBanner.php | 187 ++++++ .../ShippingLabelBannerDisplayRules.php | 229 ++++++++ ...ts-shipping-label-banner-display-rules.php | 306 ++++++++++ plugins/woocommerce-admin/webpack.config.js | 1 + 19 files changed, 2260 insertions(+), 5 deletions(-) create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/dismiss-modal/index.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/dismiss-modal/test/index.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/index.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/setup-notice/index.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/setup-notice/test/index.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/shipping-banner/index.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/shipping-banner/test/index.js create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/style.scss create mode 100644 plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/wcs-api.js create mode 100644 plugins/woocommerce-admin/images/shippingillustration.svg create mode 100644 plugins/woocommerce-admin/src/Features/ShippingLabelBanner.php create mode 100644 plugins/woocommerce-admin/src/Features/ShippingLabelBannerDisplayRules.php create mode 100644 plugins/woocommerce-admin/tests/features/class-wc-tests-shipping-label-banner-display-rules.php diff --git a/plugins/woocommerce-admin/bin/make-zip.sh b/plugins/woocommerce-admin/bin/make-zip.sh index b8c1cf60ac1..b6c9268e6dc 100755 --- a/plugins/woocommerce-admin/bin/make-zip.sh +++ b/plugins/woocommerce-admin/bin/make-zip.sh @@ -21,9 +21,9 @@ output 2 "Creating archive... 🎁" ZIP_FILE=$1 -build_files=$(ls dist/*/*.{js,css}) +build_files=$(find dist/ \( -name '*.js' -o -name '*.css' \)) -zip -r ${ZIP_FILE} \ +zip -r "${ZIP_FILE}" \ woocommerce-admin.php \ uninstall.php \ includes/ \ diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/dismiss-modal/index.js b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/dismiss-modal/index.js new file mode 100644 index 00000000000..93073213a7b --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/dismiss-modal/index.js @@ -0,0 +1,81 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { Button, Modal } from '@wordpress/components'; +import { withDispatch } from '@wordpress/data'; +import { compose } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import '../style.scss'; + +export class DismissModal extends Component { + setDismissed = ( timestamp ) => { + this.props.updateOptions( { + woocommerce_shipping_dismissed_timestamp: timestamp, + } ); + }; + + hideBanner = () => { + document.getElementById( + 'woocommerce-admin-print-label' + ).style.display = 'none'; + }; + + remindMeLaterClicked = () => { + const { onCloseAll, trackElementClicked } = this.props; + this.setDismissed( Date.now() ); + onCloseAll(); + this.hideBanner(); + trackElementClicked( 'shipping_banner_dismiss_modal_remind_me_later' ); + }; + + closeForeverClicked = () => { + const { onCloseAll, trackElementClicked } = this.props; + this.setDismissed( -1 ); + onCloseAll(); + this.hideBanner(); + trackElementClicked( 'shipping_banner_dismiss_modal_close_forever' ); + }; + + render() { + const { onClose, visible } = this.props; + + if ( ! visible ) { + return null; + } + + return ( + +

+ { __( + 'With WooCommerce Shipping you can Print shipping labels from your WooCommerce dashboard at the lowest USPS rates.', + 'woocommerce-admin' + ) } +

+
+ + +
+
+ ); + } +} + +export default compose( + withDispatch( ( dispatch ) => { + const { updateOptions } = dispatch( 'wc-api' ); + return { updateOptions }; + } ) +)( DismissModal ); diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/dismiss-modal/test/index.js b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/dismiss-modal/test/index.js new file mode 100644 index 00000000000..af171bc8122 --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/dismiss-modal/test/index.js @@ -0,0 +1,141 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import { DismissModal } from '../index.js'; + +describe( 'Option Save events in DismissModal', () => { + const spyUpdateOptions = jest.fn(); + + let dismissModalWrapper; + + beforeEach( () => { + document.body.innerHTML = + document.body.innerHTML + + '
'; + dismissModalWrapper = shallow( + + ); + } ); + + test( 'Should save permanent dismissal', () => { + const permanentDismissTimestamp = -1; + const actionButtons = dismissModalWrapper.find( Button ); + expect( actionButtons.length ).toBe( 2 ); + const permanenttDismissButton = actionButtons.last(); + permanenttDismissButton.simulate( 'click' ); + expect( spyUpdateOptions ).toHaveBeenCalledWith( { + woocommerce_shipping_dismissed_timestamp: permanentDismissTimestamp, + } ); + } ); + + test( 'Should save temporary dismissal', () => { + // Mock Date.now() so a known timestamp will be saved. + const mockDate = 123456; + const realDateNow = Date.now.bind( global.Date ); + global.Date.now = jest.fn( () => mockDate ); + + const actionButtons = dismissModalWrapper.find( Button ); + expect( actionButtons.length ).toBe( 2 ); + const remindMeLaterButton = actionButtons.first(); + remindMeLaterButton.simulate( 'click' ); + expect( spyUpdateOptions ).toHaveBeenCalledWith( { + woocommerce_shipping_dismissed_timestamp: mockDate, + } ); + + // Restore Date.now(). + global.Date.now = realDateNow; + } ); +} ); + +describe( 'Tracking events in DismissModal', () => { + const trackElementClicked = jest.fn(); + + let dismissModalWrapper; + + beforeEach( () => { + dismissModalWrapper = shallow( + + ); + } ); + + it( 'should record an event when user clicks "I don\'t need this"', () => { + const actionButtons = dismissModalWrapper.find( Button ); + expect( actionButtons.length ).toBe( 2 ); + const iDoNotNeedThisButton = actionButtons.last(); + iDoNotNeedThisButton.simulate( 'click' ); + expect( trackElementClicked ).toHaveBeenCalledWith( + 'shipping_banner_dismiss_modal_close_forever' + ); + } ); + + it( 'should record an event when user clicks "Remind me later"', () => { + const actionButtons = dismissModalWrapper.find( Button ); + expect( actionButtons.length ).toBe( 2 ); + const remindMeLaterButton = actionButtons.first(); + remindMeLaterButton.simulate( 'click' ); + expect( trackElementClicked ).toHaveBeenCalledWith( + 'shipping_banner_dismiss_modal_remind_me_later' + ); + } ); +} ); + +describe( 'Dismissing modal', () => { + let dismissModalWrapper; + + beforeEach( () => { + document.body.innerHTML = + document.body.innerHTML + + '
'; + dismissModalWrapper = shallow( + + ); + } ); + + test( 'Should hide the banner by clicking permanent dismissal', () => { + const actionButtons = dismissModalWrapper.find( Button ); + expect( actionButtons.length ).toBe( 2 ); + const permanenttDismissButton = actionButtons.last(); + permanenttDismissButton.simulate( 'click' ); + + const bannerStyle = document.getElementById( + 'woocommerce-admin-print-label' + ).style; + expect( bannerStyle.display ).toBe( 'none' ); + } ); + + test( 'Should hide the banner by clicking temporary dismissal', () => { + const actionButtons = dismissModalWrapper.find( Button ); + expect( actionButtons.length ).toBe( 2 ); + const remindMeLaterButton = actionButtons.first(); + remindMeLaterButton.simulate( 'click' ); + + const bannerStyle = document.getElementById( + 'woocommerce-admin-print-label' + ).style; + expect( bannerStyle.display ).toBe( 'none' ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/index.js b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/index.js new file mode 100644 index 00000000000..e58454b2bcb --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/index.js @@ -0,0 +1,16 @@ +/** + * External dependencies + */ +import { render } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import ShippingBanner from './shipping-banner'; + +const metaBox = document.getElementById( 'wc-admin-shipping-banner-root' ); +const args = + ( metaBox.dataset.args && JSON.parse( metaBox.dataset.args ) ) || {}; + +// Render the header. +render( , metaBox ); diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/setup-notice/index.js b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/setup-notice/index.js new file mode 100644 index 00000000000..a0bd080825c --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/setup-notice/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Dashicon } from '@wordpress/components'; + +export const setupErrorTypes = { + DOWNLOAD: 'download', + INSTALL: 'install', + ACTIVATE: 'activate', + SETUP: 'setup', + START: 'start', +}; + +const setupErrorDescriptions = { + [ setupErrorTypes.DOWNLOAD ]: __( 'download', 'woocommerce-admin' ), + [ setupErrorTypes.INSTALL ]: __( 'install', 'woocommerce-admin' ), + [ setupErrorTypes.ACTIVATE ]: __( 'activate', 'woocommerce-admin' ), + [ setupErrorTypes.SETUP ]: __( 'set up', 'woocommerce-admin' ), + [ setupErrorTypes.START ]: __( 'start', 'woocommerce-admin' ), +}; + +export default function SetupNotice( { isSetupError, errorReason } ) { + const getErrorMessage = ( errorType ) => { + // Default to 'set up' description if the error type somehow doesn't exist. + const description = + errorType in setupErrorDescriptions + ? setupErrorDescriptions[ errorType ] + : setupErrorDescriptions[ setupErrorTypes.SETUP ]; + + return sprintf( + __( + 'Unable to %s the plugin. Refresh the page and try again.', + 'woocommerce-admin' + ), + description + ); + }; + + if ( ! isSetupError ) { + return null; + } + + return ( +
+ + { getErrorMessage( errorReason ) } +
+ ); +} diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/setup-notice/test/index.js b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/setup-notice/test/index.js new file mode 100644 index 00000000000..e4301ac605f --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/setup-notice/test/index.js @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import SetupNotice, { setupErrorTypes } from '../'; + +describe( 'SetupNotice', () => { + it( 'should be hidden for no error', () => { + const notice = mount( ); + expect( notice.isEmptyRender() ).toBe( true ); + } ); + + it( 'should show div if there is an error', () => { + const notice = mount( ); + const contents = notice.find( + '.wc-admin-shipping-banner-install-error' + ); + expect( contents.length ).toBe( 1 ); + } ); + + it( 'should show download message for download error', () => { + const notice = mount( + + ); + expect( + notice.text().includes( 'Unable to download the plugin' ) + ).toBe( true ); + } ); + + it( 'should show default message for unset error', () => { + const notice = mount( ); + expect( notice.text().includes( 'Unable to set up the plugin' ) ).toBe( + true + ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/shipping-banner/index.js b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/shipping-banner/index.js new file mode 100644 index 00000000000..7ed29ccbb16 --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/shipping-banner/index.js @@ -0,0 +1,556 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { Component } from '@wordpress/element'; +import { Button, ExternalLink } from '@wordpress/components'; +import { compose } from '@wordpress/compose'; +import { recordEvent } from 'lib/tracks'; +import interpolateComponents from 'interpolate-components'; +import PropTypes from 'prop-types'; +import { get, isArray } from 'lodash'; + +/** + * Internal dependencies + */ +import '../style.scss'; +import DismissModal from '../dismiss-modal'; +import { getSetting } from '@woocommerce/wc-admin-settings'; +import withSelect from 'wc-api/with-select'; +import SetupNotice, { setupErrorTypes } from '../setup-notice'; +import { withDispatch } from '@wordpress/data'; +import { getWcsAssets, acceptWcsTos } from '../wcs-api'; + +const wcAdminAssetUrl = getSetting( 'wcAdminAssetUrl', '' ); + +export class ShippingBanner extends Component { + constructor( props ) { + super( props ); + + const orderId = new URL( window.location.href ).searchParams.get( + 'post' + ); + + this.state = { + showShippingBanner: true, + isDismissModalOpen: false, + setupErrorReason: setupErrorTypes.SETUP, + orderId: parseInt( orderId, 10 ), + wcsAssetsLoaded: false, + wcsAssetsLoading: false, + wcsSetupError: false, + isShippingLabelButtonBusy: false, + installText: this.getInstallText(), + isWcsModalOpen: false, + }; + } + + componentDidMount() { + const { showShippingBanner } = this.state; + + if ( showShippingBanner ) { + this.trackImpression(); + } + } + + componentDidUpdate( prevProps ) { + const { activatePlugins, wcsPluginSlug } = this.props; + + if ( this.justInstalledWcs( prevProps ) ) { + activatePlugins( [ wcsPluginSlug ] ); + } + if ( this.justActivatedWcs( prevProps ) ) { + this.acceptTosAndGetWCSAssets(); + } + } + + justInstalledWcs( prevProps ) { + const { installedPlugins, wcsPluginSlug } = this.props; + const wcsNowInstalled = installedPlugins.includes( wcsPluginSlug ); + const wcsPrevInstalled = prevProps.installedPlugins.includes( + wcsPluginSlug + ); + return wcsNowInstalled && ! wcsPrevInstalled; + } + + justActivatedWcs( prevProps ) { + const { activatedPlugins, wcsPluginSlug } = this.props; + const wcsNowActivated = activatedPlugins.includes( wcsPluginSlug ); + const wcsPrevActivated = prevProps.activatedPlugins.includes( + wcsPluginSlug + ); + return wcsNowActivated && ! wcsPrevActivated; + } + + hasActivationError = () => { + return Boolean( this.props.activationErrors.length ); + }; + + hasInstallationError = () => { + return Boolean( this.props.installationErrors.length ); + }; + + isSetupError = () => { + return ( + this.hasActivationError() || + this.hasInstallationError() || + this.state.wcsSetupError + ); + }; + + setupErrorReason = () => { + if ( this.hasInstallationError() ) { + return setupErrorTypes.INSTALL; + } + if ( this.hasActivationError() ) { + return setupErrorTypes.ACTIVATE; + } + return setupErrorTypes.SETUP; + }; + + closeDismissModal = () => { + this.setState( { isDismissModalOpen: false } ); + this.trackElementClicked( + 'shipping_banner_dismiss_modal_close_button' + ); + }; + + openDismissModal = () => { + this.setState( { isDismissModalOpen: true } ); + this.trackElementClicked( 'shipping_banner_dimiss' ); + }; + + hideBanner = () => { + this.setState( { showShippingBanner: false } ); + }; + + createShippingLabelClicked = () => { + const { wcsPluginSlug, activePlugins } = this.props; + this.setState( { isShippingLabelButtonBusy: true } ); + this.trackElementClicked( 'shipping_banner_create_label' ); + if ( ! activePlugins.includes( wcsPluginSlug ) ) { + this.installAndActivatePlugins( wcsPluginSlug ); + } else { + this.acceptTosAndGetWCSAssets(); + } + }; + + installAndActivatePlugins( pluginSlug ) { + // Avoid double activating. + const { installPlugins, isRequesting } = this.props; + if ( isRequesting ) { + return false; + } + installPlugins( [ pluginSlug ] ); + } + + woocommerceServiceLinkClicked = () => { + this.trackElementClicked( 'shipping_banner_woocommerce_service_link' ); + }; + + trackBannerEvent = ( eventName, customProps = {} ) => { + const { activePlugins, isJetpackConnected, wcsPluginSlug } = this.props; + recordEvent( eventName, { + banner_name: 'wcadmin_install_wcs_prompt', + jetpack_installed: activePlugins.includes( 'jetpack' ), + jetpack_connected: isJetpackConnected, + wcs_installed: activePlugins.includes( wcsPluginSlug ), + ...customProps, + } ); + }; + + trackImpression = () => { + this.trackBannerEvent( 'banner_impression' ); + }; + + trackElementClicked = ( element ) => { + this.trackBannerEvent( 'banner_element_clicked', { + element, + } ); + }; + + acceptTosAndGetWCSAssets() { + return acceptWcsTos() + .then( () => getWcsAssets() ) + .then( ( wcsAssets ) => this.loadWcsAssets( wcsAssets ) ) + .catch( () => this.setState( { wcsSetupError: true } ) ); + } + + generateMetaBoxHtml( nodeId, title, args ) { + const argsJsonString = JSON.stringify( args ).replace( /"/g, '"' ); // JS has no native html_entities so we just replace. + + const togglePanelText = __( 'Toggle panel:', 'woocommerce-admin' ); + + return ` +
+ +

${ title }

+
+
+
+
+
+`; + } + + loadWcsAssets( { assets } ) { + if ( this.state.wcsAssetsLoaded || this.state.wcsAssetsLoading ) { + this.openWcsModal(); + return; + } + + this.setState( { wcsAssetsLoading: true } ); + + const js = assets.wc_connect_admin_script; + const styles = assets.wc_connect_admin_style; + + const { orderId } = this.state; + const { itemsCount } = this.props; + + const shippingLabelContainerHtml = this.generateMetaBoxHtml( + 'woocommerce-order-label', + __( 'Shipping Label', 'woocommerce-admin' ), + { + orderId, + context: 'shipping_label', + items: itemsCount, + } + ); + // Insert shipping label metabox just above main order details box. + document + .getElementById( 'woocommerce-order-data' ) + .insertAdjacentHTML( 'beforebegin', shippingLabelContainerHtml ); + + const shipmentTrackingHtml = this.generateMetaBoxHtml( + 'woocommerce-order-shipment-tracking', + __( 'Shipment Tracking', 'woocommerce-admin' ), + { + orderId, + context: 'shipment_tracking', + items: itemsCount, + } + ); + // Insert tracking metabox in the side after the order actions. + document + .getElementById( 'woocommerce-order-actions' ) + .insertAdjacentHTML( 'afterend', shipmentTrackingHtml ); + + if ( window.jQuery ) { + // Need to refresh so the new metaboxes are sortable. + window.jQuery( '#normal-sortables' ).sortable( 'refresh' ); + window.jQuery( '#side-sortables' ).sortable( 'refresh' ); + + window.jQuery( '#woocommerce-order-label' ).hide(); + } + + Promise.all( [ + new Promise( ( resolve, reject ) => { + const script = document.createElement( 'script' ); + script.src = js; + script.async = true; + script.onload = resolve; + script.onerror = reject; + document.body.appendChild( script ); + } ), + new Promise( ( resolve, reject ) => { + const head = document.getElementsByTagName( 'head' )[ 0 ]; + const link = document.createElement( 'link' ); + link.rel = 'stylesheet'; + link.type = 'text/css'; + link.href = styles; + link.media = 'all'; + link.onload = resolve; + link.onerror = reject; + head.appendChild( link ); + } ), + ] ).then( () => { + this.setState( { + wcsAssetsLoaded: true, + wcsAssetsLoading: false, + isShippingLabelButtonBusy: false, + } ); + this.openWcsModal(); + } ); + } + + getInstallText = () => { + const { activePlugins, wcsPluginSlug } = this.props; + if ( activePlugins.includes( wcsPluginSlug ) ) { + // If WCS is active, then the only remaining step is to agree to the ToS. + return __( + 'You\'ve already installed WooCommerce Shipping. By clicking "Create shipping label", you agree to its {{tosLink}}Terms of Service{{/tosLink}}.', + 'woocommerce-admin' + ); + } + return __( + 'By clicking "Create shipping label", {{wcsLink}}WooCommerce Shipping{{/wcsLink}} will be installed and you agree to its {{tosLink}}Terms of Service{{/tosLink}}.', + 'woocommerce-admin' + ); + }; + + openWcsModal() { + if ( window.wcsGetAppStore ) { + const wcsStore = window.wcsGetAppStore( + 'wc-connect-create-shipping-label' + ); + const state = wcsStore.getState(); + const { orderId } = this.state; + const siteId = state.ui.selectedSiteId; + + const wcsStoreUnsubscribe = wcsStore.subscribe( () => { + const latestState = wcsStore.getState(); + + const shippingLabelState = get( + latestState, + [ + 'extensions', + 'woocommerce', + 'woocommerceServices', + siteId, + 'shippingLabel', + orderId, + ], + null + ); + + const labelSettingsState = get( + latestState, + [ + 'extensions', + 'woocommerce', + 'woocommerceServices', + siteId, + 'labelSettings', + ], + null + ); + + const packageState = get( + latestState, + [ + 'extensions', + 'woocommerce', + 'woocommerceServices', + siteId, + 'packages', + ], + null + ); + + const locationsState = get( latestState, [ + 'extensions', + 'woocommerce', + 'sites', + siteId, + 'data', + 'locations', + ] ); + + if ( + shippingLabelState && + labelSettingsState && + labelSettingsState.meta && + packageState && + locationsState + ) { + if ( + shippingLabelState.loaded && + labelSettingsState.meta.isLoaded && + packageState.isLoaded && + isArray( locationsState ) && + ! this.state.isWcsModalOpen + ) { + if ( window.jQuery ) { + this.setState( { isWcsModalOpen: true } ); + window + .jQuery( '.shipping-label__new-label-button' ) + .click(); + } + wcsStore.dispatch( { + type: 'NOTICE_CREATE', + notice: { + duration: 10000, + status: 'is-success', + text: __( + 'Plugin installed and activated', + 'woocommerce-admin' + ), + }, + } ); + } else if ( shippingLabelState.showPurchaseDialog ) { + wcsStoreUnsubscribe(); + if ( window.jQuery ) { + window.jQuery( '#woocommerce-order-label' ).show(); + } + } + } + } ); + + document.getElementById( + 'woocommerce-admin-print-label' + ).style.display = 'none'; + } + } + + render() { + const { + isDismissModalOpen, + showShippingBanner, + isShippingLabelButtonBusy, + } = this.state; + if ( ! showShippingBanner ) { + return null; + } + + return ( +
+
+ { +
+

+ { __( + 'Print discounted shipping labels with a click.', + 'woocommerce-admin' + ) } +

+

+ { interpolateComponents( { + mixedString: this.state.installText, + components: { + tosLink: ( + + ), + wcsLink: ( + + ), + }, + } ) } +

+ +
+ + + +
+ +
+ ); + } +} + +ShippingBanner.propTypes = { + itemsCount: PropTypes.number.isRequired, + isJetpackConnected: PropTypes.bool.isRequired, + activatedPlugins: PropTypes.array.isRequired, + activePlugins: PropTypes.array.isRequired, + installedPlugins: PropTypes.array.isRequired, + wcsPluginSlug: PropTypes.string.isRequired, + activationErrors: PropTypes.array.isRequired, + installationErrors: PropTypes.array.isRequired, + activatePlugins: PropTypes.func.isRequired, + installPlugins: PropTypes.func.isRequired, + isRequesting: PropTypes.bool.isRequired, +}; + +export default compose( + withSelect( ( select ) => { + const wcsPluginSlug = 'woocommerce-services'; + const { + getActivePlugins, + getPluginInstallations, + getPluginActivations, + getPluginActivationErrors, + getPluginInstallationErrors, + isJetpackConnected, + isPluginActivateRequesting, + isPluginInstallRequesting, + } = select( 'wc-api' ); + + const isRequesting = + isPluginActivateRequesting() || isPluginInstallRequesting(); + const allInstallationErrors = getPluginInstallationErrors( [ + wcsPluginSlug, + ] ); + const installedPlugins = Object.keys( + getPluginInstallations( [ wcsPluginSlug ] ) + ); + const allActivationErrors = getPluginActivationErrors( [ + wcsPluginSlug, + ] ); + const activatedPlugins = Object.keys( + getPluginActivations( [ wcsPluginSlug ] ) + ); + const activationErrors = []; + const installationErrors = []; + Object.keys( allActivationErrors ).map( ( plugin ) => + activationErrors.push( allActivationErrors[ plugin ].message ) + ); + Object.keys( allInstallationErrors ).map( ( plugin ) => + installationErrors.push( allInstallationErrors[ plugin ].message ) + ); + return { + activePlugins: getActivePlugins(), + isJetpackConnected: isJetpackConnected(), + isRequesting, + installedPlugins, + activatedPlugins, + wcsPluginSlug, + activationErrors, + installationErrors, + }; + } ), + withDispatch( ( dispatch ) => { + const { activatePlugins, installPlugins } = dispatch( 'wc-api' ); + + return { + activatePlugins, + installPlugins, + }; + } ) +)( ShippingBanner ); diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/shipping-banner/test/index.js b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/shipping-banner/test/index.js new file mode 100644 index 00000000000..72a5f1d17fc --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/shipping-banner/test/index.js @@ -0,0 +1,459 @@ +/** + * External dependencies + */ +import { shallow } from 'enzyme'; +import { recordEvent } from 'lib/tracks'; +import { ExternalLink, Button } from '@wordpress/components'; + +/** + * Internal dependencies + */ + +jest.mock( '../../wcs-api.js' ); +import { acceptWcsTos, getWcsAssets } from '../../wcs-api.js'; + +acceptWcsTos.mockReturnValue( Promise.resolve() ); +const wcsAssetsMock = {}; +getWcsAssets.mockReturnValue( Promise.resolve( wcsAssetsMock ) ); + +import { ShippingBanner } from '../index.js'; + +jest.mock( 'lib/tracks' ); +jest.mock( '@woocommerce/wc-admin-settings' ); + +describe( 'Tracking impression in shippingBanner', () => { + const expectedTrackingData = { + banner_name: 'wcadmin_install_wcs_prompt', + jetpack_connected: true, + jetpack_installed: true, + wcs_installed: true, + }; + const wcsPluginSlug = 'it-does-n-t-matter-since-its-a-prop'; + + beforeEach( () => { + shallow( + + ); + } ); + + it( 'should record an event when user sees banner loaded', () => { + expect( recordEvent ).toHaveBeenCalledTimes( 1 ); + expect( recordEvent ).toHaveBeenCalledWith( + 'banner_impression', + expectedTrackingData + ); + } ); +} ); + +describe( 'Tracking clicks in shippingBanner', () => { + const isJetpackConnected = true; + let shippingBannerWrapper; + + const getExpectedTrackingData = ( element ) => { + return { + banner_name: 'wcadmin_install_wcs_prompt', + jetpack_connected: true, + jetpack_installed: true, + wcs_installed: true, + element, + }; + }; + const wcsPluginSlug = 'it-does-n-t-matter-since-its-a-prop'; + + beforeEach( () => { + shippingBannerWrapper = shallow( + + ); + } ); + + it( 'should record an event when user clicks "Create shipping label"', () => { + const createShippingLabelButton = shippingBannerWrapper.find( Button ); + expect( createShippingLabelButton.length ).toBe( 1 ); + createShippingLabelButton.simulate( 'click' ); + expect( recordEvent ).toHaveBeenCalledWith( + 'banner_element_clicked', + getExpectedTrackingData( 'shipping_banner_create_label' ) + ); + } ); + + it( 'should record an event when user clicks "WooCommerce Service"', () => { + const links = shippingBannerWrapper.find( ExternalLink ); + expect( links.length ).toBe( 1 ); + const wcsLink = links.first(); + wcsLink.simulate( 'click' ); + expect( recordEvent ).toHaveBeenCalledWith( + 'banner_impression', + getExpectedTrackingData() + ); + } ); + + it( 'should record an event when user clicks "x" to dismiss the banner', () => { + const noticeDimissButton = shippingBannerWrapper.find( + '.notice-dismiss' + ); + expect( noticeDimissButton.length ).toBe( 1 ); + noticeDimissButton.simulate( 'click' ); + expect( recordEvent ).toHaveBeenCalledWith( + 'banner_element_clicked', + getExpectedTrackingData( 'shipping_banner_dimiss' ) + ); + } ); +} ); + +describe( 'Create shipping label button', () => { + let shippingBannerWrapper; + const installPlugins = jest.fn(); + const activatePlugins = jest.fn(); + delete window.location; // jsdom won't allow to rewrite window.location unless deleted first + window.location = { + href: 'http://wcship.test/wp-admin/post.php?post=1000&action=edit', + }; + + beforeEach( () => { + shippingBannerWrapper = shallow( + + ); + } ); + + it( 'should install WooCommerce Shipping when button is clicked', () => { + const createShippingLabelButton = shippingBannerWrapper.find( Button ); + expect( createShippingLabelButton.length ).toBe( 1 ); + createShippingLabelButton.simulate( 'click' ); + expect( installPlugins ).toHaveBeenCalledWith( [ + 'woocommerce-services', + ] ); + } ); + + it( 'should activate WooCommerce Shipping when installation finishes', () => { + // Cause a 'componentDidUpdate' by changing the props. + shippingBannerWrapper.setProps( { + installedPlugins: [ 'woocommerce-services' ], + } ); + expect( activatePlugins ).toHaveBeenCalledWith( [ + 'woocommerce-services', + ] ); + } ); + + it( 'should perform a request to accept the TOS and get WCS assets to load', async () => { + const loadWcsAssetsMock = jest.fn(); + shippingBannerWrapper.instance().loadWcsAssets = loadWcsAssetsMock; + + await shippingBannerWrapper.instance().acceptTosAndGetWCSAssets(); + + expect( acceptWcsTos ).toHaveBeenCalled(); + + expect( getWcsAssets ).toHaveBeenCalled(); + + expect( loadWcsAssetsMock ).toHaveBeenCalledWith( wcsAssetsMock ); + } ); + + it( 'should load WCS assets when a path is provided', () => { + const scriptMock = {}; + const linkMock = {}; + const divMock = { dataset: { args: null } }; + const createElementMockReturn = { + div: divMock, + script: scriptMock, + link: linkMock, + }; + + window.jQuery = jest.fn(); + window.jQuery.mockReturnValue( { + sortable: jest.fn(), + hide: jest.fn(), + } ); + + const createElementMock = jest.fn( ( tagName ) => { + return createElementMockReturn[ tagName ]; + } ); + const createElement = document.createElement; + document.createElement = createElementMock; + + const getElementsByTagNameMock = jest.fn(); + const headMock = { + appendChild: jest.fn(), + }; + getElementsByTagNameMock.mockReturnValueOnce( [ headMock ] ); + const getElementsByTagName = document.getElementsByTagName; + document.getElementsByTagName = getElementsByTagNameMock; + const getElementByIdMock = jest.fn(); + getElementByIdMock.mockReturnValue( { + insertAdjacentHTML: jest.fn(), + } ); + const getElementById = document.getElementById; + document.getElementById = getElementByIdMock; + + const appendChildMock = jest.fn(); + const appendChild = document.body.appendChild; + document.body.appendChild = appendChildMock; + + const openWcsModalMock = jest.fn(); + shippingBannerWrapper.instance().openWcsModal = openWcsModalMock; + + shippingBannerWrapper.instance().loadWcsAssets( { + assets: { + wc_connect_admin_script: '/path/to/wcs.js', + wc_connect_admin_style: '/path/to/wcs.css', + }, + } ); + + expect( createElementMock ).toHaveBeenCalledWith( 'script' ); + expect( createElementMock ).toHaveNthReturnedWith( 1, scriptMock ); + expect( scriptMock.async ).toEqual( true ); + expect( scriptMock.src ).toEqual( '/path/to/wcs.js' ); + expect( appendChildMock ).toHaveBeenCalledWith( scriptMock ); + + expect( getElementsByTagNameMock ).toHaveBeenCalledWith( 'head' ); + expect( getElementsByTagNameMock ).toHaveReturnedWith( [ headMock ] ); + expect( createElementMock ).toHaveBeenCalledWith( 'link' ); + expect( createElementMock ).toHaveNthReturnedWith( 2, linkMock ); + expect( linkMock.rel ).toEqual( 'stylesheet' ); + expect( linkMock.type ).toEqual( 'text/css' ); + expect( linkMock.href ).toEqual( '/path/to/wcs.css' ); + expect( linkMock.media ).toEqual( 'all' ); + + document.createElement = createElement; + document.getElementById = getElementById; + document.body.appendChild = appendChild; + document.getElementsByTagName = getElementsByTagName; + } ); + + it( 'should open WCS modal', () => { + window.wcsGetAppStore = jest.fn(); + const getState = jest.fn(); + const dispatch = jest.fn(); + const subscribe = jest.fn(); + window.wcsGetAppStore.mockReturnValueOnce( { + getState, + dispatch, + subscribe, + } ); + getState.mockReturnValueOnce( { + ui: { + selectedSiteId: 'SITE_ID', + }, + } ); + + const getElementByIdMock = jest.fn(); + getElementByIdMock.mockReturnValue( { + style: { display: null }, + } ); + const getElementById = document.getElementById; + document.getElementById = getElementByIdMock; + + shippingBannerWrapper.instance().openWcsModal(); + expect( window.wcsGetAppStore ).toHaveBeenCalledWith( + 'wc-connect-create-shipping-label' + ); + expect( getState ).toHaveBeenCalledTimes( 1 ); + expect( subscribe ).toHaveBeenCalledTimes( 1 ); + expect( getElementByIdMock ).toHaveBeenCalledWith( + 'woocommerce-admin-print-label' + ); + + document.getElementById = getElementById; + } ); +} ); + +describe( 'In the process of installing, activating, loading assets for WooCommerce Service', () => { + let shippingBannerWrapper; + + beforeEach( () => { + shippingBannerWrapper = shallow( + + ); + } ); + + it( 'should show a busy loading state on "Create shipping label"', () => { + shippingBannerWrapper.setState( { isShippingLabelButtonBusy: true } ); + const createShippingLabelButton = shippingBannerWrapper.find( Button ); + expect( createShippingLabelButton.length ).toBe( 1 ); + expect( createShippingLabelButton.prop( 'disabled' ) ).toBe( true ); + expect( createShippingLabelButton.prop( 'isBusy' ) ).toBe( true ); + } ); + + it( 'should disable the dismiss button ', () => { + shippingBannerWrapper.setState( { isShippingLabelButtonBusy: true } ); + const dismissButton = shippingBannerWrapper.find( '.notice-dismiss' ); + expect( dismissButton.length ).toBe( 1 ); + expect( dismissButton.prop( 'disabled' ) ).toBe( true ); + } ); +} ); + +describe( 'Setup error message', () => { + let shippingBannerWrapper; + + beforeEach( () => { + shippingBannerWrapper = shallow( + + ); + } ); + + it( 'should not show if there is no error', () => { + expect( shippingBannerWrapper.instance().isSetupError() ).toBe( false ); + expect( shippingBannerWrapper.instance().hasActivationError() ).toBe( + false + ); + expect( shippingBannerWrapper.instance().hasInstallationError() ).toBe( + false + ); + } ); + + it( 'should show if there is activation error', () => { + shippingBannerWrapper.setProps( { + activationErrors: [ 'Can not activate' ], + } ); + expect( shippingBannerWrapper.instance().isSetupError() ).toBe( true ); + expect( shippingBannerWrapper.instance().hasActivationError() ).toBe( + true + ); + expect( shippingBannerWrapper.instance().hasInstallationError() ).toBe( + false + ); + } ); + + it( 'should show if there is installation error', () => { + shippingBannerWrapper.setProps( { + installationErrors: [ 'Can not activate' ], + } ); + expect( shippingBannerWrapper.instance().isSetupError() ).toBe( true ); + expect( shippingBannerWrapper.instance().hasActivationError() ).toBe( + false + ); + expect( shippingBannerWrapper.instance().hasInstallationError() ).toBe( + true + ); + } ); +} ); + +describe( 'The message in the banner', () => { + const createShippingBannerWrapper = ( { + activePlugins, + installedPlugins, + } ) => + shallow( + + ); + + const notActivatedMessage = + 'By clicking "Create shipping label", WooCommerce Shipping will be installed and you agree to its Terms of Service.'; + const activatedMessage = + 'You\'ve already installed WooCommerce Shipping. By clicking "Create shipping label", you agree to its Terms of Service.'; + + it( 'should show install text "By clicking "Create shipping label"..." when first loaded.', () => { + const shippingBannerWrapper = createShippingBannerWrapper( { + activePlugins: [], + installedPlugins: [], + } ); + const createShippingLabelText = shippingBannerWrapper.find( 'p' ); + expect( createShippingLabelText.text() ).toBe( notActivatedMessage ); + } ); + + it( 'should continue to show the initial message "By clicking "Create shipping label"..." after WooCommerce Service is installed successfully.', () => { + const shippingBannerWrapper = createShippingBannerWrapper( { + activePlugins: [], + installedPlugins: [ 'woocommerce-services' ], + } ); + // Mock installation and activation successful + shippingBannerWrapper.setProps( { + activePlugins: [ 'woocommerce-services' ], + } ); + const createShippingLabelText = shippingBannerWrapper.find( 'p' ); + expect( createShippingLabelText.text() ).toBe( notActivatedMessage ); + } ); + + it( 'should continue to show the initial message "By clicking "Create shipping label"..." after WooCommerce Service is installed and activated successfully.', () => { + const shippingBannerWrapper = createShippingBannerWrapper( { + activePlugins: [], + installedPlugins: [], + } ); + // Mock installation and activation successful + shippingBannerWrapper.setProps( { + activePlugins: [ 'woocommerce-services' ], + installedPlugins: [ 'woocommerce-services' ], + } ); + const createShippingLabelText = shippingBannerWrapper.find( 'p' ); + expect( createShippingLabelText.text() ).toBe( notActivatedMessage ); + } ); + + it( 'should show install text "By clicking "You\'ve already installed WooCommerce Shipping."..." when WooCommerce Service is already installed.', () => { + const shippingBannerWrapper = createShippingBannerWrapper( { + activePlugins: [ 'woocommerce-services' ], + installedPlugins: [ 'woocommerce-services' ], + } ); + const createShippingLabelText = shippingBannerWrapper.find( 'p' ); + expect( createShippingLabelText.text() ).toBe( activatedMessage ); + } ); +} ); diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/style.scss b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/style.scss new file mode 100644 index 00000000000..d128579ec8f --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/style.scss @@ -0,0 +1,153 @@ +// This is needed so ExternalLinks appear correctly. Should be part of 'wp-components' style but for some reason it's not. +@import '@wordpress/components/src/visually-hidden/style'; + +#woocommerce-admin-print-label { + min-height: 100px; + + .hndle, + .handlediv { + display: none; + } + + .wc-admin-shipping-banner-container { + display: flex; + align-items: center; + margin-top: 18px; + margin-bottom: 6px; + } + + .wc-admin-shipping-banner-illustration { + margin-right: 8px; + } + + h3 { + margin: 0; + font-size: 1.15em; + } + + p { + margin: 2px 0 11px; + font-size: 15px; + line-height: 19px; + } + + .components-button.is-button { + margin: 10px 61px 10px auto; + padding: 0 30px; + font-size: 14px; + font-weight: 500; + line-height: 25px; + text-decoration: none; + text-shadow: none; + } + + .notice-dismiss { + top: 14px; + right: 18px; + } + + .wc-admin-shipping-banner-install-error { + color: #d63638; + > .dashicon { + width: 16px; + height: 16px; + margin-right: 3px; + vertical-align: middle; + margin-top: -2px; + path { + fill: #d63638; + } + } + } + .components-external-link__icon { + display: none; + } +} + +.components-modal__frame.wc-admin-shipping-banner__dismiss-modal { + + width: 500px; + max-width: 100%; + + .components-modal__header { + border-bottom: 0; + margin: 4px 0; + padding: 0; + height: 50px; + .components-button.components-icon-button { + + left: 15px; + svg.dashicon { + width: 30px; + height: 30px; + } + } + } + + .components-modal__header-heading { + font-style: normal; + font-weight: 500; + font-size: 20px; + line-height: 32px; + } + + + .wc-admin-shipping-banner__dismiss-modal-help-text { + font-size: 16px; + line-height: 24px; + margin: 0 60px 1em 0; + } + + .wc-admin-shipping-banner__dismiss-modal-actions { + text-align: right; + + .components-button.is-button { + height: 30px; + padding-left: 15px; + padding-right: 15px; + text-align: center; + font-size: 14px; + line-height: 15px; + font-weight: 500; + align-items: center; + margin-left: 10px; + } + + button.is-primary { + align-self: flex-end; + } + } +} + +@media (max-width: 1080px) { + #woocommerce-admin-print-label { + text-align: center; + + .wc-admin-shipping-banner-container { + flex-wrap: wrap; + justify-content: center; + } + .components-button.is-button { + margin: 10px 0 0 0; + } + p { + margin: 5px 15px; + max-width: initial; + } + h3 { + margin-top: 10px; + } + } +} + +.notice.wcs-nux__notice { + display: none; +} + +.wc-admin-shipping-banner__dismiss-modal { + .components-button { + span { + display: none; + } + } +} diff --git a/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/wcs-api.js b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/wcs-api.js new file mode 100644 index 00000000000..92af9f6c6b7 --- /dev/null +++ b/plugins/woocommerce-admin/client/wp-admin-scripts/print-shipping-label-banner/wcs-api.js @@ -0,0 +1,21 @@ +/** + * External dependencies + */ +import apiFetch from '@wordpress/api-fetch'; + +export function acceptWcsTos() { + const path = '/wc/v1/connect/tos'; + return apiFetch( { + path, + method: 'POST', + data: { accepted: true }, + } ); +} + +export function getWcsAssets() { + const path = '/wc/v1/connect/assets'; + return apiFetch( { + path, + method: 'GET', + } ); +} diff --git a/plugins/woocommerce-admin/config/core.json b/plugins/woocommerce-admin/config/core.json index 0534058c879..6beff4ec70c 100644 --- a/plugins/woocommerce-admin/config/core.json +++ b/plugins/woocommerce-admin/config/core.json @@ -6,6 +6,7 @@ "analytics-dashboard/customizable": true, "devdocs": false, "onboarding": true, - "store-alerts": true + "store-alerts": true, + "shipping-label-banner": true } } diff --git a/plugins/woocommerce-admin/config/development.json b/plugins/woocommerce-admin/config/development.json index f07fe454530..c3446406c85 100644 --- a/plugins/woocommerce-admin/config/development.json +++ b/plugins/woocommerce-admin/config/development.json @@ -6,6 +6,7 @@ "analytics-dashboard/customizable": true, "devdocs": true, "onboarding": true, - "store-alerts": true + "store-alerts": true, + "shipping-label-banner": true } } diff --git a/plugins/woocommerce-admin/config/plugin.json b/plugins/woocommerce-admin/config/plugin.json index 0534058c879..f58b2a48f70 100644 --- a/plugins/woocommerce-admin/config/plugin.json +++ b/plugins/woocommerce-admin/config/plugin.json @@ -6,6 +6,7 @@ "analytics-dashboard/customizable": true, "devdocs": false, "onboarding": true, - "store-alerts": true + "store-alerts": true, + "shipping-label-banner": true } } diff --git a/plugins/woocommerce-admin/images/shippingillustration.svg b/plugins/woocommerce-admin/images/shippingillustration.svg new file mode 100644 index 00000000000..5b617539092 --- /dev/null +++ b/plugins/woocommerce-admin/images/shippingillustration.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/plugins/woocommerce-admin/src/API/Init.php b/plugins/woocommerce-admin/src/API/Init.php index 93557c8fd51..706ff17947d 100644 --- a/plugins/woocommerce-admin/src/API/Init.php +++ b/plugins/woocommerce-admin/src/API/Init.php @@ -84,6 +84,14 @@ class Init { 'Automattic\WooCommerce\Admin\API\OnboardingThemes', ) ); + } elseif ( Loader::is_feature_enabled( 'shipping-label-banner' ) ) { + // Shipping Banner needs to use /active /install and /activate endpoints. + $controllers = array_merge( + $controllers, + array( + \Automattic\WooCommerce\Admin\API\OnboardingPlugins::class, + ) + ); } // The performance indicators controller must be registered last, after other /stats endpoints have been registered. diff --git a/plugins/woocommerce-admin/src/Features/ShippingLabelBanner.php b/plugins/woocommerce-admin/src/Features/ShippingLabelBanner.php new file mode 100644 index 00000000000..26bb4b95b5c --- /dev/null +++ b/plugins/woocommerce-admin/src/Features/ShippingLabelBanner.php @@ -0,0 +1,187 @@ +shipping_label_banner_display_rules ) { + $jetpack_version = null; + $jetpack_connected = null; + $wcs_version = null; + $wcs_tos_accepted = null; + + if ( class_exists( '\Jetpack_Data' ) ) { + $user_token = \Jetpack_Data::get_access_token( JETPACK_MASTER_USER ); + + $jetpack_connected = isset( $user_token->external_user_id ); + $jetpack_version = JETPACK__VERSION; + } + + if ( class_exists( '\WC_Connect_Loader' ) ) { + $wcs_version = \WC_Connect_Loader::get_wcs_version(); + } + if ( class_exists( '\WC_Connect_Options' ) ) { + $wcs_tos_accepted = \WC_Connect_Options::get_option( 'tos_accepted' ); + } + + $incompatible_plugins = class_exists( '\WC_Shipping_Fedex_Init' ) || + class_exists( '\WC_Shipping_UPS_Init' ) || + class_exists( '\WC_Integration_ShippingEasy' ) || + class_exists( '\WC_ShipStation_Integration' ); + + $this->shipping_label_banner_display_rules = + new ShippingLabelBannerDisplayRules( + $jetpack_version, + $jetpack_connected, + $wcs_version, + $wcs_tos_accepted, + $incompatible_plugins + ); + } + + return $this->shipping_label_banner_display_rules->should_display_banner(); + } + + /** + * Add metabox to order page. + * + * @param string $post_type current post type. + * @param \WP_Post $post Current post object. + */ + public function add_meta_boxes( $post_type, $post ) { + $order = wc_get_order( $post ); + if ( $this->should_show_meta_box() ) { + add_meta_box( + 'woocommerce-admin-print-label', + __( 'Shipping Label', 'woocommerce-admin' ), + array( $this, 'meta_box' ), + null, + 'normal', + 'high', + array( + 'context' => 'shipping_label', + 'order_id' => $post->ID, + 'shippable_items_count' => $this->count_shippable_items( $order ), + ) + ); + add_action( 'admin_enqueue_scripts', array( $this, 'add_print_shipping_label_script' ) ); + } + } + + /** + * Count shippable items + * + * @param \WC_Order $order Current order. + * @return int + */ + private function count_shippable_items( \WC_Order $order ) { + $count = 0; + foreach ( $order->get_items() as $item ) { + if ( $item instanceof \WC_Order_Item_Product ) { + $product = $item->get_product(); + if ( $product && $product->needs_shipping() ) { + $count += $item->get_quantity(); + } + } + } + return $count; + } + /** + * Adds JS to order page to render shipping banner. + * + * @param string $hook current page hook. + */ + public function add_print_shipping_label_script( $hook ) { + $rtl = is_rtl() ? '-rtl' : ''; + wp_enqueue_style( + 'print-shipping-label-banner-style', + Loader::get_url( "print-shipping-label-banner/style{$rtl}.css" ), + array( 'wp-components' ), + Loader::get_file_version( 'print-shipping-label-banner/style.css' ) + ); + + wp_enqueue_script( + 'print-shipping-label-banner', + Loader::get_url( 'wp-admin-scripts/print-shipping-label-banner.js' ), + array( 'wp-i18n', 'wp-data', 'wp-element', 'moment', 'wp-api-fetch', WC_ADMIN_APP ), + Loader::get_file_version( 'wp-admin-scripts/print-shipping-label-banner.js' ), + true + ); + + $payload = array( + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'baseURL' => get_rest_url(), + 'wcs_server_connection' => true, + ); + + wp_localize_script( 'print-shipping-label-banner', 'wcConnectData', $payload ); + } + + /** + * Render placeholder metabox. + * + * @param \WP_Post $post current post. + * @param array $args empty args. + */ + public function meta_box( $post, $args ) { + + ?> +
+
+ jetpack_version = $jetpack_version; + $this->jetpack_connected = $jetpack_connected; + $this->wcs_version = $wcs_version; + $this->wcs_tos_accepted = $wcs_tos_accepted; + $this->no_incompatible_plugins_installed = ! $incompatible_plugins_installed; + } + + /** + * Determines whether or not the banner should be displayed. + */ + public function should_display_banner() { + if ( ! $this->should_allow_banner() ) { + return false; + } + $ab_test = get_option( 'woocommerce_shipping_prompt_ab' ); + + // If it doesn't exist yet, generate it for later use and save it, so we always show the same to this user. + if ( ! $ab_test ) { + $ab_test = 1 !== wp_rand( 1, 4 ) ? 'a' : 'b'; // 25% of users. b gets the prompt. + update_option( 'woocommerce_shipping_prompt_ab', $ab_test, false ); + } + + return 'b' === $ab_test; + } + + + /** + * Determines whether banner is eligible for display (does not include a/b logic). + */ + public function should_allow_banner() { + return $this->banner_not_dismissed() && + $this->jetpack_installed_and_active() && + $this->jetpack_up_to_date() && + $this->jetpack_connected && + $this->no_incompatible_plugins_installed && + $this->order_has_shippable_products() && + $this->store_in_us_and_usd() && + ( $this->wcs_not_installed() || ( + $this->wcs_up_to_date() && ! $this->wcs_tos_accepted + ) ); + } + + /** + * Checks if the banner was not dismissed by the user. + * + * @return bool + */ + private function banner_not_dismissed() { + $dismissed_timestamp_ms = get_option( 'woocommerce_shipping_dismissed_timestamp' ); + + if ( ! is_numeric( $dismissed_timestamp_ms ) ) { + return true; + } + $dismissed_timestamp_ms = intval( $dismissed_timestamp_ms ); + $dismissed_timestamp = intval( round( $dismissed_timestamp_ms / 1000 ) ); + $expired_timestamp = $dismissed_timestamp + 24 * 60 * 60; // 24 hours from click time + + $dismissed_for_good = -1 === $dismissed_timestamp_ms; + $dismissed_24h = time() < $expired_timestamp; + + return ! $dismissed_for_good && ! $dismissed_24h; + } + + /** + * Checks if jetpack is installed and active. + * + * @return bool + */ + private function jetpack_installed_and_active() { + return ! ! $this->jetpack_version; + } + + /** + * Checks if Jetpack version is supported. + * + * @return bool + */ + private function jetpack_up_to_date() { + return version_compare( $this->jetpack_version, $this->min_jetpack_version, '>=' ); + } + + /** + * Checks if there's a shippable product in the current order. + * + * @return bool + */ + private function order_has_shippable_products() { + $post = get_post(); + if ( ! $post ) { + return false; + } + + $order = wc_get_order( get_post()->ID ); + + if ( ! $order ) { + return false; + } + // At this point (no packaging data), only show if there's at least one existing and shippable product. + foreach ( $order->get_items() as $item ) { + if ( $item instanceof \WC_Order_Item_Product ) { + $product = $item->get_product(); + + if ( $product && $product->needs_shipping() ) { + return true; + } + } + } + + return false; + } + + /** + * Checks if the store is in the US and has its default currency set to USD. + * + * @return bool + */ + private function store_in_us_and_usd() { + $base_currency = get_woocommerce_currency(); + $base_location = wc_get_base_location(); + + return in_array( $base_currency, $this->supported_currencies, true ) && in_array( $base_location['country'], $this->supported_countries, true ); + } + + /** + * Checks if WooCommerce Services is not installed. + * + * @return bool + */ + private function wcs_not_installed() { + return ! $this->wcs_version; + } + + /** + * Checks if WooCommerce Services is up to date. + */ + private function wcs_up_to_date() { + return $this->wcs_version && version_compare( $this->wcs_version, $this->min_wcs_version, '>=' ); + } +} diff --git a/plugins/woocommerce-admin/tests/features/class-wc-tests-shipping-label-banner-display-rules.php b/plugins/woocommerce-admin/tests/features/class-wc-tests-shipping-label-banner-display-rules.php new file mode 100644 index 00000000000..f47a1adde73 --- /dev/null +++ b/plugins/woocommerce-admin/tests/features/class-wc-tests-shipping-label-banner-display-rules.php @@ -0,0 +1,306 @@ + null, + 'woocommerce_currency' => null, + 'woocommerce_shipping_prompt_ab' => null, + 'woocommerce_shipping_dismissed_timestamp' => null, + ); + + /** + * Setup for every single test. + */ + public function setUp() { + parent::setup(); + + update_option( 'woocommerce_default_country', 'US' ); + update_option( 'woocommerce_currency', 'USD' ); + } + + /** + * Setup for the whole test class. + */ + public static function setUpBeforeClass() { + parent::setUpBeforeClass(); + + foreach ( self::$modified_options as $option_name => $option_value ) { + self::$modified_options[ $option_name ] = $option_value; + } + } + + /** + * Cleans up test data once all test have run. + */ + public static function tearDownAfterClass() { + parent::tearDownAfterClass(); + + foreach ( self::$modified_options as $option_name => $option_value ) { + update_option( $option_name, $option_value ); + } + } + + /** + * Test if the banner is displayed when all conditions are satisfied: + * - Banner NOT dismissed + * - Jetpack >= 4.4 installed and active + * - Jetpack Connected + * - No incompatible extensions installed: + * - Shipstation not installed + * - UPS not Installed + * - Fedex not installed + * - ShippingEasy not installed + * - Order contains physical products which need to be shipped (we should check that the order status is not set to complete) + * - Store is located in US + * - Store currency is set to USD + * - WCS plugin not installed OR WCS is installed *AND* ToS have NOT been accepted *AND* WCS version is 1.22.5 or greater + * (The 1.22.5 or greater requirement is so we can launch the shipping modal from the banner) + */ + public function test_display_banner_if_all_conditions_are_met() { + $this->with_order( + function( $that ) { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + + $that->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), true ); + } + ); + } + + /** + * Test if the banner is hidden when Jetpack is not active. + */ + public function test_if_banner_hidden_when_jetpack_disconnected() { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( null, null, null, null, null ); + + $this->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + + /** + * Test if the banner is hidden when a dismiss banner option is checked. + */ + public function test_if_banner_hidden_when_dismiss_option_enabled() { + update_option( 'woocommerce_shipping_dismissed_timestamp', -1 ); + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + + $this->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + + /** + * Banner should not show if it was dismissed 2 hours ago. + */ + public function test_if_banner_hidden_when_dismiss_was_clicked_2_hrs_ago() { + $two_hours_from_ago = ( time() - 2 * 60 * 60 ) * 1000; + update_option( 'woocommerce_shipping_dismissed_timestamp', $two_hours_from_ago ); + + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + + $this->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + + /** + * Banner should show if it was dismissed 24 hours and 1 second ago. + */ + public function test_if_banner_hidden_when_dismiss_was_clicked_24_hrs_1s_ago() { + $twenty_four_hours_one_sec_ago = ( time() - 24 * 60 * 60 - 1 ) * 1000; + update_option( 'woocommerce_shipping_dismissed_timestamp', $twenty_four_hours_one_sec_ago ); + + $this->with_order( + function( $that ) { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + + $that->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), true ); + } + ); + } + + /** + * Test if the banner is hidden when no shippable product available. + */ + public function test_if_banner_hidden_when_no_shippable_product() { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + + $this->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + + /** + * Test if the banner is displayed when the store is in the US. + */ + public function test_if_banner_hidden_when_store_is_not_in_us() { + update_option( 'woocommerce_default_country', 'ES' ); + $this->with_order( + function( $that ) { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + + $that->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + ); + } + + /** + * Test if the banner is displayed when the store's currency is USD. + */ + public function test_if_banner_hidden_when_currency_is_not_usd() { + update_option( 'woocommerce_currency', 'EUR' ); + $this->with_order( + function( $that ) { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + + $that->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + ); + } + + /** + * Test if the banner is hidden when an incompatible plugin is installed + */ + public function test_if_banner_hidden_when_incompatible_plugin_installed() { + $this->with_order( + function( $that ) { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, true ); + + $that->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + ); + } + + /** + * Test if the banner is hidden when Jetpack version is not at least 4.4. + */ + public function test_if_banner_hidden_when_jetpack_version_is_old() { + $this->with_order( + function( $that ) { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.3', true, '1.22.5', false, false ); + + $that->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + ); + } + + /** + * Test if the banner is hidden when the WooCommerce Services Terms of Service has been already accepted. + */ + public function test_if_banner_hidden_when_wcs_tos_accepted() { + $this->with_order( + function( $that ) { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', true, false ); + + $that->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + ); + } + + /** + * Test if the banner is hidden when WooCommerce Services is installed but not up to date. + */ + public function test_if_banner_hidden_when_wcs_not_installed() { + $this->with_order( + function( $that ) { + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.4', false, false ); + + $that->assertEquals( $shipping_label_banner_display_rules->should_allow_banner(), false ); + } + ); + } + + /** + * Test if the banner is displayed when site is in 'b' group. + */ + public function test_display_banner_if_b_flag() { + $this->with_order( + function( $that ) { + update_option( 'woocommerce_shipping_prompt_ab', 'b' ); + + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + $that->assertEquals( $shipping_label_banner_display_rules->should_display_banner(), true ); + } + ); + } + + /** + * Test if the banner is displayed when site is in 'a' group. + */ + public function test_no_display_banner_if_a_flag() { + $this->with_order( + function( $that ) { + update_option( 'woocommerce_shipping_prompt_ab', 'a' ); + + $shipping_label_banner_display_rules = new ShippingLabelBannerDisplayRules( '4.4', true, '1.22.5', false, false ); + $that->assertEquals( $shipping_label_banner_display_rules->should_display_banner(), false ); + } + ); + } + + /** + * Creates a test order. + */ + private function create_order() { + $product = WC_Helper_Product::create_simple_product(); + $product->set_props( array( 'virtual' => true ) ); + + $order = new WC_Order(); + $order_item = new WC_Order_Item_Product(); + $order_item->set_props( array( 'product' => $product ) ); + $order->add_item( $order_item ); + $order->save(); + + global $post; + + // phpcs:disable WordPress.WP.GlobalVariablesOverride.Prohibited + $post = new \stdClass(); + $post->ID = $order->get_id(); + + return $order; + } + + /** + * Destroys the test order. + * + * @param object $order to destroy. + */ + private function destroy_order( $order ) { + foreach ( $order->get_items() as $item ) { + $product = $item->get_product(); + $product->delete( true ); + $item->delete( true ); + } + + $order->delete( true ); + } + + /** + * Wraps a function call within an order creation/deletion lifecycle. + * + * @param function $callback to wrap. + */ + private function with_order( $callback ) { + $order = $this->create_order(); + + $callback( $this ); + + $this->destroy_order( $order ); + } + +} diff --git a/plugins/woocommerce-admin/webpack.config.js b/plugins/woocommerce-admin/webpack.config.js index ceefc7a0d6f..6545fa76523 100644 --- a/plugins/woocommerce-admin/webpack.config.js +++ b/plugins/woocommerce-admin/webpack.config.js @@ -77,6 +77,7 @@ const wpAdminScripts = [ 'onboarding-product-notice', 'onboarding-product-import-notice', 'onboarding-tax-notice', + 'print-shipping-label-banner', 'onboarding-menu-experience', ]; wpAdminScripts.forEach( ( name ) => {