diff --git a/assets/images/bnpl_announcement_afterpay.png b/assets/images/bnpl_announcement_afterpay.png new file mode 100644 index 00000000000..ec2fd1666d0 Binary files /dev/null and b/assets/images/bnpl_announcement_afterpay.png differ diff --git a/assets/images/bnpl_announcement_clearpay.png b/assets/images/bnpl_announcement_clearpay.png new file mode 100644 index 00000000000..63ead5c0893 Binary files /dev/null and b/assets/images/bnpl_announcement_clearpay.png differ diff --git a/changelog/feat-bnpl-announcement-apr b/changelog/feat-bnpl-announcement-apr new file mode 100644 index 00000000000..7d339efbfeb --- /dev/null +++ b/changelog/feat-bnpl-announcement-apr @@ -0,0 +1,4 @@ +Significance: patch +Type: add + +feat: BNPL April announcement. diff --git a/client/bnpl-announcement/index.js b/client/bnpl-announcement/index.js new file mode 100644 index 00000000000..7a64c93d005 --- /dev/null +++ b/client/bnpl-announcement/index.js @@ -0,0 +1,99 @@ +/** + * External dependencies + */ +import React, { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { __ } from '@wordpress/i18n'; +import { Button, ExternalLink } from '@wordpress/components'; +import { recordEvent } from 'tracks'; + +/** + * Internal dependencies + */ +import './style.scss'; +import ConfirmationModal from 'wcpay/components/confirmation-modal'; +import AfterpayBanner from 'assets/images/bnpl_announcement_afterpay.png?asset'; +import ClearpayBanner from 'assets/images/bnpl_announcement_clearpay.png?asset'; + +const BannerIcon = + window.wcpayBnplAnnouncement?.accountCountry === 'GB' + ? ClearpayBanner + : AfterpayBanner; + +const Dialog = () => { + useEffect( () => { + recordEvent( 'wcpay_bnpl_april15_feature_announcement_view' ); + }, [] ); + + const [ isHidden, setIsHidden ] = useState( false ); + + if ( isHidden ) return null; + + return ( + setIsHidden( true ) } + actions={ + <> + { + recordEvent( + 'wcpay_bnpl_april15_feature_announcement_learn_click' + ); + setIsHidden( true ); + } } + href="https://woo.com/document/woopayments/payment-methods/buy-now-pay-later/" + > + { __( 'Learn more', 'woocommerce-payments' ) } + + + + } + > +
+ { +
+

+ { __( 'Buy now, pay later is here', 'woocommerce-payments' ) } +

+

+ { __( + // eslint-disable-next-line max-len + 'Boost conversions and give your shoppers additional buying power, with buy now, pay later — now available in your WooPayments dashboard.*', + 'woocommerce-payments' + ) } +

+

+ { __( + '*Subject to country availability', + 'woocommerce-payments' + ) } +

+
+ ); +}; + +const container = document.getElementById( 'wpwrap' ); +if ( container ) { + const dialogWrapper = document.createElement( 'div' ); + container.appendChild( dialogWrapper ); + + ReactDOM.createRoot( dialogWrapper ).render( ); +} diff --git a/client/bnpl-announcement/style.scss b/client/bnpl-announcement/style.scss new file mode 100644 index 00000000000..4a6ca64e4cf --- /dev/null +++ b/client/bnpl-announcement/style.scss @@ -0,0 +1,68 @@ +.wcpay-bnpl-announcement { + &.wcpay-confirmation-modal.wcpay-confirmation-modal { + margin-top: auto; + height: auto; + + @media screen and ( min-width: 600px ) { + max-width: 400px; + } + + .components-modal__header { + padding: 0; + + .components-button.has-icon { + position: absolute; + top: 18px; + left: auto; + right: 18px; + } + } + + .components-modal__content { + padding: 0 20px 100px; + margin-top: 60px; + + @media screen and ( min-width: 600px ) { + padding: 0 35px 24px; + } + } + + .wcpay-confirmation-modal__separator { + opacity: 0; + } + } + + &__payment-icons { + display: flex; + justify-content: center; + align-items: flex-start; + flex-wrap: wrap; + gap: 17px; + margin-bottom: 20px; + + .payment-method__icon { + margin-right: 0; + max-height: 35px; + outline: none; + + &[alt='Affirm'] { + max-height: 30px; + } + } + } + + h1 { + text-align: left; + width: 100%; + } + + p { + text-align: left; + } + + .components-external-link { + padding: 6px 12px; + align-items: center; + display: flex; + } +} diff --git a/client/connect-account-page/index.tsx b/client/connect-account-page/index.tsx index 1696db52b2d..7cb4fdf9a15 100644 --- a/client/connect-account-page/index.tsx +++ b/client/connect-account-page/index.tsx @@ -31,6 +31,12 @@ import strings from './strings'; import './style.scss'; import InlineNotice from 'components/inline-notice'; +const SandboxModeNotice = () => ( + + { strings.sandboxModeNotice } + +); + const ConnectAccountPage: React.FC = () => { const firstName = wcSettings.admin?.currentUserData?.first_name; const incentive = wcpaySettings.connectIncentive; @@ -50,12 +56,6 @@ const ConnectAccountPage: React.FC = () => { const isCountrySupported = !! availableCountries[ country ]; - const SandboxModeNotice = () => ( - - { strings.sandboxModeNotice } - - ); - useEffect( () => { recordEvent( 'page_view', { path: 'payments_connect_v2', diff --git a/includes/admin/class-wc-payments-bnpl-announcement.php b/includes/admin/class-wc-payments-bnpl-announcement.php new file mode 100644 index 00000000000..8fca5c3d561 --- /dev/null +++ b/includes/admin/class-wc-payments-bnpl-announcement.php @@ -0,0 +1,209 @@ +gateway = $gateway; + $this->account = $account; + $this->current_time = $current_time; + } + + /** + * Initializes this class's WP hooks. + * + * @return void + */ + public function init_hooks() { + add_action( 'current_screen', [ $this, 'maybe_enqueue_scripts' ] ); + } + + /** + * Needs to run after `current_screen`, to determine which page we're currently on. + * + * @return void + */ + public function maybe_enqueue_scripts() { + if ( ! is_admin() ) { + return; + } + + // Only shown once to each Administrator and Shop Manager users. + if ( ! current_user_can( 'manage_woocommerce' ) ) { + return; + } + + // Time boxed - Campaign expires after 90 days. + if ( $this->current_time > strtotime( '2024-07-15 00:00:00' ) ) { + return; + } + + if ( get_user_meta( get_current_user_id(), '_wcpay_bnpl_april15_viewed', true ) === '1' ) { + return; + } + + // Only displayed to BNPL eligible countries - AU, NZ, US, AT, BE, CA, CZ, DK, FI, FR, DE, GR, IE, IT, NO, PL, PT, ES, SE, CH, NL, UK, US. + if ( ! in_array( + $this->account->get_account_country(), + [ + \WCPay\Constants\Country_Code::AUSTRALIA, + \WCPay\Constants\Country_Code::AUSTRIA, + \WCPay\Constants\Country_Code::NEW_ZEALAND, + \WCPay\Constants\Country_Code::UNITED_STATES, + \WCPay\Constants\Country_Code::BELGIUM, + \WCPay\Constants\Country_Code::CANADA, + \WCPay\Constants\Country_Code::CZECHIA, + \WCPay\Constants\Country_Code::DENMARK, + \WCPay\Constants\Country_Code::FINLAND, + \WCPay\Constants\Country_Code::FRANCE, + \WCPay\Constants\Country_Code::GERMANY, + \WCPay\Constants\Country_Code::GREECE, + \WCPay\Constants\Country_Code::IRELAND, + \WCPay\Constants\Country_Code::ITALY, + \WCPay\Constants\Country_Code::NORWAY, + \WCPay\Constants\Country_Code::POLAND, + \WCPay\Constants\Country_Code::PORTUGAL, + \WCPay\Constants\Country_Code::SPAIN, + \WCPay\Constants\Country_Code::SWEDEN, + \WCPay\Constants\Country_Code::SWITZERLAND, + \WCPay\Constants\Country_Code::NETHERLANDS, + \WCPay\Constants\Country_Code::UNITED_KINGDOM, + ], + true + ) ) { + return; + } + + // just to be safe for older versions. + if ( ! class_exists( '\Automattic\WooCommerce\Admin\PageController' ) ) { + return; + } + + // Target page to be displayed on - Any WooPayments page except disputes. + $current_page = \Automattic\WooCommerce\Admin\PageController::get_instance()->get_current_page(); + if ( ! WC_Payments_Utils::is_payments_settings_page() && ( empty( $current_page ) || ! in_array( + $current_page['id'], + [ + 'wc-payments', + 'wc-payments-deposits', + 'wc-payments-transactions', + 'wc-payments-deposit-details', + 'wc-payments-transaction-details', + 'wc-payments-multi-currency-setup', + ], + true + ) ) ) { + return; + } + + // at least 3 purchases (on any payment method). + $woopayments_successful_orders_count = $this->get_woopayments_successful_orders_count(); + if ( $woopayments_successful_orders_count < 3 ) { + return; + } + + // don't display the promo if the merchant already has BNPL methods enabled. + $enabled_bnpl_payment_methods = array_intersect( + \WCPay\Constants\Payment_Method::BNPL_PAYMENT_METHODS, + $this->gateway->get_upe_enabled_payment_method_ids() + ); + if ( ! empty( $enabled_bnpl_payment_methods ) ) { + return; + } + + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_scripts' ] ); + + add_user_meta( get_current_user_id(), '_wcpay_bnpl_april15_viewed', '1' ); + } + + /** + * Enqueues the script & styles for the BNPL announcement dialog. + * + * @return void + */ + public function enqueue_scripts() { + WC_Payments::register_script_with_dependencies( 'WCPAY_BNPL_ANNOUNCEMENT', 'dist/bnpl-announcement' ); + wp_set_script_translations( 'WCPAY_BNPL_ANNOUNCEMENT', 'woocommerce-payments' ); + WC_Payments_Utils::register_style( + 'WCPAY_BNPL_ANNOUNCEMENT', + plugins_url( 'dist/bnpl-announcement.css', WCPAY_PLUGIN_FILE ), + [ 'wc-components' ], + WC_Payments::get_file_version( 'dist/bnpl-announcement.css' ), + 'all' + ); + // conditionally show afterpay/clearpay based on account country. + $wcpay_bnpl_announcement = rawurlencode( wp_json_encode( [ 'accountCountry' => $this->account->get_account_country() ] ) ); + wp_add_inline_script( + 'WCPAY_BNPL_ANNOUNCEMENT', + " + var wcpayBnplAnnouncement = wcpayBnplAnnouncement || JSON.parse( decodeURIComponent( '" . esc_js( $wcpay_bnpl_announcement ) . "' ) ); + ", + 'before' + ); + + wp_enqueue_script( 'WCPAY_BNPL_ANNOUNCEMENT' ); + wp_enqueue_style( 'WCPAY_BNPL_ANNOUNCEMENT' ); + } + + /** + * Returns the number of successful orders paid with any WooPayments payment method. + * + * @return int + */ + private function get_woopayments_successful_orders_count() { + // using a transient to store the value of a previous calculation, since it can be expensive on each page load. + $wcpay_successful_orders_count = get_transient( 'wcpay_bnpl_april15_successful_purchases_count' ); + if ( false !== $wcpay_successful_orders_count ) { + return intval( $wcpay_successful_orders_count ); + } + + $orders = wc_get_orders( + [ + // we don't need them all, just more than 3. + 'limit' => 5, + 'status' => [ 'completed', 'processing' ], + ] + ); + $orders_count = count( $orders ); + + // storing the transient for a couple of days is probably sufficient, in case the value is too low (less than 3). + set_transient( 'wcpay_bnpl_april15_successful_purchases_count', $orders_count, 2 * DAY_IN_SECONDS ); + + return $orders_count; + } +} diff --git a/includes/class-wc-payments.php b/includes/class-wc-payments.php index 3fc25562912..22c21a3c4f4 100644 --- a/includes/class-wc-payments.php +++ b/includes/class-wc-payments.php @@ -635,6 +635,10 @@ public static function init() { $admin_settings = new WC_Payments_Admin_Settings( self::get_gateway() ); $admin_settings->init_hooks(); + include_once WCPAY_ABSPATH . 'includes/admin/class-wc-payments-bnpl-announcement.php'; + $bnpl_announcement = new WC_Payments_Bnpl_Announcement( self::get_gateway(), self::get_account_service(), time() ); + $bnpl_announcement->init_hooks(); + // Use tracks loader only in admin screens because it relies on WC_Tracks loaded by WC_Admin. include_once WCPAY_ABSPATH . 'includes/admin/tracks/tracks-loader.php'; diff --git a/tests/unit/admin/test-class-wc-payments-bnpl-announcement.php b/tests/unit/admin/test-class-wc-payments-bnpl-announcement.php new file mode 100644 index 00000000000..4bbcbfa5a96 --- /dev/null +++ b/tests/unit/admin/test-class-wc-payments-bnpl-announcement.php @@ -0,0 +1,136 @@ +gateway_mock = $this->getMockBuilder( WC_Payment_Gateway_WCPay::class ) + ->disableOriginalConstructor() + ->setMethods( [ 'get_upe_enabled_payment_method_ids' ] ) + ->getMock(); + + $this->account_service_mock = $this->getMockBuilder( WC_Payments_Account::class )->disableOriginalConstructor()->setMethods( [ 'get_account_country' ] )->getMock(); + + $this->bnpl_announcement = new WC_Payments_Bnpl_Announcement( $this->gateway_mock, $this->account_service_mock, strtotime( '2024-06-06' ) ); + } + + protected function tearDown(): void { + parent::tearDown(); + + wp_deregister_script( 'WCPAY_BNPL_ANNOUNCEMENT' ); + } + + public function test_it_enqueues_scripts_for_eligible_users() { + global $current_section, $current_tab, $wp_actions; + + // mocking the settings page URL. + $current_section = 'woocommerce_payments'; + $current_tab = 'checkout'; + $this->set_is_admin( true ); + + // mocking the "did action" for 'current_screen'. + $wp_actions['current_screen'] = true; // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + WC_Payments::mode()->live(); + $this->set_current_user_can( true ); + $this->account_service_mock->method( 'get_account_country' )->willReturn( 'US' ); + $this->gateway_mock->method( 'get_upe_enabled_payment_method_ids' )->willReturn( [ 'card' ] ); + + $this->bnpl_announcement->maybe_enqueue_scripts(); + + do_action( 'admin_enqueue_scripts' ); + + // ensuring the dialog has been marked as "viewed". + $this->assertEquals( '1', get_user_meta( get_current_user_id(), '_wcpay_bnpl_april15_viewed', true ) ); + $this->assertTrue( wp_script_is( 'WCPAY_BNPL_ANNOUNCEMENT', 'registered' ) ); + } + + public function test_it_does_not_enqueues_scripts_for_users_that_have_already_seen_the_message() { + global $current_section, $current_tab, $wp_actions; + + // mocking the settings page URL. + $current_section = 'woocommerce_payments'; + $current_tab = 'checkout'; + $this->set_is_admin( true ); + + // mocking the "did action" for 'current_screen'. + $wp_actions['current_screen'] = true; // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited + + wp_set_current_user( self::factory()->user->create( [ 'role' => 'administrator' ] ) ); + WC_Payments::mode()->live(); + $this->set_current_user_can( true ); + $this->account_service_mock->method( 'get_account_country' )->willReturn( 'US' ); + $this->gateway_mock->method( 'get_upe_enabled_payment_method_ids' )->willReturn( [ 'card' ] ); + + // marking it as "already viewed" for the current user. + add_user_meta( get_current_user_id(), '_wcpay_bnpl_april15_viewed', '1' ); + + $this->bnpl_announcement->maybe_enqueue_scripts(); + + do_action( 'admin_enqueue_scripts' ); + + $this->assertFalse( wp_script_is( 'WCPAY_BNPL_ANNOUNCEMENT', 'registered' ) ); + } + + private function set_current_user_can( bool $can ) { + global $current_user_can; + + // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited + $current_user_can = $this->getMockBuilder( \stdClass::class ) + ->addMethods( [ 'current_user_can' ] ) + ->getMock(); + + $current_user_can->method( 'current_user_can' )->willReturn( $can ); + } + + /** + * @param bool $is_admin + */ + private function set_is_admin( bool $is_admin ) { + global $current_screen; + + if ( ! $is_admin ) { + $current_screen = null; // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited + + return; + } + + // phpcs:ignore: WordPress.WP.GlobalVariablesOverride.Prohibited + $current_screen = $this->getMockBuilder( \stdClass::class ) + ->setMethods( [ 'in_admin' ] ) + ->getMock(); + + $current_screen->method( 'in_admin' )->willReturn( $is_admin ); + $current_screen->id = 'wc-payments-deposits'; + $current_screen->action = null; + } +} diff --git a/tests/unit/bootstrap.php b/tests/unit/bootstrap.php index e5afd62b0e5..a73e0120e4a 100755 --- a/tests/unit/bootstrap.php +++ b/tests/unit/bootstrap.php @@ -96,6 +96,7 @@ function () { require_once $_plugin_dir . 'includes/class-woopay-tracker.php'; require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-customer-controller.php'; require_once $_plugin_dir . 'includes/admin/class-wc-rest-payments-refunds-controller.php'; + require_once $_plugin_dir . 'includes/admin/class-wc-payments-bnpl-announcement.php'; // Load currency helper class early to ensure its implementation is used over the one resolved during further test initialization. require_once __DIR__ . '/helpers/class-wc-helper-site-currency.php'; diff --git a/webpack/shared.js b/webpack/shared.js index 63bff86aa23..63a71ca649a 100644 --- a/webpack/shared.js +++ b/webpack/shared.js @@ -10,6 +10,7 @@ module.exports = { entry: mapValues( { index: './client/index.js', + 'bnpl-announcement': './client/bnpl-announcement/index.js', settings: './client/settings/index.js', 'blocks-checkout': './client/checkout/blocks/index.js', woopay: './client/checkout/woopay/index.js', diff --git a/woocommerce-payments.php b/woocommerce-payments.php index eaa03a07f8a..13540148710 100644 --- a/woocommerce-payments.php +++ b/woocommerce-payments.php @@ -55,6 +55,8 @@ function wcpay_activated() { function wcpay_deactivated() { require_once WCPAY_ABSPATH . '/includes/class-wc-payments.php'; WC_Payments::remove_woo_admin_notes(); + delete_user_meta( get_current_user_id(), '_wcpay_bnpl_april15_viewed' ); + delete_transient( 'wcpay_bnpl_april15_successful_purchases_count' ); } register_activation_hook( __FILE__, 'wcpay_activated' );