diff --git a/.husky/pre-push b/.husky/pre-push index 05b3f2a4632..cb2c2d3005b 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,4 +1,4 @@ -#!/bin/bash +#!/bin/sh . "$(dirname "$0")/_/husky.sh" # check if main stream (stdout and stderr) are attached to the terminal @@ -7,12 +7,12 @@ if [ -t 1 ] && [ -t 2 ]; then exec < /dev/tty fi -PROTECTED_BRANCH=("develop" "trunk") +PROTECTED_BRANCH_LIST="develop trunk" CURRENT_BRANCH=$(git branch --show-current) -if [[ " ${PROTECTED_BRANCH[@]} " =~ " ${CURRENT_BRANCH} " ]]; then +if echo "$PROTECTED_BRANCH_LIST" | grep -q -w "$CURRENT_BRANCH"; then read -p "$CURRENT_BRANCH is a protected branch. Are you sure you want to push? (y/n): " confirmation - if [ "$confirmation" != "y" ]; then + if [ "$confirmation" != "y" ]; then echo "Push aborted" exit 1 fi diff --git a/changelog.txt b/changelog.txt index ec139f830ba..acdd013efe5 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,45 @@ *** WooPayments Changelog *** += 7.8.0 - 2024-06-19 = +* Add - Add a feedback survey modal upon deactivation. +* Add - Add new select component to be used for reporting filters, e.g. Payments overview currency select +* Add - Add payment processing using ECE in the Blocks checkout and cart pages. +* Add - Add the WooPay Direct Checkout flow to the classic mini cart widget. +* Add - Add woocommerce-return-previous-exceptions filter +* Add - Enable adapted extensions compatibility with Direct Checkout. +* Add - feat: add pay-for-order support w/ tokenized cart PRBs +* Add - Fix ECE not working without WooPay. +* Add - Reset notifications about duplicate enabled payment methods when new plugins are enabling them. +* Fix - Fall back to credit card as default payment method when a payment method is toggled off. +* Fix - fix: address normalization on checkout for tokenized cart PRBs +* Fix - fix: itemized totals & pending amount on tokenized cart +* Fix - fix: Store API tokenized cart payment method title +* Fix - Fixes some cases where redirects to the onboarding will open in a new tab. +* Fix - Fix input-specific credit card errors. +* Fix - Fix Payment method title for PRBs not displaying correctly because of ECE code. +* Fix - Fix Teams for WooCommerce Memberships on product WooPay Express Checkout Button. +* Fix - Fix WooPay Direct Checkout feature check. +* Fix - Improve consistency of Manage button for different WooPayments KYC states +* Fix - Make it so that the WooPay button is not triggered on Checkout pages when the "Enter" key is pressed on a keyboard. +* Fix - Prevent account creation during WooPay preflight request. +* Update - chore: update incompatibility notice wrapping +* Update - Declare compatibility with the Cart and Checkout blocks. +* Update - Improve the transition from the WCPay KYC to the WC Admin Payments Task +* Update - Update the Payments Overview screen with a new currency selection UI for stores with multiple deposit currencies +* Update - Use FILTER_SANITIZE_EMAIL to sanitize email input +* Dev - Add New_Process_Payment_Exception +* Dev - Add Order_ID_Mismatch_Exception +* Dev - Add sh support in pre-push husky script. +* Dev - Add validation for path variables. +* Dev - Bump WooCommerce Tested To version to 8.9.2 +* Dev - Bump WooCommerce Tested To version to 8.9.3 +* Dev - chore: EPMs to always send shipping phone +* Dev - Clean up and refactor some old code which is no longer in use. +* Dev - Fix PHPStan warnings. +* Dev - Fix unused parameter phpcs sniffs in checkout classes. +* Dev - Improve test coverage of upe.js and rename isPaymentMethodRestrictedToLocation to hasPaymentMethodCountryRestrictions +* Dev - Remove redundant wrapper around method invocation. + = 7.7.0 - 2024-05-29 = * Add - Add share key query param when sending data to Stripe KYC. * Add - Add the WooPay Direct Checkout flow to the blocks mini cart widget. diff --git a/client/checkout/api/index.js b/client/checkout/api/index.js index 6a088a9c5c1..3775aa7f394 100644 --- a/client/checkout/api/index.js +++ b/client/checkout/api/index.js @@ -8,6 +8,8 @@ import { getPaymentRequestData, getPaymentRequestAjaxURL, buildAjaxURL, + getExpressCheckoutAjaxURL, + getExpressCheckoutConfig, } from 'utils/express-checkout'; /** @@ -406,6 +408,37 @@ export default class WCPayAPI { } ); } + /** + * Submits shipping address to get available shipping options + * from Express Checkout ECE payment method. + * + * @param {Object} shippingAddress Shipping details. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutECECalculateShippingOptions( shippingAddress ) { + return this.request( + getExpressCheckoutAjaxURL( 'get_shipping_options' ), + { + security: getExpressCheckoutConfig( 'nonce' )?.shipping, + is_product_page: getExpressCheckoutConfig( 'is_product_page' ), + ...shippingAddress, + } + ); + } + + /** + * Creates order based on Express Checkout ECE payment method. + * + * @param {Object} paymentData Order data. + * @return {Promise} Promise for the request to the server. + */ + expressCheckoutECECreateOrder( paymentData ) { + return this.request( getExpressCheckoutAjaxURL( 'create_order' ), { + _wpnonce: getExpressCheckoutConfig( 'nonce' )?.checkout, + ...paymentData, + } ); + } + initWooPay( userEmail, woopayUserSession ) { if ( ! this.isWooPayRequesting ) { this.isWooPayRequesting = true; diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index 0e26e9ed4b1..9f858acd87d 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -154,8 +154,11 @@ if ( getUPEConfig( 'isWooPayEnabled' ) ) { } } -registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); -registerExpressPaymentMethod( expressCheckoutElementPaymentMethod( api ) ); +if ( getUPEConfig( 'isExpressCheckoutElementEnabled' ) ) { + registerExpressPaymentMethod( expressCheckoutElementPaymentMethod( api ) ); +} else { + registerExpressPaymentMethod( paymentRequestPaymentMethod( api ) ); +} window.addEventListener( 'load', () => { enqueueFraudScripts( getUPEConfig( 'fraudServices' ) ); addCheckoutTracking(); diff --git a/client/checkout/blocks/payment-processor.js b/client/checkout/blocks/payment-processor.js index aaaf3e21772..cbb1e8d412f 100644 --- a/client/checkout/blocks/payment-processor.js +++ b/client/checkout/blocks/payment-processor.js @@ -66,7 +66,7 @@ const PaymentProcessor = ( { } ) => { const stripe = useStripe(); const elements = useElements(); - const isPaymentInformationCompleteRef = useRef( false ); + const hasLoadErrorRef = useRef( false ); const paymentMethodsConfig = getUPEConfig( 'paymentMethodsConfig' ); const isTestMode = getUPEConfig( 'testMode' ); @@ -140,11 +140,11 @@ const PaymentProcessor = ( { return; } - if ( ! isPaymentInformationCompleteRef.current ) { + if ( hasLoadErrorRef.current ) { return { type: 'error', message: __( - 'Your payment information is incomplete.', + 'Invalid or missing payment details. Please ensure the provided payment method is correctly entered.', 'woocommerce-payments' ), }; @@ -237,8 +237,9 @@ const PaymentProcessor = ( { shouldSavePayment ); - const setPaymentInformationCompletionStatus = ( event ) => { - isPaymentInformationCompleteRef.current = event.complete; + const setHasLoadError = ( event ) => { + hasLoadErrorRef.current = true; + onLoadError( event ); }; return ( @@ -256,8 +257,7 @@ const PaymentProcessor = ( { shouldSavePayment, paymentMethodsConfig ) } - onLoadError={ onLoadError } - onChange={ setPaymentInformationCompletionStatus } + onLoadError={ setHasLoadError } className="wcpay-payment-element" /> diff --git a/client/checkout/blocks/test/payment-processor.test.js b/client/checkout/blocks/test/payment-processor.test.js index c94c7e432e9..39e0a754022 100644 --- a/client/checkout/blocks/test/payment-processor.test.js +++ b/client/checkout/blocks/test/payment-processor.test.js @@ -29,20 +29,12 @@ jest.mock( '@stripe/react-stripe-js', () => ( { useStripe: jest.fn(), } ) ); -const MockPaymentElement = ( { onChange } ) => { - useEffect( () => { - onChange( { complete: true } ); - }, [ onChange ] ); - - return null; -}; - describe( 'PaymentProcessor', () => { let mockApi; let mockCreatePaymentMethod; beforeEach( () => { global.wcpay_upe_config = { paymentMethodsConfig: {} }; - PaymentElement.mockImplementation( MockPaymentElement ); + PaymentElement.mockImplementation( () => null ); mockCreatePaymentMethod = jest .fn() .mockResolvedValue( { paymentMethod: {} } ); @@ -97,8 +89,14 @@ describe( 'PaymentProcessor', () => { ).not.toBeInTheDocument(); } ); - it( 'should return an error when the payment information is incomplete', async () => { - PaymentElement.mockImplementation( () => null ); + it( 'should return an error if the payment method could not be loaded', async () => { + PaymentElement.mockImplementation( ( { onLoadError } ) => { + useEffect( () => { + onLoadError(); + }, [ onLoadError ] ); + + return null; + } ); let onPaymentSetupCallback; render( { fingerprint="" shouldSavePayment={ false } upeMethods={ { card: 'woocommerce_payments' } } + onLoadError={ jest.fn() } /> ); expect( await onPaymentSetupCallback() ).toEqual( { type: 'error', - message: 'Your payment information is incomplete.', + message: + 'Invalid or missing payment details. Please ensure the provided payment method is correctly entered.', } ); expect( mockCreatePaymentMethod ).not.toHaveBeenCalled(); } ); diff --git a/client/checkout/classic/event-handlers.js b/client/checkout/classic/event-handlers.js index 156b73e1942..c034aaccedd 100644 --- a/client/checkout/classic/event-handlers.js +++ b/client/checkout/classic/event-handlers.js @@ -9,7 +9,7 @@ import { generateCheckoutEventNames, getSelectedUPEGatewayPaymentMethod, isLinkEnabled, - isPaymentMethodRestrictedToLocation, + hasPaymentMethodCountryRestrictions, isUsingSavedPaymentMethod, togglePaymentMethodForCountry, } from '../utils/upe'; @@ -228,7 +228,7 @@ jQuery( function ( $ ) { } function restrictPaymentMethodToLocation( upeElement ) { - if ( isPaymentMethodRestrictedToLocation( upeElement ) ) { + if ( hasPaymentMethodCountryRestrictions( upeElement ) ) { togglePaymentMethodForCountry( upeElement ); // this event only applies to the checkout form, but not "place order" or "add payment method" pages. diff --git a/client/checkout/classic/payment-processing.js b/client/checkout/classic/payment-processing.js index 204a84adc10..c3c50b60f33 100644 --- a/client/checkout/classic/payment-processing.js +++ b/client/checkout/classic/payment-processing.js @@ -39,7 +39,7 @@ for ( const paymentMethodType in getUPEConfig( 'paymentMethodsConfig' ) ) { gatewayUPEComponents[ paymentMethodType ] = { elements: null, upeElement: null, - isPaymentInformationComplete: false, + hasLoadError: false, }; } @@ -406,11 +406,9 @@ export async function mountStripePaymentElement( api, domElement ) { gatewayUPEComponents[ paymentMethodType ].upeElement || ( await createStripePaymentElement( api, paymentMethodType ) ); upeElement.mount( domElement ); - upeElement.on( 'change', ( e ) => { - gatewayUPEComponents[ paymentMethodType ].isPaymentInformationComplete = - e.complete; - } ); upeElement.on( 'loaderror', ( e ) => { + // setting the flag to true to prevent the form from being submitted. + gatewayUPEComponents[ paymentMethodType ].hasLoadError = true; // unset any styling to ensure the WC error message wrapper can take more width. domElement.style.padding = '0'; // creating a new element to be added to the DOM, so that the message can be displayed. @@ -524,15 +522,14 @@ export const processPayment = ( try { await blockUI( $form ); - const { - elements, - isPaymentInformationComplete, - } = gatewayUPEComponents[ paymentMethodType ]; + const { elements, hasLoadError } = gatewayUPEComponents[ + paymentMethodType + ]; - if ( ! isPaymentInformationComplete ) { + if ( hasLoadError ) { throw new Error( __( - 'Your payment information is incomplete.', + 'Invalid or missing payment details. Please ensure the provided payment method is correctly entered.', 'woocommerce-payments' ) ); diff --git a/client/checkout/classic/test/payment-processing.test.js b/client/checkout/classic/test/payment-processing.test.js index ad8a24315b9..edcaf14107e 100644 --- a/client/checkout/classic/test/payment-processing.test.js +++ b/client/checkout/classic/test/payment-processing.test.js @@ -76,14 +76,6 @@ const mockCreateFunction = jest.fn( () => ( { eventHandlersFromElementsCreate[ event ].push( handler ); }, } ) ); -const callAllCreateHandlersWith = ( event, ...args ) => { - eventHandlersFromElementsCreate[ event ]?.forEach( ( handler ) => { - handler.apply( null, args ); - } ); -}; -const markAllPaymentElementsAsComplete = () => { - callAllCreateHandlersWith( 'change', { complete: true } ); -}; const mockSubmit = jest.fn( () => ( { then: jest.fn(), @@ -396,7 +388,6 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); - markAllPaymentElementsAsComplete(); const mockJqueryForm = { submit: jest.fn(), @@ -443,7 +434,6 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); - markAllPaymentElementsAsComplete(); const checkoutForm = { submit: jest.fn(), @@ -487,7 +477,6 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); - markAllPaymentElementsAsComplete(); const checkoutForm = { submit: jest.fn(), @@ -527,7 +516,6 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); - markAllPaymentElementsAsComplete(); const checkoutForm = { submit: jest.fn(), @@ -564,7 +552,6 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); - markAllPaymentElementsAsComplete(); const checkoutForm = { submit: jest.fn(), @@ -599,7 +586,6 @@ describe( 'Payment processing', () => { mockDomElement.dataset.paymentMethodType = 'card'; await mountStripePaymentElement( apiMock, mockDomElement ); - markAllPaymentElementsAsComplete(); const addPaymentMethodForm = { submit: jest.fn(), diff --git a/client/checkout/utils/test/upe.test.js b/client/checkout/utils/test/upe.test.js index 8af706e27d1..fb9f961d18f 100644 --- a/client/checkout/utils/test/upe.test.js +++ b/client/checkout/utils/test/upe.test.js @@ -8,8 +8,10 @@ import { getStripeElementOptions, blocksShowLinkButtonHandler, getSelectedUPEGatewayPaymentMethod, + hasPaymentMethodCountryRestrictions, isUsingSavedPaymentMethod, dispatchChangeEventFor, + togglePaymentMethodForCountry, } from '../upe'; import { getPaymentMethodsConstants } from '../../constants'; import { getUPEConfig } from 'wcpay/utils/checkout'; @@ -125,6 +127,155 @@ describe( 'UPE checkout utils', () => { } ); } ); + describe( 'hasPaymentMethodCountryRestrictions', () => { + let container; + + beforeAll( () => { + container = document.createElement( 'div' ); + container.innerHTML = ` + + `; + document.body.appendChild( container ); + } ); + + afterAll( () => { + document.body.removeChild( container ); + container = null; + } ); + + beforeEach( () => { + jest.clearAllMocks(); + getUPEConfig.mockImplementation( ( argument ) => { + if ( argument === 'paymentMethodsConfig' ) { + return { + card: { countries: [] }, + bancontact: { countries: [ 'BE' ] }, + }; + } + } ); + } ); + + it( 'should be true when the payment method is restricted to the location', () => { + const bancontactUpeElement = document.querySelector( + '.payment_method_woocommerce_payments_bancontact' + ); + + expect( + hasPaymentMethodCountryRestrictions( bancontactUpeElement ) + ).toBe( true ); + } ); + + it( 'should be false when the payment method is not restricted to the location', () => { + const cardUpeElement = document.querySelector( + '.payment_method_woocommerce_payments_card' + ); + + expect( + hasPaymentMethodCountryRestrictions( cardUpeElement ) + ).toBe( false ); + } ); + } ); + + describe( 'togglePaymentMethodForCountry', () => { + let container; + + beforeAll( () => { + container = document.createElement( 'div' ); + container.innerHTML = ` + + + `; + document.body.appendChild( container ); + } ); + + afterAll( () => { + document.body.removeChild( container ); + container = null; + } ); + + beforeEach( () => { + jest.clearAllMocks(); + getUPEConfig.mockImplementation( ( argument ) => { + if ( argument === 'paymentMethodsConfig' ) { + return { + card: { countries: [ 'US' ] }, + bancontact: { countries: [ 'BE' ] }, + }; + } + + if ( argument === 'gatewayId' ) { + return 'woocommerce_payments'; + } + } ); + window.wcpayCustomerData = { billing_country: 'BE' }; + } ); + + afterEach( () => { + window.wcpayCustomerData = null; + } ); + + it( 'should show payment method if country is supported', () => { + const upeElement = document.querySelector( + '.payment_method_woocommerce_payments_card' + ); + document.getElementById( 'billing_country' ).value = 'US'; + + togglePaymentMethodForCountry( upeElement ); + + expect( upeElement.style.display ).toBe( 'block' ); + } ); + + it( 'should hide payment method if country is not supported', () => { + const upeElement = document.querySelector( + '.payment_method_woocommerce_payments_card' + ); + document.getElementById( 'billing_country' ).value = 'BE'; + + togglePaymentMethodForCountry( upeElement ); + + expect( upeElement.style.display ).toBe( 'none' ); + } ); + + it( 'should fall back to card as the default payment method if the selected payment method is toggled off', () => { + const input = document.querySelector( + '#payment_method_woocommerce_payments_bancontact' + ); + input.checked = true; + + const upeElement = document.querySelector( + '.payment_method_woocommerce_payments_bancontact' + ); + document.getElementById( 'billing_country' ).value = 'US'; + + const cardPaymentMethod = document.querySelector( + '#payment_method_woocommerce_payments' + ); + jest.spyOn( cardPaymentMethod, 'click' ); + + togglePaymentMethodForCountry( upeElement ); + + expect( upeElement.style.display ).toBe( 'none' ); + expect( cardPaymentMethod.click ).toHaveBeenCalled(); + } ); + } ); + describe( 'getUPESettings', () => { afterEach( () => { const checkboxElement = document.getElementById( diff --git a/client/checkout/utils/upe.js b/client/checkout/utils/upe.js index 65cdb5718aa..aa85be44436 100644 --- a/client/checkout/utils/upe.js +++ b/client/checkout/utils/upe.js @@ -292,25 +292,28 @@ export const blocksShowLinkButtonHandler = ( linkAutofill ) => { }; /** - * Hides payment method if it has set specific countries in the PHP class. + * Returns true if the payment method has configured with any country restrictions. * - * @param {Object} upeElement The selector of the DOM element of particular payment method to mount the UPE element to. + * @param {HTMLElement} upeElement The selector of the DOM element of particular payment method to mount the UPE element to. * @return {boolean} Whether the payment method is restricted to selected billing country. **/ -export const isPaymentMethodRestrictedToLocation = ( upeElement ) => { +export const hasPaymentMethodCountryRestrictions = ( upeElement ) => { const paymentMethodsConfig = getUPEConfig( 'paymentMethodsConfig' ); const paymentMethodType = upeElement.dataset.paymentMethodType; return !! paymentMethodsConfig[ paymentMethodType ].countries.length; }; /** - * @param {Object} upeElement The selector of the DOM element of particular payment method to mount the UPE element to. + * Hides payment method if it has set specific countries in the PHP class. + * + * @param {HTMLElement} upeElement The selector of the DOM element of particular payment method to mount the UPE element to. **/ export const togglePaymentMethodForCountry = ( upeElement ) => { const paymentMethodsConfig = getUPEConfig( 'paymentMethodsConfig' ); const paymentMethodType = upeElement.dataset.paymentMethodType; const supportedCountries = paymentMethodsConfig[ paymentMethodType ].countries; + const selectedPaymentMethod = getSelectedUPEGatewayPaymentMethod(); /* global wcpayCustomerData */ // in the case of "pay for order", there is no "billing country" input, so we need to rely on backend data. @@ -326,5 +329,11 @@ export const togglePaymentMethodForCountry = ( upeElement ) => { upeContainer.style.display = 'block'; } else { upeContainer.style.display = 'none'; + // if the toggled off payment method was selected, we need to fall back to credit card + if ( paymentMethodType === selectedPaymentMethod ) { + document + .querySelector( '#payment_method_woocommerce_payments' ) + .click(); + } } }; diff --git a/client/checkout/woopay/connect/user-connect.js b/client/checkout/woopay/connect/user-connect.js index 9e71772f086..ebae7e7ecf2 100644 --- a/client/checkout/woopay/connect/user-connect.js +++ b/client/checkout/woopay/connect/user-connect.js @@ -11,6 +11,7 @@ class WooPayUserConnect extends WoopayConnect { this.listeners = { ...this.listeners, getIsUserLoggedInCallback: () => {}, + getEncryptedDataCallback: () => {}, }; } @@ -26,6 +27,18 @@ class WooPayUserConnect extends WoopayConnect { ); } + /** + * Retrieves encrypted data from WooPay. + * + * @return {Promise} Resolves to an object with encrypted data. + */ + async getEncryptedData() { + return await this.sendMessageAndListenWith( + { action: 'getEncryptedData' }, + 'getEncryptedDataCallback' + ); + } + /** * Handles the callback from the WooPayConnectIframe. * @@ -38,6 +51,9 @@ class WooPayUserConnect extends WoopayConnect { case 'get_is_user_logged_in_success': this.listeners.getIsUserLoggedInCallback( data.value ); break; + case 'get_encrypted_data_success': + this.listeners.getEncryptedDataCallback( data.value ); + break; } } } diff --git a/client/checkout/woopay/connect/woopay-connect-iframe.js b/client/checkout/woopay/connect/woopay-connect-iframe.js index d87d24d8fdc..bfe6d783c11 100644 --- a/client/checkout/woopay/connect/woopay-connect-iframe.js +++ b/client/checkout/woopay/connect/woopay-connect-iframe.js @@ -22,9 +22,11 @@ export const WooPayConnectIframe = () => { const fetchConfigAndSetIframeUrl = async () => { const testMode = getConfig( 'testMode' ); const woopayHost = getConfig( 'woopayHost' ); + const blogId = getConfig( 'woopayMerchantId' ); const urlParams = new URLSearchParams( { testMode, - source_url: window.location.href, + source_url: window.location.href, // TODO: refactor this to camel case. + blogId, } ); const tracksUserId = await getTracksIdentity(); diff --git a/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js b/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js index b44db8d07e6..f969d6160c9 100644 --- a/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js +++ b/client/checkout/woopay/direct-checkout/woopay-direct-checkout.js @@ -21,6 +21,8 @@ class WooPayDirectCheckout { '.wp-block-woocommerce-proceed-to-checkout-block', BLOCKS_MINI_CART_PROCEED_BUTTON: 'a.wp-block-woocommerce-mini-cart-checkout-button-block', + CLASSIC_MINI_CART_PROCEED_BUTTON: + '.widget_shopping_cart a.button.checkout', }; /** @@ -79,12 +81,21 @@ class WooPayDirectCheckout { /** * Checks if the user is logged in. * - * @return {Promise<*>} Resolves to true if the user is logged in. + * @return {Promise} Resolves to true if the user is logged in. */ static async isUserLoggedIn() { return this.getUserConnect().isUserLoggedIn(); } + /** + * Retrieves encrypted data from WooPay. + * + * @return {Promise} Resolves to an object with encrypted data. + */ + static async getEncryptedData() { + return this.getUserConnect().getEncryptedData(); + } + /** * Checks if third-party cookies are enabled. * @@ -213,6 +224,9 @@ class WooPayDirectCheckout { addElementBySelector( this.redirectElements.BLOCKS_CART_PROCEED_BUTTON ); + addElementBySelector( + this.redirectElements.CLASSIC_MINI_CART_PROCEED_BUTTON + ); return elements; } @@ -363,10 +377,12 @@ class WooPayDirectCheckout { * @return {Promise|*>} Resolves to the WooPay session response. */ static async getEncryptedSessionData() { + const encryptedData = await this.getEncryptedData(); return request( buildAjaxURL( getConfig( 'wcAjaxUrl' ), 'get_woopay_session' ), { _ajax_nonce: getConfig( 'woopaySessionNonce' ), + ...( encryptedData && { encrypted_data: encryptedData } ), } ); } diff --git a/client/checkout/woopay/express-button/woopay-express-checkout-button.js b/client/checkout/woopay/express-button/woopay-express-checkout-button.js index 2220a1422e6..00b8a38b2c4 100644 --- a/client/checkout/woopay/express-button/woopay-express-checkout-button.js +++ b/client/checkout/woopay/express-button/woopay-express-checkout-button.js @@ -346,6 +346,7 @@ export const WoopayExpressCheckoutButton = ( { data-width-type={ buttonWidthType } style={ { height: `${ height }px` } } disabled={ isLoading } + type="button" > { isLoading ? ( diff --git a/client/components/account-balances/index.tsx b/client/components/account-balances/index.tsx index 6d65e661104..53010482c0f 100644 --- a/client/components/account-balances/index.tsx +++ b/client/components/account-balances/index.tsx @@ -2,14 +2,13 @@ * External dependencies */ import * as React from 'react'; -import { Flex, TabPanel } from '@wordpress/components'; +import { Flex } from '@wordpress/components'; /** * Internal dependencies */ import { useAllDepositsOverviews } from 'wcpay/data'; import { useSelectedCurrency } from 'wcpay/overview/hooks'; -import { getCurrencyTabTitle } from './utils'; import BalanceBlock from './balance-block'; import { TotalBalanceTooltip, @@ -17,99 +16,55 @@ import { } from './balance-tooltip'; import { fundLabelStrings } from './strings'; import InstantDepositButton from 'deposits/instant-deposits'; -import { recordEvent } from 'tracks'; import type * as AccountOverview from 'wcpay/types/account-overview'; import './style.scss'; /** - * BalanceTabProps - * - * @typedef {Object} BalanceTab - * - * @param {string} name Name of the tab. - * @param {string} title Title of the tab. - * @param {string} currencyCode Currency code of the tab. - * @param {number} availableFunds Available funds of the tab. - * @param {number} pendingFunds Pending funds of the tab. - * @param {number} delayDays The account's pending period in days. - */ -type BalanceTabProps = { - name: string; - title: string; - currencyCode: string; - availableFunds: number; - pendingFunds: number; - delayDays: number; - instantBalance?: AccountOverview.InstantBalance; -}; - -/** - * Renders an account balances panel with tab navigation for each deposit currency. - * - * @return {JSX.Element} Rendered balances panel with tab navigation for each currency. + * Renders account balances for the selected currency. */ const AccountBalances: React.FC = () => { const { overviews, isLoading } = useAllDepositsOverviews(); - const { selectedCurrency, setSelectedCurrency } = useSelectedCurrency(); + const { selectedCurrency } = useSelectedCurrency(); if ( ! isLoading && overviews.currencies.length === 0 ) { return null; } - const onTabSelect = ( tabName: BalanceTabProps[ 'name' ] ) => { - setSelectedCurrency( tabName ); - recordEvent( 'wcpay_overview_balances_currency_tab_click', { - selected_currency: tabName, - } ); - }; - if ( isLoading ) { - // While the data is loading, we show a loading currency tab. - const loadingTabs: BalanceTabProps[] = [ - { - name: 'loading', - title: getCurrencyTabTitle( - wcpaySettings.accountDefaultCurrency - ), - currencyCode: wcpaySettings.accountDefaultCurrency, - availableFunds: 0, - pendingFunds: 0, - delayDays: 0, - }, - ]; + // While the data is loading, we show a loading state for the balances. + const loadingData = { + name: 'loading', + currencyCode: wcpaySettings.accountDefaultCurrency, + availableFunds: 0, + pendingFunds: 0, + delayDays: 0, + }; + return ( - - { ( tab: BalanceTabProps ) => ( - - - - - ) } - + + + + ); } const { currencies, account } = overviews; - const depositCurrencyTabs = currencies.map( + const depositCurrencyOverviews = currencies.map( ( overview: AccountOverview.Overview ) => ( { name: overview.currency, - title: getCurrencyTabTitle( overview.currency ), currencyCode: overview.currency, availableFunds: overview.available?.amount ?? 0, pendingFunds: overview.pending?.amount ?? 0, @@ -118,65 +73,48 @@ const AccountBalances: React.FC = () => { } ) ); - // Selected currency is not valid if it is not in the list of deposit currencies. - const isSelectedCurrencyValid = - selectedCurrency && - depositCurrencyTabs.some( ( tab ) => tab.name === selectedCurrency ); + const selectedOverview = + depositCurrencyOverviews.find( + ( overview ) => overview.name === selectedCurrency + ) || depositCurrencyOverviews[ 0 ]; - return ( - - { ( tab: BalanceTabProps ) => { - const totalBalance = tab.availableFunds + tab.pendingFunds; + const totalBalance = + selectedOverview.availableFunds + selectedOverview.pendingFunds; - return ( - <> - - - } - /> - - } - /> - - { tab.instantBalance && tab.instantBalance.amount > 0 && ( - - - - ) } - - ); - } } - + return ( + <> + + } + /> + + } + /> + + { selectedOverview.instantBalance && + selectedOverview.instantBalance.amount > 0 && ( + + + + ) } + ); }; diff --git a/client/components/account-balances/style.scss b/client/components/account-balances/style.scss index 1c33d4abdf9..22b44cd9591 100644 --- a/client/components/account-balances/style.scss +++ b/client/components/account-balances/style.scss @@ -1,7 +1,3 @@ -.wcpay-account-balances__balances { - border-top: 1px solid $gray-200; -} - .wcpay-account-balances__balances > * + * { border-left: 1px solid $gray-200; } @@ -21,7 +17,7 @@ &__amount { font-weight: 500; font-size: 20px; - line-height: 0px; + margin: 0; color: $gray-900; } } @@ -29,6 +25,7 @@ .wcpay-account-balances__balances__item__title { display: flex; align-items: center; + margin-top: 0; } .wcpay-account-balances__instant-deposit { diff --git a/client/components/account-balances/test/__snapshots__/index.test.tsx.snap b/client/components/account-balances/test/__snapshots__/index.test.tsx.snap index a8488b51be8..49b207843f8 100644 --- a/client/components/account-balances/test/__snapshots__/index.test.tsx.snap +++ b/client/components/account-balances/test/__snapshots__/index.test.tsx.snap @@ -2,127 +2,102 @@ exports[`AccountBalances renders the correct tooltip text for the total balance 1`] = `
-
+ + + + + +
+
+ +

+ diff --git a/client/components/account-balances/test/index.test.tsx b/client/components/account-balances/test/index.test.tsx index 6c277094ae5..176488a605c 100644 --- a/client/components/account-balances/test/index.test.tsx +++ b/client/components/account-balances/test/index.test.tsx @@ -8,7 +8,6 @@ import { render, screen, fireEvent, within } from '@testing-library/react'; * Internal dependencies */ import AccountBalances from '..'; -import { getCurrencyTabTitle } from '../utils'; import { useAllDepositsOverviews, useInstantDeposit } from 'wcpay/data'; import { useSelectedCurrency } from 'wcpay/overview/hooks'; import type * as AccountOverview from 'wcpay/types/account-overview'; @@ -55,11 +54,6 @@ const mockWcPaySettings = { }, }; -jest.mock( '../utils', () => ( { - getTimeOfDayString: jest.fn(), - getCurrencyTabTitle: jest.fn(), -} ) ); - jest.mock( 'wcpay/data', () => ( { useAllDepositsOverviews: jest.fn(), useInstantDeposit: jest.fn(), @@ -69,9 +63,6 @@ jest.mock( 'wcpay/overview/hooks', () => ( { useSelectedCurrency: jest.fn(), } ) ); -const mockGetCurrencyTabTitle = getCurrencyTabTitle as jest.MockedFunction< - typeof getCurrencyTabTitle ->; const mockUseAllDepositsOverviews = useAllDepositsOverviews as jest.MockedFunction< typeof useAllDepositsOverviews >; @@ -154,14 +145,12 @@ describe( 'AccountBalances', () => { global.wcpaySettings = mockWcPaySettings; } ); - test( 'renders the correct tab title and currency data', () => { - mockGetCurrencyTabTitle.mockReturnValue( 'USD Balance' ); + test( 'renders USD currency correctly', () => { mockOverviews( [ createMockOverview( 'usd', 10000, 20000, 0 ) ] ); // Use a query method returned by the render function: (you could also use `container` which will represent `document`) const { getByText, getByLabelText } = render( ); - // Check the tab title is rendered correctly. getByText( 'Total balance' ); getByText( 'Available funds' ); @@ -174,12 +163,10 @@ describe( 'AccountBalances', () => { } ); test( 'renders JPY currency correctly', () => { - mockGetCurrencyTabTitle.mockReturnValue( 'JPY Balance' ); mockOverviews( [ createMockOverview( 'jpy', 12300, 4560, 0 ) ] ); const { getByText, getByLabelText } = render( ); - // Check the tab title is rendered correctly. getByText( 'Total balance' ); getByText( 'Available funds' ); @@ -191,12 +178,7 @@ describe( 'AccountBalances', () => { expect( availableAmount ).toHaveTextContent( '¥46' ); } ); - test( 'renders with selected currency correctly', () => { - mockGetCurrencyTabTitle.mockImplementation( - ( currencyCode: string ) => { - return `${ currencyCode.toUpperCase() } Balance`; - } - ); + test( 'renders with selected currency correctly when multiple deposit currencies exist', () => { mockOverviews( [ createMockOverview( 'eur', 7660, 2739, 0 ), createMockOverview( 'usd', 84875, 47941, 0 ), @@ -207,13 +189,7 @@ describe( 'AccountBalances', () => { setSelectedCurrency: mockSetSelectedCurrency, } ); - const { getByLabelText, getByRole } = render( ); - - // Check the active tab is rendered correctly. - getByRole( 'tab', { - selected: true, - name: /JPY Balance/, - } ); + const { getByLabelText } = render( ); const totalAmount = getByLabelText( 'Total balance' ); const availableAmount = getByLabelText( 'Available funds' ); @@ -223,12 +199,7 @@ describe( 'AccountBalances', () => { expect( availableAmount ).toHaveTextContent( '¥90' ); } ); - test( 'renders default tab with invalid selected currency', () => { - mockGetCurrencyTabTitle.mockImplementation( - ( currencyCode: string ) => { - return `${ currencyCode.toUpperCase() } Balance`; - } - ); + test( 'renders default currency when invalid selected currency', () => { mockOverviews( [ createMockOverview( 'eur', 7660, 2739, 0 ), createMockOverview( 'usd', 84875, 47941, 0 ), @@ -240,13 +211,7 @@ describe( 'AccountBalances', () => { setSelectedCurrency: mockSetSelectedCurrency, } ); - const { getByLabelText, getByRole } = render( ); - - // Check the default active tab is rendered correctly. - getByRole( 'tab', { - selected: true, - name: /EUR Balance/, - } ); + const { getByLabelText } = render( ); const totalAmount = getByLabelText( 'Total balance' ); const availableAmount = getByLabelText( 'Available funds' ); @@ -256,62 +221,6 @@ describe( 'AccountBalances', () => { expect( availableAmount ).toHaveTextContent( '€27.39' ); } ); - test( 'renders multiple currency tabs', () => { - mockGetCurrencyTabTitle.mockImplementation( - ( currencyCode: string ) => { - return `${ currencyCode.toUpperCase() } Balance`; - } - ); - mockOverviews( [ - createMockOverview( 'eur', 7660, 2739, 0 ), - createMockOverview( 'usd', 84875, 47941, 0 ), - createMockOverview( 'jpy', 2000, 9000, 0 ), - ] ); - - const { getByLabelText } = render( ); - - // Get all the tab elements to check the tab titles are rendered correctly and for testing tab switching. - const tabTitles = screen.getAllByRole( 'tab' ); - - expect( tabTitles[ 0 ] ).toHaveTextContent( 'EUR Balance' ); - expect( tabTitles[ 1 ] ).toHaveTextContent( 'USD Balance' ); - expect( tabTitles[ 2 ] ).toHaveTextContent( 'JPY Balance' ); - - // Check the first tab (EUR). - const eurTotalAmount = getByLabelText( 'Total balance' ); - const eurAvailableAmount = getByLabelText( 'Available funds' ); - - // Check the total and available amounts are rendered correctly for the first tab. - expect( eurTotalAmount ).toHaveTextContent( '€103.99' ); - expect( eurAvailableAmount ).toHaveTextContent( '€27.39' ); - - /** - * Change the tab to the second tab (USD). - */ - fireEvent.click( tabTitles[ 1 ] ); - expect( mockSetSelectedCurrency ).toHaveBeenCalledTimes( 1 ); - expect( mockSetSelectedCurrency ).toHaveBeenCalledWith( 'usd' ); - const usdTotalAmount = getByLabelText( 'Total balance' ); - const usdAvailableAmount = getByLabelText( 'Available funds' ); - - // Check the total and available amounts are rendered correctly for the first tab. - expect( usdTotalAmount ).toHaveTextContent( '$1,328.16' ); - expect( usdAvailableAmount ).toHaveTextContent( '$479.41' ); - - /** - * Change the tab to the third tab (JPY). - */ - fireEvent.click( tabTitles[ 2 ] ); - expect( mockSetSelectedCurrency ).toHaveBeenCalledTimes( 2 ); - expect( mockSetSelectedCurrency ).toHaveBeenLastCalledWith( 'jpy' ); - const jpyTotalAmount = getByLabelText( 'Total balance' ); - const jpyAvailableAmount = getByLabelText( 'Available funds' ); - - // Check the total and available amounts are rendered correctly for the first tab. - expect( jpyTotalAmount ).toHaveTextContent( '¥110' ); - expect( jpyAvailableAmount ).toHaveTextContent( '¥90' ); - } ); - test( 'renders the correct tooltip text for the available balance', () => { mockOverviews( [ createMockOverview( 'usd', 10000, 20000, 0 ) ] ); render( ); diff --git a/client/components/account-balances/utils.ts b/client/components/account-balances/utils.ts deleted file mode 100644 index 56890cded1a..00000000000 --- a/client/components/account-balances/utils.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * External dependencies - */ -import { __, sprintf } from '@wordpress/i18n'; - -/** - * Generates a currency tab title. - * - * @param {string} currencyCode The currency code. - * @return {string} The currency tab title. Example: "USD balance" - */ -export const getCurrencyTabTitle = ( currencyCode: string ): string => { - return sprintf( - /** translators: %s is the currency code, e.g. USD. */ - __( '%s Balance', 'woocommerce-payments' ), - currencyCode.toUpperCase() - ); -}; diff --git a/client/components/account-status/account-tools/index.tsx b/client/components/account-status/account-tools/index.tsx index e417d4349ed..34886cc95c3 100644 --- a/client/components/account-status/account-tools/index.tsx +++ b/client/components/account-status/account-tools/index.tsx @@ -56,7 +56,6 @@ export const AccountTools: React.FC< Props > = ( props: Props ) => { ) } href={ accountLink } - target={ '_blank' } > { strings.finish } diff --git a/client/components/account-status/account-tools/test/__snapshots__/index.test.tsx.snap b/client/components/account-status/account-tools/test/__snapshots__/index.test.tsx.snap index b5b1f5eb8fc..988d1e2d7fe 100644 --- a/client/components/account-status/account-tools/test/__snapshots__/index.test.tsx.snap +++ b/client/components/account-status/account-tools/test/__snapshots__/index.test.tsx.snap @@ -24,7 +24,6 @@ exports[`AccountTools should render in live mode 1`] = ` Finish setup @@ -63,7 +62,6 @@ exports[`AccountTools should render in sandbox mode 1`] = ` Finish setup diff --git a/client/components/account-status/test/__snapshots__/index.js.snap b/client/components/account-status/test/__snapshots__/index.js.snap index 97a0ae5fc41..8c3367a63fc 100644 --- a/client/components/account-status/test/__snapshots__/index.js.snap +++ b/client/components/account-status/test/__snapshots__/index.js.snap @@ -189,7 +189,6 @@ exports[`AccountStatus renders normal status 1`] = ` Finish setup diff --git a/client/components/deposits-overview/recent-deposits-list.tsx b/client/components/deposits-overview/recent-deposits-list.tsx index 119e7adc92f..1fed2448758 100644 --- a/client/components/deposits-overview/recent-deposits-list.tsx +++ b/client/components/deposits-overview/recent-deposits-list.tsx @@ -24,26 +24,26 @@ import { CachedDeposit } from 'wcpay/types/deposits'; import { formatCurrency } from 'wcpay/utils/currency'; import { getDetailsURL } from 'wcpay/components/details-link'; -interface DepositRowProps { - deposit: CachedDeposit; -} - interface RecentDepositsProps { deposits: CachedDeposit[]; } -const tableClass = 'wcpay-deposits-overview__table'; - /** - * Renders a recent deposits table row. + * Renders the Recent Deposit list component. * - * @return {JSX.Element} Deposit table row. + * This component includes the recent deposit heading, table and notice. */ -const DepositTableRow: React.FC< DepositRowProps > = ( { - deposit, -} ): JSX.Element => { - return ( - +const RecentDepositsList: React.FC< RecentDepositsProps > = ( { + deposits, +} ) => { + if ( deposits.length === 0 ) { + return null; + } + + const tableClass = 'wcpay-deposits-overview__table'; + + const depositRows = deposits.map( ( deposit ) => ( + @@ -57,33 +57,10 @@ const DepositTableRow: React.FC< DepositRowProps > = ( { { formatCurrency( deposit.amount, deposit.currency ) } - ); -}; - -/** - * Renders the Recent Deposit details component. - * - * This component includes the recent deposit heading, table and notice. - * - * @param {RecentDepositsProps} props Recent Deposit props. - * @return {JSX.Element} Rendered element with Next Deposit details. - */ -const RecentDepositsList: React.FC< RecentDepositsProps > = ( { - deposits, -} ): JSX.Element => { - if ( deposits.length === 0 ) { - return <>; - } - - const depositRows = deposits.map( ( deposit ) => ( - - - ) ); return ( <> - { /* Next Deposit Table */ } diff --git a/client/components/duplicate-notice/index.tsx b/client/components/duplicate-notice/index.tsx index 2a509147d0e..faca5adb573 100644 --- a/client/components/duplicate-notice/index.tsx +++ b/client/components/duplicate-notice/index.tsx @@ -8,21 +8,38 @@ import { __ } from '@wordpress/i18n'; import { getAdminUrl } from 'wcpay/utils'; import { useDispatch } from '@wordpress/data'; +export type PaymentMethodToPluginsMap = { [ key: string ]: string[] }; interface DuplicateNoticeProps { paymentMethod: string; - dismissedDuplicateNotices: string[]; - setDismissedDuplicateNotices: ( notices: string[] ) => void; + gatewaysEnablingPaymentMethod: string[]; + dismissedNotices: PaymentMethodToPluginsMap; + setDismissedDuplicateNotices: ( + notices: PaymentMethodToPluginsMap + ) => null; } function DuplicateNotice( { paymentMethod, - dismissedDuplicateNotices, + gatewaysEnablingPaymentMethod, + dismissedNotices, setDismissedDuplicateNotices, }: DuplicateNoticeProps ): JSX.Element | null { const { updateOptions } = useDispatch( 'wc/admin/options' ); const handleDismiss = useCallback( () => { - const updatedNotices = [ ...dismissedDuplicateNotices, paymentMethod ]; + const updatedNotices = { ...dismissedNotices }; + if ( updatedNotices[ paymentMethod ] ) { + // If there are existing dismissed notices for the payment method, append to the current array. + updatedNotices[ paymentMethod ] = [ + ...new Set( [ + ...updatedNotices[ paymentMethod ], + ...gatewaysEnablingPaymentMethod, + ] ), + ]; + } else { + updatedNotices[ paymentMethod ] = gatewaysEnablingPaymentMethod; + } + setDismissedDuplicateNotices( updatedNotices ); updateOptions( { wcpay_duplicate_payment_method_notices_dismissed: updatedNotices, @@ -30,13 +47,20 @@ function DuplicateNotice( { wcpaySettings.dismissedDuplicateNotices = updatedNotices; }, [ paymentMethod, - dismissedDuplicateNotices, + gatewaysEnablingPaymentMethod, + dismissedNotices, setDismissedDuplicateNotices, updateOptions, ] ); - if ( dismissedDuplicateNotices.includes( paymentMethod ) ) { - return null; + if ( dismissedNotices?.[ paymentMethod ] ) { + const isNoticeDismissedForEveryGateway = gatewaysEnablingPaymentMethod.every( + ( value ) => dismissedNotices[ paymentMethod ].includes( value ) + ); + + if ( isNoticeDismissedForEveryGateway ) { + return null; + } } return ( diff --git a/client/components/duplicate-notice/tests/index.test.tsx b/client/components/duplicate-notice/tests/index.test.tsx index aacb7c3a90c..290f756d650 100644 --- a/client/components/duplicate-notice/tests/index.test.tsx +++ b/client/components/duplicate-notice/tests/index.test.tsx @@ -27,10 +27,14 @@ describe( 'DuplicateNotice', () => { } ); test( 'does not render when the payment method is dismissed', () => { + const dismissedDuplicateNotices = { + bancontact: [ 'woocommerce_payments' ], + }; render( ); @@ -41,11 +45,36 @@ describe( 'DuplicateNotice', () => { ).not.toBeInTheDocument(); } ); + test( 'renders correctly when the payment method is dismissed by some plugins but not all', () => { + const dismissedDuplicateNotices = { + bancontact: [ 'woocommerce_payments' ], + }; + + render( + + ); + expect( + screen.getByText( + 'This payment method is enabled by other extensions. Review extensions to improve the shopper experience.' + ) + ).toBeInTheDocument(); + cleanup(); + } ); + test( 'renders correctly when the payment method is not dismissed', () => { render( ); @@ -61,7 +90,8 @@ describe( 'DuplicateNotice', () => { const paymentMethod = 'ideal'; const props = { paymentMethod: paymentMethod, - dismissedDuplicateNotices: [], + gatewaysEnablingPaymentMethod: [ 'woocommerce_payments' ], + dismissedNotices: {}, setDismissedDuplicateNotices: jest.fn(), }; const { container } = render( ); @@ -75,11 +105,13 @@ describe( 'DuplicateNotice', () => { } // Check if local state update function and Redux action dispatcher are called correctly - expect( props.setDismissedDuplicateNotices ).toHaveBeenCalledWith( [ - paymentMethod, - ] ); + expect( props.setDismissedDuplicateNotices ).toHaveBeenCalledWith( { + [ paymentMethod ]: [ 'woocommerce_payments' ], + } ); expect( mockDispatch ).toHaveBeenCalledWith( { - wcpay_duplicate_payment_method_notices_dismissed: [ paymentMethod ], + wcpay_duplicate_payment_method_notices_dismissed: { + [ paymentMethod ]: [ 'woocommerce_payments' ], + }, } ); } ); @@ -88,7 +120,8 @@ describe( 'DuplicateNotice', () => { diff --git a/client/components/inline-label-select/index.tsx b/client/components/inline-label-select/index.tsx new file mode 100644 index 00000000000..df1233ef890 --- /dev/null +++ b/client/components/inline-label-select/index.tsx @@ -0,0 +1,250 @@ +/** + * This is a copy of Gutenberg's CustomSelectControl component, found here: + * https://github.com/WordPress/gutenberg/tree/7aa042605ff42bb437e650c39132c0aa8eb4ef95/packages/components/src/custom-select-control + * + * It has been forked from the existing WooPayments copy of this component (client/components/custom-select-control) + * to match this specific select input design with an inline label and option hints. + */ + +/** + * External Dependencies + */ +import React from 'react'; +import { Button } from '@wordpress/components'; +import { check, chevronDown, Icon } from '@wordpress/icons'; +import { useCallback } from '@wordpress/element'; +import classNames from 'classnames'; +import { __, sprintf } from '@wordpress/i18n'; +import { useSelect, UseSelectState } from 'downshift'; + +/** + * Internal Dependencies + */ +import './style.scss'; + +export interface SelectItem { + /** The unique key for the item. */ + key: string; + /** The display name of the item. */ + name?: string; + /** Descriptive hint for the item, displayed to the right of the name. */ + hint?: string; + /** Additional class name to apply to the item. */ + className?: string; + /** Additional inline styles to apply to the item. */ + style?: React.CSSProperties; +} + +export interface ControlProps< SelectItemType > { + /** The name attribute for the select input. */ + name?: string; + /** Additional class name to apply to the select control. */ + className?: string; + /** The label for the select control. */ + label: string; + /** The ID of an element that describes the select control. */ + describedBy?: string; + /** A list of options/items for the select control. */ + options: SelectItemType[]; + /** The currently selected option/item. */ + value?: SelectItemType | null; + /** A placeholder to display when no item is selected. */ + placeholder?: string; + /** Callback function to run when the selected item changes. */ + onChange?: ( changes: Partial< UseSelectState< SelectItemType > > ) => void; + /** A function to render the children of the item. Takes an item as an argument, must return a JSX element. */ + children?: ( item: SelectItemType ) => JSX.Element; +} + +/** + * Converts a select option/item object to a string. + */ +const itemToString = ( item: { name?: string } | null ) => item?.name || ''; + +/** + * State reducer for the select component. + * This is needed so that in Windows, where the menu does not necessarily open on + * key up/down, you can still switch between options with the menu closed. + */ +const stateReducer = ( + { selectedItem }: any, + { type, changes, props: { items } }: any +) => { + switch ( type ) { + case useSelect.stateChangeTypes.ToggleButtonKeyDownArrowDown: + // If we already have a selected item, try to select the next one, + // without circular navigation. Otherwise, select the first item. + return { + selectedItem: + items[ + selectedItem + ? Math.min( + items.indexOf( selectedItem ) + 1, + items.length - 1 + ) + : 0 + ], + }; + case useSelect.stateChangeTypes.ToggleButtonKeyDownArrowUp: + // If we already have a selected item, try to select the previous one, + // without circular navigation. Otherwise, select the last item. + return { + selectedItem: + items[ + selectedItem + ? Math.max( items.indexOf( selectedItem ) - 1, 0 ) + : items.length - 1 + ], + }; + default: + return changes; + } +}; + +/** + * InlineLabelSelect component. + * A select control with a list of options, inline label, and option hints. + */ +function InlineLabelSelect< ItemType extends SelectItem >( { + name, + className, + label, + describedBy, + options: items, + onChange: onSelectedItemChange, + value, + placeholder, + children, +}: ControlProps< ItemType > ): JSX.Element { + const { + getLabelProps, + getToggleButtonProps, + getMenuProps, + getItemProps, + isOpen, + highlightedIndex, + selectedItem, + } = useSelect( { + initialSelectedItem: items[ 0 ], + items, + itemToString, + onSelectedItemChange, + selectedItem: value || ( {} as ItemType ), + stateReducer, + } ); + + const itemString = itemToString( selectedItem ); + + function getDescribedBy() { + if ( describedBy ) { + return describedBy; + } + + if ( ! itemString ) { + return __( 'No selection' ); + } + + // translators: %s: The selected option. + return sprintf( __( 'Currently selected: %s' ), itemString ); + } + + const menuProps = getMenuProps( { + className: 'wcpay-filter components-custom-select-control__menu', + 'aria-hidden': ! isOpen, + } ); + + const onKeyDownHandler = useCallback( + ( e ) => { + e.stopPropagation(); + menuProps?.onKeyDown?.( e ); + }, + [ menuProps ] + ); + + // We need this here, because the null active descendant is not fully ARIA compliant. + if ( + menuProps[ 'aria-activedescendant' ]?.startsWith( 'downshift-null' ) + ) { + delete menuProps[ 'aria-activedescendant' ]; + } + return ( +
+ + { /* eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions */ } +
    + { isOpen && + items.map( ( item, index ) => ( + // eslint-disable-next-line react/jsx-key +
  • + + { children ? children( item ) : item.name } + { item.hint && ( + + { item.hint } + + ) } +
  • + ) ) } +
+
+ ); +} + +export default InlineLabelSelect; diff --git a/client/components/inline-label-select/style.scss b/client/components/inline-label-select/style.scss new file mode 100644 index 00000000000..41f1cbf65fb --- /dev/null +++ b/client/components/inline-label-select/style.scss @@ -0,0 +1,95 @@ +.wcpay-filter.components-custom-select-control { + font-size: 13px; + color: $gray-900; + + .wcpay-filter.components-custom-select-control__label { + display: inline-block; + margin-bottom: 0; + color: $wp-gray-40; + margin-right: 0.2em; + white-space: nowrap; + + &::after { + content: ':'; + } + } + + .wcpay-filter.components-custom-select-control__item { + padding: $gap-small; + margin: 0; + line-height: initial; + grid-template-columns: auto auto auto; + justify-content: start; + white-space: nowrap; + border-radius: 2px; + + &.is-highlighted { + background: $gray-0; + } + } + + .wcpay-filter.components-custom-select-control__item-icon { + margin-right: 0.2em; + } + + .wcpay-filter.components-custom-select-control__item-hint { + margin-left: 1.8em; + text-align: left; + color: $gray-700; + } + + button.wcpay-filter.components-custom-select-control__button { + width: 100%; + background-color: #fff; + margin: 0 1px; + display: flex; + justify-content: space-between; + align-items: center; + padding: 6px 8px; + border: 1px solid $gray-200; + font-size: 13px; + + &:hover, + &:focus { + color: initial; + background-color: $gray-0; + box-shadow: none; + } + + &.placeholder { + color: $gray-50; + } + + .wcpay-filter.components-custom-select-control__button-value { + text-overflow: ellipsis; + white-space: nowrap; + overflow-x: hidden; + } + + svg { + fill: initial; + width: 18px; + flex: 0 0 18px; + } + + &[aria-expanded='true'] { + .wcpay-filter.components-custom-select-control__button-value { + visibility: hidden; + } + .components-custom-select-control__label::after { + visibility: hidden; + } + } + + @media screen and ( max-width: 782px ) { + font-size: 16px; + } + } + + .wcpay-filter.components-custom-select-control__menu { + margin: -1px 1px; + border-color: $gray-300; + max-height: 300px; + padding: $grid-unit-10 $grid-unit-15; + } +} diff --git a/client/components/inline-label-select/test/__snapshots__/index.test.tsx.snap b/client/components/inline-label-select/test/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..dd8eb1be580 --- /dev/null +++ b/client/components/inline-label-select/test/__snapshots__/index.test.tsx.snap @@ -0,0 +1,312 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`InlineLabelSelect renders options 1`] = ` +
+
+ +
    +
  • + + EUR € +
  • +
  • + + JPY ¥ + + Japanese Yen + +
  • +
  • + + GBP £ + + British Pound + +
  • +
+
+
+`; + +exports[`InlineLabelSelect renders options with custom children 1`] = ` +
+
+ +
+
+`; + +exports[`InlineLabelSelect renders with placeholder 1`] = ` +
+
+ +
    +
  • + + EUR € +
  • +
  • + + JPY ¥ + + Japanese Yen + +
  • +
  • + + GBP £ + + British Pound + +
  • +
+
+
+`; diff --git a/client/components/inline-label-select/test/index.test.tsx b/client/components/inline-label-select/test/index.test.tsx new file mode 100644 index 00000000000..be1f671e862 --- /dev/null +++ b/client/components/inline-label-select/test/index.test.tsx @@ -0,0 +1,105 @@ +/** @format */ + +/** + * External dependencies + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import user from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import InlineLabelSelect from '..'; + +interface Item { + key: string; + name: string; + icon?: string; + hint?: string; +} + +const options: Item[] = [ + { + key: 'EUR', + name: 'EUR €', + icon: '💶', + }, + { + key: 'JPY', + name: 'JPY ¥', + icon: '💴', + hint: 'Japanese Yen', + }, + { + key: 'GBP', + name: 'GBP £', + icon: '💷', + hint: 'British Pound', + }, +]; + +describe( 'InlineLabelSelect', () => { + test( 'renders options', () => { + const { container, getByText } = render( + + ); + + user.click( screen.getByRole( 'button' ) ); + + // Option names should be visible. + getByText( 'JPY ¥' ); + // Hints should be visible. + getByText( 'British Pound' ); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'renders options with custom children', () => { + const { container, getByText } = render( + ( + <> + { item.icon } + { item.name } + + ) } + /> + ); + + user.click( screen.getByRole( 'button' ) ); + + // Option icons should be visible. + getByText( '💴' ); + + user.click( screen.getByRole( 'button' ) ); + + expect( container ).toMatchSnapshot(); + } ); + + test( 'renders with placeholder', () => { + const { container } = render( + + ); + + user.click( screen.getByRole( 'button' ) ); + + expect( container ).toMatchSnapshot(); + } ); +} ); diff --git a/client/components/payment-activity/hooks.ts b/client/components/payment-activity/hooks.ts new file mode 100644 index 00000000000..2f5d04da975 --- /dev/null +++ b/client/components/payment-activity/hooks.ts @@ -0,0 +1,132 @@ +/** + * External dependencies + */ +import { useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import moment from 'moment'; + +interface DateRange { + /** The name of the date range preset. e.g. last_7_days */ + preset_name: string; + /** The date range start datetime used to calculate transaction data, e.g. 2024-04-29T16:19:29 */ + date_start: string; + /** The date range end datetime used to calculate transaction data, e.g. 2024-04-29T16:19:29 */ + date_end: string; +} + +/** + * Hook to manage the selected date range and date range presets for the payment activity widget. + */ +export const usePaymentActivityDateRangePresets = (): { + selectedDateRange: DateRange; + setSelectedDateRange: ( dateRange: DateRange ) => void; + dateRangePresets: { + [ key: string ]: { + start: moment.Moment; + end: moment.Moment; + displayKey: string; + }; + }; +} => { + const now = moment(); + const yesterdayEndOfDay = moment() + .clone() + .subtract( 1, 'd' ) + .set( { hour: 23, minute: 59, second: 59, millisecond: 0 } ); + const todayEndOfDay = moment() + .clone() + .set( { hour: 23, minute: 59, second: 59, millisecond: 0 } ); + + const dateRangePresets: { + [ key: string ]: { + start: moment.Moment; + end: moment.Moment; + displayKey: string; + }; + } = { + today: { + start: now + .clone() + .set( { hour: 0, minute: 0, second: 0, millisecond: 0 } ), + end: todayEndOfDay, + displayKey: __( 'Today', 'woocommerce-payments' ), + }, + last_7_days: { + start: now + .clone() + .subtract( 7, 'days' ) + .set( { hour: 0, minute: 0, second: 0, millisecond: 0 } ), + end: yesterdayEndOfDay, + displayKey: __( 'Last 7 days', 'woocommerce-payments' ), + }, + last_4_weeks: { + start: now + .clone() + .subtract( 4, 'weeks' ) + .set( { hour: 0, minute: 0, second: 0, millisecond: 0 } ), + end: yesterdayEndOfDay, + displayKey: __( 'Last 4 weeks', 'woocommerce-payments' ), + }, + last_3_months: { + start: now + .clone() + .subtract( 3, 'months' ) + .set( { hour: 0, minute: 0, second: 0, millisecond: 0 } ), + end: yesterdayEndOfDay, + displayKey: __( 'Last 3 months', 'woocommerce-payments' ), + }, + last_12_months: { + start: now + .clone() + .subtract( 12, 'months' ) + .set( { hour: 0, minute: 0, second: 0, millisecond: 0 } ), + end: yesterdayEndOfDay, + displayKey: __( 'Last 12 months', 'woocommerce-payments' ), + }, + month_to_date: { + start: now.clone().startOf( 'month' ), + end: todayEndOfDay, + displayKey: __( 'Month to date', 'woocommerce-payments' ), + }, + quarter_to_date: { + start: now.clone().startOf( 'quarter' ), + end: todayEndOfDay, + displayKey: __( 'Quarter to date', 'woocommerce-payments' ), + }, + year_to_date: { + start: now.clone().startOf( 'year' ), + end: todayEndOfDay, + displayKey: __( 'Year to date', 'woocommerce-payments' ), + }, + all_time: { + start: moment( + wcpaySettings.accountStatus.created, + 'YYYY-MM-DD\\THH:mm:ss' + ), + end: todayEndOfDay, + displayKey: __( 'All time', 'woocommerce-payments' ), + }, + }; + + const defaultDateRange = { + preset_name: 'last_7_days', + date_start: dateRangePresets.last_7_days.start.format( + 'YYYY-MM-DD\\THH:mm:ss' + ), + date_end: dateRangePresets.last_7_days.end.format( + 'YYYY-MM-DD\\THH:mm:ss' + ), + }; + + const [ selectedDateRange, setSelectedDateRange ] = useState( { + preset_name: defaultDateRange.preset_name, + date_start: defaultDateRange.date_start, + date_end: defaultDateRange.date_end, + } ); + + return { + selectedDateRange, + setSelectedDateRange, + dateRangePresets, + }; +}; diff --git a/client/components/payment-activity/index.tsx b/client/components/payment-activity/index.tsx index 6a7e7bad00d..2e1f8fe2196 100644 --- a/client/components/payment-activity/index.tsx +++ b/client/components/payment-activity/index.tsx @@ -1,7 +1,7 @@ /** * External dependencies */ -import * as React from 'react'; +import React from 'react'; import { Card, CardBody, CardHeader } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import interpolateComponents from '@automattic/interpolate-components'; @@ -12,26 +12,16 @@ import moment from 'moment'; */ import EmptyStateAsset from 'assets/images/payment-activity-empty-state.svg?asset'; +import InlineLabelSelect from '../inline-label-select'; import PaymentActivityDataComponent from './payment-activity-data'; import Survey from './survey'; -import { WcPayOverviewSurveyContextProvider } from './survey/context'; +import { recordEvent } from 'wcpay/tracks'; import { usePaymentActivityData } from 'wcpay/data'; -import type { DateRange } from './types'; +import { usePaymentActivityDateRangePresets } from './hooks'; +import { useSelectedCurrency } from 'wcpay/overview/hooks'; +import { WcPayOverviewSurveyContextProvider } from './survey/context'; import './style.scss'; -/** - * This will be replaces in the future with a dynamic date range picker. - */ -const getDateRange = (): DateRange => { - return { - // Subtract 7 days from the current date. - date_start: moment() - .subtract( 7, 'd' ) - .format( 'YYYY-MM-DD\\THH:mm:ss' ), - date_end: moment().format( 'YYYY-MM-DD\\THH:mm:ss' ), - }; -}; - const PaymentActivityEmptyState: React.FC = () => ( @@ -61,12 +51,40 @@ const PaymentActivityEmptyState: React.FC = () => ( ); +const formatDateRange = ( + start: moment.Moment, + end: moment.Moment +): string => { + // Today - show only today's date. + if ( start.isSame( end, 'day' ) ) { + return start.format( 'MMMM D, YYYY' ); + } + + // Different years - show year for both start and end + if ( ! start.isSame( end, 'year' ) ) { + return `${ start.format( 'MMMM D, YYYY' ) } - ${ end.format( + 'MMMM D, YYYY' + ) }`; + } + + // Same year - show year only for end date. + return `${ start.format( 'MMMM D' ) } - ${ end.format( 'MMMM D, YYYY' ) }`; +}; + const PaymentActivity: React.FC = () => { const isOverviewSurveySubmitted = wcpaySettings.isOverviewSurveySubmitted ?? false; + const { selectedCurrency } = useSelectedCurrency(); + const { + selectedDateRange, + setSelectedDateRange, + dateRangePresets, + } = usePaymentActivityDateRangePresets(); const { paymentActivityData, isLoading } = usePaymentActivityData( { - ...getDateRange(), + currency: selectedCurrency ?? wcpaySettings.accountDefaultCurrency, + date_start: selectedDateRange.date_start, + date_end: selectedDateRange.date_end, timezone: moment( new Date() ).format( 'Z' ), } ); @@ -79,11 +97,53 @@ const PaymentActivity: React.FC = () => { return <>; } + const options = Object.keys( dateRangePresets ).map( ( presetName ) => { + const preset = dateRangePresets[ presetName ]; + return { + key: presetName, + name: preset.displayKey, + hint: formatDateRange( preset.start, preset.end ), + }; + } ); + return ( - + { __( 'Your payment activity', 'woocommerce-payments' ) } - { /* Filters go here */ } + + option.key === selectedDateRange.preset_name + ) } + placeholder="Select an option..." + onChange={ ( changes ) => { + const selectedItem = changes.selectedItem; + if ( selectedItem ) { + const start = dateRangePresets[ + selectedItem.key + ].start + .clone() + .format( 'YYYY-MM-DD\\THH:mm:ss' ); + const end = dateRangePresets[ selectedItem.key ].end + .clone() + .format( 'YYYY-MM-DD\\THH:mm:ss' ); + const { key: presetName } = selectedItem; + recordEvent( + 'wcpay_overview_payment_activity_period_change', + { + preset_name: presetName, + } + ); + setSelectedDateRange( { + date_start: start, + date_end: end, + preset_name: presetName, + } ); + } + } } + /> = ( { page: 'wc-admin', path: '/payments/transactions', filter: 'advanced', + store_currency_is: currency, 'date_between[0]': moment( paymentActivityData?.date_start ).format( 'YYYY-MM-DD' ), @@ -154,6 +156,7 @@ const PaymentActivityDataComponent: React.FC< Props > = ( { page: 'wc-admin', path: '/payments/transactions', filter: 'advanced', + store_currency_is: currency, 'date_between[0]': moment( paymentActivityData?.date_start ).format( 'YYYY-MM-DD' ), @@ -176,6 +179,7 @@ const PaymentActivityDataComponent: React.FC< Props > = ( { page: 'wc-admin', path: '/payments/transactions', filter: 'advanced', + store_currency_is: currency, 'date_between[0]': moment( paymentActivityData?.date_start ).format( 'YYYY-MM-DD' ), @@ -198,6 +202,7 @@ const PaymentActivityDataComponent: React.FC< Props > = ( { page: 'wc-admin', path: '/payments/transactions', filter: 'advanced', + store_currency_is: currency, 'date_between[0]': moment( paymentActivityData?.date_start ).format( 'YYYY-MM-DD' ), diff --git a/client/components/payment-activity/style.scss b/client/components/payment-activity/style.scss index ab75d9370eb..b10ee2fdf5c 100644 --- a/client/components/payment-activity/style.scss +++ b/client/components/payment-activity/style.scss @@ -23,6 +23,28 @@ padding: 16px 0 19px; } } + + &__header { + .wcpay-filter.components-custom-select-control { + .wcpay-filter.components-custom-select-control { + &__menu { + // Ensure date preset items are shown without vertical scroll. + max-height: fit-content; + } + + &__item { + // Set alignment of columns in the date preset dropdown. + grid-template-columns: 10% 25% 65%; + } + } + } + + @include breakpoint( '<660px' ) { + // Sets Mobile view of the payment activity widget header. Date preset dropdown is moved to a new line. + flex-direction: column !important; + gap: 16px; + } + } } } diff --git a/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap b/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap index 2a55f39d20a..58982a205e5 100644 --- a/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap +++ b/client/components/payment-activity/test/__snapshots__/index.test.tsx.snap @@ -11,11 +11,58 @@ exports[`PaymentActivity component should render 1`] = ` class="css-mgwsf4-View-Content em57xhy0" >
Your payment activity +
+ +
View report @@ -136,7 +183,7 @@ exports[`PaymentActivity component should render 1`] = `

View report @@ -165,7 +212,7 @@ exports[`PaymentActivity component should render 1`] = `

View report @@ -194,7 +241,7 @@ exports[`PaymentActivity component should render 1`] = `

View report diff --git a/client/components/payment-activity/test/index.test.tsx b/client/components/payment-activity/test/index.test.tsx index cfd35de8451..fe3d29376de 100644 --- a/client/components/payment-activity/test/index.test.tsx +++ b/client/components/payment-activity/test/index.test.tsx @@ -2,7 +2,8 @@ * External dependencies */ import React from 'react'; -import { render } from '@testing-library/react'; +import { render, fireEvent } from '@testing-library/react'; +import moment from 'moment'; /** * Internal dependencies @@ -64,6 +65,7 @@ declare const global: { [ currencyCode: string ]: number; }; }; + created: string; }; accountDefaultCurrency: string; zeroDecimalCurrencies: string[]; @@ -87,6 +89,7 @@ describe( 'PaymentActivity component', () => { usd: 500, }, }, + created: '2022-01-01T00:00:00Z', }, accountDefaultCurrency: 'eur', zeroDecimalCurrencies: [], @@ -122,7 +125,7 @@ describe( 'PaymentActivity component', () => { } ); it( 'should render', () => { - const { container, getByText, getByLabelText } = render( + const { container, getByText, getByLabelText, getAllByText } = render( ); @@ -133,6 +136,15 @@ describe( 'PaymentActivity component', () => { const tpvElement = getByLabelText( 'Total payment volume' ); expect( tpvElement ).toHaveTextContent( '€1.234,56' ); + // Check the "View report" link is rendered with the correct currency query param. + const viewReportLinks = getAllByText( 'View report' ); + viewReportLinks.forEach( ( link ) => { + expect( link ).toHaveAttribute( + 'href', + expect.stringContaining( 'store_currency_is=eur' ) + ); + } ); + expect( container ).toMatchSnapshot(); } ); @@ -154,4 +166,121 @@ describe( 'PaymentActivity component', () => { queryByText( 'Are these metrics helpful?' ) ).not.toBeInTheDocument(); } ); + + describe( 'Date selector renders correct ranges', () => { + afterEach( () => { + Date.now = () => new Date().getTime(); + } ); + + const mockDateNowTo = ( date: string ) => { + Date.now = jest.fn( () => + moment.tz( new Date( date ).getTime(), 'UTC' ).valueOf() + ); + }; + const dataSet = [ + { + // Ordinary case or Happy Path + dateNow: '2024-06-10T16:19:29', + expected: { + today: 'June 10, 2024', + last7Days: 'June 3 - June 9, 2024', + last4Weeks: 'May 13 - June 9, 2024', + last3Months: 'March 10 - June 9, 2024', + last12Months: 'June 10, 2023 - June 9, 2024', + monthToDate: 'June 1 - June 10, 2024', + quarterToDate: 'April 1 - June 10, 2024', + yearToDate: 'January 1 - June 10, 2024', + allTime: 'January 1, 2022 - June 10, 2024', + }, + }, + { + // Start of the year + dateNow: '2024-01-01T00:00:00', + expected: { + today: 'January 1, 2024', + last7Days: 'December 25 - December 31, 2023', + last4Weeks: 'December 4 - December 31, 2023', + last3Months: 'October 1 - December 31, 2023', + last12Months: 'January 1 - December 31, 2023', + monthToDate: 'January 1, 2024', + quarterToDate: 'January 1, 2024', + yearToDate: 'January 1, 2024', + allTime: 'January 1, 2022 - January 1, 2024', + }, + }, + { + // Leap year + dateNow: '2024-02-29T00:00:00', + expected: { + today: 'February 29, 2024', + last7Days: 'February 22 - February 28, 2024', + last4Weeks: 'February 1 - February 28, 2024', + last3Months: 'November 29, 2023 - February 28, 2024', + last12Months: 'February 28, 2023 - February 28, 2024', + monthToDate: 'February 1 - February 29, 2024', + quarterToDate: 'January 1 - February 29, 2024', + yearToDate: 'January 1 - February 29, 2024', + allTime: 'January 1, 2022 - February 29, 2024', + }, + }, + ]; + + it.each( dataSet )( + 'should render the correct date ranges', + ( { dateNow, expected } ) => { + mockDateNowTo( dateNow ); + + const { getByRole } = render( ); + + const dateSelectorButton = getByRole( 'button', { + name: 'Period', + } ); + fireEvent.click( dateSelectorButton ); + + expect( + getByRole( 'option', { name: `Today ${ expected.today }` } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { + name: `Last 7 days ${ expected.last7Days }`, + } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { + name: `Last 4 weeks ${ expected.last4Weeks }`, + } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { + name: `Last 3 months ${ expected.last3Months }`, + } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { + name: `Last 12 months ${ expected.last12Months }`, + } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { + name: `Month to date ${ expected.monthToDate }`, + } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { + name: `Quarter to date ${ expected.quarterToDate }`, + } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { + name: `Year to date ${ expected.yearToDate }`, + } ) + ).toBeInTheDocument(); + expect( + getByRole( 'option', { + name: `All time ${ expected.allTime }`, + } ) + ).toBeInTheDocument(); + } + ); + } ); } ); diff --git a/client/components/payment-activity/types.ts b/client/components/payment-activity/types.ts deleted file mode 100644 index 50276a89067..00000000000 --- a/client/components/payment-activity/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export interface DateRange { - date_start: string; // Start date - date_end: string; // End date -} diff --git a/client/components/payment-methods-list/payment-method.tsx b/client/components/payment-methods-list/payment-method.tsx index 7aa2bd7f8fa..76561c686f3 100644 --- a/client/components/payment-methods-list/payment-method.tsx +++ b/client/components/payment-methods-list/payment-method.tsx @@ -151,7 +151,7 @@ const PaymentMethod = ( { dismissedDuplicateNotices, setDismissedDuplicateNotices, } = useContext( DuplicatedPaymentMethodsContext ); - const isDuplicate = duplicates.includes( id ); + const isDuplicate = Object.keys( duplicates ).includes( id ); const needsOverlay = ( isManualCaptureEnabled && ! isAllowingManualCapture ) || @@ -368,7 +368,8 @@ const PaymentMethod = ( { { isDuplicate && ( { render( @@ -172,8 +172,8 @@ describe( 'PaymentMethod', () => { render( diff --git a/client/components/welcome/currency-select.tsx b/client/components/welcome/currency-select.tsx new file mode 100644 index 00000000000..7f82c412313 --- /dev/null +++ b/client/components/welcome/currency-select.tsx @@ -0,0 +1,108 @@ +/** + * External dependencies + */ +import React, { useEffect } from 'react'; +import { decodeEntities } from '@wordpress/html-entities'; + +/** + * Internal dependencies + */ +import { useSelectedCurrency } from 'overview/hooks'; +import { getCurrency } from 'utils/currency'; +import InlineLabelSelect from '../inline-label-select'; +import { recordEvent } from 'tracks'; + +/** + * Returns an select option object for a currency select control. + */ +const getCurrencyOption = ( + currency: string +): { + name: string; + key: string; +} => { + const { code, symbol } = getCurrency( currency )?.getCurrencyConfig() || {}; + const currencySymbolDecoded = decodeEntities( symbol || '' ); + + if ( + // Show just the currency the currency code is used as the name, e.g. 'EUR' + // if no currency config is found, + ! code || + ! symbol || + // or if the symbol is identical to the currency code, e.g. 'CHF CHF'. + currencySymbolDecoded === code + ) { + return { + name: currency.toUpperCase(), + key: currency, + }; + } + return { + // A rendered name of the currency with symbol, e.g. `EUR €`. + name: `${ code } ${ currencySymbolDecoded }`, + key: currency, + }; +}; + +/** + * Custom hook to get the selected currency from the URL query parameter 'selected_currency'. + * If no currency is selected, the store's default currency will be selected. + */ +const useSelectedCurrencyWithDefault = ( depositCurrencies: string[] ) => { + const { selectedCurrency, setSelectedCurrency } = useSelectedCurrency(); + + useEffect( () => { + // The selected currency is invalid if: + // * no currency is explicitly selected via URL query, or + // * no currency is found for the provided query parameter. + const isSelectedCurrencyValid = + selectedCurrency && + depositCurrencies.find( + ( currency ) => + currency.toLowerCase() === selectedCurrency.toLowerCase() + ); + + // Select the store's default currency if the selected currency is invalid. + if ( ! isSelectedCurrencyValid && depositCurrencies.length > 0 ) { + setSelectedCurrency( depositCurrencies[ 0 ].toLowerCase() ); + } + }, [ depositCurrencies, selectedCurrency, setSelectedCurrency ] ); + + return { selectedCurrency, setSelectedCurrency }; +}; + +/** + * Renders a currency select input used for the Payments Overview page. + * Should only be rendered if there are multiple deposit currencies available. + */ +export const CurrencySelect: React.FC< { + /** An array of available deposit currencies, e.g. ['usd', 'eur']. */ + depositCurrencies: string[]; +} > = ( { depositCurrencies } ) => { + const currencyOptions = depositCurrencies.map( getCurrencyOption ); + const { + selectedCurrency, + setSelectedCurrency, + } = useSelectedCurrencyWithDefault( depositCurrencies ); + + return ( + option.key === selectedCurrency + ) } + options={ currencyOptions } + onChange={ ( { selectedItem } ) => { + if ( ! selectedItem ) { + return; + } + + const currencyCode = selectedItem.key.toLowerCase(); + setSelectedCurrency( currencyCode ); + recordEvent( 'wcpay_overview_currency_select_change', { + selected_currency: currencyCode, + } ); + } } + /> + ); +}; diff --git a/client/components/welcome/index.tsx b/client/components/welcome/index.tsx index 2a102592cd9..20ef939a4bf 100644 --- a/client/components/welcome/index.tsx +++ b/client/components/welcome/index.tsx @@ -8,8 +8,9 @@ import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies */ +import { useAllDepositsOverviews } from 'data'; import { useCurrentWpUser } from './hooks'; -import wooPaymentsLogo from 'assets/images/woopayments.svg?asset'; +import { CurrencySelect } from './currency-select'; import './style.scss'; type TimeOfDay = 'morning' | 'afternoon' | 'evening'; @@ -67,13 +68,16 @@ const getGreeting = ( name?: string, date: Date = new Date() ): string => { }; /** - * Renders a welcome card header with a greeting and the WooPayments logo. - * - * @return {JSX.Element} Rendered element with the account balances card header. + * Renders a welcome card header with a greeting and a currency select input if supported. */ const Welcome: React.FC = () => { const { user } = useCurrentWpUser(); const greeting = getGreeting( user?.first_name ); + const { overviews } = useAllDepositsOverviews(); + const depositCurrencies = + overviews?.currencies.map( ( currencyObj ) => currencyObj.currency ) || + []; + const renderCurrencySelect = depositCurrencies.length > 1; return ( @@ -85,14 +89,14 @@ const Welcome: React.FC = () => { { greeting } - - WooPayments logo - + + { renderCurrencySelect && ( + + + + ) } ); diff --git a/client/components/welcome/style.scss b/client/components/welcome/style.scss index cfb9ae0c098..0e91bf4c271 100644 --- a/client/components/welcome/style.scss +++ b/client/components/welcome/style.scss @@ -1,13 +1,8 @@ .wcpay-welcome.components-card__header { - // Override the default padding to adjust for additional margins on child elements. - padding-top: 8px; - padding-bottom: 8px; -} - -.wcpay-welcome__flex__logo { - display: block; - margin-top: 14px; - margin-bottom: 10px; + // Override the Card Header styles – used to keep consistent header styles. + padding: 0; + margin-bottom: $grid-unit-30; + border: 0; } .wcpay-welcome__flex__greeting { @@ -17,6 +12,9 @@ @media screen and ( max-width: $break-mobile ) { .components-flex.wcpay-welcome__flex { - flex-direction: column-reverse; + flex-direction: column; + } + .wcpay-welcome__flex__greeting { + margin-bottom: 24px; } } diff --git a/client/components/welcome/test/index.test.tsx b/client/components/welcome/test/index.test.tsx index 3f727b7cf85..d5bcd4681db 100644 --- a/client/components/welcome/test/index.test.tsx +++ b/client/components/welcome/test/index.test.tsx @@ -3,22 +3,119 @@ */ import React from 'react'; import { render } from '@testing-library/react'; +import user from '@testing-library/user-event'; /** * Internal dependencies */ import Welcome from '..'; import { useCurrentWpUser } from '../hooks'; +import { useAllDepositsOverviews } from 'data'; +import { useSelectedCurrency } from 'overview/hooks'; +import type { Overview } from 'types/account-overview'; + +declare const global: { + wcpaySettings: { + accountDefaultCurrency: string; + zeroDecimalCurrencies: string[]; + currencyData: Record< string, any >; + connect: { + country: string; + }; + }; +}; jest.mock( '../hooks', () => ( { useCurrentWpUser: jest.fn(), } ) ); +jest.mock( 'wcpay/data', () => ( { + useAllDepositsOverviews: jest.fn(), +} ) ); +jest.mock( 'wcpay/overview/hooks', () => ( { + useSelectedCurrency: jest.fn(), +} ) ); + +const mockUseAllDepositsOverviews = useAllDepositsOverviews as jest.MockedFunction< + typeof useAllDepositsOverviews +>; +const mockUseSelectedCurrency = useSelectedCurrency as jest.MockedFunction< + typeof useSelectedCurrency +>; + +const mockAccountOverviewCurrencies: Partial< Overview >[] = [ + { + currency: 'usd', + }, +]; +mockUseAllDepositsOverviews.mockReturnValue( { + overviews: { + account: null, + currencies: mockAccountOverviewCurrencies as Overview[], + }, + isLoading: false, +} ); + +// Mocks the useSelectedCurrency hook to return no previously selected currency. +const mockSetSelectedCurrency = jest.fn(); +mockUseSelectedCurrency.mockReturnValue( { + selectedCurrency: 'usd', + setSelectedCurrency: mockSetSelectedCurrency, +} ); const mockUseCurrentWpUser = useCurrentWpUser as jest.MockedFunction< typeof useCurrentWpUser >; +mockUseCurrentWpUser.mockReturnValue( { + user: { + id: 123, + first_name: 'Tester', + username: 'admin', + name: 'admin', + nickname: 'Tester-nickname', + last_name: 'Tester-lastname', + email: 'tester@test.com', + locale: 'en', + }, + isLoading: false, +} ); + +describe( 'Welcome and Currency Select', () => { + beforeEach( () => { + global.wcpaySettings = { + accountDefaultCurrency: 'USD', + zeroDecimalCurrencies: [], + connect: { + country: 'US', + }, + currencyData: { + US: { + code: 'USD', + symbol: '$', + symbolPosition: 'left', + thousandSeparator: ',', + decimalSeparator: '.', + precision: 2, + }, + DE: { + code: 'EUR', + symbol: '€', + symbolPosition: 'right_space', + thousandSeparator: ' ', + decimalSeparator: ',', + precision: 2, + }, + NO: { + code: 'NOK', + symbol: 'kr', + symbolPosition: 'right', + thousandSeparator: ' ', + decimalSeparator: ',', + precision: 2, + }, + }, + }; + } ); -describe( 'Welcome', () => { test( 'renders the correct greeting when the user first name exists', () => { const mockUser = { id: 123, @@ -58,4 +155,63 @@ describe( 'Welcome', () => { const { getByText } = render( ); getByText( expectedGreeting ); } ); + + test( 'renders the currency select control if multiple deposit currencies', () => { + mockUseAllDepositsOverviews.mockReturnValue( { + overviews: { + account: null, + currencies: [ + { + currency: 'usd', + }, + { + currency: 'eur', + }, + { + currency: 'nok', + }, + ] as Overview[], + }, + isLoading: false, + } ); + const { getByRole } = render( ); + getByRole( 'button', { + name: /currency/i, + } ); + + // Check default selected currency. + const selectControl = getByRole( 'button', { name: /currency/i } ); + expect( selectControl ).toHaveTextContent( /usd/i ); + + user.click( getByRole( 'button' ) ); + + // Currency options should be visible. + getByRole( 'option', { name: 'USD $' } ); + getByRole( 'option', { name: 'EUR €' } ); + getByRole( 'option', { name: 'NOK kr' } ); + + // Select a currency. + user.click( getByRole( 'option', { name: 'NOK kr' } ) ); + expect( mockSetSelectedCurrency ).toHaveBeenCalledWith( 'nok' ); + } ); + + test( 'does not render the currency select control if single deposit currency', () => { + mockUseAllDepositsOverviews.mockReturnValue( { + overviews: { + account: null, + currencies: [ + { + currency: 'nok', + }, + ] as Overview[], + }, + isLoading: false, + } ); + const { queryByRole } = render( ); + expect( + queryByRole( 'button', { + name: /currency/i, + } ) + ).toBeNull(); + } ); } ); diff --git a/client/data/payment-activity/actions.ts b/client/data/payment-activity/actions.ts index 0d0fdc168c9..c21104ce4c4 100644 --- a/client/data/payment-activity/actions.ts +++ b/client/data/payment-activity/actions.ts @@ -4,13 +4,19 @@ * Internal Dependencies */ import TYPES from './action-types'; -import { PaymentActivityData, PaymentActivityAction } from './types'; +import type { + PaymentActivityData, + PaymentActivityAction, + PaymentActivityQuery, +} from './types'; export function updatePaymentActivity( - data: PaymentActivityData + data: PaymentActivityData, + query: PaymentActivityQuery ): PaymentActivityAction { return { type: TYPES.SET_PAYMENT_ACTIVITY_DATA, + query, data, }; } diff --git a/client/data/payment-activity/hooks.ts b/client/data/payment-activity/hooks.ts index c737c3a87c6..7b0d4a634b7 100644 --- a/client/data/payment-activity/hooks.ts +++ b/client/data/payment-activity/hooks.ts @@ -8,17 +8,25 @@ import { useSelect } from '@wordpress/data'; * Internal dependencies */ import { STORE_NAME } from '../constants'; -import { PaymentActivityState, PaymentActivityQuery } from './types'; +import { PaymentActivityData, PaymentActivityQuery } from './types'; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export const usePaymentActivityData = ( query: PaymentActivityQuery -): PaymentActivityState => - useSelect( ( select ) => { - const { getPaymentActivityData, isResolving } = select( STORE_NAME ); +): { + paymentActivityData: PaymentActivityData | undefined; + isLoading: boolean; +} => + useSelect( + ( select ) => { + const { getPaymentActivityData, isResolving } = select( + STORE_NAME + ); - return { - paymentActivityData: getPaymentActivityData( query ), - isLoading: isResolving( 'getPaymentActivityData', [ query ] ), - }; - }, [] ); + return { + paymentActivityData: getPaymentActivityData( query ), + isLoading: isResolving( 'getPaymentActivityData', [ query ] ), + }; + }, + [ query.currency, query.date_start, query.date_end ] + ); diff --git a/client/data/payment-activity/reducer.ts b/client/data/payment-activity/reducer.ts index dfb53c7c95c..bf5d7c69aed 100644 --- a/client/data/payment-activity/reducer.ts +++ b/client/data/payment-activity/reducer.ts @@ -3,18 +3,33 @@ /** * Internal dependencies */ +import { getResourceId } from 'wcpay/utils/data'; import TYPES from './action-types'; import { PaymentActivityAction, PaymentActivityState } from './types'; const receivePaymentActivity = ( state: PaymentActivityState = {}, - { type, data }: PaymentActivityAction + { type, query, data }: PaymentActivityAction ): PaymentActivityState => { + if ( ! query ) { + return state; + } + + /* + Responses are stored in a key-value store where the key is a unique identifier for the query. + This is consistent with other query-based stores (i.e. transactions, disputes, etc.) + It allows us to temporarily cache responses to avoid re-fetching identical data. + For example, when a user is comparing two date ranges, we can store the responses for each date range + and switch between them without re-fetching. + This data is not persisted between browser sessions (e.g. on page refresh). + */ + const index = getResourceId( query ); + switch ( type ) { case TYPES.SET_PAYMENT_ACTIVITY_DATA: state = { ...state, - paymentActivityData: data, + [ index ]: data, }; break; } diff --git a/client/data/payment-activity/resolvers.ts b/client/data/payment-activity/resolvers.ts index 64d9ade814a..d595b0ffe4b 100644 --- a/client/data/payment-activity/resolvers.ts +++ b/client/data/payment-activity/resolvers.ts @@ -31,7 +31,7 @@ export function* getPaymentActivityData( try { const results = yield apiFetch( { path } ); - yield updatePaymentActivity( results as PaymentActivityData ); + yield updatePaymentActivity( results as PaymentActivityData, query ); } catch ( e ) { yield controls.dispatch( 'core/notices', diff --git a/client/data/payment-activity/selectors.ts b/client/data/payment-activity/selectors.ts index 3432c80d9fc..0d5e7626873 100644 --- a/client/data/payment-activity/selectors.ts +++ b/client/data/payment-activity/selectors.ts @@ -3,11 +3,14 @@ /** * Internal Dependencies */ -import { State } from 'wcpay/data/types'; -import { PaymentActivityData } from './types'; +import type { State } from 'wcpay/data/types'; +import type { PaymentActivityData, PaymentActivityQuery } from './types'; +import { getResourceId } from 'wcpay/utils/data'; export const getPaymentActivityData = ( - state: State + state: State, + query: PaymentActivityQuery ): PaymentActivityData | undefined => { - return state?.paymentActivity?.paymentActivityData; + const index = getResourceId( query ); + return state?.paymentActivity?.[ index ]; }; diff --git a/client/data/payment-activity/test/hooks.test.ts b/client/data/payment-activity/test/hooks.test.ts index 4cd6114f69a..3bda3efb3cc 100644 --- a/client/data/payment-activity/test/hooks.test.ts +++ b/client/data/payment-activity/test/hooks.test.ts @@ -34,6 +34,7 @@ describe( 'usePaymentActivityData', () => { ); const result = usePaymentActivityData( { + currency: 'jpy', date_start: '2021-01-01', date_end: '2021-01-31', timezone: 'UTC', diff --git a/client/data/payment-activity/test/reducer.test.ts b/client/data/payment-activity/test/reducer.test.ts index cbbbd1eb5b2..ae73a0ad717 100644 --- a/client/data/payment-activity/test/reducer.test.ts +++ b/client/data/payment-activity/test/reducer.test.ts @@ -3,7 +3,8 @@ */ import receivePaymentActivity from '../reducer'; import types from '../action-types'; -import { PaymentActivityData } from '../types'; +import { PaymentActivityData, PaymentActivityAction } from '../types'; +import { getResourceId } from 'utils/data'; describe( 'receivePaymentActivity', () => { const mockPaymentActivityData: PaymentActivityData = { @@ -21,15 +22,23 @@ describe( 'receivePaymentActivity', () => { test( 'should set payment activity data correctly', () => { const initialState = {}; - const action = { + const query = { + currency: 'jpy', + date_start: '2024-01-01', + date_end: '2024-01-31', + timezone: 'UTC', + }; + const action: PaymentActivityAction = { type: types.SET_PAYMENT_ACTIVITY_DATA, data: mockPaymentActivityData, + query, }; const newState = receivePaymentActivity( initialState, action ); + const stateIndex = getResourceId( query ); expect( newState ).toEqual( { - paymentActivityData: action.data, + [ stateIndex ]: action.data, } ); } ); } ); diff --git a/client/data/payment-activity/test/resolver.test.ts b/client/data/payment-activity/test/resolver.test.ts index a9a93977180..2c744c71c8a 100644 --- a/client/data/payment-activity/test/resolver.test.ts +++ b/client/data/payment-activity/test/resolver.test.ts @@ -13,6 +13,7 @@ import { updatePaymentActivity } from '../actions'; import { getPaymentActivityData } from '../resolvers'; const query = { + currency: 'usd', date_start: '2020-04-29T04:00:00', date_end: '2020-04-29T03:59:59', timezone: '+2:30', @@ -21,7 +22,7 @@ const query = { describe( 'getPaymentActivityData resolver', () => { const successfulResponse: any = { amount: 3000 }; const expectedQueryString = - 'date_start=2020-04-29T04%3A00%3A00&date_end=2020-04-29T03%3A59%3A59&timezone=%2B2%3A30'; + 'currency=usd&date_start=2020-04-29T04%3A00%3A00&date_end=2020-04-29T03%3A59%3A59&timezone=%2B2%3A30'; const errorResponse = new Error( 'Error retrieving payment activity data.' ); @@ -44,7 +45,7 @@ describe( 'getPaymentActivityData resolver', () => { describe( 'on success', () => { test( 'should update state with payment activity data', () => { expect( generator.next( successfulResponse ).value ).toEqual( - updatePaymentActivity( successfulResponse ) + updatePaymentActivity( successfulResponse, query ) ); } ); } ); diff --git a/client/data/payment-activity/types.d.ts b/client/data/payment-activity/types.d.ts index 76690dc459c..11d5f736812 100644 --- a/client/data/payment-activity/types.d.ts +++ b/client/data/payment-activity/types.d.ts @@ -24,15 +24,22 @@ export interface PaymentActivityData { } export interface PaymentActivityState { - paymentActivityData?: PaymentActivityData; - isLoading?: boolean; + [ key: string ]: PaymentActivityData; } export interface PaymentActivityAction { type: string; + query?: PaymentActivityQuery; data: PaymentActivityData; } +/** + * Query parameters for fetching payment activity data for overview widget. + * Note that these are must match the query parameters for the REST API endpoint. + * + * @see Reporting_Service::get_payment_activity_totals() on WooPayments service. + * Musing: we could move all rest endpoint typedefs to a single place to make it clear that they are coupled to server code. + */ export interface PaymentActivityQuery { /** The date range start datetime used to calculate transaction data, e.g. 2024-04-29T16:19:29 */ date_start: string; @@ -40,4 +47,6 @@ export interface PaymentActivityQuery { date_end: string; /** The timezone used to calculate the transaction data date range, e.g. 'UTC' */ timezone: string; + /** The currency to display */ + currency: string; } diff --git a/client/express-checkout/blocks/components/express-checkout-component.js b/client/express-checkout/blocks/components/express-checkout-component.js new file mode 100644 index 00000000000..a8bbc56ba4b --- /dev/null +++ b/client/express-checkout/blocks/components/express-checkout-component.js @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import { ExpressCheckoutElement } from '@stripe/react-stripe-js'; +import { shippingAddressChangeHandler } from '../../event-handlers'; +import { useExpressCheckout } from '../hooks/use-express-checkout'; + +/** + * ExpressCheckout express payment method component. + * + * @param {Object} props PaymentMethodProps. + * + * @return {ReactNode} Stripe Elements component. + */ +const ExpressCheckoutComponent = ( { + api, + billing, + shippingData, + setExpressPaymentError, + onClick, + onClose, +} ) => { + const { + buttonOptions, + onButtonClick, + onConfirm, + onCancel, + } = useExpressCheckout( { + api, + billing, + shippingData, + onClick, + onClose, + setExpressPaymentError, + } ); + + const onShippingAddressChange = ( event ) => { + shippingAddressChangeHandler( api, event ); + }; + + return ( + + ); +}; + +export default ExpressCheckoutComponent; diff --git a/client/express-checkout/blocks/components/express-checkout-container.js b/client/express-checkout/blocks/components/express-checkout-container.js new file mode 100644 index 00000000000..9285227bf63 --- /dev/null +++ b/client/express-checkout/blocks/components/express-checkout-container.js @@ -0,0 +1,30 @@ +/** + * External dependencies + */ +import { Elements } from '@stripe/react-stripe-js'; + +/** + * Internal dependencies + */ +import ExpressCheckoutComponent from './express-checkout-component'; + +const ExpressCheckoutContainer = ( props ) => { + const { stripe, billing } = props; + + const options = { + mode: 'payment', + paymentMethodCreation: 'manual', + amount: billing.cartTotal.value, + currency: billing.currency.code.toLowerCase(), + }; + + return ( +
+ + + +
+ ); +}; + +export default ExpressCheckoutContainer; diff --git a/client/express-checkout/blocks/express-checkout.js b/client/express-checkout/blocks/express-checkout.js deleted file mode 100644 index 5b4c47658bd..00000000000 --- a/client/express-checkout/blocks/express-checkout.js +++ /dev/null @@ -1,36 +0,0 @@ -/* global wcpayExpressCheckoutParams */ - -/** - * External dependencies - */ -import { Elements, ExpressCheckoutElement } from '@stripe/react-stripe-js'; - -/** - * ExpressCheckout express payment method component. - * - * @param {Object} props PaymentMethodProps. - * - * @return {ReactNode} Stripe Elements component. - */ -export const ExpressCheckout = ( props ) => { - const { stripe } = props; - - const options = { - mode: 'payment', - amount: 1099, - currency: 'usd', - }; - - const buttonOptions = { - buttonType: { - googlePay: wcpayExpressCheckoutParams.button.type, - applePay: wcpayExpressCheckoutParams.button.type, - }, - }; - - return ( - - - - ); -}; diff --git a/client/express-checkout/blocks/hooks/use-express-checkout.js b/client/express-checkout/blocks/hooks/use-express-checkout.js new file mode 100644 index 00000000000..67dc33cc489 --- /dev/null +++ b/client/express-checkout/blocks/hooks/use-express-checkout.js @@ -0,0 +1,93 @@ +/* global wcpayExpressCheckoutParams */ + +/** + * External dependencies + */ +import { useCallback } from '@wordpress/element'; +import { useStripe, useElements } from '@stripe/react-stripe-js'; +import { normalizeLineItems } from 'wcpay/express-checkout/utils'; +import { onConfirmHandler } from 'wcpay/express-checkout/event-handlers'; + +export const useExpressCheckout = ( { + api, + billing, + shippingData, + onClick, + onClose, + setExpressPaymentError, +} ) => { + const stripe = useStripe(); + const elements = useElements(); + + const buttonOptions = { + paymentMethods: { + applePay: 'always', + googlePay: 'always', + link: 'auto', + }, + buttonType: { + googlePay: wcpayExpressCheckoutParams.button.type, + applePay: wcpayExpressCheckoutParams.button.type, + }, + }; + + const onCancel = () => { + onClose(); + }; + + const completePayment = ( redirectUrl ) => { + window.location = redirectUrl; + }; + + const abortPayment = ( onConfirmEvent, message ) => { + onConfirmEvent.paymentFailed( 'fail' ); + setExpressPaymentError( message ); + }; + + const onButtonClick = useCallback( + ( event ) => { + const options = { + lineItems: normalizeLineItems( billing?.cartTotalItems ), + emailRequired: true, + shippingAddressRequired: shippingData?.needsShipping, + phoneNumberRequired: + wcpayExpressCheckoutParams?.checkout?.needs_payer_phone, + shippingRates: shippingData?.shippingRates[ 0 ]?.shipping_rates?.map( + ( r ) => { + return { + id: r.rate_id, + amount: parseInt( r.price, 10 ), + displayName: r.name, + }; + } + ), + }; + event.resolve( options ); + onClick(); + }, + [ + onClick, + billing.cartTotalItems, + shippingData.needsShipping, + shippingData.shippingRates, + ] + ); + + const onConfirm = async ( event ) => { + onConfirmHandler( + api, + stripe, + elements, + completePayment, + abortPayment, + event + ); + }; + + return { + buttonOptions, + onButtonClick, + onConfirm, + onCancel, + }; +}; diff --git a/client/express-checkout/blocks/index.js b/client/express-checkout/blocks/index.js index 17b2c1221dd..79e54d80a0b 100644 --- a/client/express-checkout/blocks/index.js +++ b/client/express-checkout/blocks/index.js @@ -1,16 +1,19 @@ -/* global wcpayConfig, wcpayExpressCheckoutParams */ - /** * Internal dependencies */ import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from '../../checkout/constants'; -import { ExpressCheckout } from './express-checkout'; +import ExpressCheckoutContainer from './components/express-checkout-container'; import { getConfig } from '../../utils/checkout'; import ApplePayPreview from './apple-pay-preview'; const expressCheckoutElementPaymentMethod = ( api ) => ( { name: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, - content: , + content: ( + + ), edit: , paymentMethodId: PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT, supports: { @@ -21,11 +24,7 @@ const expressCheckoutElementPaymentMethod = ( api ) => ( { return false; } - if ( typeof wcpayConfig !== 'undefined' ) { - return wcpayConfig.isExpressCheckoutElementEnabled; - } - - return false; + return true; }, } ); diff --git a/client/express-checkout/event-handlers.js b/client/express-checkout/event-handlers.js new file mode 100644 index 00000000000..e46931838a2 --- /dev/null +++ b/client/express-checkout/event-handlers.js @@ -0,0 +1,61 @@ +/** + * Internal dependencies + */ +import { normalizeOrderData, normalizeShippingAddress } from './utils'; +import { getErrorMessageFromNotice } from 'utils/express-checkout'; + +export const shippingAddressChangeHandler = async ( api, event ) => { + const response = await api.expressCheckoutECECalculateShippingOptions( + normalizeShippingAddress( event.shippingAddress ) + ); + event.resolve( { + shippingRates: response.shipping_options, + } ); +}; + +export const onConfirmHandler = async ( + api, + stripe, + elements, + completePayment, + abortPayment, + event +) => { + const { paymentMethod, error } = await stripe.createPaymentMethod( { + elements, + } ); + + if ( error ) { + abortPayment( event, error.message ); + return; + } + + // Kick off checkout processing step. + const createOrderResponse = await api.expressCheckoutECECreateOrder( + normalizeOrderData( event, paymentMethod.id ) + ); + + if ( createOrderResponse.result !== 'success' ) { + return abortPayment( + event, + getErrorMessageFromNotice( createOrderResponse.messages ) + ); + } + + try { + const confirmationRequest = api.confirmIntent( + createOrderResponse.redirect + ); + + // `true` means there is no intent to confirm. + if ( confirmationRequest === true ) { + completePayment( createOrderResponse.redirect ); + } else { + const redirectUrl = await confirmationRequest; + + completePayment( redirectUrl ); + } + } catch ( e ) { + abortPayment( event, error.message ); + } +}; diff --git a/client/express-checkout/utils/index.js b/client/express-checkout/utils/index.js new file mode 100644 index 00000000000..d29d7cccc32 --- /dev/null +++ b/client/express-checkout/utils/index.js @@ -0,0 +1 @@ +export * from './normalize'; diff --git a/client/express-checkout/utils/normalize.js b/client/express-checkout/utils/normalize.js new file mode 100644 index 00000000000..5e940d158de --- /dev/null +++ b/client/express-checkout/utils/normalize.js @@ -0,0 +1,105 @@ +/** + * Normalizes incoming cart total items for use as a displayItems with the Stripe api. + * + * @param {Array} displayItems Items to normalize. + * @param {boolean} pending Whether to mark items as pending or not. + * + * @return {Array} An array of PaymentItems + */ +export const normalizeLineItems = ( displayItems ) => { + return displayItems + .filter( ( displayItem ) => { + return !! displayItem.value; + } ) + .map( ( displayItem ) => { + return { + amount: displayItem.value, + name: displayItem.label, + }; + } ); +}; + +/** + * Normalize order data from Stripe's object to the expected format for WC. + * + * @param {Object} event Stripe's event object. + * @param {Object} paymentMethodId Stripe's payment method id. + * + * @return {Object} Order object in the format WooCommerce expects. + */ +export const normalizeOrderData = ( event, paymentMethodId ) => { + const name = event?.billingDetails?.name; + const email = event?.billingDetails?.email ?? ''; + const billing = event?.billingDetails?.address ?? {}; + const shipping = event?.shippingAddress ?? {}; + const fraudPreventionTokenValue = window.wcpayFraudPreventionToken ?? ''; + + const phone = + event?.billingDetails?.phone ?? + event?.payerPhone?.replace( '/[() -]/g', '' ) ?? + ''; + + return { + billing_first_name: + name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', + billing_last_name: name?.split( ' ' )?.slice( 1 )?.join( ' ' ) || '-', + billing_company: billing?.organization ?? '', + billing_email: email ?? event?.payerEmail ?? '', + billing_phone: phone, + billing_country: billing?.country ?? '', + billing_address_1: billing?.line1 ?? '', + billing_address_2: billing?.line2 ?? '', + billing_city: billing?.city ?? '', + billing_state: billing?.state ?? '', + billing_postcode: billing?.postal_code ?? '', + shipping_first_name: + shipping?.name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', + shipping_last_name: + shipping?.name?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '', + shipping_company: shipping?.organization ?? '', + shipping_phone: phone, + shipping_country: shipping?.address?.country ?? '', + shipping_address_1: shipping?.address?.line1 ?? '', + shipping_address_2: shipping?.address?.line2 ?? '', + shipping_city: shipping?.address?.city ?? '', + shipping_state: shipping?.address?.state ?? '', + shipping_postcode: shipping?.address?.postal_code ?? '', + shipping_method: [ event?.shippingRate?.id ?? null ], + order_comments: '', + payment_method: 'woocommerce_payments', + ship_to_different_address: 1, + terms: 1, + 'wcpay-payment-method': paymentMethodId, + payment_request_type: event?.expressPaymentType, + express_payment_type: event?.expressPaymentType, + 'wcpay-fraud-prevention-token': fraudPreventionTokenValue, + }; +}; + +/** + * Normalize shipping address information from Stripe's address object to + * the cart shipping address object shape. + * + * @param {Object} shippingAddress Stripe's shipping address item + * + * @return {Object} The shipping address in the shape expected by the cart. + */ +export const normalizeShippingAddress = ( shippingAddress ) => { + return { + first_name: + shippingAddress?.recipient + ?.split( ' ' ) + ?.slice( 0, 1 ) + ?.join( ' ' ) ?? '', + last_name: + shippingAddress?.recipient?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? + '', + company: '', + address_1: shippingAddress?.addressLine?.[ 0 ] ?? '', + address_2: shippingAddress?.addressLine?.[ 1 ] ?? '', + city: shippingAddress?.city ?? '', + state: shippingAddress?.region ?? '', + country: shippingAddress?.country ?? '', + postcode: shippingAddress?.postalCode?.replace( ' ', '' ) ?? '', + }; +}; diff --git a/client/globals.d.ts b/client/globals.d.ts index c64240cd04f..db7b9ce42a4 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -2,6 +2,7 @@ * Internal dependencies */ import type { MccsDisplayTreeItem, Country } from 'onboarding/types'; +import { PaymentMethodToPluginsMap } from './components/duplicate-notice'; declare global { const wcpaySettings: { @@ -90,7 +91,7 @@ declare global { isEligibilityModalDismissed: boolean; }; enabledPaymentMethods: string[]; - dismissedDuplicateNotices: string[]; + dismissedDuplicateNotices: PaymentMethodToPluginsMap; accountDefaultCurrency: string; isFRTReviewFeatureActive: boolean; frtDiscoverBannerSettings: string; diff --git a/client/overview/index.js b/client/overview/index.js index 669055b1813..ab6ca7e8404 100644 --- a/client/overview/index.js +++ b/client/overview/index.js @@ -234,40 +234,37 @@ const OverviewPage = () => { { showConnectionSuccess && } { ! accountRejected && ! accountUnderReview && ( - <> - { showTaskList ? ( - <> - - - - - - - - - - - ) : ( - - - - - ) } - { - /* Show Payment Activity widget only when feature flag is set. To be removed before go live */ - isPaymentOverviewWidgetEnabled && ( - - - - ) - } - - + + + { showTaskList && ( + + + + + + ) } + + + + + + + + { + /* Show Payment Activity widget only when feature flag is set. To be removed before go live */ + isPaymentOverviewWidgetEnabled && ( + + + + ) + } + + ) } diff --git a/client/overview/task-list/tasks/update-business-details-task.tsx b/client/overview/task-list/tasks/update-business-details-task.tsx index 3d1061311be..2de66b9890f 100644 --- a/client/overview/task-list/tasks/update-business-details-task.tsx +++ b/client/overview/task-list/tasks/update-business-details-task.tsx @@ -27,6 +27,7 @@ export const getUpdateBusinessDetailsTask = ( const accountDetailsPastDue = 'restricted' === status && pastDue; const hasMultipleErrors = 1 < errorMessages.length; const hasSingleError = 1 === errorMessages.length; + const connectUrl = wcpaySettings.connectUrl; const accountLinkWithSource = addQueryArgs( accountLink, { source: 'overview-page__update-business-details-task', } ); @@ -113,7 +114,14 @@ export const getUpdateBusinessDetailsTask = ( recordEvent( 'wcpay_account_details_link_clicked', { source: 'overview-page__update-business-details-task', } ); - window.open( accountLinkWithSource, '_blank' ); + + // If the onboarding isn't complete use the connectUrl instead, + // as the accountLink doesn't handle redirecting back to the overview page. + if ( ! detailsSubmitted ) { + window.location.href = connectUrl; + } else { + window.open( accountLinkWithSource, '_blank' ); + } } }; diff --git a/client/payment-methods/test/index.js b/client/payment-methods/test/index.js index 6ab41a25c90..de01bb5d512 100644 --- a/client/payment-methods/test/index.js +++ b/client/payment-methods/test/index.js @@ -448,8 +448,8 @@ describe( 'PaymentMethods', () => { render( diff --git a/client/payment-request/utils/normalize.js b/client/payment-request/utils/normalize.js index e55b78675c0..92a9414bc3b 100644 --- a/client/payment-request/utils/normalize.js +++ b/client/payment-request/utils/normalize.js @@ -32,7 +32,6 @@ export const normalizeOrderData = ( paymentData ) => { paymentData?.paymentMethod?.billing_details?.name ?? paymentData.payerName; const email = paymentData?.paymentMethod?.billing_details?.email ?? ''; - const phone = paymentData?.paymentMethod?.billing_details?.phone ?? ''; const billing = paymentData?.paymentMethod?.billing_details?.address ?? {}; const shipping = paymentData?.shippingAddress ?? {}; const fraudPreventionTokenValue = window.wcpayFraudPreventionToken ?? ''; @@ -44,14 +43,17 @@ export const normalizeOrderData = ( paymentData ) => { paymentRequestType = 'google_pay'; } + const phone = + paymentData?.paymentMethod?.billing_details?.phone ?? + paymentData?.payerPhone?.replace( '/[() -]/g', '' ) ?? + ''; return { billing_first_name: name?.split( ' ' )?.slice( 0, 1 )?.join( ' ' ) ?? '', billing_last_name: name?.split( ' ' )?.slice( 1 )?.join( ' ' ) || '-', billing_company: billing?.organization ?? '', billing_email: email ?? paymentData?.payerEmail ?? '', - billing_phone: - phone ?? paymentData?.payerPhone?.replace( '/[() -]/g', '' ) ?? '', + billing_phone: phone, billing_country: billing?.country ?? '', billing_address_1: billing?.line1 ?? '', billing_address_2: billing?.line2 ?? '', @@ -63,6 +65,7 @@ export const normalizeOrderData = ( paymentData ) => { shipping_last_name: shipping?.recipient?.split( ' ' )?.slice( 1 )?.join( ' ' ) ?? '', shipping_company: shipping?.organization ?? '', + shipping_phone: phone, shipping_country: shipping?.country ?? '', shipping_address_1: shipping?.addressLine?.[ 0 ] ?? '', shipping_address_2: shipping?.addressLine?.[ 1 ] ?? '', diff --git a/client/plugins-page/deactivation-survey/index.js b/client/plugins-page/deactivation-survey/index.js new file mode 100644 index 00000000000..faa4eaf2228 --- /dev/null +++ b/client/plugins-page/deactivation-survey/index.js @@ -0,0 +1,50 @@ +/** + * External dependencies + */ +import React, { useState } from 'react'; +import { __ } from '@wordpress/i18n'; +import { Modal } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import './style.scss'; +import Loadable from 'wcpay/components/loadable'; +import WooPaymentsIcon from 'assets/images/woopayments.svg?asset'; + +const PluginDisableSurvey = ( { onRequestClose } ) => { + const [ isLoading, setIsLoading ] = useState( true ); + + return ( + + } + isDismissible={ true } + shouldCloseOnClickOutside={ false } // Should be false because of the iframe. + shouldCloseOnEsc={ true } + onRequestClose={ onRequestClose } + className="woopayments-disable-survey" + > + +