diff --git a/.github/workflows/compatibility.yml b/.github/workflows/compatibility.yml index 12439ae65b1..28aeab14f4b 100644 --- a/.github/workflows/compatibility.yml +++ b/.github/workflows/compatibility.yml @@ -64,7 +64,7 @@ jobs: - name: "Generate matrix" id: generate_matrix run: | - PHP_VERSIONS=$( echo "[\"$PHP_MIN_SUPPORTED_VERSION\", \"8.0\", \"8.1\"]" ) + PHP_VERSIONS=$( echo "[\"7.4\", \"8.0\", \"8.1\"]" ) echo "matrix={\"woocommerce\":[\"beta\"],\"wordpress\":[\"latest\"],\"gutenberg\":[\"latest\"],\"php\":$PHP_VERSIONS}" >> $GITHUB_OUTPUT # a dedicated job, as allowed to fail diff --git a/assets/css/admin.css b/assets/css/admin.css index 8b17d53e766..28ff0430c73 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -128,6 +128,10 @@ background-image: url( '../images/payment-methods/affirm-icon.svg' ); } +.payment-method__brand--klarna { + background-image: url( '../images/payment-methods/klarna.svg' ); +} + .wc_gateways tr[data-gateway_id='woocommerce_payments'] .payment-method__icon { border: 1px solid #ddd; border-radius: 2px; diff --git a/assets/images/payment-methods/klarna.svg b/assets/images/payment-methods/klarna.svg new file mode 100644 index 00000000000..948f281a04b --- /dev/null +++ b/assets/images/payment-methods/klarna.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/changelog.txt b/changelog.txt index 760c80618f3..655ddaebbba 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,83 @@ *** WooPayments Changelog *** += 6.6.0 - 2023-10-11 = +* Add - Add a notice on the Settings page to request JCB capability for Japanese customers. +* Add - Add current user data to the onboarding init request payload. This data is used for fraud prevention. +* Add - Added API endpoint to fetch customer's saved payment methods. +* Add - Added docs for cancel_authorization endpoint +* Add - Added documentation for create payment intent API endpoint. +* Add - Added documentation for payment methods API endpoint +* Add - Add functionality to enable WooPay first party auth behind feature flag. +* Add - Add helper function/method for raw currency amount conversion. +* Add - Add Klarna payment method +* Add - Add loading state to WooPay button +* Add - Add payment intent creation endpoint +* Add - Add the feature flag check for pay-for-order flow +* Add - Add WC blocks spinner to the WooPay checkout styles. +* Add - Behind a feature flag: dispute message added to transactions screen for disputes not needing a response. +* Add - Display dispute information, recommended resolution steps, and actions directly on the transaction details screen to help merchants with dispute resolution. +* Add - Display server error messages on Settings save +* Add - Expand the data points added to the WooCommerce SSR to include all the main WooPayments features. +* Add - Handle server-side feature flag for new UPE type enablement. +* Add - Introduce the "Subscription Relationship" column under the Orders list admin page when HPOS is enabled. +* Add - Show survey for merchants that disable WooPay. +* Fix - Add Mix and Match Products support on WooPay. +* Fix - Add multi-currency enablement check in WooPay session handling. +* Fix - Comment: Behind a feature flag: Update documentation links (new/changed docs content) when notifying merchant that a dispute needs response. +* Fix - Disable automatic currency switching and switcher widgets on pay_for_order page. +* Fix - Ensure renewal orders paid via the Block Checkout are correctly linked to their subscription. +* Fix - Ensure the order needs processing transient is deleted when a subscription order (eg renewal) is created. Fixes issues with renewal orders going straight to a completed status. +* Fix - fix: save platform checkout info on blocks +* Fix - Fix Apple Pay and Google Pay if card payments are disabled. +* Fix - Fix error when disabling WCPay with core disabled. +* Fix - Fix init WooPay and empty cart error +* Fix - Fix modal header alignment on safari browser +* Fix - Fix onboarding section on MultiCurrency settings page. +* Fix - Fix WooPay express checkout button with product bundles on product page. +* Fix - Hide tooltip related to Storefront theme in Multi-Currency settings when Storefront is not the active theme +* Fix - Improved product details script with enhanced price calculation, and fallbacks for potential undefined values. +* Fix - Improve escaping around attributes. +* Fix - Load multi-currency class on setup page. +* Fix - Missing styles on the Edit Subscription page when HPOS is enabled. +* Fix - Only request WooPay session data once on blocks pages. +* Fix - Payment method section missing for Affirm and Afterpay on transaction details page +* Fix - Prevent charging completed or processing orders with a new payment intent ID +* Fix - Prevent WooPay-related implementation to modify non-WooPay-specific webhooks by changing their data. +* Fix - Prevent WooPay multiple redirect requests. +* Fix - Redirect back to the connect page when attempting to access the new onboarding flow without a server connection. +* Fix - Redirect back to the pay-for-order page when it's pay-for-order order +* Fix - Resolved an issue that caused paying for failed/pending parent orders that include Product Add-ons to not calculate the correct total. +* Fix - Speed up capturing terminal and authorized payments. +* Fix - Store the correct subscription start date in postmeta and ordermeta when HPOS and data syncing is being used. +* Fix - Tracking conditions +* Fix - Virtual variable products no longer require shipping details when checking out with Apple Pay and Google Pay +* Fix - When HPOS is enabled, deleting a customer will now delete their subscriptions. +* Fix - When HPOS is enabled, make the orders_by_type_query filter box work in the WooCommerce orders screen. +* Fix - WooPay save my info phone number fallback for virtual products +* Update - Adapt the PO congratulations card copy for pending account status. +* Update - Allow deferred intent creation UPE to support SEPA payments. +* Update - Enhance design of bnpl payment methods status in settings screen +* Update - Increase GBP transaction limit for Afterpay +* Update - Only display the WCPay Subscriptions setting to existing users as part of deprecating this feature. +* Update - Set WooPay First Party Authentication feature flag to default on. +* Update - Store customer currencies as an option to avoid expensive calculation. +* Update - Updated Transaction Details summary with added fee breakdown tooltip for disputed transactions. +* Update - Update links that pointed to the dispute details screen to point to the transaction details screen +* Update - Update Name Your Price compatibility to use new Compatibility methods. +* Update - Update the content of modals that are displayed when deactivating the WooPayments or Woo Subscriptions plugins when the store has active Stripe Billing subscriptions. +* Update - Update URL used to communicate with WooPay from the iFrame in the merchant site. +* Dev - Added missing API docs links for payment intents and payment methods API endpoints +* Dev - Capitalize the JCB label on transactions details page. +* Dev - e2e tests for progressive onboarding +* Dev - Extracting payment metadata and level 3 data generation into services. +* Dev - Migrate away from hooking into actions in certain classes +* Dev - Move fraud related service hooks out of class constructors and into new init_hooks methods. +* Dev - Move hooks out of MultiCurrency constructor into own init_hooks method. +* Dev - Refactored request class send() method +* Dev - Refactor to move hook initialisation out of constructors. +* Dev - This work is part of a UI improvements to increase disputes response that is behind a feature flag. A changelog entry will be added to represent the work as a whole. +* Dev - Update subscriptions-core to 6.3.0. + = 6.5.1 - 2023-09-26 = * Fix - fix incorrect payment method title for non-WooPayments gateways diff --git a/client/additional-methods-setup/constants.js b/client/additional-methods-setup/constants.js index 4f2222ea780..41747020d95 100644 --- a/client/additional-methods-setup/constants.js +++ b/client/additional-methods-setup/constants.js @@ -10,6 +10,7 @@ export const upeMethods = [ 'affirm', 'afterpay_clearpay', 'jcb', + 'klarna', ]; export const upeCapabilityStatuses = { diff --git a/client/checkout/api/index.js b/client/checkout/api/index.js index d53f7170b68..b7deafca8af 100644 --- a/client/checkout/api/index.js +++ b/client/checkout/api/index.js @@ -26,6 +26,7 @@ export default class WCPayAPI { this.stripe = null; this.stripePlatform = null; this.request = request; + this.isWooPayRequesting = false; } createStripe( publishableKey, locale, accountId = '', betas = [] ) { @@ -688,17 +689,21 @@ export default class WCPayAPI { } initWooPay( userEmail, woopayUserSession ) { - const wcAjaxUrl = getConfig( 'wcAjaxUrl' ); - const nonce = getConfig( 'initWooPayNonce' ); - - return this.request( buildAjaxURL( wcAjaxUrl, 'init_woopay' ), { - _wpnonce: nonce, - email: userEmail, - user_session: woopayUserSession, - order_id: getConfig( 'order_id' ), - key: getConfig( 'key' ), - billing_email: getConfig( 'billing_email' ), - } ); + if ( ! this.isWooPayRequesting ) { + this.isWooPayRequesting = true; + const wcAjaxUrl = getConfig( 'wcAjaxUrl' ); + const nonce = getConfig( 'initWooPayNonce' ); + return this.request( buildAjaxURL( wcAjaxUrl, 'init_woopay' ), { + _wpnonce: nonce, + email: userEmail, + user_session: woopayUserSession, + order_id: getConfig( 'order_id' ), + key: getConfig( 'key' ), + billing_email: getConfig( 'billing_email' ), + } ).finally( () => { + this.isWooPayRequesting = false; + } ); + } } expressCheckoutAddToCart( productData ) { diff --git a/client/checkout/api/test/index.test.js b/client/checkout/api/test/index.test.js index 5a2711a4da0..3ed3cf22284 100644 --- a/client/checkout/api/test/index.test.js +++ b/client/checkout/api/test/index.test.js @@ -6,7 +6,9 @@ import request from 'wcpay/checkout/utils/request'; import { buildAjaxURL } from 'wcpay/payment-request/utils'; import { getConfig } from 'wcpay/utils/checkout'; -jest.mock( 'wcpay/checkout/utils/request', () => jest.fn() ); +jest.mock( 'wcpay/checkout/utils/request', () => + jest.fn( () => Promise.resolve( {} ).finally( () => {} ) ) +); jest.mock( 'wcpay/payment-request/utils', () => ( { buildAjaxURL: jest.fn(), } ) ); @@ -15,7 +17,7 @@ jest.mock( 'wcpay/utils/checkout', () => ( { } ) ); describe( 'WCPayAPI', () => { - test( 'initializes woopay using config params', () => { + test( 'does not initialize woopay if already requesting', async () => { buildAjaxURL.mockReturnValue( 'https://example.org/' ); getConfig.mockImplementation( ( key ) => { const mockProperties = { @@ -28,7 +30,27 @@ describe( 'WCPayAPI', () => { } ); const api = new WCPayAPI( {}, request ); - api.initWooPay( 'foo@bar.com', 'qwerty123' ); + api.isWooPayRequesting = true; + await api.initWooPay( 'foo@bar.com', 'qwerty123' ); + + expect( request ).not.toHaveBeenCalled(); + expect( api.isWooPayRequesting ).toBe( true ); + } ); + + test( 'initializes woopay using config params', async () => { + buildAjaxURL.mockReturnValue( 'https://example.org/' ); + getConfig.mockImplementation( ( key ) => { + const mockProperties = { + initWooPayNonce: 'foo', + order_id: 1, + key: 'testkey', + billing_email: 'test@example.com', + }; + return mockProperties[ key ]; + } ); + + const api = new WCPayAPI( {}, request ); + await api.initWooPay( 'foo@bar.com', 'qwerty123' ); expect( request ).toHaveBeenLastCalledWith( 'https://example.org/', { _wpnonce: 'foo', @@ -38,5 +60,6 @@ describe( 'WCPayAPI', () => { key: 'testkey', billing_email: 'test@example.com', } ); + expect( api.isWooPayRequesting ).toBe( false ); } ); } ); diff --git a/client/checkout/blocks/upe-split.js b/client/checkout/blocks/upe-split.js index ab2463aadc7..6166bdf907a 100644 --- a/client/checkout/blocks/upe-split.js +++ b/client/checkout/blocks/upe-split.js @@ -30,6 +30,7 @@ import { PAYMENT_METHOD_NAME_SOFORT, PAYMENT_METHOD_NAME_AFFIRM, PAYMENT_METHOD_NAME_AFTERPAY, + PAYMENT_METHOD_NAME_KLARNA, } from '../constants.js'; import { getSplitUPEFields } from './upe-split-fields'; import { getDeferredIntentCreationUPEFields } from './upe-deferred-intent-creation/payment-elements'; @@ -46,6 +47,7 @@ const upeMethods = { sofort: PAYMENT_METHOD_NAME_SOFORT, affirm: PAYMENT_METHOD_NAME_AFFIRM, afterpay_clearpay: PAYMENT_METHOD_NAME_AFTERPAY, + klarna: PAYMENT_METHOD_NAME_KLARNA, }; const enabledPaymentMethodsConfig = getUPEConfig( 'paymentMethodsConfig' ); diff --git a/client/checkout/classic/upe-deferred-intent-creation/event-handlers.js b/client/checkout/classic/upe-deferred-intent-creation/event-handlers.js index c174a861ca3..e80b8bb4cf8 100644 --- a/client/checkout/classic/upe-deferred-intent-creation/event-handlers.js +++ b/client/checkout/classic/upe-deferred-intent-creation/event-handlers.js @@ -8,7 +8,9 @@ import { generateCheckoutEventNames, getSelectedUPEGatewayPaymentMethod, isLinkEnabled, + isPaymentMethodRestrictedToLocation, isUsingSavedPaymentMethod, + togglePaymentMethodForCountry, } from '../../utils/upe'; import { processPayment, @@ -109,8 +111,18 @@ jQuery( function ( $ ) { ) { for ( const upeElement of $( '.wcpay-upe-element' ).toArray() ) { await mountStripePaymentElement( api, upeElement ); + restrictPaymentMethodToLocation( upeElement ); } maybeEnableStripeLink( api ); } } + + function restrictPaymentMethodToLocation( upeElement ) { + if ( isPaymentMethodRestrictedToLocation( upeElement ) ) { + togglePaymentMethodForCountry( upeElement ); + $( '#billing_country' ).on( 'change', function () { + togglePaymentMethodForCountry( upeElement ); + } ); + } + } } ); diff --git a/client/checkout/classic/upe-split.js b/client/checkout/classic/upe-split.js index 87edf7333ff..05ad9f1de49 100644 --- a/client/checkout/classic/upe-split.js +++ b/client/checkout/classic/upe-split.js @@ -21,6 +21,7 @@ import { PAYMENT_METHOD_NAME_SOFORT, PAYMENT_METHOD_NAME_AFFIRM, PAYMENT_METHOD_NAME_AFTERPAY, + PAYMENT_METHOD_NAME_KLARNA, SHORTCODE_SHIPPING_ADDRESS_FIELDS, SHORTCODE_BILLING_ADDRESS_FIELDS, } from '../constants'; @@ -657,6 +658,7 @@ jQuery( function ( $ ) { PAYMENT_METHOD_NAME_SOFORT, PAYMENT_METHOD_NAME_AFFIRM, PAYMENT_METHOD_NAME_AFTERPAY, + PAYMENT_METHOD_NAME_KLARNA, paymentMethodsConfig.card !== undefined && PAYMENT_METHOD_NAME_CARD, ].filter( Boolean ); const checkoutEvents = wcpayPaymentMethods diff --git a/client/checkout/constants.js b/client/checkout/constants.js index 5b6c5468905..4f48eae96fd 100644 --- a/client/checkout/constants.js +++ b/client/checkout/constants.js @@ -10,6 +10,7 @@ export const PAYMENT_METHOD_NAME_SOFORT = 'woocommerce_payments_sofort'; export const PAYMENT_METHOD_NAME_AFFIRM = 'woocommerce_payments_affirm'; export const PAYMENT_METHOD_NAME_AFTERPAY = 'woocommerce_payments_afterpay_clearpay'; +export const PAYMENT_METHOD_NAME_KLARNA = 'woocommerce_payments_klarna'; export const PAYMENT_METHOD_NAME_UPE = 'woocommerce_payments_upe'; export const PAYMENT_METHOD_NAME_PAYMENT_REQUEST = 'woocommerce_payments_payment_request'; @@ -30,6 +31,7 @@ export function getPaymentMethodsConstants() { PAYMENT_METHOD_NAME_AFFIRM, PAYMENT_METHOD_NAME_AFTERPAY, PAYMENT_METHOD_NAME_CARD, + PAYMENT_METHOD_NAME_KLARNA, ]; } diff --git a/client/checkout/utils/upe.js b/client/checkout/utils/upe.js index da11bc2bfd7..71a673d3a2a 100644 --- a/client/checkout/utils/upe.js +++ b/client/checkout/utils/upe.js @@ -421,3 +421,35 @@ export const getShippingDetails = ( fields ) => { return billingAsShippingAddress; }; + +/** + * Hides payment method if it has set specific countries in the PHP class. + * + * @param {Object} 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 ) => { + 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. + **/ +export const togglePaymentMethodForCountry = ( upeElement ) => { + const paymentMethodsConfig = getUPEConfig( 'paymentMethodsConfig' ); + const paymentMethodType = upeElement.dataset.paymentMethodType; + const supportedCountries = + paymentMethodsConfig[ paymentMethodType ].countries; + + const billingCountry = document.getElementById( 'billing_country' ).value; + const upeContainer = document.querySelector( + '.payment_method_woocommerce_payments_' + paymentMethodType + ); + if ( supportedCountries.includes( billingCountry ) ) { + upeContainer.style.display = 'block'; + } else { + upeContainer.style.display = 'none'; + } +}; diff --git a/client/checkout/woopay/express-button/express-checkout-iframe.js b/client/checkout/woopay/express-button/express-checkout-iframe.js index b021e9eab15..b6080610310 100644 --- a/client/checkout/woopay/express-button/express-checkout-iframe.js +++ b/client/checkout/woopay/express-button/express-checkout-iframe.js @@ -2,8 +2,13 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ import { getConfig } from 'utils/checkout'; import request from 'wcpay/checkout/utils/request'; +import { showErrorMessage } from 'wcpay/checkout/woopay/express-button/utils'; import { buildAjaxURL } from 'wcpay/payment-request/utils'; import { getTargetElement, @@ -129,52 +134,6 @@ export const expressCheckoutIframe = async ( api, context, emailSelector ) => { // Add the iframe to the wrapper. iframeWrapper.insertBefore( iframe, null ); - const showErrorMessage = () => { - // Set the notice text. - const errorMessage = __( - 'WooPay is unavailable at this time. Sorry for the inconvenience.', - 'woocommerce-payments' - ); - - // Handle Blocks Cart and Checkout notices. - if ( wcSettings.wcBlocksConfig && context !== 'product' ) { - // This handles adding the error notice to the cart page. - wp.data - .dispatch( 'core/notices' ) - ?.createNotice( 'error', errorMessage, { - context: `wc/${ context }`, - } ); - } else { - // We're either on a shortcode cart/checkout or single product page. - fetch( getConfig( 'ajaxUrl' ), { - method: 'POST', - body: new URLSearchParams( { - action: 'woopay_express_checkout_button_show_error_notice', - _ajax_nonce: getConfig( 'woopayButtonNonce' ), - context, - message: errorMessage, - } ), - } ) - .then( ( response ) => response.json() ) - .then( ( response ) => { - if ( response.success ) { - // We need to manually add the notice to the page. - const noticesWrapper = document.querySelector( - '.woocommerce-notices-wrapper' - ); - const wrapper = document.createElement( 'div' ); - wrapper.innerHTML = response.data.notice; - noticesWrapper.insertBefore( wrapper, null ); - - noticesWrapper.scrollIntoView( { - behavior: 'smooth', - block: 'center', - } ); - } - } ); - } - }; - const closeIframe = () => { window.removeEventListener( 'resize', getWindowSize ); window.removeEventListener( 'resize', setPopoverPosition ); @@ -282,7 +241,12 @@ export const expressCheckoutIframe = async ( api, context, emailSelector ) => { response.url ); } else { - showErrorMessage(); + // Set the notice text. + const errorMessage = __( + 'WooPay is unavailable at this time. Sorry for the inconvenience.', + 'woocommerce-payments' + ); + showErrorMessage( context, errorMessage ); closeIframe( false ); } } ); diff --git a/client/checkout/woopay/express-button/index.js b/client/checkout/woopay/express-button/index.js index 8db44bea906..bd2aeff02c2 100644 --- a/client/checkout/woopay/express-button/index.js +++ b/client/checkout/woopay/express-button/index.js @@ -13,7 +13,9 @@ import WCPayAPI from '../../api'; import request from '../../utils/request'; import '../../express-checkout-buttons.scss'; -const renderWooPayExpressCheckoutButton = () => { +const oldWoopayContainers = []; + +const renderWooPayExpressCheckoutButton = ( listenForCartChanges = {} ) => { // Create an API object, which will be used throughout the checkout. const api = new WCPayAPI( { @@ -28,8 +30,17 @@ const renderWooPayExpressCheckoutButton = () => { const woopayContainer = document.getElementById( 'wcpay-woopay-button' ); if ( woopayContainer ) { + while ( oldWoopayContainers.length > 0 ) { + // Ensure previous buttons are unmounted and cleaned up. + const oldWoopayContainer = oldWoopayContainers.pop(); + ReactDOM.unmountComponentAtNode( oldWoopayContainer ); + } + + oldWoopayContainers.push( woopayContainer ); + ReactDOM.render( { } }; -window.addEventListener( 'load', renderWooPayExpressCheckoutButton ); +let listenForCartChanges = null; +const renderWooPayExpressCheckoutButtonWithCallbacks = () => { + renderWooPayExpressCheckoutButton( listenForCartChanges ); +}; jQuery( ( $ ) => { - $( document.body ).on( 'updated_cart_totals', () => { - renderWooPayExpressCheckoutButton(); - } ); + listenForCartChanges = { + start: () => { + $( document.body ).on( + 'updated_cart_totals', + renderWooPayExpressCheckoutButtonWithCallbacks + ); + }, + stop: () => { + $( document.body ).off( + 'updated_cart_totals', + renderWooPayExpressCheckoutButtonWithCallbacks + ); + }, + }; + + listenForCartChanges.start(); } ); + +window.addEventListener( + 'load', + renderWooPayExpressCheckoutButtonWithCallbacks +); diff --git a/client/checkout/woopay/express-button/test/woopay-express-checkout-button.test.js b/client/checkout/woopay/express-button/test/woopay-express-checkout-button.test.js index 7b67433e342..457b1dabb4b 100644 --- a/client/checkout/woopay/express-button/test/woopay-express-checkout-button.test.js +++ b/client/checkout/woopay/express-button/test/woopay-express-checkout-button.test.js @@ -29,6 +29,13 @@ jest.mock( 'tracks', () => ( { jest.mock( '../use-express-checkout-product-handler', () => jest.fn() ); +jest.spyOn( window, 'alert' ).mockImplementation( () => {} ); + +global.fetch = jest.fn( () => Promise.resolve( { json: () => ( {} ) } ) ); +global.window.wc_add_to_cart_variation_params = { + i18n_make_a_selection_text: 'Mock text', +}; + describe( 'WoopayExpressCheckoutButton', () => { const buttonSettings = { type: 'default', @@ -74,6 +81,9 @@ describe( 'WoopayExpressCheckoutButton', () => { } ); test( 'call `expressCheckoutIframe` on button click when `isPreview` is false', () => { + getConfig.mockImplementation( ( v ) => { + return v === 'isWoopayFirstPartyAuthEnabled' ? false : 'foo'; + } ); render( { } ); describe( 'Product Page', () => { - test( 'should enable the button when add to cart button is enabled', () => { - render( - - ); - - const expressButton = screen.queryByRole( 'button', { - name: 'WooPay', + test( 'should shown an alert when clicking the button when add to cart button is disabled', () => { + getConfig.mockImplementation( ( v ) => { + return v === 'isWoopayFirstPartyAuthEnabled' ? false : 'foo'; } ); - expect( expressButton ).toBeEnabled(); - } ); - - test( 'should disable the button when add to cart button is disabled', () => { useExpressCheckoutProductHandler.mockImplementation( () => ( { addToCart: mockAddToCart, isAddToCartDisabled: true, @@ -152,10 +148,19 @@ describe( 'WoopayExpressCheckoutButton', () => { const expressButton = screen.queryByRole( 'button', { name: 'WooPay', } ); - expect( expressButton ).toBeDisabled(); + + userEvent.click( expressButton ); + + expect( window.alert ).toBeCalledWith( + window.wc_add_to_cart_variation_params + .i18n_make_a_selection_text + ); } ); test( 'call `addToCart` and `expressCheckoutIframe` on express button click on product page', async () => { + getConfig.mockImplementation( ( v ) => { + return v === 'isWoopayFirstPartyAuthEnabled' ? false : 'foo'; + } ); useExpressCheckoutProductHandler.mockImplementation( () => ( { addToCart: mockAddToCart, getProductData: jest.fn().mockReturnValue( {} ), @@ -189,6 +194,9 @@ describe( 'WoopayExpressCheckoutButton', () => { } ); test( 'do not call `addToCart` on express button click on product page when validation fails', async () => { + getConfig.mockImplementation( ( v ) => { + return v === 'isWoopayFirstPartyAuthEnabled' ? false : 'foo'; + } ); useExpressCheckoutProductHandler.mockImplementation( () => ( { addToCart: mockAddToCart, getProductData: jest.fn().mockReturnValue( false ), diff --git a/client/checkout/woopay/express-button/use-express-checkout-product-handler.js b/client/checkout/woopay/express-button/use-express-checkout-product-handler.js index f9a5bb842b0..2e106c15925 100644 --- a/client/checkout/woopay/express-button/use-express-checkout-product-handler.js +++ b/client/checkout/woopay/express-button/use-express-checkout-product-handler.js @@ -64,24 +64,48 @@ const useExpressCheckoutProductHandler = ( api, isProductPage = false ) => { }; const getProductData = () => { - let productId = document.querySelector( '.single_add_to_cart_button' ) + const productId = document.querySelector( '.single_add_to_cart_button' ) .value; + // Check if product is a bundle product. + const bundleForm = document.querySelector( '.bundle_form' ); // Check if product is a variable product. const variation = document.querySelector( '.single_variation_wrap' ); - if ( variation ) { - productId = variation.querySelector( 'input[name="product_id"]' ) - .value; - } - const data = { + let data = { product_id: productId, - qty: document.querySelector( '.quantity .qty' ).value, - attributes: document.querySelector( '.variations_form' ) - ? getAttributes() - : [], + quantity: document.querySelector( '.quantity .qty' ).value, }; + if ( variation && ! bundleForm ) { + data.product_id = variation.querySelector( + 'input[name="product_id"]' + ).value; + data.attributes = document.querySelector( '.variations_form' ) + ? getAttributes() + : []; + } else { + const formData = new FormData( + document.querySelector( 'form.cart' ) + ); + + // Remove add-to-cart attribute to prevent redirection + // when "Redirect to the cart page after successful addition" + // option is enabled. + formData.delete( 'add-to-cart' ); + + const attributes = {}; + + for ( const fields of formData.entries() ) { + attributes[ fields[ 0 ] ] = fields[ 1 ]; + } + + data = { + ...data, + ...attributes, + }; + } + const addOnForm = document.querySelector( 'form.cart' ); if ( addOnForm ) { @@ -133,31 +157,80 @@ const useExpressCheckoutProductHandler = ( api, isProductPage = false ) => { addToCartButton.classList.contains( 'disabled' ) ); }; + setIsAddToCartDisabled( getIsAddToCartDisabled() ); - const onVariationChange = () => - setIsAddToCartDisabled( getIsAddToCartDisabled() ); + const enableAddToCartButton = () => { + setIsAddToCartDisabled( false ); + }; - const variationList = document.querySelector( '.variations_form' ); + const disableAddToCartButton = () => { + setIsAddToCartDisabled( true ); + }; - if ( variationList ) { - variationList.addEventListener( 'change', onVariationChange ); + const bundleForm = document.querySelector( '.bundle_form' ); + const mixAndMatchForm = document.querySelector( '.mnm_form' ); + const variationForm = document.querySelector( '.variations_form' ); + + if ( bundleForm ) { + // eslint-disable-next-line no-undef + jQuery( bundleForm ) + .on( 'woocommerce-product-bundle-show', enableAddToCartButton ) + .on( + 'woocommerce-product-bundle-hide', + disableAddToCartButton + ); + } else if ( mixAndMatchForm ) { + // eslint-disable-next-line no-undef + jQuery( mixAndMatchForm ) + .on( + 'wc-mnm-display-add-to-cart-button', + enableAddToCartButton + ) + .on( 'wc-mnm-hide-add-to-cart-button', disableAddToCartButton ); + } else if ( variationForm ) { + // eslint-disable-next-line no-undef + jQuery( variationForm ) + .on( 'show_variation', enableAddToCartButton ) + .on( 'hide_variation', disableAddToCartButton ); } return () => { - if ( variationList ) { - variationList.removeEventListener( - 'change', - onVariationChange - ); + if ( bundleForm ) { + // eslint-disable-next-line no-undef + jQuery( bundleForm ) + .off( + 'woocommerce-product-bundle-show', + enableAddToCartButton + ) + .off( + 'woocommerce-product-bundle-hide', + disableAddToCartButton + ); + } else if ( mixAndMatchForm ) { + // eslint-disable-next-line no-undef + jQuery( mixAndMatchForm ) + .off( + 'wc-mnm-display-add-to-cart-button', + enableAddToCartButton + ) + .off( + 'wc-mnm-hide-add-to-cart-button', + disableAddToCartButton + ); + } else if ( variationForm ) { + // eslint-disable-next-line no-undef + jQuery( variationForm ) + .off( 'show_variation', enableAddToCartButton ) + .off( 'hide_variation', disableAddToCartButton ); } }; }, [ isProductPage, setIsAddToCartDisabled ] ); return { - addToCart: addToCart, - getProductData: getProductData, - isAddToCartDisabled: isAddToCartDisabled, + addToCart, + getProductData, + isAddToCartDisabled, }; }; diff --git a/client/checkout/woopay/express-button/utils.js b/client/checkout/woopay/express-button/utils.js new file mode 100644 index 00000000000..a4449d9d782 --- /dev/null +++ b/client/checkout/woopay/express-button/utils.js @@ -0,0 +1,50 @@ +/** + * Internal dependencies + */ +import { getConfig } from 'wcpay/utils/checkout'; + +/** + * Show an error message to the user, from the WooPay express checkout button. + * + * @param {string} context The context for where the button is being displayed. + * @param {string} errorMessage The error message to display. + */ +export const showErrorMessage = ( context, errorMessage ) => { + // Handle Blocks Cart and Checkout notices. + if ( wcSettings.wcBlocksConfig && context !== 'product' ) { + // This handles adding the error notice to the cart page. + wp.data + .dispatch( 'core/notices' ) + ?.createNotice( 'error', errorMessage, { + context: `wc/${ context }`, + } ); + } else { + // We're either on a shortcode cart/checkout or single product page. + fetch( getConfig( 'ajaxUrl' ), { + method: 'POST', + body: new URLSearchParams( { + action: 'woopay_express_checkout_button_show_error_notice', + _ajax_nonce: getConfig( 'woopayButtonNonce' ), + context, + message: errorMessage, + } ), + } ) + .then( ( response ) => response.json() ) + .then( ( response ) => { + if ( response.success ) { + // We need to manually add the notice to the page. + const noticesWrapper = document.querySelector( + '.woocommerce-notices-wrapper' + ); + const wrapper = document.createElement( 'div' ); + wrapper.innerHTML = response.data.notice; + noticesWrapper.insertBefore( wrapper, null ); + + noticesWrapper.scrollIntoView( { + behavior: 'smooth', + block: 'center', + } ); + } + } ); + } +}; 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 76477a4e0b1..ea53795faf4 100644 --- a/client/checkout/woopay/express-button/woopay-express-checkout-button.js +++ b/client/checkout/woopay/express-button/woopay-express-checkout-button.js @@ -2,7 +2,8 @@ * External dependencies */ import { sprintf, __ } from '@wordpress/i18n'; -import React, { useEffect, useState, useRef } from 'react'; +import React, { useCallback, useEffect, useState, useRef } from 'react'; +import classNames from 'classnames'; /** * Internal dependencies @@ -12,8 +13,15 @@ import WoopayIconLight from './woopay-icon-light'; import { expressCheckoutIframe } from './express-checkout-iframe'; import useExpressCheckoutProductHandler from './use-express-checkout-product-handler'; import wcpayTracks from 'tracks'; +import { getConfig } from 'wcpay/utils/checkout'; +import request from 'wcpay/checkout/utils/request'; +import { showErrorMessage } from 'wcpay/checkout/woopay/express-button/utils'; +import { buildAjaxURL } from 'wcpay/payment-request/utils'; + +const BUTTON_WIDTH_THRESHOLD = 140; export const WoopayExpressCheckoutButton = ( { + listenForCartChanges = {}, isPreview = false, buttonSettings, api, @@ -24,8 +32,12 @@ export const WoopayExpressCheckoutButton = ( { narrow: 'narrow', wide: 'wide', }; - const buttonRef = useRef(); + const sessionDataPromiseRef = useRef( null ); + const initWoopayRef = useRef( null ); + const buttonRef = useRef( null ); + const isLoadingRef = useRef( false ); const { type: buttonType, height, size, theme, context } = buttonSettings; + const [ isLoading, setIsLoading ] = useState( false ); const [ buttonWidthType, setButtonWidthType ] = useState( buttonWidthTypes.wide ); @@ -45,6 +57,8 @@ export const WoopayExpressCheckoutButton = ( { getProductData, isAddToCartDisabled, } = useExpressCheckoutProductHandler( api, isProductPage ); + const getProductDataRef = useRef( getProductData ); + const addToCartRef = useRef( addToCart ); useEffect( () => { if ( ! buttonRef.current ) { @@ -52,7 +66,7 @@ export const WoopayExpressCheckoutButton = ( { } const buttonWidth = buttonRef.current.getBoundingClientRect().width; - const isButtonWide = buttonWidth > 140; + const isButtonWide = buttonWidth > BUTTON_WIDTH_THRESHOLD; setButtonWidthType( isButtonWide ? buttonWidthTypes.wide : buttonWidthTypes.narrow ); @@ -69,52 +83,292 @@ export const WoopayExpressCheckoutButton = ( { } }, [ isPreview, context ] ); - const initWooPay = ( e ) => { - e.preventDefault(); + const defaultOnClick = useCallback( + ( e ) => { + e.preventDefault(); + + if ( isPreview ) { + return; // eslint-disable-line no-useless-return + } + + wcpayTracks.recordUserEvent( + wcpayTracks.events.WOOPAY_BUTTON_CLICK, + { + source: context, + } + ); + + if ( isProductPage ) { + if ( isAddToCartDisabled ) { + alert( + window.wc_add_to_cart_variation_params + ?.i18n_make_a_selection_text || + __( + 'Please select all required options to continue.', + 'woocommerce-payments' + ) + ); + return; + } + + const productData = getProductDataRef.current(); + if ( ! productData ) { + return; + } + + addToCartRef + .current( productData ) + .then( ( res ) => { + if ( res.error ) { + if ( res.submit ) { + // Some extensions needs to submit the form + // to show error messages. + document.querySelector( 'form.cart' ).submit(); + } + return; + } - if ( isPreview ) { - return; // eslint-disable-line no-useless-return + expressCheckoutIframe( api, context, emailSelector ); + } ) + .catch( () => { + // handle error. + } ); + } else { + expressCheckoutIframe( api, context, emailSelector ); + } + }, + [ + api, + context, + emailSelector, + isAddToCartDisabled, + isPreview, + isProductPage, + ] + ); + + const newIframe = useCallback( () => { + if ( ! getConfig( 'isWoopayFirstPartyAuthEnabled' ) ) { + return; } - wcpayTracks.recordUserEvent( wcpayTracks.events.WOOPAY_BUTTON_CLICK, { - source: context, + const getWoopayOtpUrl = () => { + const tracksUserId = JSON.stringify( + getConfig( 'tracksUserIdentity' ) + ); + + const urlParams = new URLSearchParams(); + urlParams.append( 'testMode', getConfig( 'testMode' ) ); + urlParams.append( 'source_url', window.location.href ); + urlParams.append( 'tracksUserIdentity', tracksUserId ); + + return ( + getConfig( 'woopayHost' ) + '/connect/?' + urlParams.toString() + ); + }; + + const iframe = document.createElement( 'iframe' ); + iframe.src = getWoopayOtpUrl(); + iframe.height = 0; + iframe.style.visibility = 'hidden'; + iframe.style.position = 'absolute'; + iframe.style.top = '0'; + + iframe.addEventListener( 'load', () => { + // Change button's onClick handle to use express checkout flow. + initWoopayRef.current = ( e ) => { + e.preventDefault(); + + if ( isPreview || isLoadingRef.current ) { + return; + } + + // Set isLoadingRef to true to prevent multiple clicks. + isLoadingRef.current = true; + setIsLoading( true ); + + wcpayTracks.recordUserEvent( + wcpayTracks.events.WOOPAY_BUTTON_CLICK, + { + source: context, + } + ); + + if ( isProductPage ) { + const productData = getProductDataRef.current(); + + if ( ! productData ) { + return; + } + + if ( listenForCartChanges.stop ) { + // Temporarily stop listening for cart changes to prevent + // rendering a new button + iFrame when the cart is updated. + listenForCartChanges.stop(); + } + + addToCartRef.current( productData ).then( () => { + if ( listenForCartChanges.start ) { + // Start listening for cart changes, again. + listenForCartChanges.start(); + } + request( + buildAjaxURL( + getConfig( 'wcAjaxUrl' ), + 'get_woopay_session' + ), + { + _ajax_nonce: getConfig( 'woopaySessionNonce' ), + } + ) + .then( ( response ) => { + iframe.contentWindow.postMessage( + { + action: 'setPreemptiveSessionData', + value: response, + }, + getConfig( 'woopayHost' ) + ); + } ) + .catch( () => { + const errorMessage = __( + 'Something went wrong. Please try again.', + 'woocommerce-payments' + ); + showErrorMessage( context, errorMessage ); + isLoadingRef.current = false; + setIsLoading( false ); + } ); + } ); + } else { + // Non-product pages already have pre-fetched session data. + sessionDataPromiseRef.current + ?.then( ( response ) => { + iframe.contentWindow.postMessage( + { + action: 'setPreemptiveSessionData', + value: response, + }, + getConfig( 'woopayHost' ) + ); + } ) + .catch( () => { + const errorMessage = __( + 'Something went wrong. Please try again.', + 'woocommerce-payments' + ); + showErrorMessage( context, errorMessage ); + isLoadingRef.current = false; + setIsLoading( false ); + } ); + } + }; } ); - if ( isProductPage ) { - const productData = getProductData(); + return iframe; + }, [ isProductPage, context, isPreview, listenForCartChanges ] ); + + useEffect( () => { + if ( isPreview || ! getConfig( 'isWoopayFirstPartyAuthEnabled' ) ) { + return; + } + + if ( ! isProductPage ) { + // Start to pre-fetch session data for non-product pages. + sessionDataPromiseRef.current = request( + buildAjaxURL( getConfig( 'wcAjaxUrl' ), 'get_woopay_session' ), + { + _ajax_nonce: getConfig( 'woopaySessionNonce' ), + } + ).then( ( response ) => response ); + } + + buttonRef.current.parentElement.style.position = 'relative'; + buttonRef.current.parentElement.appendChild( newIframe() ); - if ( ! productData ) { + const onMessage = ( event ) => { + const isFromWoopayHost = getConfig( 'woopayHost' ).startsWith( + event.origin + ); + const isSessionDataSuccess = + event.data.action === 'set_preemptive_session_data_success'; + const isSessionDataError = + event.data.action === 'set_preemptive_session_data_error'; + const isSessionDataResponse = + isSessionDataSuccess || isSessionDataError; + if ( ! isFromWoopayHost || ! isSessionDataResponse ) { return; } - addToCart( productData ) - .then( () => { - expressCheckoutIframe( api, context, emailSelector ); - } ) - .catch( () => { - // handle error. - } ); - } else { - expressCheckoutIframe( api, context, emailSelector ); - } - }; + if ( isSessionDataSuccess ) { + window.location.href = event.data.value.redirect_url; + } else if ( isSessionDataError ) { + const errorMessage = __( + 'WooPay is unavailable at this time. Please try again.', + 'woocommerce-payments' + ); + showErrorMessage( context, errorMessage ); + + // Set button's default onClick handle to use modal checkout flow. + initWoopayRef.current = defaultOnClick; + isLoadingRef.current = false; + setIsLoading( false ); + } + }; + + window.addEventListener( 'message', onMessage ); + + return () => { + window.removeEventListener( 'message', onMessage ); + }; + // Note: Any changes to this dependency array may cause a duplicate iframe to be appended. + }, [ context, defaultOnClick, isPreview, isProductPage, newIframe ] ); + + useEffect( () => { + // Set button's default onClick handle to use modal checkout flow. + initWoopayRef.current = defaultOnClick; + }, [ defaultOnClick ] ); + + useEffect( () => { + const handlePageShow = ( event ) => { + // Re-enable the button after navigating back/forward to the page if bfcache is used. + if ( event?.persisted ) { + isLoadingRef.current = false; + setIsLoading( false ); + } + }; + + window.addEventListener( 'pageshow', handlePageShow ); + + return () => { + window.removeEventListener( 'pageshow', handlePageShow ); + }; + }, [] ); return ( ); }; diff --git a/client/checkout/woopay/express-button/woopay-express-checkout-payment-method.js b/client/checkout/woopay/express-button/woopay-express-checkout-payment-method.js index 94637e94988..228032bbf6d 100644 --- a/client/checkout/woopay/express-button/woopay-express-checkout-payment-method.js +++ b/client/checkout/woopay/express-button/woopay-express-checkout-payment-method.js @@ -1,3 +1,9 @@ +/** + * External dependencies + */ +import { useCallback } from 'react'; +import ReactDOM from 'react-dom'; + /** * Internal dependencies */ @@ -18,15 +24,27 @@ const api = new WCPayAPI( request ); +const WooPayExpressCheckoutButtonContainer = () => { + const onRefChange = useCallback( ( node ) => { + if ( node ) { + const root = ReactDOM.createRoot( node ); + + root.render( + + ); + } + }, [] ); + + return ; +}; + const wooPayExpressCheckoutPaymentMethod = () => ( { name: PAYMENT_METHOD_NAME_WOOPAY_EXPRESS_CHECKOUT, - content: ( - - ), + content: , edit: ( { + name?: string; className?: string; label: string; describedBy?: string; @@ -81,6 +82,7 @@ const stateReducer = ( }; function CustomSelectControl< ItemType extends Item >( { + name, className, label, describedBy, @@ -168,6 +170,7 @@ function CustomSelectControl< ItemType extends Item >( { 'components-custom-select-control__button', { placeholder: ! itemString } ), + name, } ) } > diff --git a/client/components/dispute-status-chip/index.tsx b/client/components/dispute-status-chip/index.tsx index ccb9e11a553..393089a8a78 100644 --- a/client/components/dispute-status-chip/index.tsx +++ b/client/components/dispute-status-chip/index.tsx @@ -4,6 +4,7 @@ * External dependencies */ import React from 'react'; +import { __, sprintf } from '@wordpress/i18n'; /** * Internal dependencies @@ -21,10 +22,24 @@ import type { interface Props { status: DisputeStatus | string; dueBy?: CachedDispute[ 'due_by' ] | EvidenceDetails[ 'due_by' ]; + prefixDisputeType?: boolean; } -const DisputeStatusChip: React.FC< Props > = ( { status, dueBy } ) => { +const DisputeStatusChip: React.FC< Props > = ( { + status, + dueBy, + prefixDisputeType, +} ) => { const mapping = displayStatus[ status ] || {}; - const message = mapping.message || formatStringValue( status ); + let message = mapping.message || formatStringValue( status ); + + // Statuses starting with warning_ are Inquiries and these are already prefaced with "Inquiry: " + if ( prefixDisputeType && ! status.startsWith( 'warning' ) ) { + message = sprintf( + /** translators: %s is the status of the Dispute. */ + __( 'Disputed: %s', 'woocommerce-payments' ), + message + ); + } const needsResponse = isAwaitingResponse( status ); const isUrgent = diff --git a/client/components/grouped-select-control/index.tsx b/client/components/grouped-select-control/index.tsx index 013605ce609..a8b75dfea0c 100644 --- a/client/components/grouped-select-control/index.tsx +++ b/client/components/grouped-select-control/index.tsx @@ -27,11 +27,13 @@ export interface GroupedSelectControlProps< ItemType > { value?: ItemType | null; placeholder?: string; searchable?: boolean; + name?: string; className?: string; onChange?: ( changes: Partial< UseSelectState< ItemType > > ) => void; } const GroupedSelectControl = < ItemType extends ListItem >( { + name, className, label, options: listItems, @@ -176,6 +178,7 @@ const GroupedSelectControl = < ItemType extends ListItem >( { 'components-text-control__input wcpay-component-grouped-select-control__button', { placeholder } ), + name, } ) } > diff --git a/client/components/inline-notice/index.tsx b/client/components/inline-notice/index.tsx index d0c59812e96..bb230170ab9 100644 --- a/client/components/inline-notice/index.tsx +++ b/client/components/inline-notice/index.tsx @@ -7,6 +7,7 @@ import classNames from 'classnames'; import CheckmarkIcon from 'gridicons/dist/checkmark'; import NoticeOutlineIcon from 'gridicons/dist/notice-outline'; import InfoOutlineIcon from 'gridicons/dist/info-outline'; +import { Action } from 'wcpay/types/notices'; /** * Internal dependencies. @@ -21,6 +22,8 @@ interface InlineNoticeProps extends Notice.Props { * @default undefined */ icon?: boolean | JSX.Element; + + actions?: readonly Action[] | undefined; } /** @@ -72,6 +75,8 @@ function InlineNotice( props: InlineNoticeProps ): JSX.Element { key={ index } className={ actionClass } onClick={ action.onClick } + isBusy={ action.isBusy ?? false } + disabled={ action.disabled ?? false } > { action.label } diff --git a/client/components/loadable-checkbox/index.js b/client/components/loadable-checkbox/index.js index 31ac7c60d70..8df3c49f8fe 100644 --- a/client/components/loadable-checkbox/index.js +++ b/client/components/loadable-checkbox/index.js @@ -27,6 +27,7 @@ const LoadableCheckboxControl = ( { setupTooltip = '', delayMsOnCheck = 0, delayMsOnUncheck = 0, + needsAttention = false, } ) => { const [ isLoading, setLoading ] = useState( false ); const [ checkedState, setCheckedState ] = useState( checked ); @@ -87,24 +88,15 @@ const LoadableCheckboxControl = ( { ) } { ( isManualCaptureEnabled && ! isAllowingManualCapture ) || - isSetupRequired ? ( + isSetupRequired || + needsAttention ? (
{ ); case 'affirm': case 'afterpay_clearpay': + case 'klarna': default: return ; } diff --git a/client/components/payment-method-disabled-tooltip/index.tsx b/client/components/payment-method-disabled-tooltip/index.tsx index 8df2e14f594..a2b169bf98c 100644 --- a/client/components/payment-method-disabled-tooltip/index.tsx +++ b/client/components/payment-method-disabled-tooltip/index.tsx @@ -26,6 +26,7 @@ export const getDocumentationUrlForDisabledPaymentMethod = ( switch ( paymentMethodId ) { case PAYMENT_METHOD_IDS.AFTERPAY_CLEARPAY: case PAYMENT_METHOD_IDS.AFFIRM: + case PAYMENT_METHOD_IDS.KLARNA: url = DocumentationUrlForDisabledPaymentMethod.BNPLS; break; default: diff --git a/client/components/payment-methods-list/payment-method.scss b/client/components/payment-methods-list/payment-method.scss index 108a3bdb311..c00b22fd91a 100644 --- a/client/components/payment-methods-list/payment-method.scss +++ b/client/components/payment-methods-list/payment-method.scss @@ -183,25 +183,12 @@ } } - .wcpay-pill { + .chip { margin-left: $gap-smaller; padding: 2px $gap-smaller; @include breakpoint( '<660px' ) { margin-left: 0; } - - &.payment-status-pending-approval, - &.payment-status-pending-verification { - border: 0 solid transparent; - background: #f0b849; - color: #1f1f1f; - } - - &.payment-status-inactive { - border: 0 solid transparent; - background: $studio-yellow-5; - color: $studio-yellow-50; - } } } diff --git a/client/components/payment-methods-list/payment-method.tsx b/client/components/payment-methods-list/payment-method.tsx index 965488d957c..28385de1ad7 100644 --- a/client/components/payment-methods-list/payment-method.tsx +++ b/client/components/payment-methods-list/payment-method.tsx @@ -8,6 +8,7 @@ import React, { useContext } from 'react'; /** * Internal dependencies */ +import interpolateComponents from '@automattic/interpolate-components'; import { __, sprintf } from '@wordpress/i18n'; import { HoverTooltip } from 'components/tooltip'; import { upeCapabilityStatuses } from 'wcpay/additional-methods-setup/constants'; @@ -18,9 +19,10 @@ import { formatMethodFeesTooltip, } from 'wcpay/utils/account-fees'; import WCPaySettingsContext from '../../settings/wcpay-settings-context'; +import Chip from '../chip'; import LoadableCheckboxControl from '../loadable-checkbox'; +import { getDocumentationUrlForDisabledPaymentMethod } from '../payment-method-disabled-tooltip'; import Pill from '../pill'; -import PaymentMethodDisabledTooltip from '../payment-method-disabled-tooltip'; import './payment-method.scss'; interface PaymentMethodProps { @@ -48,13 +50,11 @@ const PaymentMethodLabel = ( { required, status, disabled, - id, }: { label: string; required: boolean; status: string; disabled: boolean; - id: string; } ): React.ReactElement => { return ( <> @@ -65,43 +65,28 @@ const PaymentMethodLabel = ( { ) } { upeCapabilityStatuses.PENDING_APPROVAL === status && ( - - - { __( 'Pending approval', 'woocommerce-payments' ) } - - + ) } { upeCapabilityStatuses.PENDING_VERIFICATION === status && ( - - - { __( 'Pending activation', 'woocommerce-payments' ) } - - + type="warning" + /> ) } { disabled && ( - - - { __( - 'More information needed', - 'woocommerce-payments' - ) } - - + ) } ); @@ -136,14 +121,16 @@ const PaymentMethod = ( { ); const [ isManualCaptureEnabled ] = useManualCapture(); + const needsAttention = [ + upeCapabilityStatuses.INACTIVE, + upeCapabilityStatuses.PENDING_APPROVAL, + upeCapabilityStatuses.PENDING_VERIFICATION, + ].includes( status ); + const needsOverlay = ( isManualCaptureEnabled && ! isAllowingManualCapture ) || - isSetupRequired; - - // As the JCB is not a separate payment method we fallback to card. - if ( id === 'jcb' ) { - id = 'card'; - } + isSetupRequired || + needsAttention; const handleChange = ( newStatus: string ) => { // If the payment method control is locked, reject any changes. @@ -157,6 +144,68 @@ const PaymentMethod = ( { return onUncheckClick( id ); }; + const getTooltipContent = ( paymentMethodId: string ) => { + if ( upeCapabilityStatuses.PENDING_APPROVAL === status ) { + return __( + 'This payment method is pending approval. Once approved, you will be able to use it.', + 'woocommerce-payments' + ); + } + + if ( upeCapabilityStatuses.PENDING_VERIFICATION === status ) { + return sprintf( + __( + "%s won't be visible to your customers until you provide the required " + + 'information. Follow the instructions sent by our partner Stripe to %s.', + 'woocommerce-payments' + ), + label, + wcpaySettings?.accountEmail ?? '' + ); + } + + if ( isSetupRequired ) { + return setupTooltip; + } + + if ( needsAttention ) { + return interpolateComponents( { + // translators: {{learnMoreLink}}: placeholders are opening and closing anchor tags. + mixedString: __( + 'We need more information from you to enable this method. ' + + '{{learnMoreLink}}Learn more.{{/learnMoreLink}}', + 'woocommerce-payments' + ), + components: { + learnMoreLink: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + }, + } ); + } + + return sprintf( + /* translators: %s: a payment method name. */ + __( + '%s is not available to your customers when the "manual capture" setting is enabled.', + 'woocommerce-payments' + ), + label + ); + }; + return (
  • @@ -190,7 +240,6 @@ const PaymentMethod = ( { required={ required } status={ status } disabled={ disabled } - id={ id } />
    @@ -201,7 +250,6 @@ const PaymentMethod = ( { required={ required } status={ status } disabled={ disabled } - id={ id } />
    diff --git a/client/components/payment-methods-list/style.scss b/client/components/payment-methods-list/style.scss index b08ff5ed6f6..7cf4f570e85 100644 --- a/client/components/payment-methods-list/style.scss +++ b/client/components/payment-methods-list/style.scss @@ -1,3 +1,14 @@ .payment-methods-list { margin: 0; } + +.woopayments-request-jcb { + margin: $grid-unit-30 $grid-unit-30 0 $grid-unit-30 !important; +} + +.wcpay-tooltip__tooltip { + &.wcpay-tooltip__tooltip--dark a { + color: $white; + text-decoration: underline; + } +} diff --git a/client/components/tooltip/index.tsx b/client/components/tooltip/index.tsx index 3a53b8b691f..1526a4b78d8 100644 --- a/client/components/tooltip/index.tsx +++ b/client/components/tooltip/index.tsx @@ -2,6 +2,7 @@ * External dependencies */ import React, { useState, useRef } from 'react'; +import classNames from 'classnames'; import { noop } from 'lodash'; import { Icon } from '@wordpress/components'; @@ -112,6 +113,7 @@ export const ClickTooltip: React.FC< TooltipProps > = ( { buttonLabel, buttonSize = 16, children, + className, ...props } ) => { const [ isClicked, setIsClicked ] = useState( false ); @@ -144,7 +146,10 @@ export const ClickTooltip: React.FC< TooltipProps > = ( { parentElement={ tooltipParentRef.current || undefined } onHide={ handleHide } isVisible={ isVisible || isClicked } - className="wcpay-tooltip--click__tooltip" + className={ classNames( + 'wcpay-tooltip--click__tooltip', + className + ) } > { buttonIcon ? (
    div { + display: inline; + } + // Styles for buttonIcon [role='button'] { cursor: pointer; @@ -12,6 +17,7 @@ transition: all 0.3s ease; fill: currentColor; margin: 0 0.4em; + vertical-align: text-bottom; &:focus, &:hover, diff --git a/client/components/woopay/save-user/checkout-page-save-user.js b/client/components/woopay/save-user/checkout-page-save-user.js index 3337cb4cb1d..fffe352092f 100644 --- a/client/components/woopay/save-user/checkout-page-save-user.js +++ b/client/components/woopay/save-user/checkout-page-save-user.js @@ -51,6 +51,8 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { phoneFieldValue = document.getElementById( 'phone' )?.value || document.getElementById( 'shipping-phone' )?.value || + // in case of virtual products, the shipping phone is not available. So we also need to check the billing phone. + document.getElementById( 'billing-phone' )?.value || ''; } else { // for classic checkout. @@ -97,7 +99,7 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { if ( isChecked ) { setPhoneNumber( getPhoneFieldValue() ); } else { - setPhoneNumber( null ); + setPhoneNumber( '' ); if ( isBlocksCheckout ) { sendExtensionData( true ); } @@ -287,11 +289,7 @@ const CheckoutPageSaveUser = ( { isBlocksCheckout } ) => { value={ `${ viewportWidth }x${ viewportHeight }` } /> ( { recordUserEvent: jest.fn(), } ) ); +const BlocksCheckoutEnvironmentMock = ( { children } ) => ( +
    + + + + + { children } +
    +); + describe( 'CheckoutPageSaveUser', () => { beforeEach( () => { useWooPayUser.mockImplementation( () => false ); + extensionCartUpdate.mockResolvedValue( {} ); useSelectedPaymentMethod.mockImplementation( () => ( { isWCPayChosen: true, @@ -65,7 +78,7 @@ describe( 'CheckoutPageSaveUser', () => { } ); afterEach( () => { - jest.restoreAllMocks(); + jest.resetAllMocks(); } ); it( 'should render checkbox for saving WooPay user when user is not registered and selected payment method is card', () => { @@ -137,7 +150,9 @@ describe( 'CheckoutPageSaveUser', () => { } ); it( 'should render the save user form when checkbox is checked for blocks checkout', () => { - render( ); + render( , { + wrapper: BlocksCheckoutEnvironmentMock, + } ); const label = screen.getByLabelText( 'Save my information for a faster and secure checkout' @@ -173,18 +188,9 @@ describe( 'CheckoutPageSaveUser', () => { } ); it( 'call `extensionCartUpdate` on blocks checkout when checkbox is clicked', async () => { - extensionCartUpdate.mockResolvedValue( {} ); - const placeOrderButton = document.createElement( 'button' ); - placeOrderButton.classList.add( - 'wc-block-components-checkout-place-order-button' - ); - document.body.appendChild( placeOrderButton ); - const phoneField = document.createElement( 'input' ); - phoneField.setAttribute( 'id', 'phone' ); - phoneField.value = '+12015555555'; - document.body.appendChild( phoneField ); - - render( ); + render( , { + wrapper: BlocksCheckoutEnvironmentMock, + } ); const label = screen.getByLabelText( 'Save my information for a faster and secure checkout' @@ -206,7 +212,7 @@ describe( 'CheckoutPageSaveUser', () => { woopay_is_blocks: true, woopay_viewport: '0x0', woopay_user_phone_field: { - full: '+12015555555', + full: '+12015555551', }, }, } ) @@ -222,28 +228,65 @@ describe( 'CheckoutPageSaveUser', () => { data: {}, } ) ); + } ); - document.body.removeChild( - document.querySelector( - 'button.wc-block-components-checkout-place-order-button' - ) + it( 'fills the phone input on blocks checkout with phone number field fallback', async () => { + render( , { + wrapper: BlocksCheckoutEnvironmentMock, + } ); + + const saveMyInfoCheckbox = screen.getByLabelText( + 'Save my information for a faster and secure checkout' + ); + // initial state + expect( saveMyInfoCheckbox ).not.toBeChecked(); + + // click on the checkbox to show the phone field, input should be filled with the first phone input field + userEvent.click( saveMyInfoCheckbox ); + expect( saveMyInfoCheckbox ).toBeChecked(); + expect( screen.getByLabelText( 'Mobile phone number' ).value ).toEqual( + '2015555551' ); - } ); - it( 'call `extensionCartUpdate` on blocks checkout when checkbox is clicked with a phone without country code', async () => { - jest.clearAllMocks(); - extensionCartUpdate.mockResolvedValue( {} ); - const placeOrderButton = document.createElement( 'button' ); - placeOrderButton.classList.add( - 'wc-block-components-checkout-place-order-button' + // click on the checkbox to hide/show it again (and reset the previously entered values) + userEvent.click( saveMyInfoCheckbox ); + document.getElementById( 'phone' ).remove(); + await waitFor( () => expect( extensionCartUpdate ).toHaveBeenCalled() ); + + userEvent.click( saveMyInfoCheckbox ); + expect( saveMyInfoCheckbox ).toBeChecked(); + expect( screen.getByLabelText( 'Mobile phone number' ).value ).toEqual( + '2015555552' + ); + + // click on the checkbox to hide/show it again (and reset the previously entered values) + userEvent.click( saveMyInfoCheckbox ); + document.getElementById( 'shipping-phone' ).remove(); + await waitFor( () => expect( extensionCartUpdate ).toHaveBeenCalled() ); + + userEvent.click( saveMyInfoCheckbox ); + expect( saveMyInfoCheckbox ).toBeChecked(); + expect( screen.getByLabelText( 'Mobile phone number' ).value ).toEqual( + '2015555553' ); - document.body.appendChild( placeOrderButton ); - const phoneField = document.createElement( 'input' ); - phoneField.setAttribute( 'id', 'phone' ); - phoneField.value = '2015555555'; - document.body.appendChild( phoneField ); - render( ); + // click on the checkbox to hide/show it again (and reset the previously entered values) + userEvent.click( saveMyInfoCheckbox ); + document.getElementById( 'billing-phone' ).remove(); + await waitFor( () => expect( extensionCartUpdate ).toHaveBeenCalled() ); + + userEvent.click( saveMyInfoCheckbox ); + expect( saveMyInfoCheckbox ).toBeChecked(); + expect( screen.getByLabelText( 'Mobile phone number' ).value ).toEqual( + '' + ); + await waitFor( () => expect( extensionCartUpdate ).toHaveBeenCalled() ); + } ); + + it( 'call `extensionCartUpdate` on blocks checkout when checkbox is clicked with a phone without country code', async () => { + render( , { + wrapper: BlocksCheckoutEnvironmentMock, + } ); const label = screen.getByLabelText( 'Save my information for a faster and secure checkout' @@ -265,7 +308,7 @@ describe( 'CheckoutPageSaveUser', () => { woopay_is_blocks: true, woopay_viewport: '0x0', woopay_user_phone_field: { - full: '+12015555555', + full: '+12015555551', }, }, } ) diff --git a/client/data/disputes/action-types.js b/client/data/disputes/action-types.js index a88cdbdc02c..318d331c84c 100644 --- a/client/data/disputes/action-types.js +++ b/client/data/disputes/action-types.js @@ -2,6 +2,7 @@ export default { SET_DISPUTE: 'SET_DISPUTE', + SET_ERROR_FOR_DISPUTE: 'SET_ERROR_FOR_DISPUTE', SET_DISPUTES: 'SET_DISPUTES', SET_DISPUTES_SUMMARY: 'SET_DISPUTES_SUMMARY', }; diff --git a/client/data/disputes/actions.js b/client/data/disputes/actions.js index 2e9975e2cf9..4a0f82197e1 100644 --- a/client/data/disputes/actions.js +++ b/client/data/disputes/actions.js @@ -14,6 +14,7 @@ import { NAMESPACE, STORE_NAME } from '../constants'; import TYPES from './action-types'; import wcpayTracks from 'tracks'; import { getAdminUrl } from 'wcpay/utils'; +import { getPaymentIntent } from '../payment-intents/resolvers'; export function updateDispute( data ) { return { @@ -22,6 +23,15 @@ export function updateDispute( data ) { }; } +export function updateErrorForDispute( id, data, error ) { + return { + type: TYPES.SET_ERROR_FOR_DISPUTE, + id, + data, + error, + }; +} + export function updateDisputes( query, data ) { return { type: TYPES.SET_DISPUTES, @@ -88,3 +98,63 @@ export function* acceptDispute( id ) { yield controls.dispatch( 'core/notices', 'createErrorNotice', message ); } } + +// This function handles the dispute acceptance flow from the Transaction Details screen. +// It differs from the `acceptDispute` function above in that it also fetches and updates +// the payment intent associated with the dispute to reflect changes to the dispute +// on the Transaction Details screen. +// +// Once the '_wcpay_feature_dispute_on_transaction_page' is enabled by default, +// the `acceptDispute` function above can be removed and this function can be renamed +// to `acceptDispute`. +export function* acceptTransactionDetailsDispute( dispute ) { + const { id, payment_intent: paymentIntent } = dispute; + + try { + yield controls.dispatch( STORE_NAME, 'startResolution', 'getDispute', [ + id, + ] ); + + const updatedDispute = yield apiFetch( { + path: `${ NAMESPACE }/disputes/${ id }/close`, + method: 'post', + } ); + + yield updateDispute( updatedDispute ); + + // Fetch and update the payment intent associated with the dispute + // to reflect changes to the dispute on the Transaction Details screen. + yield getPaymentIntent( paymentIntent ); + + yield controls.dispatch( STORE_NAME, 'finishResolution', 'getDispute', [ + id, + ] ); + + wcpayTracks.recordEvent( 'wcpay_dispute_accept_success' ); + const message = updatedDispute.order + ? sprintf( + /* translators: #%s is an order number, e.g. 15 */ + __( + 'You have accepted the dispute for order #%s.', + 'woocommerce-payments' + ), + updatedDispute.order.number + ) + : __( 'You have accepted the dispute.', 'woocommerce-payments' ); + yield controls.dispatch( + 'core/notices', + 'createSuccessNotice', + message + ); + } catch ( e ) { + const message = __( + 'There has been an error accepting the dispute. Please try again later.', + 'woocommerce-payments' + ); + wcpayTracks.recordEvent( 'wcpay_dispute_accept_failed' ); + yield controls.dispatch( 'core/notices', 'createErrorNotice', message ); + yield controls.dispatch( STORE_NAME, 'finishResolution', 'getDispute', [ + id, + ] ); + } +} diff --git a/client/data/disputes/hooks.ts b/client/data/disputes/hooks.ts index c4b7eb6f505..e3797499537 100644 --- a/client/data/disputes/hooks.ts +++ b/client/data/disputes/hooks.ts @@ -15,22 +15,31 @@ import type { CachedDisputes, DisputesSummary, } from 'wcpay/types/disputes'; +import type { ApiError } from 'wcpay/types/errors'; import { STORE_NAME } from '../constants'; import { disputeAwaitingResponseStatuses } from 'wcpay/disputes/filters/config'; +/** + * Returns the dispute object, loading state, and accept function. + * Fetches the dispute object if it is not already cached. + */ export const useDispute = ( id: string ): { - dispute: Dispute; + dispute?: Dispute; + error?: ApiError; isLoading: boolean; doAccept: () => void; } => { - const { dispute, isLoading } = useSelect( + const { dispute, error, isLoading } = useSelect( ( select ) => { - const { getDispute, isResolving } = select( STORE_NAME ); + const { getDispute, getDisputeError, isResolving } = select( + STORE_NAME + ); return { - dispute: getDispute( id ), + dispute: getDispute( id ), + error: getDisputeError( id ), isLoading: isResolving( 'getDispute', [ id ] ), }; }, @@ -40,7 +49,32 @@ export const useDispute = ( const { acceptDispute } = useDispatch( STORE_NAME ); const doAccept = () => acceptDispute( id ); - return { dispute, isLoading, doAccept }; + return { dispute, isLoading, error, doAccept }; +}; + +/** + * Returns the dispute accept function and loading state. + * Does not return or fetch the dispute object. + */ +export const useDisputeAccept = ( + dispute: Dispute +): { + doAccept: () => void; + isLoading: boolean; +} => { + const { isLoading } = useSelect( + ( select ) => { + const { isResolving } = select( STORE_NAME ); + + return { + isLoading: isResolving( 'getDispute', [ dispute.id ] ), + }; + }, + [ dispute.id ] + ); + const { acceptTransactionDetailsDispute } = useDispatch( STORE_NAME ); + const doAccept = () => acceptTransactionDetailsDispute( dispute ); + return { doAccept, isLoading }; }; export const useDisputeEvidence = (): { diff --git a/client/data/disputes/reducer.js b/client/data/disputes/reducer.js index 3312bb2b9f9..f6b88f90452 100644 --- a/client/data/disputes/reducer.js +++ b/client/data/disputes/reducer.js @@ -15,7 +15,7 @@ const defaultState = { byId: {}, queries: {}, summary: {}, cached: {} }; const receiveDisputes = ( state = defaultState, - { type, query = {}, data = [] } + { type, query = {}, data = [], id, error } ) => { const index = getResourceId( query ); @@ -25,6 +25,12 @@ const receiveDisputes = ( ...state, byId: { ...state.byId, [ data.id ]: data }, }; + case TYPES.SET_ERROR_FOR_DISPUTE: + state = { + ...state, + byId: { ...state.byId, [ id ]: { error } }, + }; + break; case TYPES.SET_DISPUTES: return { ...state, diff --git a/client/data/disputes/resolvers.js b/client/data/disputes/resolvers.js index 3676df92d60..ee82f790c51 100644 --- a/client/data/disputes/resolvers.js +++ b/client/data/disputes/resolvers.js @@ -18,6 +18,7 @@ import { updateDispute, updateDisputes, updateDisputesSummary, + updateErrorForDispute, } from './actions'; const formatQueryFilters = ( query ) => ( { @@ -61,6 +62,7 @@ export function* getDispute( id ) { 'createErrorNotice', __( 'Error retrieving dispute.', 'woocommerce-payments' ) ); + yield updateErrorForDispute( id, undefined, e ); } } diff --git a/client/data/disputes/selectors.js b/client/data/disputes/selectors.js index 4bd5347c82a..b592e518298 100644 --- a/client/data/disputes/selectors.js +++ b/client/data/disputes/selectors.js @@ -26,6 +26,11 @@ export const getDispute = ( state, id ) => { return disputeById[ id ]; }; +export const getDisputeError = ( state, id ) => { + const disputeById = getDisputesState( state ).byId || {}; + return disputeById[ id ]?.error; +}; + export const getCachedDispute = ( state, id ) => { const disputeById = getDisputesState( state ).cached || {}; return disputeById[ id ]; diff --git a/client/data/disputes/test/resolvers.js b/client/data/disputes/test/resolvers.js index 5b576a65cf7..cd5538de148 100644 --- a/client/data/disputes/test/resolvers.js +++ b/client/data/disputes/test/resolvers.js @@ -13,6 +13,7 @@ import { updateDispute, updateDisputes, updateDisputesSummary, + updateErrorForDispute, } from '../actions'; import { getDispute, getDisputes, getDisputesSummary } from '../resolvers'; @@ -68,6 +69,9 @@ describe( 'getDispute resolver', () => { expect.any( String ) ) ); + expect( generator.next().value ).toEqual( + updateErrorForDispute( 'dp_mock1', undefined, errorResponse ) + ); } ); } ); } ); diff --git a/client/data/multi-currency/actions.js b/client/data/multi-currency/actions.js index 857107ef732..6da75dcef32 100644 --- a/client/data/multi-currency/actions.js +++ b/client/data/multi-currency/actions.js @@ -118,7 +118,8 @@ export function* submitCurrencySettings( currencyCode, settings ) { export function* submitStoreSettingsUpdate( isAutoSwitchEnabled, - isStorefrontSwitcherEnabled + isStorefrontSwitcherEnabled, + suppressNotices = false ) { try { const result = yield apiFetch( { @@ -136,6 +137,8 @@ export function* submitStoreSettingsUpdate( yield updateStoreSettings( result ); + if ( suppressNotices ) return; + yield dispatch( 'core/notices' ).createSuccessNotice( __( 'Store settings saved.', 'woocommerce-payments' ) ); diff --git a/client/data/settings/actions.js b/client/data/settings/actions.js index 6444df92b4b..137a01b31fc 100644 --- a/client/data/settings/actions.js +++ b/client/data/settings/actions.js @@ -225,6 +225,12 @@ export function* saveSettings() { yield dispatch( 'core/notices' ).createErrorNotice( __( 'Error saving settings.', 'woocommerce-payments' ) ); + + if ( error.server_error ) { + yield dispatch( 'core/notices' ).createErrorNotice( + error.server_error + ); + } } finally { yield updateIsSavingSettings( false, error ); } diff --git a/client/disputes/details/index.tsx b/client/disputes/details/index.tsx index e8e43312fcc..e8af4bbcf97 100644 --- a/client/disputes/details/index.tsx +++ b/client/disputes/details/index.tsx @@ -23,7 +23,7 @@ import { TestModeNotice, topics } from 'components/test-mode-notice'; import '../style.scss'; import { Dispute } from 'wcpay/types/disputes'; -const DisputeDetails = ( { +const LegacyDisputeDetails = ( { query: { id: disputeId }, }: { query: { id: string }; @@ -156,4 +156,4 @@ const DisputeDetails = ( { ); }; -export default DisputeDetails; +export default LegacyDisputeDetails; diff --git a/client/disputes/index.tsx b/client/disputes/index.tsx index 3ba2782f82f..90880e592d2 100644 --- a/client/disputes/index.tsx +++ b/client/disputes/index.tsx @@ -207,14 +207,17 @@ export const DisputesList = (): JSX.Element => { const rows = disputes.map( ( dispute ) => { const clickable = ( children: React.ReactNode ): JSX.Element => ( { children } ); const detailsLink = ( - + ); const reasonMapping = reasons[ dispute.reason ]; @@ -303,7 +306,10 @@ export const DisputesList = (): JSX.Element => { display: (
    ); }; diff --git a/client/multi-currency-setup/tasks/add-currencies-task/test/__snapshots__/index.test.js.snap b/client/multi-currency-setup/tasks/add-currencies-task/test/__snapshots__/index.test.js.snap index 1f971098e88..277911ece2e 100644 --- a/client/multi-currency-setup/tasks/add-currencies-task/test/__snapshots__/index.test.js.snap +++ b/client/multi-currency-setup/tasks/add-currencies-task/test/__snapshots__/index.test.js.snap @@ -1437,10 +1437,9 @@ exports[`Multi-Currency enabled currencies list should hide recommended currenci
    diff --git a/client/multi-currency-setup/tasks/add-currencies-task/test/index.test.js b/client/multi-currency-setup/tasks/add-currencies-task/test/index.test.js index 094d38fae08..f58beb09ced 100644 --- a/client/multi-currency-setup/tasks/add-currencies-task/test/index.test.js +++ b/client/multi-currency-setup/tasks/add-currencies-task/test/index.test.js @@ -420,10 +420,8 @@ describe( 'Multi-Currency enabled currencies list', () => { ).not.toBeInTheDocument(); expect( - screen.getByRole( 'button', { - name: /Add ([a-z0-9]+ )?currenc(y|ies)/i, - } ) - ).toBeDisabled(); + screen.getByRole( 'button', { name: 'Continue' } ) + ).not.toBeDisabled(); // Reset mock currencies to original state. useEnabledCurrencies.mockReturnValue( { diff --git a/client/multi-currency-setup/tasks/store-settings-task/index.js b/client/multi-currency-setup/tasks/store-settings-task/index.js index dbfc7381c08..7e20fc0f3e4 100644 --- a/client/multi-currency-setup/tasks/store-settings-task/index.js +++ b/client/multi-currency-setup/tasks/store-settings-task/index.js @@ -15,10 +15,15 @@ import WizardTaskItem from '../../wizard/task-item'; import PreviewModal from '../../../multi-currency/preview-modal'; import './index.scss'; -import { useStoreSettings } from 'wcpay/data'; +import { useStoreSettings, useSettings, useMultiCurrency } from 'wcpay/data'; const StoreSettingsTask = () => { const { storeSettings, submitStoreSettingsUpdate } = useStoreSettings(); + const { saveSettings, isSaving } = useSettings(); + const [ + isMultiCurrencyEnabled, + updateIsMultiCurrencyEnabled, + ] = useMultiCurrency(); const [ isPending, setPending ] = useState( false ); @@ -65,10 +70,18 @@ const StoreSettingsTask = () => { const handleContinueClick = () => { setPending( true ); + + if ( ! isMultiCurrencyEnabled ) { + updateIsMultiCurrencyEnabled( true ); + saveSettings(); + } + submitStoreSettingsUpdate( isAutomaticSwitchEnabledValue, - isStorefrontSwitcherEnabledValue + isStorefrontSwitcherEnabledValue, + ! isMultiCurrencyEnabled ); + setPending( false ); setCompleted( true, 'setup-complete' ); }; @@ -127,31 +140,30 @@ const StoreSettingsTask = () => { 'woocommerce-payments' ) } /> -
    +
    + { __( + 'A currency switcher is also available in your widgets.', + 'woocommerce-payments' + ) } +
    ) : null } -
    - { __( - 'A currency switcher is also available in your widgets.', - 'woocommerce-payments' - ) } -
    diff --git a/client/multi-currency-setup/tasks/store-settings-task/test/__snapshots__/index.test.js.snap b/client/multi-currency-setup/tasks/store-settings-task/test/__snapshots__/index.test.js.snap index 835869834ca..ef97ef8d897 100644 --- a/client/multi-currency-setup/tasks/store-settings-task/test/__snapshots__/index.test.js.snap +++ b/client/multi-currency-setup/tasks/store-settings-task/test/__snapshots__/index.test.js.snap @@ -121,9 +121,8 @@ exports[`Multi-Currency store settings store settings task renders correctly: sn -
    A currency switcher is also available in your widgets.
    diff --git a/client/multi-currency-setup/tasks/store-settings-task/test/index.test.js b/client/multi-currency-setup/tasks/store-settings-task/test/index.test.js index 0fd0310fe52..e81b7b08f99 100644 --- a/client/multi-currency-setup/tasks/store-settings-task/test/index.test.js +++ b/client/multi-currency-setup/tasks/store-settings-task/test/index.test.js @@ -8,12 +8,19 @@ import { render, screen, fireEvent } from '@testing-library/react'; * Internal dependencies */ import WizardTaskContext from '../../../../additional-methods-setup/wizard/task/context'; -import { useCurrencies, useStoreSettings } from 'wcpay/data'; +import { + useCurrencies, + useStoreSettings, + useSettings, + useMultiCurrency, +} from 'wcpay/data'; import StoreSettingsTask from '..'; jest.mock( 'wcpay/data', () => ( { useStoreSettings: jest.fn(), useCurrencies: jest.fn(), + useSettings: jest.fn(), + useMultiCurrency: jest.fn(), } ) ); const changeableSettings = [ @@ -43,6 +50,11 @@ useStoreSettings.mockReturnValue( { submitStoreSettingsUpdate: jest.fn(), } ); +useSettings.mockReturnValue( { + saveSettings: jest.fn().mockResolvedValue( {} ), + isSaving: false, +} ); + const setCompletedMock = jest.fn(); const createContainer = () => { @@ -61,6 +73,10 @@ describe( 'Multi-Currency store settings', () => { jest.clearAllMocks(); } ); + beforeEach( () => { + useMultiCurrency.mockReturnValue( [ true, jest.fn() ] ); + } ); + test( 'store settings task renders correctly', () => { const container = createContainer(); expect( container ).toMatchSnapshot( @@ -83,6 +99,40 @@ describe( 'Multi-Currency store settings', () => { } ); } ); + test( 'multi-currency is enabled if it was previously disabled', async () => { + useMultiCurrency.mockReturnValue( [ false, jest.fn() ] ); + + createContainer(); + const { submitStoreSettingsUpdate } = useStoreSettings(); + const { saveSettings } = useSettings(); + const [ , updateIsMultiCurrencyEnabled ] = useMultiCurrency(); + + fireEvent.click( + screen.getByRole( 'button', { + name: /Continue/, + } ) + ); + + expect( saveSettings ).toBeCalled(); + expect( updateIsMultiCurrencyEnabled ).toBeCalledWith( true ); + expect( submitStoreSettingsUpdate ).toBeCalledWith( + false, + false, + true + ); + + changeableSettings.forEach( ( setting ) => { + fireEvent.click( screen.getByTestId( setting ) ); + expect( screen.getByTestId( setting ) ).toBeChecked(); + } ); + fireEvent.click( + screen.getByRole( 'button', { + name: /Continue/, + } ) + ); + expect( submitStoreSettingsUpdate ).toBeCalledWith( true, true, true ); + } ); + test( 'store settings are saved with continue button click', () => { createContainer(); const { submitStoreSettingsUpdate } = useStoreSettings(); @@ -91,7 +141,11 @@ describe( 'Multi-Currency store settings', () => { name: /Continue/, } ) ); - expect( submitStoreSettingsUpdate ).toBeCalledWith( false, false ); + expect( submitStoreSettingsUpdate ).toBeCalledWith( + false, + false, + false + ); changeableSettings.forEach( ( setting ) => { fireEvent.click( screen.getByTestId( setting ) ); @@ -102,7 +156,7 @@ describe( 'Multi-Currency store settings', () => { name: /Continue/, } ) ); - expect( submitStoreSettingsUpdate ).toBeCalledWith( true, true ); + expect( submitStoreSettingsUpdate ).toBeCalledWith( true, true, false ); } ); test( 'store settings preview should open a modal with an iframe', () => { diff --git a/client/multi-currency/multi-currency-settings/store-settings/index.js b/client/multi-currency/multi-currency-settings/store-settings/index.js index ccb8b8f4818..eddeb570ccf 100644 --- a/client/multi-currency/multi-currency-settings/store-settings/index.js +++ b/client/multi-currency/multi-currency-settings/store-settings/index.js @@ -139,35 +139,43 @@ const StoreSettings = () => { ) } { storeSettings.site_theme === 'Storefront' ? ( - - ) : null } -
    - { createInterpolateElement( - sprintf( - /* translators: %s: url to the widgets page */ - __( - 'A currency switcher is also available in your widgets. ' + - 'Configure now', + <> + , - } - ) } -
    + ) } + /> +
    + { createInterpolateElement( + sprintf( + /* translators: %s: url to the widgets page */ + __( + 'A currency switcher is also available in your widgets. ' + + 'Configure now', + 'woocommerce-payments' + ), + 'widgets.php' + ), + { + linkToWidgets: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + } + ) } +
    + + ) : null } { name: keyof OnboardingFields; } -export const OnboardingTextField: React.FC< OnboardingTextFieldProps > = ( { - name, - ...rest -} ) => { +export const OnboardingTextField: React.FC< OnboardingTextFieldProps > = ( + props +) => { + const { name } = props; const { data, setData, touched } = useOnboardingContext(); const { validate, error } = useValidation( name ); const inputRef = React.useRef< HTMLInputElement >( null ); @@ -85,7 +85,7 @@ export const OnboardingTextField: React.FC< OnboardingTextFieldProps > = ( { if ( event.key === 'Enter' ) validate(); } } error={ error() } - { ...rest } + { ...props } /> ); }; @@ -95,10 +95,10 @@ interface OnboardingPhoneNumberFieldProps name: keyof OnboardingFields; } -export const OnboardingPhoneNumberField: React.FC< OnboardingPhoneNumberFieldProps > = ( { - name, - ...rest -} ) => { +export const OnboardingPhoneNumberField: React.FC< OnboardingPhoneNumberFieldProps > = ( + props +) => { + const { name } = props; const { data, setData, temp, setTemp, touched } = useOnboardingContext(); const { validate, error } = useValidation( name ); @@ -117,7 +117,7 @@ export const OnboardingPhoneNumberField: React.FC< OnboardingPhoneNumberFieldPro onKeyDown={ ( event: React.KeyboardEvent< HTMLInputElement > ) => { if ( event.key === 'Enter' ) validate(); } } - { ...rest } + { ...props } /> ); }; @@ -129,10 +129,10 @@ interface OnboardingSelectFieldProps< ItemType > } export const OnboardingSelectField = < ItemType extends SelectItem >( { - name, onChange, ...rest }: OnboardingSelectFieldProps< ItemType > ): JSX.Element => { + const { name } = rest; const { data, setData } = useOnboardingContext(); const { validate, error } = useValidation( name ); @@ -169,10 +169,10 @@ interface OnboardingGroupedSelectFieldProps< ItemType > export const OnboardingGroupedSelectField = < ListItemType extends GroupedSelectItem >( { - name, onChange, ...rest }: OnboardingGroupedSelectFieldProps< ListItemType > ): JSX.Element => { + const { name } = rest; const { data, setData } = useOnboardingContext(); const { validate, error } = useValidation( name ); diff --git a/client/order/index.js b/client/order/index.js index 35254d5a6da..02493a65431 100644 --- a/client/order/index.js +++ b/client/order/index.js @@ -158,7 +158,10 @@ const DisputeNotice = ( { chargeId } ) => { // Disable the refund button. refundButton.disabled = true; - const disputeDetailsLink = getDetailsURL( dispute.id, 'disputes' ); + const disputeDetailsLink = getDetailsURL( + chargeId, + 'transactions' + ); let tooltipText = ''; @@ -315,8 +318,8 @@ const DisputeNotice = ( { chargeId } ) => { } ); window.location = getDetailsURL( - dispute.id, - 'disputes' + chargeId, + 'transactions' ); }, }, diff --git a/client/overview/connection-sucess-notice.tsx b/client/overview/connection-sucess-notice.tsx index d7826a7595b..5a0e4c0af16 100644 --- a/client/overview/connection-sucess-notice.tsx +++ b/client/overview/connection-sucess-notice.tsx @@ -15,7 +15,11 @@ const ConnectionSuccessNotice: React.FC = () => { const { accountStatus: { - progressiveOnboarding: { isComplete, isEnabled }, + progressiveOnboarding: { + isEnabled: isPoEnabled, + isComplete: isPoComplete, + }, + status: accountStatus, }, onboardingTestMode, } = wcpaySettings; @@ -41,9 +45,9 @@ const ConnectionSuccessNotice: React.FC = () => { { /* Show dismiss button only at the end of Progressive Onboarding // or at the end of the full KYC flow. */ } - { ! ( isEnabled && ! isComplete ) && } + { ! ( isPoEnabled && ! isPoComplete ) && } confetti - { isEnabled && ! isComplete ? ( + { isPoEnabled && ! isPoComplete ? ( <>

    { __( @@ -60,12 +64,21 @@ const ConnectionSuccessNotice: React.FC = () => { ) : ( <> -

    - { __( - 'Congratulations! Your store has been verified.', - 'woocommerce-payments' - ) } -

    + { accountStatus !== 'complete' ? ( +

    + { __( + 'Congratulations! Your store is being verified.', + 'woocommerce-payments' + ) } +

    + ) : ( +

    + { __( + 'Congratulations! Your store has been verified.', + 'woocommerce-payments' + ) } +

    + ) } ) }
    diff --git a/client/overview/task-list/tasks/dispute-task.tsx b/client/overview/task-list/tasks/dispute-task.tsx index 9df718633f6..ec2d5bd0151 100644 --- a/client/overview/task-list/tasks/dispute-task.tsx +++ b/client/overview/task-list/tasks/dispute-task.tsx @@ -56,13 +56,13 @@ export const getDisputeResolutionTask = ( } ); const history = getHistory(); if ( activeDisputeCount === 1 ) { - // Redirect to the dispute details page if there is only one dispute. - const disputeId = activeDisputes[ 0 ].dispute_id; + // Redirect to the transaction details page if there is only one dispute. + const chargeId = activeDisputes[ 0 ].charge_id; history.push( getAdminUrl( { page: 'wc-admin', - path: '/payments/disputes/details', - id: disputeId, + path: '/payments/transactions/details', + id: chargeId, } ) ); } else { diff --git a/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx b/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx new file mode 100644 index 00000000000..fe3aa20c942 --- /dev/null +++ b/client/payment-details/dispute-details/dispute-awaiting-response-details.tsx @@ -0,0 +1,373 @@ +/** @format **/ + +/** + * External dependencies + */ +import React, { useState, useContext } from 'react'; +import moment from 'moment'; +import { __, sprintf } from '@wordpress/i18n'; +import { backup, edit, lock, arrowRight } from '@wordpress/icons'; +import { useDispatch } from '@wordpress/data'; +import { createInterpolateElement } from '@wordpress/element'; +import { Link } from '@woocommerce/components'; +import { + Button, + Card, + CardBody, + Flex, + FlexItem, + Icon, + Modal, +} from '@wordpress/components'; + +/** + * Internal dependencies + */ +import type { Dispute } from 'wcpay/types/disputes'; +import type { ChargeBillingDetails } from 'wcpay/types/charges'; +import wcpayTracks from 'tracks'; +import { useDisputeAccept } from 'wcpay/data'; +import { getDisputeFeeFormatted, isInquiry } from 'wcpay/disputes/utils'; +import { getAdminUrl } from 'wcpay/utils'; +import DisputeNotice from './dispute-notice'; +import IssuerEvidenceList from './evidence-list'; +import DisputeSummaryRow from './dispute-summary-row'; +import { DisputeSteps, InquirySteps } from './dispute-steps'; +import InlineNotice from 'components/inline-notice'; +import WCPaySettingsContext from 'wcpay/settings/wcpay-settings-context'; +import './style.scss'; + +interface Props { + dispute: Dispute; + customer: ChargeBillingDetails | null; + chargeCreated: number; + orderUrl: string | undefined; +} + +/** + * The lines of text to display in the modal to confirm acceptance / refunding of the dispute / inquiry. + */ +interface ModalLineItem { + icon: JSX.Element; + description: string | JSX.Element; +} + +interface AcceptDisputeProps { + /** + * The label for the button that opens the modal. + */ + acceptButtonLabel: string; + /** + * The event to track when the button that opens the modal is clicked. + */ + acceptButtonTracksEvent: string; + /** + * The title of the modal. + */ + modalTitle: string; + /** + * The lines of text to display in the modal. + */ + modalLines: ModalLineItem[]; + /** + * The label for the primary button in the modal to Accept / Refund the dispute / inquiry. + */ + modalButtonLabel: string; + /** + * The event to track when the primary button in the modal is clicked. + */ + modalButtonTracksEvent: string; +} + +/** + * Disputes and Inquiries have different text for buttons and the modal. + * They also have different icons and tracks events. This function returns the correct props. + * + * @param dispute + */ +function getAcceptDisputeProps( dispute: Dispute ): AcceptDisputeProps { + if ( isInquiry( dispute ) ) { + return { + acceptButtonLabel: __( 'Issue refund', 'woocommerce-payments' ), + acceptButtonTracksEvent: + wcpayTracks.events.DISPUTE_INQUIRY_REFUND_MODAL_VIEW, + modalTitle: __( 'Issue a refund?', 'woocommerce-payments' ), + modalLines: [ + { + icon: , + description: __( + 'Issuing a refund will close the inquiry, returning the amount in question back to the cardholder. No additional fees apply.', + 'woocommerce-payments' + ), + }, + { + icon: , + description: __( + 'You will be taken to the order, where you must complete the refund process manually.', + 'woocommerce-payments' + ), + }, + ], + modalButtonLabel: __( + 'View order to issue refund', + 'woocommerce-payments' + ), + modalButtonTracksEvent: + wcpayTracks.events.DISPUTE_INQUIRY_REFUND_CLICK, + }; + } + + return { + acceptButtonLabel: __( 'Accept dispute', 'woocommerce-payments' ), + acceptButtonTracksEvent: wcpayTracks.events.DISPUTE_ACCEPT_MODAL_VIEW, + modalTitle: __( 'Accept the dispute?', 'woocommerce-payments' ), + modalLines: [ + { + icon: , + description: createInterpolateElement( + sprintf( + /* translators: %s: dispute fee, : emphasis HTML element. */ + __( + 'Accepting the dispute marks it as Lost. The disputed amount and the %s dispute fee will not be returned to you.', + 'woocommerce-payments' + ), + getDisputeFeeFormatted( dispute, true ) ?? '-' + ), + { + em: , + } + ), + }, + { + icon: , + description: __( + 'This action is final and cannot be undone.', + 'woocommerce-payments' + ), + }, + ], + modalButtonLabel: __( 'Accept dispute', 'woocommerce-payments' ), + modalButtonTracksEvent: wcpayTracks.events.DISPUTE_ACCEPT_CLICK, + }; +} + +const DisputeAwaitingResponseDetails: React.FC< Props > = ( { + dispute, + customer, + chargeCreated, + orderUrl, +} ) => { + const { doAccept, isLoading } = useDisputeAccept( dispute ); + const [ isModalOpen, setModalOpen ] = useState( false ); + + const now = moment(); + const dueBy = moment.unix( dispute.evidence_details?.due_by ?? 0 ); + const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) ); + const hasStagedEvidence = dispute.evidence_details?.has_evidence; + const { createErrorNotice } = useDispatch( 'core/notices' ); + + const { + featureFlags: { isDisputeIssuerEvidenceEnabled }, + } = useContext( WCPaySettingsContext ); + + const onModalClose = () => { + setModalOpen( false ); + }; + + const viewOrder = () => { + if ( orderUrl ) { + window.location.href = orderUrl; + return; + } + + createErrorNotice( + __( + 'Unable to view order. Order not found.', + 'woocommerce-payments' + ) + ); + }; + + const disputeAcceptAction = getAcceptDisputeProps( dispute ); + + const challengeButtonDefaultText = isInquiry( dispute ) + ? __( 'Submit evidence', 'woocommerce-payments' ) + : __( 'Challenge dispute', 'woocommerce-payments' ); + + return ( +
    + + + + { hasStagedEvidence && ( + + { __( + `You initiated a challenge to this dispute. Click 'Continue with challenge' to proceed with your draft response.`, + 'woocommerce-payments' + ) } + + ) } + + + + { isInquiry( dispute ) ? ( + + ) : ( + + ) } + + { isDisputeIssuerEvidenceEnabled && ( + + ) } + + { /* Dispute Actions */ } + { +
    + + + + + + + { /** Accept dispute modal */ } + { isModalOpen && ( + +

    + + { __( + 'Before proceeding, please take note of the following:', + 'woocommerce-payments' + ) } + +

    + + { disputeAcceptAction.modalLines.map( + ( line, key ) => ( + + + { line.icon } + + + { line.description } + + + ) + ) } + + + + + +
    + ) } +
    + } +
    +
    +
    + ); +}; + +export default DisputeAwaitingResponseDetails; diff --git a/client/payment-details/dispute-details/dispute-due-by-date.tsx b/client/payment-details/dispute-details/dispute-due-by-date.tsx new file mode 100644 index 00000000000..00c74ee2745 --- /dev/null +++ b/client/payment-details/dispute-details/dispute-due-by-date.tsx @@ -0,0 +1,52 @@ +/** + * External dependencies + */ +import React from 'react'; +import { dateI18n } from '@wordpress/date'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import classNames from 'classnames'; +import moment from 'moment'; + +const DisputeDueByDate: React.FC< { + dueBy: number; +} > = ( { dueBy } ) => { + const daysRemaining = Math.floor( + moment.unix( dueBy ).diff( moment(), 'days', true ) + ); + const respondByDate = dateI18n( + 'M j, Y, g:ia', + moment( dueBy * 1000 ).toISOString() + ); + return ( + + { respondByDate } + 2, + } ) } + > + { daysRemaining > 0 && + sprintf( + // Translators: %d is the number of days left to respond to the dispute. + _n( + '(%d day left to respond)', + '(%d days left to respond)', + daysRemaining, + 'woocommerce-payments' + ), + daysRemaining + ) } + + { daysRemaining === 0 && + __( '(Last day today)', 'woocommerce-payments' ) } + { daysRemaining < 0 && + __( '(Past due)', 'woocommerce-payments' ) } + + + ); +}; + +export default DisputeDueByDate; diff --git a/client/payment-details/dispute-details/dispute-notice.tsx b/client/payment-details/dispute-details/dispute-notice.tsx index 44c3286704b..664bfd7b897 100644 --- a/client/payment-details/dispute-details/dispute-notice.tsx +++ b/client/payment-details/dispute-details/dispute-notice.tsx @@ -5,6 +5,7 @@ */ import React from 'react'; import { __, sprintf } from '@wordpress/i18n'; +import { ExternalLink } from '@wordpress/components'; import { createInterpolateElement } from '@wordpress/element'; /** @@ -18,52 +19,55 @@ import { isInquiry } from 'wcpay/disputes/utils'; interface DisputeNoticeProps { dispute: Dispute; - urgent: boolean; + isUrgent: boolean; } const DisputeNotice: React.FC< DisputeNoticeProps > = ( { dispute, - urgent, + isUrgent, } ) => { - const clientClaim = + const shopperDisputeReason = reasons[ dispute.reason ]?.claim ?? __( 'The cardholder claims this is an unrecognized charge.', 'woocommerce-payments' ); - const noticeText = isInquiry( dispute ) - ? /* translators:
    link to dispute documentation. %s is the clients claim for the dispute, eg "The cardholder claims this is an unrecognized charge." */ - __( - // eslint-disable-next-line max-len - '%s You can challenge their claim if you believe it’s invalid. Not responding will result in an automatic loss. Learn more', - 'woocommerce-payments' - ) - : /* translators: link to dispute documentation. %s is the clients claim for the dispute, eg "The cardholder claims this is an unrecognized charge." */ - __( - // eslint-disable-next-line max-len - '%s Challenge the dispute if you believe the claim is invalid, or accept to forfeit the funds and pay the dispute fee. Non-response will result in an automatic loss. Learn more about responding to disputes', - 'woocommerce-payments' - ); + /* translators: link to dispute documentation. %s is the clients claim for the dispute, eg "The cardholder claims this is an unrecognized charge." */ + let noticeText = __( + '%s Challenge the dispute if you believe the claim is invalid, ' + + 'or accept to forfeit the funds and pay the dispute fee. ' + + 'Non-response will result in an automatic loss. Learn more about responding to disputes', + 'woocommerce-payments' + ); + let learnMoreDocsUrl = + 'https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#responding'; + + if ( isInquiry( dispute ) ) { + /* translators: link to dispute inquiry documentation. %s is the clients claim for the dispute, eg "The cardholder claims this is an unrecognized charge." */ + noticeText = __( + '%s You can challenge their claim if you believe it’s invalid. ' + + 'Not responding will result in an automatic loss. Learn more about payment inquiries', + 'woocommerce-payments' + ); + learnMoreDocsUrl = + 'https://woocommerce.com/document/woopayments/fraud-and-disputes/managing-disputes/#inquiries'; + } return ( - { createInterpolateElement( sprintf( noticeText, clientClaim ), { - a: ( - // eslint-disable-next-line jsx-a11y/anchor-has-content - - ), - strong: , - } ) } + { createInterpolateElement( + sprintf( noticeText, shopperDisputeReason ), + { + a: , + strong: , + } + ) } ); }; diff --git a/client/payment-details/dispute-details/dispute-resolution-footer.tsx b/client/payment-details/dispute-details/dispute-resolution-footer.tsx new file mode 100644 index 00000000000..92d7e0a987c --- /dev/null +++ b/client/payment-details/dispute-details/dispute-resolution-footer.tsx @@ -0,0 +1,449 @@ +/** + * External dependencies + */ +import React from 'react'; +import moment from 'moment'; +import { dateI18n } from '@wordpress/date'; +import { __, sprintf } from '@wordpress/i18n'; +import { Link } from '@woocommerce/components'; +import { createInterpolateElement } from '@wordpress/element'; +import { Button, CardFooter, Flex, FlexItem } from '@wordpress/components'; + +/** + * Internal dependencies + */ +import type { Dispute } from 'wcpay/types/disputes'; +import wcpayTracks from 'tracks'; +import { getAdminUrl } from 'wcpay/utils'; +import { getDisputeFeeFormatted } from 'wcpay/disputes/utils'; +import './style.scss'; + +const DisputeUnderReviewFooter: React.FC< { + dispute: Pick< Dispute, 'id' | 'metadata' | 'status' >; +} > = ( { dispute } ) => { + const submissionDateFormatted = dispute.metadata.__evidence_submitted_at + ? dateI18n( + 'M j, Y', + moment + .unix( + parseInt( dispute.metadata.__evidence_submitted_at, 10 ) + ) + .toISOString() + ) + : '-'; + + return ( + + + + { createInterpolateElement( + sprintf( + /* Translators: %s - formatted date, - link to documentation page */ + __( + 'You submitted evidence for this dispute on %s. The cardholder’s bank is reviewing the case, which can take 60 days or more. You will be alerted when they make their final decision. Learn more about the dispute process.', + 'woocommerce-payments' + ), + submissionDateFormatted + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content -- Link content is provided by createInterpolateElement + + ), + } + ) } + + + + + + + + + ); +}; + +const DisputeWonFooter: React.FC< { + dispute: Pick< Dispute, 'id' | 'metadata' | 'status' >; +} > = ( { dispute } ) => { + const closedDateFormatted = dispute.metadata.__dispute_closed_at + ? dateI18n( + 'M j, Y', + moment + .unix( + parseInt( dispute.metadata.__dispute_closed_at, 10 ) + ) + .toISOString() + ) + : '-'; + + return ( + + + + { createInterpolateElement( + sprintf( + /* Translators: %s - formatted date, - link to documentation page */ + __( + 'Good news! You won this dispute on %s. The disputed amount and the dispute fee have been credited back to your account. Learn more about preventing disputes.', + 'woocommerce-payments' + ), + closedDateFormatted + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content -- Link content is provided by createInterpolateElement + + ), + } + ) } + + + + + + + + + ); +}; + +const DisputeLostFooter: React.FC< { + dispute: Pick< + Dispute, + 'id' | 'metadata' | 'status' | 'balance_transactions' + >; +} > = ( { dispute } ) => { + const isSubmitted = !! dispute.metadata.__evidence_submitted_at; + const isAccepted = dispute.metadata.__closed_by_merchant === '1'; + const disputeFeeFormatted = getDisputeFeeFormatted( dispute, true ) ?? '-'; + + const closedDateFormatted = dispute.metadata.__dispute_closed_at + ? dateI18n( + 'M j, Y', + moment + .unix( + parseInt( dispute.metadata.__dispute_closed_at, 10 ) + ) + .toISOString() + ) + : '-'; + + let messagePrefix = sprintf( + /* Translators: %1$s - formatted date */ + __( + 'This dispute was lost on %1$s due to non-response.', + 'woocommerce-payments' + ), + closedDateFormatted + ); + + if ( isAccepted ) { + messagePrefix = sprintf( + /* Translators: %1$s - formatted date */ + __( + 'This dispute was accepted and lost on %1$s.', + 'woocommerce-payments' + ), + closedDateFormatted + ); + } + + if ( isSubmitted ) { + messagePrefix = sprintf( + /* Translators: %1$s - formatted date */ + __( 'This dispute was lost on %1$s.', 'woocommerce-payments' ), + closedDateFormatted + ); + } + + return ( + + + + { messagePrefix }{ ' ' } + { createInterpolateElement( + sprintf( + /* Translators: %1$s – the formatted dispute fee amount, - link to documentation page */ + __( + 'The %1$s fee has been deducted from your account, and the disputed amount returned to the cardholder. Learn more about preventing disputes.', + 'woocommerce-payments' + ), + disputeFeeFormatted + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content -- Link content is provided by createInterpolateElement + + ), + } + ) } + + + { isSubmitted && ( + + + + + + ) } + + + ); +}; + +const InquiryUnderReviewFooter: React.FC< { + dispute: Pick< Dispute, 'id' | 'metadata' | 'status' >; +} > = ( { dispute } ) => { + const submissionDateFormatted = dispute.metadata.__evidence_submitted_at + ? dateI18n( + 'M j, Y', + moment + .unix( + parseInt( dispute.metadata.__evidence_submitted_at, 10 ) + ) + .toISOString() + ) + : '-'; + + return ( + + + + { createInterpolateElement( + sprintf( + /* Translators: %s - formatted date, - link to documentation page */ + __( + 'You submitted evidence for this inquiry on %s. The cardholder’s bank is reviewing the case, which can take 120 days or more. You will be alerted when they make their final decision. Learn more.', + 'woocommerce-payments' + ), + submissionDateFormatted + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content -- Link content is provided by createInterpolateElement + + ), + } + ) } + + + + + + + + + ); +}; + +const InquiryClosedFooter: React.FC< { + dispute: Pick< Dispute, 'id' | 'metadata' | 'status' >; +} > = ( { dispute } ) => { + const isSubmitted = !! dispute.metadata.__evidence_submitted_at; + const closedDateFormatted = dispute.metadata.__dispute_closed_at + ? dateI18n( + 'M j, Y', + moment + .unix( + parseInt( dispute.metadata.__dispute_closed_at, 10 ) + ) + .toISOString() + ) + : '-'; + + return ( + + + + { createInterpolateElement( + sprintf( + /* Translators: %s - formatted date, - link to documentation page */ + __( + 'This inquiry was closed on %s. Learn more about preventing disputes.', + 'woocommerce-payments' + ), + closedDateFormatted + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content -- Link content is provided by createInterpolateElement + + ), + } + ) } + + + { isSubmitted && ( + + + + + + ) } + + + ); +}; + +const DisputeResolutionFooter: React.FC< { + dispute: Pick< + Dispute, + 'id' | 'metadata' | 'status' | 'balance_transactions' + >; +} > = ( { dispute } ) => { + if ( dispute.status === 'under_review' ) { + return ; + } + if ( dispute.status === 'won' ) { + return ; + } + if ( dispute.status === 'lost' ) { + return ; + } + if ( dispute.status === 'warning_under_review' ) { + return ; + } + if ( dispute.status === 'warning_closed' ) { + return ; + } + + return null; +}; + +export default DisputeResolutionFooter; diff --git a/client/payment-details/dispute-details/dispute-steps.tsx b/client/payment-details/dispute-details/dispute-steps.tsx new file mode 100644 index 00000000000..ab592b52ce3 --- /dev/null +++ b/client/payment-details/dispute-details/dispute-steps.tsx @@ -0,0 +1,278 @@ +/** @format **/ + +/** + * External dependencies + */ +import React from 'react'; +import { __, sprintf } from '@wordpress/i18n'; +import { createInterpolateElement } from '@wordpress/element'; +import { ExternalLink } from '@wordpress/components'; +import { dateI18n } from '@wordpress/date'; +import moment from 'moment'; +import HelpOutlineIcon from 'gridicons/dist/help-outline'; + +/** + * Internal dependencies + */ +import type { Dispute } from 'wcpay/types/disputes'; +import { ChargeBillingDetails } from 'wcpay/types/charges'; +import { formatExplicitCurrency } from 'utils/currency'; +import { ClickTooltip } from 'wcpay/components/tooltip'; +import { getDisputeFeeFormatted } from 'wcpay/disputes/utils'; +import DisputeDueByDate from './dispute-due-by-date'; + +interface Props { + dispute: Dispute; + customer: ChargeBillingDetails | null; + chargeCreated: number; +} + +export const DisputeSteps: React.FC< Props > = ( { + dispute, + customer, + chargeCreated, +} ) => { + let emailLink; + if ( customer?.email ) { + const chargeDate = dateI18n( + 'Y-m-d', + moment( chargeCreated * 1000 ).toISOString() + ); + const disputeDate = dateI18n( + 'Y-m-d', + moment( dispute.created * 1000 ).toISOString() + ); + const emailSubject = sprintf( + // Translators: %1$s is the store name, %2$s is the charge date. + __( + `Problem with your purchase from %1$s on %2$s?`, + 'woocommerce-payments' + ), + wcpaySettings.storeName, + chargeDate + ); + const customerName = customer?.name || ''; + const emailBody = sprintf( + // Translators: %1$s is the customer name, %2$s is the dispute date, %3$s is the dispute amount with currency-code e.g. $15 USD, %4$s is the charge date. + __( + `Hello %1$s,\n\n` + + `We noticed that on %2$s, you disputed a %3$s charge on %4$s. We wanted to contact you to make sure everything was all right with your purchase and see if there's anything else we can do to resolve any problems you might have had.\n\n` + + `Alternatively, if the dispute was a mistake, you can easily withdraw it by calling the number on the back of your card. Thank you so much - we appreciate your business and look forward to working with you.`, + 'woocommerce-payments' + ), + customerName, + disputeDate, + formatExplicitCurrency( dispute.amount, dispute.currency ), + chargeDate + ); + emailLink = `mailto:${ customer.email }?subject=${ encodeURIComponent( + emailSubject + ) }&body=${ encodeURIComponent( emailBody ) }`; + } + + return ( +
    +
    + { __( 'Steps to resolve:', 'woocommerce-payments' ) } +
    +
      +
    1. + { customer?.email + ? createInterpolateElement( + __( + 'Email the customer to identify the issue and work towards a resolution where possible.', + 'woocommerce-payments' + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + } + ) + : __( + 'Email the customer to identify the issue and work towards a resolution where possible.', + 'woocommerce-payments' + ) } +
    2. +
    3. + { createInterpolateElement( + __( + 'Assist the customer in withdrawing their dispute if they agree to do so.', + 'woocommerce-payments' + ), + { + a: ( + + ), + } + ) } +
    4. +
    5. + { createInterpolateElement( + __( + 'Challenge or accept the dispute by .', + 'woocommerce-payments' + ), + { + challengeIcon: ( + } + buttonLabel={ __( + 'Challenge the dispute tooltip', + 'woocommerce-payments' + ) } + content={ __( + "Challenge the dispute if you consider the claim invalid. You'll need to provide evidence to back your claim. Keep in mind that challenging doesn't ensure a resolution in your favor.", + 'woocommerce-payments' + ) } + /> + ), + acceptIcon: ( + } + buttonLabel={ __( + 'Accept the dispute tooltip', + 'woocommerce-payments' + ) } + content={ sprintf( + // Translators: %s is a formatted currency amount, eg $10.00. + __( + `Accepting this dispute will automatically close it. The disputed amount and the %s dispute fee will not be returned to you.`, + 'woocommerce-payments' + ), + getDisputeFeeFormatted( + dispute, + true + ) || '-' + ) } + /> + ), + dueByDate: ( + + ), + } + ) } +
    6. +
    +
    + ); +}; + +export const InquirySteps: React.FC< Props > = ( { + dispute, + customer, + chargeCreated, +} ) => { + let emailLink; + if ( customer?.email ) { + const chargeDate = dateI18n( + 'Y-m-d', + moment( chargeCreated * 1000 ).toISOString() + ); + const disputeDate = dateI18n( + 'Y-m-d', + moment( dispute.created * 1000 ).toISOString() + ); + const emailSubject = sprintf( + // Translators: %1$s is the store name, %2$s is the charge date. + __( + `Problem with your purchase from %1$s on %2$s?`, + 'woocommerce-payments' + ), + wcpaySettings.storeName, + chargeDate + ); + const customerName = customer?.name || ''; + const emailBody = sprintf( + // Translators: %1$s is the customer name, %2$s is the dispute date, %3$s is the dispute amount with currency-code e.g. $15 USD, %4$s is the charge date. + __( + `Hello %1$s,\n\n` + + `We noticed that on %2$s, you disputed a %3$s charge on %4$s. We wanted to contact you to make sure everything was all right with your purchase and see if there's anything else we can do to resolve any problems you might have had.\n\n` + + `Alternatively, if the dispute was a mistake, you can easily withdraw it by calling the number on the back of your card. Thank you so much - we appreciate your business and look forward to working with you.`, + 'woocommerce-payments' + ), + customerName, + disputeDate, + formatExplicitCurrency( dispute.amount, dispute.currency ), + chargeDate + ); + emailLink = `mailto:${ customer.email }?subject=${ encodeURIComponent( + emailSubject + ) }&body=${ encodeURIComponent( emailBody ) }`; + } + + return ( +
    +
    + { __( 'Steps to resolve:', 'woocommerce-payments' ) } +
    +
      +
    1. + { customer?.email + ? createInterpolateElement( + __( + 'Email the customer to identify the issue and work towards a resolution where possible.', + 'woocommerce-payments' + ), + { + a: ( + // eslint-disable-next-line jsx-a11y/anchor-has-content + + ), + } + ) + : __( + 'Email the customer to identify the issue and work towards a resolution where possible.', + 'woocommerce-payments' + ) } +
    2. +
    3. + { createInterpolateElement( + __( + 'Submit evidence or issue a refund by .', + 'woocommerce-payments' + ), + { + submitEvidenceIcon: ( + } + buttonLabel={ __( + 'Submit evidence tooltip', + 'woocommerce-payments' + ) } + content={ createInterpolateElement( + __( + "To submit evidence, provide documentation that supports your case. Keep in mind that submitting evidence doesn't ensure a favorable outcome. If the cardholder agrees to withdraw the inquiry, you'll still need to officially submit your evidence to prevent bank escalation. Learn more", + 'woocommerce-payments' + ), + { + learnMoreLink: ( + + ), + } + ) } + /> + ), + dueByDate: ( + + ), + } + ) } +
    4. +
    +
    + ); +}; diff --git a/client/payment-details/dispute-details/dispute-summary-row.tsx b/client/payment-details/dispute-details/dispute-summary-row.tsx index 160abaeb000..ac6dada265e 100644 --- a/client/payment-details/dispute-details/dispute-summary-row.tsx +++ b/client/payment-details/dispute-details/dispute-summary-row.tsx @@ -6,34 +6,26 @@ import React from 'react'; import moment from 'moment'; import HelpOutlineIcon from 'gridicons/dist/help-outline'; -import { __, _n, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { dateI18n } from '@wordpress/date'; -import classNames from 'classnames'; /** * Internal dependencies */ import type { Dispute } from 'wcpay/types/disputes'; import { HorizontalList } from 'wcpay/components/horizontal-list'; -import { formatCurrency } from 'wcpay/utils/currency'; +import { formatExplicitCurrency } from 'wcpay/utils/currency'; import { reasons } from 'wcpay/disputes/strings'; import { formatStringValue } from 'wcpay/utils'; import { ClickTooltip } from 'wcpay/components/tooltip'; import Paragraphs from 'wcpay/components/paragraphs'; +import DisputeDueByDate from './dispute-due-by-date'; interface Props { dispute: Dispute; - daysRemaining: number; } -const DisputeSummaryRow: React.FC< Props > = ( { dispute, daysRemaining } ) => { - const respondByDate = dispute.evidence_details?.due_by - ? dateI18n( - 'M j, Y, g:ia', - moment( dispute.evidence_details?.due_by * 1000 ).toISOString() - ) - : '–'; - +const DisputeSummaryRow: React.FC< Props > = ( { dispute } ) => { const disputeReason = formatStringValue( reasons[ dispute.reason ]?.display || dispute.reason ); @@ -42,7 +34,7 @@ const DisputeSummaryRow: React.FC< Props > = ( { dispute, daysRemaining } ) => { const columns = [ { title: __( 'Dispute Amount', 'woocommerce-payments' ), - content: formatCurrency( dispute.amount, dispute.currency ), + content: formatExplicitCurrency( dispute.amount, dispute.currency ), }, { title: __( 'Disputed On', 'woocommerce-payments' ), @@ -93,30 +85,7 @@ const DisputeSummaryRow: React.FC< Props > = ( { dispute, daysRemaining } ) => { { title: __( 'Respond By', 'woocommerce-payments' ), content: ( - - { respondByDate } - 2, - } ) } - > - { daysRemaining === 0 - ? __( '(Last day today)', 'woocommerce-payments' ) - : sprintf( - // Translators: %s is the number of days left to respond to the dispute. - _n( - '(%s day left to respond)', - '(%s days left to respond)', - daysRemaining, - 'woocommerce-payments' - ), - daysRemaining - ) } - - + ), }, ]; diff --git a/client/payment-details/dispute-details/evidence-list.tsx b/client/payment-details/dispute-details/evidence-list.tsx index e6610b74206..687bb3630ca 100644 --- a/client/payment-details/dispute-details/evidence-list.tsx +++ b/client/payment-details/dispute-details/evidence-list.tsx @@ -103,14 +103,15 @@ const FileEvidence: React.FC< { }; interface Props { - issuerEvidence: IssuerEvidence | null; + issuerEvidence: IssuerEvidence[] | null; } const IssuerEvidenceList: React.FC< Props > = ( { issuerEvidence } ) => { if ( - ! issuerEvidence || - ! issuerEvidence.file_evidence.length || - ! issuerEvidence.text_evidence + ! issuerEvidence?.some( + ( evidence ) => + evidence.file_evidence.length || evidence.text_evidence + ) ) { return <>; } @@ -122,20 +123,19 @@ const IssuerEvidenceList: React.FC< Props > = ( { issuerEvidence } ) => { initialOpen={ false } >
      - { issuerEvidence.text_evidence && ( -
    • - + { issuerEvidence.map( ( evidence, i ) => ( +
    • + { evidence.text_evidence && ( + + ) } + { evidence.file_evidence.map( ( fileId ) => ( + + ) ) }
    • - ) } - { issuerEvidence.file_evidence.map( - ( fileId: string, i: any ) => ( -
    • - -
    • - ) - ) } + ) ) }
    ); diff --git a/client/payment-details/dispute-details/index.tsx b/client/payment-details/dispute-details/index.tsx deleted file mode 100644 index bb41511d293..00000000000 --- a/client/payment-details/dispute-details/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/** @format **/ - -/** - * External dependencies - */ -import React from 'react'; -import moment from 'moment'; -import { __ } from '@wordpress/i18n'; -import { Card, CardBody } from '@wordpress/components'; -import { edit } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import type { Dispute } from 'wcpay/types/disputes'; -import { isAwaitingResponse } from 'wcpay/disputes/utils'; -import DisputeNotice from './dispute-notice'; -import IssuerEvidenceList from './evidence-list'; -import DisputeSummaryRow from './dispute-summary-row'; -import InlineNotice from 'components/inline-notice'; -import './style.scss'; - -interface DisputeDetailsProps { - dispute: Dispute; -} - -const DisputeDetails: React.FC< DisputeDetailsProps > = ( { dispute } ) => { - const now = moment(); - const dueBy = moment.unix( dispute.evidence_details?.due_by ?? 0 ); - const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) ); - const hasStagedEvidence = dispute.evidence_details?.has_evidence; - - return ( -
    - - - { isAwaitingResponse( dispute.status ) && - countdownDays >= 0 && ( - <> - - { hasStagedEvidence && ( - - { __( - `You initiated a challenge to this dispute. Click 'Continue with challenge' to proceed with your draft response.`, - 'woocommerce-payments' - ) } - - ) } - - - - ) } - - -
    - ); -}; - -export default DisputeDetails; diff --git a/client/payment-details/dispute-details/style.scss b/client/payment-details/dispute-details/style.scss index 628ab098b39..e51559f4fcd 100644 --- a/client/payment-details/dispute-details/style.scss +++ b/client/payment-details/dispute-details/style.scss @@ -17,7 +17,7 @@ } .dispute-summary-row { - margin: 24px 0; + margin-top: 24px; &__response-date { display: flex; @@ -34,6 +34,22 @@ } } } + + &__actions { + display: flex; + justify-content: start; + gap: $grid-unit-10; + margin-top: 24px; + + @media screen and ( max-width: $break-small ) { + flex-direction: column; + + .components-button { + width: 100%; + justify-content: center; + } + } + } } } .dispute-reason-tooltip { @@ -48,6 +64,85 @@ margin-bottom: 8px; } } +.dispute-steps { + margin-top: 24px; + + &__header { + font-weight: 600; + font-size: 14px; + } + &__steps { + list-style-position: inside; + margin: 0; + + > li { + margin: 0; + padding: 16px 10px 16px 4px; + border-bottom: 1px solid $wp-gray-5; + } + + .wcpay-tooltip__content-wrapper > [role='button'] { + margin: 0; + } + + &__response-date { + display: inline-flex; + align-items: center; + gap: var( --grid-unit-05, 4px ); + flex-wrap: wrap; + font-weight: 600; + + &--warning { + color: $wp-yellow-30; + font-weight: 700; + } + &--urgent { + color: $alert-red; + font-weight: 700; + } + } + } +} +.transaction-details-dispute-accept-modal { + max-width: 600px; + + .components-modal__content { + padding-top: $grid-unit-30; + } + + &__icon { + flex-shrink: 0; + padding: 6px; + margin-right: $grid-unit-10; + } + + &__actions { + margin-top: $grid-unit-30; + } +} + +.transaction-details-dispute-footer { + background-color: #f2f4f5; + + &__actions { + flex-shrink: 0; + } + + &--primary { + background-color: $wp-blue-0; + } + + @media screen and ( max-width: $break-small ) { + .components-flex { + flex-direction: column; + align-items: flex-start; + } + + .components-flex-item { + margin: 10px 0; + } + } +} .dispute-evidence { // Override WordPress core PanelBody boxy styles. Ours is more inline content. diff --git a/client/payment-details/dispute-details/test/index.test.tsx b/client/payment-details/dispute-details/test/index.test.tsx deleted file mode 100644 index 9787972fd17..00000000000 --- a/client/payment-details/dispute-details/test/index.test.tsx +++ /dev/null @@ -1,203 +0,0 @@ -/** @format */ -/** - * External dependencies - */ -import { render, screen } from '@testing-library/react'; -import React from 'react'; - -/** - * Internal dependencies - */ -import type { Dispute } from 'wcpay/types/disputes'; -import type { Charge } from 'wcpay/types/charges'; -import DisputeDetails from '..'; - -declare const global: { - wcSettings: { - locale: { - siteLocale: string; - }; - }; - wcpaySettings: { - isSubscriptionsActive: boolean; - zeroDecimalCurrencies: string[]; - currencyData: Record< string, any >; - connect: { - country: string; - }; - featureFlags: { - isAuthAndCaptureEnabled: boolean; - }; - }; -}; - -global.wcpaySettings = { - isSubscriptionsActive: false, - zeroDecimalCurrencies: [], - connect: { - country: 'US', - }, - featureFlags: { - isAuthAndCaptureEnabled: true, - }, - currencyData: { - US: { - code: 'USD', - symbol: '$', - symbolPosition: 'left', - thousandSeparator: ',', - decimalSeparator: '.', - precision: 2, - }, - }, -}; - -interface ChargeWithDisputeRequired extends Charge { - dispute: Dispute; -} - -const getBaseCharge = (): ChargeWithDisputeRequired => - ( { - id: 'ch_38jdHA39KKA', - /* Stripe data comes in seconds, instead of the default Date milliseconds */ - created: Date.parse( 'Sep 19, 2019, 5:24 pm' ) / 1000, - amount: 2000, - amount_refunded: 0, - application_fee_amount: 70, - disputed: true, - dispute: { - id: 'dp_1', - amount: 6800, - charge: 'ch_38jdHA39KKA', - order: null, - balance_transactions: [ - { - amount: -2000, - currency: 'usd', - fee: 1500, - }, - ], - created: 1693453017, - currency: 'usd', - evidence: { - billing_address: '123 test address', - customer_email_address: 'test@email.com', - customer_name: 'Test customer', - shipping_address: '123 test address', - }, - issuer_evidence: null, - evidence_details: { - due_by: 1694303999, - has_evidence: false, - past_due: false, - submission_count: 0, - }, - // issuer_evidence: null, - metadata: [], - payment_intent: 'pi_1', - reason: 'fraudulent', - status: 'needs_response', - } as Dispute, - currency: 'usd', - type: 'charge', - status: 'succeeded', - paid: true, - captured: true, - balance_transaction: { - amount: 2000, - currency: 'usd', - fee: 70, - }, - refunds: { - data: [], - }, - order: { - number: 45981, - url: 'https://somerandomorderurl.com/?edit_order=45981', - }, - billing_details: { - name: 'Customer name', - }, - payment_method_details: { - card: { - brand: 'visa', - last4: '4242', - }, - type: 'card', - }, - outcome: { - risk_level: 'normal', - }, - } as any ); - -describe( 'DisputeDetails', () => { - beforeEach( () => { - // mock Date.now that moment library uses to get current date for testing purposes - Date.now = jest.fn( () => - new Date( '2023-09-08T12:33:37.000Z' ).getTime() - ); - } ); - afterEach( () => { - // roll it back - Date.now = () => new Date().getTime(); - } ); - test( 'correctly renders dispute details', () => { - const charge = getBaseCharge(); - render( ); - - // Expect this warning to be logged to the console - expect( console ).toHaveWarnedWith( - 'List with items prop is deprecated is deprecated and will be removed in version 9.0.0. Note: See ExperimentalList / ExperimentalListItem for the new API that will replace this component in future versions.' - ); - - // Dispute Notice - screen.getByText( - /The cardholder claims this is an unauthorized transaction/, - { ignore: '.a11y-speak-region' } - ); - - // Don't render the staged evidence message - expect( - screen.queryByText( - /You initiated a dispute a challenge to this dispute/, - { ignore: '.a11y-speak-region' } - ) - ).toBeNull(); - - // Dispute Summary Row - expect( - screen.getByText( /Dispute Amount/i ).nextSibling - ).toHaveTextContent( /\$68.00/ ); - expect( - screen.getByText( /Disputed On/i ).nextSibling - ).toHaveTextContent( /Aug 30, 2023/ ); - expect( screen.getByText( /Reason/i ).nextSibling ).toHaveTextContent( - /Transaction unauthorized/ - ); - expect( - screen.getByText( /Respond By/i ).nextSibling - ).toHaveTextContent( /Sep 9, 2023/ ); - } ); - - test( 'correctly renders dispute details for a dispute with staged evidence', () => { - const charge = getBaseCharge(); - charge.dispute.evidence_details = { - has_evidence: true, - due_by: 1694303999, - past_due: false, - submission_count: 0, - }; - - render( ); - - screen.getByText( - /The cardholder claims this is an unauthorized transaction/, - { ignore: '.a11y-speak-region' } - ); - - // Render the staged evidence message - screen.getByText( /You initiated a challenge to this dispute/, { - ignore: '.a11y-speak-region', - } ); - } ); -} ); diff --git a/client/payment-details/order-details/test/__snapshots__/index.test.tsx.snap b/client/payment-details/order-details/test/__snapshots__/index.test.tsx.snap index 8ffb7ce60ec..d02d14d3476 100644 --- a/client/payment-details/order-details/test/__snapshots__/index.test.tsx.snap +++ b/client/payment-details/order-details/test/__snapshots__/index.test.tsx.snap @@ -45,7 +45,7 @@ exports[`Order details page should match the snapshot - Charge without payment i >

    - Fee: + Fees: -$0.00

    diff --git a/client/payment-details/payment-method/affirm/index.js b/client/payment-details/payment-method/affirm/index.js new file mode 100644 index 00000000000..b53a55a0168 --- /dev/null +++ b/client/payment-details/payment-method/affirm/index.js @@ -0,0 +1,91 @@ +/** @format **/ + +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies. + */ +import Detail from '../detail'; + +/** + * Extracts and formats payment method details from a charge. + * + * @param {Object} charge The charge object. + * @return {Object} A flat hash of all necessary values. + */ +const formatPaymentMethodDetails = ( charge ) => { + const { billing_details: billingDetails, payment_method: id } = charge; + + const { name, email, formatted_address: formattedAddress } = billingDetails; + + return { + id, + name, + email, + formattedAddress, + }; +}; + +/** + * Placeholders to display while loading. + */ +const paymentMethodPlaceholders = { + id: 'id placeholder', + name: 'name placeholder', + email: 'email placeholder', + formattedAddress: 'address placeholder', +}; + +const CardDetails = ( { charge = {}, isLoading } ) => { + const details = + charge && charge.payment_method_details + ? formatPaymentMethodDetails( charge ) + : paymentMethodPlaceholders; + + const { id, name, email, formattedAddress } = details; + + return ( +
    +
    + + { !! id ? id : '–' } + +
    + +
    + + { name || '–' } + + + + { email || '–' } + + + + + +
    +
    + ); +}; + +export default CardDetails; diff --git a/client/payment-details/payment-method/afterpay-clearpay/index.js b/client/payment-details/payment-method/afterpay-clearpay/index.js new file mode 100644 index 00000000000..99b6f20dc26 --- /dev/null +++ b/client/payment-details/payment-method/afterpay-clearpay/index.js @@ -0,0 +1,90 @@ +/** @format **/ + +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies. + */ +import Detail from '../detail'; + +/** + * Extracts and formats payment method details from a charge. + * + * @param {Object} charge The charge object. + * @return {Object} A flat hash of all necessary values. + */ +const formatPaymentMethodDetails = ( charge ) => { + const { billing_details: billingDetails, payment_method: id } = charge; + + const { name, email, formatted_address: formattedAddress } = billingDetails; + + return { + id, + name, + email, + formattedAddress, + }; +}; + +/** + * Placeholders to display while loading. + */ +const paymentMethodPlaceholders = { + id: 'id placeholder', + name: 'name placeholder', + email: 'email placeholder', + formattedAddress: 'address placeholder', +}; + +const CardDetails = ( { charge = {}, isLoading } ) => { + const details = + charge && charge.payment_method_details + ? formatPaymentMethodDetails( charge ) + : paymentMethodPlaceholders; + + const { id, name, email, formattedAddress } = details; + + return ( +
    +
    + + { !! id ? id : '–' } + +
    +
    + + { name || '–' } + + + + { email || '–' } + + + + + +
    +
    + ); +}; + +export default CardDetails; diff --git a/client/payment-details/payment-method/card/index.js b/client/payment-details/payment-method/card/index.js index 718e536e092..15893f0e299 100644 --- a/client/payment-details/payment-method/card/index.js +++ b/client/payment-details/payment-method/card/index.js @@ -52,7 +52,9 @@ const formatPaymentMethodDetails = ( charge ) => { ? sprintf( // Translators: %1$s card brand, %2$s card funding (prepaid, credit, etc.). __( '%1$s %2$s card', 'woocommerce-payments' ), - network.charAt( 0 ).toUpperCase() + network.slice( 1 ), // Brand + network === 'jcb' + ? network.toUpperCase() + : network.charAt( 0 ).toUpperCase() + network.slice( 1 ), // Brand fundingTypes[ funding ] ) : undefined; diff --git a/client/payment-details/payment-method/index.js b/client/payment-details/payment-method/index.js index 11fde70c853..984ab87772b 100644 --- a/client/payment-details/payment-method/index.js +++ b/client/payment-details/payment-method/index.js @@ -3,32 +3,38 @@ /** * External dependencies */ -import { __ } from '@wordpress/i18n'; import { Card, CardBody, CardHeader } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; /** * Internal dependencies. */ import Loadable from 'components/loadable'; -import CardDetails from './card'; -import CardPresentDetails from './card-present'; +import AffirmDetails from './affirm'; +import AfterpayClearpayDetails from './afterpay-clearpay'; import BancontactDetails from './bancontact'; import BecsDetails from './becs'; +import CardDetails from './card'; +import CardPresentDetails from './card-present'; import EpsDetails from './eps'; import GiropayDetails from './giropay'; import IdealDetails from './ideal'; +import KlarnaDetails from './klarna'; import P24Details from './p24'; import SepaDetails from './sepa'; import SofortDetails from './sofort'; const detailsComponentMap = { - card: CardDetails, - card_present: CardPresentDetails, + affirm: AffirmDetails, + afterpay_clearpay: AfterpayClearpayDetails, au_becs_debit: BecsDetails, bancontact: BancontactDetails, + card: CardDetails, + card_present: CardPresentDetails, eps: EpsDetails, giropay: GiropayDetails, ideal: IdealDetails, + klarna: KlarnaDetails, p24: P24Details, sepa_debit: SepaDetails, sofort: SofortDetails, diff --git a/client/payment-details/payment-method/klarna/index.js b/client/payment-details/payment-method/klarna/index.js new file mode 100644 index 00000000000..0f580f036c5 --- /dev/null +++ b/client/payment-details/payment-method/klarna/index.js @@ -0,0 +1,131 @@ +/** @format **/ + +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies. + */ +import Detail from '../detail'; + +/** + * Extracts and formats payment method details from a charge. + * + * @param {Object} charge The charge object. + * @return {Object} A flat hash of all necessary values. + */ +const formatPaymentMethodDetails = ( charge ) => { + const { billing_details: billingDetails, payment_method: id } = charge; + + const { + payment_method_category: paymentMethodCategory, + preferred_locale: preferredLocale, + } = charge.payment_method_details.klarna; + + const paymentMethodCategoryTranslations = { + pay_later: __( 'pay_later', 'woocommerce-payments' ), + pay_now: __( 'pay_now', 'woocommerce-payments' ), + pay_with_financing: __( 'pay_with_financing', 'woocommerce-payments' ), + pay_in_installments: __( + 'pay_in_installments', + 'woocommerce-payments' + ), + }; + + const { name, email, formatted_address: formattedAddress } = billingDetails; + + return { + id, + name, + email, + formattedAddress, + paymentMethodCategory: + paymentMethodCategoryTranslations[ paymentMethodCategory ], + preferredLocale, + }; +}; + +/** + * Placeholders to display while loading. + */ +const paymentMethodPlaceholders = { + id: 'id placeholder', + name: 'name placeholder', + email: 'email placeholder', + formattedAddress: 'address placeholder', + paymentMethodCategory: 'category placeholder', + preferredLocale: 'locale placeholder', +}; + +const KlarnaDetails = ( { charge = {}, isLoading } ) => { + const details = charge.payment_method_details + ? formatPaymentMethodDetails( charge ) + : paymentMethodPlaceholders; + + const { + id, + name, + email, + formattedAddress, + paymentMethodCategory, + preferredLocale, + } = details; + + return ( +
    +
    + + { id } + + + + { paymentMethodCategory } + + + + { preferredLocale } + +
    + +
    + + { name } + + + + { email } + + + + + +
    +
    + ); +}; + +export default KlarnaDetails; diff --git a/client/payment-details/summary/index.tsx b/client/payment-details/summary/index.tsx index e75f69f2caf..a5ebb9ebf5d 100644 --- a/client/payment-details/summary/index.tsx +++ b/client/payment-details/summary/index.tsx @@ -5,10 +5,17 @@ */ import { __ } from '@wordpress/i18n'; import { dateI18n } from '@wordpress/date'; -import { Card, CardBody, CardFooter, CardDivider } from '@wordpress/components'; +import { + Card, + CardBody, + CardFooter, + CardDivider, + Flex, +} from '@wordpress/components'; import moment from 'moment'; import React, { useContext } from 'react'; import { createInterpolateElement } from '@wordpress/element'; +import HelpOutlineIcon from 'gridicons/dist/help-outline'; /** * Internal dependencies. @@ -28,6 +35,12 @@ import riskMappings from 'components/risk-level/strings'; import OrderLink from 'components/order-link'; import { formatCurrency, formatExplicitCurrency } from 'utils/currency'; import CustomerLink from 'components/customer-link'; +import { ClickTooltip } from 'components/tooltip'; +import DisputeStatusChip from 'components/dispute-status-chip'; +import { + getDisputeFeeFormatted, + isAwaitingResponse, +} from 'wcpay/disputes/utils'; import { useAuthorization } from 'wcpay/data'; import CaptureAuthorizationButton from 'wcpay/components/capture-authorization-button'; import './style.scss'; @@ -37,7 +50,9 @@ import WCPaySettingsContext from '../../settings/wcpay-settings-context'; import { FraudOutcome } from '../../types/fraud-outcome'; import CancelAuthorizationButton from '../../components/cancel-authorization-button'; import { PaymentIntent } from '../../types/payment-intents'; -import DisputeDetails from '../dispute-details'; +import DisputeAwaitingResponseDetails from '../dispute-details/dispute-awaiting-response-details'; +import DisputeResolutionFooter from '../dispute-details/dispute-resolution-footer'; +import ErrorBoundary from 'components/error-boundary'; declare const window: any; @@ -154,10 +169,7 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { charge.currency && balance.currency !== charge.currency; const { - featureFlags: { - isAuthAndCaptureEnabled, - isDisputeOnTransactionPageEnabled, - }, + featureFlags: { isAuthAndCaptureEnabled }, } = useContext( WCPaySettingsContext ); // We should only fetch the authorization data if the payment is marked for manual capture and it is not already captured. @@ -177,6 +189,20 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { const isFraudOutcomeReview = isOnHoldByFraudTools( charge, paymentIntent ); + const disputeFee = + charge.dispute && getDisputeFeeFormatted( charge.dispute ); + + // Use the balance_transaction fee if available. If not (e.g. authorized but not captured), use the application_fee_amount. + const transactionFee = charge.balance_transaction + ? { + fee: charge.balance_transaction.fee, + currency: charge.balance_transaction.currency, + } + : { + fee: charge.application_fee_amount, + currency: charge.currency, + }; + // WP translation strings are injected into Moment.js for relative time terms, since Moment's own translation library increases the bundle size significantly. moment.updateLocale( 'en', { relativeTime: { @@ -209,12 +235,23 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { { charge.currency || 'USD' } - + { charge.dispute ? ( + + ) : ( + + ) }

    @@ -228,10 +265,17 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { ) : null } { balance.refunded ? (

    - { `${ __( - 'Refunded', - 'woocommerce-payments' - ) }: ` } + { `${ + disputeFee + ? __( + 'Deducted', + 'woocommerce-payments' + ) + : __( + 'Refunded', + 'woocommerce-payments' + ) + }: ` } { formatExplicitCurrency( -balance.refunded, balance.currency @@ -246,13 +290,66 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { placeholder="Fee amount" > { `${ __( - 'Fee', + 'Fees', 'woocommerce-payments' ) }: ` } { formatCurrency( -balance.fee, balance.currency ) } + { disputeFee && ( + } + buttonLabel={ __( + 'Fee breakdown', + 'woocommerce-payments' + ) } + content={ + <> + + + + { formatCurrency( + transactionFee.fee, + transactionFee.currency + ) } + + + + + + { disputeFee } + + + + + + { formatCurrency( + balance.fee, + balance.currency + ) } + + + + } + /> + ) }

    { charge.paydown ? ( @@ -375,9 +472,22 @@ const PaymentDetailsSummary: React.FC< PaymentDetailsSummaryProps > = ( { /> - { isDisputeOnTransactionPageEnabled && charge.dispute && ( - + + { charge.dispute && ( + + { isAwaitingResponse( charge.dispute.status ) ? ( + + ) : ( + + ) } + ) } + { isAuthAndCaptureEnabled && authorization && ! authorization.captured && ( diff --git a/client/payment-details/summary/style.scss b/client/payment-details/summary/style.scss index 7f15b50bfe0..cbe0450fb1e 100755 --- a/client/payment-details/summary/style.scss +++ b/client/payment-details/summary/style.scss @@ -37,13 +37,35 @@ .payment-details-summary__breakdown { p { @include font-size( 14 ); - color: $gray-50; - display: inline-block; + color: $gray-700; + display: inline-flex; margin: 0.25rem 1rem 0 0; + text-transform: uppercase; + font-weight: 600; + font-size: 12px; } p:last-child { margin-right: 0; } + + &__fee-tooltip { + display: flex; + flex-direction: column; + padding: $grid-unit-15; + gap: $grid-unit-10; + font-weight: 400; + font-size: 14px; + + & > *:last-child { + padding-top: $grid-unit-10; + border-top: 1px solid $gray-200; + } + + label { + margin-right: $grid-unit-20; + color: $gray-700; + } + } } .payment-details-summary__fraud-outcome-action { diff --git a/client/payment-details/summary/test/__snapshots__/index.tsx.snap b/client/payment-details/summary/test/__snapshots__/index.test.tsx.snap similarity index 96% rename from client/payment-details/summary/test/__snapshots__/index.tsx.snap rename to client/payment-details/summary/test/__snapshots__/index.test.tsx.snap index 9dd86ba7c81..4a3bcae9a5c 100644 --- a/client/payment-details/summary/test/__snapshots__/index.tsx.snap +++ b/client/payment-details/summary/test/__snapshots__/index.test.tsx.snap @@ -41,7 +41,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders ca >

    - Fee: + Fees: $-0.70

    @@ -144,7 +144,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders ca >
    Customer name @@ -265,7 +265,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders ca this charge within the next 7 days @@ -342,7 +342,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders th >

    - Fee: + Fees: $-0.70

    @@ -461,7 +461,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders th > Customer name @@ -582,7 +582,7 @@ exports[`PaymentDetailsSummary capture notification and fraud buttons renders th this charge within the next 7 days @@ -650,7 +650,7 @@ exports[`PaymentDetailsSummary correctly renders a charge 1`] = ` >

    - Fee: + Fees: $-0.70

    @@ -753,7 +753,7 @@ exports[`PaymentDetailsSummary correctly renders a charge 1`] = ` > Customer name @@ -910,7 +910,7 @@ exports[`PaymentDetailsSummary renders a charge with subscriptions 1`] = ` >

    - Fee: + Fees: $-0.70

    @@ -1013,7 +1013,7 @@ exports[`PaymentDetailsSummary renders a charge with subscriptions 1`] = ` > Customer name @@ -1200,7 +1200,7 @@ exports[`PaymentDetailsSummary renders fully refunded information for a charge 1 $-20.00

    - Fee: + Fees: $-0.70

    @@ -1303,7 +1303,7 @@ exports[`PaymentDetailsSummary renders fully refunded information for a charge 1 > Customer name @@ -1458,7 +1458,7 @@ exports[`PaymentDetailsSummary renders loading state 1`] = ` >

    - Fee: + Fees: $0.00

    @@ -1696,7 +1696,7 @@ exports[`PaymentDetailsSummary renders partially refunded information for a char $-12.00

    - Fee: + Fees: $-0.70

    @@ -1799,7 +1799,7 @@ exports[`PaymentDetailsSummary renders partially refunded information for a char > Customer name @@ -1956,7 +1956,7 @@ exports[`PaymentDetailsSummary renders the Tap to Pay channel from metadata 1`] >

    - Fee: + Fees: $-0.70

    @@ -2059,7 +2059,7 @@ exports[`PaymentDetailsSummary renders the Tap to Pay channel from metadata 1`] > Customer name @@ -2175,7 +2175,7 @@ exports[`PaymentDetailsSummary renders the Tap to Pay channel from metadata 1`]
    `; -exports[`PaymentDetailsSummary renders the information of a disputed charge 1`] = ` +exports[`PaymentDetailsSummary renders the information of a dispute-reversal charge 1`] = `
    - Disputed: Under review + Disputed: Won

    +

    - Refunded: - $-15.00 -

    -

    - Fee: - $-15.70 + Fees: + $-0.70

    Net: - $-10.70 + $19.30

    @@ -2322,7 +2319,7 @@ exports[`PaymentDetailsSummary renders the information of a disputed charge 1`] > Customer name @@ -2421,6 +2418,50 @@ exports[`PaymentDetailsSummary renders the information of a disputed charge 1`]
    + + />
  • , }, Object { - "body": Array [ - - View dispute - , - ], + "body": Array [], "date": 2020-04-02T02:06:14.000Z, "headline": "Payment disputed as Transaction unauthorized.", "icon": , }, Object { - "body": Array [ - - View dispute - , - ], + "body": Array [], "date": 2020-04-02T02:06:14.000Z, "headline": "Payment disputed as Transaction unauthorized.", "icon": , }, Object { - "body": Array [ - - View dispute - , - ], + "body": Array [], "date": 2020-04-02T02:06:14.000Z, "headline": "Payment disputed as Transaction unauthorized.", "icon": ( props @@ -238,14 +239,26 @@ const PaymentMethodInformationObject: Record< ), icon: iconComponent( JCBIcon, 'JCB' ), currencies: [ 'JPY' ], - stripe_key: 'card_payments', + stripe_key: 'jcb_payments', allows_manual_capture: false, allows_pay_later: false, - setup_required: true, - setup_tooltip: __( - 'JCB is coming soon to your country.', + }, + klarna: { + id: 'klarna', + label: __( 'Klarna', 'woocommerce-payments' ), + brandTitles: { + affirm: __( 'Klarna', 'woocommerce-payments' ), + }, + description: __( + // translators: %s is the store currency. + 'Allow customers to pay over time with Klarna. Available to all customers paying in %s.', 'woocommerce-payments' ), + icon: iconComponent( KlarnaIcon, 'Klarna' ), + currencies: [ 'EUR', 'GBP', 'USD' ], + stripe_key: 'klarna_payments', + allows_manual_capture: false, + allows_pay_later: true, }, }; diff --git a/client/payment-methods/capability-request/capability-request-dismiss-modal.tsx b/client/payment-methods/capability-request/capability-request-dismiss-modal.tsx new file mode 100644 index 00000000000..842e7534455 --- /dev/null +++ b/client/payment-methods/capability-request/capability-request-dismiss-modal.tsx @@ -0,0 +1,54 @@ +/** @format */ +/** + * External dependencies + */ +import React from 'react'; +import { __, sprintf } from '@wordpress/i18n'; +import { Button } from '@wordpress/components'; +import { DismissConfirmationModalProps } from './types'; + +/** + * Internal dependencies + */ +import ConfirmationModal from 'wcpay/components/confirmation-modal'; + +const DismissConfirmationModal: React.FC< DismissConfirmationModalProps > = ( { + onClose, + onSubmit, + label, +} ): JSX.Element => { + const buttonContent = ( + <> + + + + ); + + return ( + +

    + { sprintf( + /** translators: %s is the capability label. */ + __( + 'Choosing to continue will remove the option to accept %s cards from your customers. ' + + 'The option to enable %s will not appear again.', + 'woocommerce-payments' + ), + label, + label + ) } +

    +
    + ); +}; +export default DismissConfirmationModal; diff --git a/client/payment-methods/capability-request/capability-request-map.ts b/client/payment-methods/capability-request/capability-request-map.ts new file mode 100644 index 00000000000..3c2cceebce6 --- /dev/null +++ b/client/payment-methods/capability-request/capability-request-map.ts @@ -0,0 +1,58 @@ +/** + * External dependencies + */ +import { __ } from '@wordpress/i18n'; +import { CapabilityRequestMap } from './types'; + +const CapabilityRequestList: Array< CapabilityRequestMap > = [ + { + id: 'jcb', + label: __( 'JCB', 'woocommerce-payments' ), + country: 'JP', + states: { + unrequested: { + status: 'info', + content: __( + 'Enable JCB for your customers, the only international payment brand based in Japan.', + 'woocommerce-payments' + ), + actions: 'request', + actionsLabel: __( 'Enable JCB', 'woocommerce-payments' ), + }, + pending_verification: { + status: 'warning', + content: __( + 'To enable JCB for your customers, you need to provide more information.', + 'woocommerce-payments' + ), + actions: 'link', + actionUrl: + 'https://woocommerce.com/document/woopayments/payment-methods/#jcb', + actionsLabel: __( 'Finish setup', 'woocommerce-payments' ), + }, + pending: { + status: 'info', + content: __( + 'Your information has been submitted and your JCB account is pending approval.', + 'woocommerce-payments' + ), + }, + inactive: { + status: 'info', + content: __( + 'Your JCB account was rejected based on the information provided.', + 'woocommerce-payments' + ), + }, + active: { + status: 'info', + content: __( + 'JCB is now enabled on your store.', + 'woocommerce-payments' + ), + }, + }, + }, +]; + +export default CapabilityRequestList; diff --git a/client/payment-methods/capability-request/capability-request-notice.tsx b/client/payment-methods/capability-request/capability-request-notice.tsx new file mode 100644 index 00000000000..f040825c138 --- /dev/null +++ b/client/payment-methods/capability-request/capability-request-notice.tsx @@ -0,0 +1,182 @@ +/** + * External dependencies + */ +import React from 'react'; +import { __ } from '@wordpress/i18n'; + +import { useGetPaymentMethodStatuses } from 'wcpay/data'; +import { useState } from '@wordpress/element'; +import { upeCapabilityStatuses } from 'wcpay/additional-methods-setup/constants'; +import methodsConfiguration from '../../payment-methods-map'; +import InlineNotice from 'components/inline-notice'; +import { select, useDispatch } from '@wordpress/data'; +import { NAMESPACE, STORE_NAME } from 'wcpay/data/constants'; +import apiFetch from '@wordpress/api-fetch'; +import DismissConfirmationModal from './capability-request-dismiss-modal'; +import { CapabilityNoticeProps } from './types'; +import { Action } from 'wcpay/types/notices'; + +const CapabilityNotice = ( { + id, + label, + country, + states, +}: CapabilityNoticeProps ): JSX.Element | null => { + const { updateOptions } = useDispatch( 'wc/admin/options' ); + const { createNotice } = useDispatch( 'core/notices' ); + const { capabilityRequestNotices } = wcpaySettings; + const [ isDismissed, setIsDismissed ] = useState( + capabilityRequestNotices[ id ] ?? false + ); + const [ isDismissModalOpen, setDismissModalOpen ] = useState( false ); + const [ isLoading, setIsLoading ] = useState( false ); + + const paymentMethodStatuses = useGetPaymentMethodStatuses() as Record< + string, + Record< string, string > + >; + + const validStatuses: Array< string > = Object.entries( + upeCapabilityStatuses + ).map( ( [ value ] ) => { + return value; + } ); + + const settings = select( STORE_NAME ).getSettings() as Record< + string, + any + >; + + // Retrieve the capability status + const stripeKey = methodsConfiguration[ id ].stripe_key ?? null; + const stripeStatusContainer = paymentMethodStatuses[ stripeKey ] ?? []; + const status = ! stripeStatusContainer + ? upeCapabilityStatuses.UNREQUESTED + : stripeStatusContainer.status; + + // Display the notice if the capability has status. + if ( validStatuses.includes( status ) ) { + return null; + } + + // Skip the notice if the country doesn't match. + if ( + typeof country !== 'undefined' && + settings.account_country !== country + ) { + return null; + } + + // If the status data doesnt exist, hide the notice. + const noticeData = states[ status ] ?? null; + if ( ! noticeData ) return null; + + const requestCapability = async () => { + setIsLoading( true ); + + try { + await apiFetch< string >( { + path: `${ NAMESPACE }/settings/request-capability`, + data: { + id: id, + }, + method: 'POST', + } ); + + setIsDismissed( true ); + + createNotice( + 'success', + __( + 'Capability requested successfully!', + 'woocommerce-payments' + ) + ); + + setIsLoading( false ); + } catch ( exception ) { + createNotice( + 'error', + __( 'Error requesting the capability!', 'woocommerce-payments' ) + ); + + setIsLoading( false ); + } + }; + + const moreDetails = () => { + if ( typeof noticeData.actionUrl !== 'undefined' ) { + window.location.href = noticeData.actionUrl; + } + }; + + const closeModal = () => { + setDismissModalOpen( false ); + }; + + const dismissNotice = () => { + updateOptions( { + wcpay_capability_request_dismissed_notices: { + ...capabilityRequestNotices, + [ id ]: true, + }, + } ); + wcpaySettings.capabilityRequestNotices = { + ...capabilityRequestNotices, + [ id ]: true, + }; + + setIsDismissed( true ); + }; + + const dismissModal = () => { + if ( status === 'unrequested' || status === 'pending_verification' ) { + setDismissModalOpen( true ); + } else { + dismissNotice(); + } + }; + + let actions; + if ( noticeData.actions === 'request' || noticeData.actions === 'link' ) { + actions = [ + { + label: noticeData.actionsLabel, + onClick: + noticeData.actions === 'request' + ? requestCapability + : moreDetails, + isBusy: isLoading, + disabled: isLoading, + }, + ]; + } + + if ( isDismissed ) { + return null; + } + + return ( + <> + + { noticeData.content } + + + { isDismissModalOpen && ( + + ) } + + ); +}; + +export default CapabilityNotice; diff --git a/client/payment-methods/capability-request/index.tsx b/client/payment-methods/capability-request/index.tsx new file mode 100644 index 00000000000..541a33ca2f7 --- /dev/null +++ b/client/payment-methods/capability-request/index.tsx @@ -0,0 +1,26 @@ +/** + * External dependencies + */ +import React from 'react'; +import { __ } from '@wordpress/i18n'; + +import CapabilityRequestList from './capability-request-map'; +import CapabilityNotice from './capability-request-notice'; + +const CapabilityRequestNotice = (): JSX.Element => { + return ( + <> + { CapabilityRequestList.map( ( request ) => ( + + ) ) } + + ); +}; + +export default CapabilityRequestNotice; diff --git a/client/payment-methods/capability-request/test/__snapshots__/index.test.js.snap b/client/payment-methods/capability-request/test/__snapshots__/index.test.js.snap new file mode 100644 index 00000000000..341ff4bf104 --- /dev/null +++ b/client/payment-methods/capability-request/test/__snapshots__/index.test.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`CapabilityRequestNotice should match the snapshot - Render UNREQUESTED CapabilityNotice 1`] = ` +
    +
    +
    +
    +
    + Enable JCB for your customers, the only international payment brand based in Japan. +
    + +
    +
    +
    +
    +
    + +
    +
    +`; diff --git a/client/payment-methods/capability-request/test/dismiss-modal.test.js b/client/payment-methods/capability-request/test/dismiss-modal.test.js new file mode 100644 index 00000000000..280a799e82f --- /dev/null +++ b/client/payment-methods/capability-request/test/dismiss-modal.test.js @@ -0,0 +1,36 @@ +/** @format **/ + +/** + * External dependencies + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * Internal dependencies + */ +import DismissConfirmationModal from '../capability-request-dismiss-modal'; + +describe( 'DismissConfirmationModal', () => { + it( 'calls the onClose handler on cancel', async () => { + const handleCloseMock = jest.fn(); + render( ); + + expect( handleCloseMock ).not.toHaveBeenCalled(); + + userEvent.click( screen.getByText( 'Cancel' ) ); + + expect( handleCloseMock ).toHaveBeenCalled(); + } ); + + it( 'calls the onSubmit handler on cancel', async () => { + const handleConfirmMock = jest.fn(); + render( ); + + expect( handleConfirmMock ).not.toHaveBeenCalled(); + + userEvent.click( screen.getByText( 'Yes, continue' ) ); + + expect( handleConfirmMock ).toHaveBeenCalled(); + } ); +} ); diff --git a/client/payment-methods/capability-request/test/index.test.js b/client/payment-methods/capability-request/test/index.test.js new file mode 100644 index 00000000000..93e27151818 --- /dev/null +++ b/client/payment-methods/capability-request/test/index.test.js @@ -0,0 +1,136 @@ +/** + * External dependencies + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { upeCapabilityStatuses } from 'wcpay/additional-methods-setup/constants'; +import { useGetPaymentMethodStatuses } from '../../../data'; +import CapabilityNotice from '../capability-request-notice'; + +const CapabilityRequestListMock = { + id: 'jcb', + label: __( 'JCB', 'woocommerce-payments' ), + country: 'JP', + states: { + unrequested: { + status: 'info', + content: __( + 'Enable JCB for your customers, the only international payment brand based in Japan.', + 'woocommerce-payments' + ), + actions: 'request', + actionsLabel: __( 'Enable JCB', 'woocommerce-payments' ), + }, + }, +}; + +jest.mock( '../../../data', () => ( { + useGetPaymentMethodStatuses: jest.fn(), +} ) ); + +jest.mock( '@wordpress/data', () => ( { + createReduxStore: jest.fn(), + register: jest.fn(), + registerStore: jest.fn(), + combineReducers: jest.fn(), + useSelect: jest.fn().mockReturnValue( {} ), + select: jest.fn().mockReturnValue( { + getSettings: jest.fn().mockReturnValue( { + account_country: 'JP', + } ), + } ), + useDispatch: jest.fn( () => ( { + updateOptions: jest.fn(), + createNotice: jest.fn(), + } ) ), +} ) ); + +jest.mock( '@wordpress/api-fetch', () => jest.fn() ); + +global.wcpaySettings = { + capabilityRequestNotices: [], +}; + +describe( 'CapabilityRequestNotice', () => { + beforeEach( () => { + useGetPaymentMethodStatuses.mockReturnValue( { + jcb_payments: { + status: upeCapabilityStatuses.UNREQUESTED, + requirements: [], + }, + } ); + } ); + + afterEach( () => { + jest.clearAllMocks(); + } ); + + it( 'should match the snapshot - Render UNREQUESTED CapabilityNotice', () => { + const { container } = render( + + ); + + expect( container ).toMatchSnapshot(); + } ); + + it( 'should render content and button - Render CapabilityNotice', () => { + render( + + ); + + expect( + screen.queryByText( /Enable JCB for your customers/, { + ignore: '.a11y-speak-region', + } ) + ).toBeInTheDocument(); + + expect( + screen.queryByRole( 'button', { name: 'Enable JCB' } ) + ).toBeInTheDocument(); + } ); + + it( 'should not render if country is not JP - CapabilityNotice', () => { + render( + + ); + + expect( + screen.queryByRole( 'button', { name: 'Enable JCB' } ) + ).not.toBeInTheDocument(); + } ); + + it( 'should not render if state is unknown - CapabilityNotice', () => { + render( + + ); + + expect( + screen.queryByRole( 'button', { name: 'Enable JCB' } ) + ).not.toBeInTheDocument(); + } ); +} ); diff --git a/client/payment-methods/capability-request/types.ts b/client/payment-methods/capability-request/types.ts new file mode 100644 index 00000000000..bb33f849e14 --- /dev/null +++ b/client/payment-methods/capability-request/types.ts @@ -0,0 +1,32 @@ +/** + * External dependencies + */ +import { Status } from '@wordpress/notices'; + +export interface CapabilityStatus { + status: Status; + content: string; + actions?: string; + actionsLabel?: string; + actionUrl?: string; +} + +export interface CapabilityRequestMap { + id: string; + label: string; + country?: string; + states: Record< string, CapabilityStatus >; +} + +export interface CapabilityNoticeProps { + id: string; + label: string; + country?: string; + states: Record< string, CapabilityStatus >; +} + +export interface DismissConfirmationModalProps { + onClose: () => void; + onSubmit: () => void; + label: string; +} diff --git a/client/payment-methods/constants.ts b/client/payment-methods/constants.ts index 2facf48e83c..1a6794aa44c 100644 --- a/client/payment-methods/constants.ts +++ b/client/payment-methods/constants.ts @@ -11,13 +11,13 @@ enum PAYMENT_METHOD_IDS { CARD = 'card', CARD_PRESENT = 'card_present', EPS = 'eps', + KLARNA = 'klarna', GIROPAY = 'giropay', IDEAL = 'ideal', LINK = 'link', P24 = 'p24', SEPA_DEBIT = 'sepa_debit', SOFORT = 'sofort', - JCB = 'jcb', } // This constant is used for rendering tooltip titles for payment methods in transaction list and details pages. diff --git a/client/payment-methods/index.js b/client/payment-methods/index.js index d47b2ddb9e1..cc009184163 100644 --- a/client/payment-methods/index.js +++ b/client/payment-methods/index.js @@ -39,7 +39,6 @@ import DisableUPEModal from '../settings/disable-upe-modal'; import PaymentMethodsList from 'components/payment-methods-list'; import PaymentMethod from 'components/payment-methods-list/payment-method'; import WCPaySettingsContext from '../settings/wcpay-settings-context'; -import Pill from '../components/pill'; import methodsConfiguration from '../payment-methods-map'; import CardBody from '../settings/card-body'; import { upeCapabilityStatuses } from 'wcpay/additional-methods-setup/constants'; @@ -47,24 +46,36 @@ import ConfirmPaymentMethodActivationModal from './activation-modal'; import ConfirmPaymentMethodDeleteModal from './delete-modal'; import { getAdminUrl } from 'wcpay/utils'; import { getPaymentMethodDescription } from 'wcpay/utils/payment-methods'; +import CapabilityRequestNotice from './capability-request'; import InlineNotice from 'wcpay/components/inline-notice'; -import interpolateComponents from '@automattic/interpolate-components'; const PaymentMethodsDropdownMenu = ( { setOpenModal } ) => { + const { isUpeEnabled, upeType } = useContext( WcPayUpeContext ); + const isDisablePossible = + isUpeEnabled && upeType !== 'deferred_intent_upe_without_fallback'; + const label = isDisablePossible + ? __( 'Add feedback or disable', 'woocommerce-payments' ) + : __( 'Add feedback', 'woocommerce-payments' ); + + const buttons = [ + { + title: __( 'Provide feedback', 'woocommerce-payments' ), + onClick: () => setOpenModal( 'survey' ), + }, + ]; + + if ( isDisablePossible ) { + buttons.push( { + title: 'Disable', + onClick: () => setOpenModal( 'disable' ), + } ); + } + return ( setOpenModal( 'survey' ), - }, - { - title: 'Disable', - onClick: () => setOpenModal( 'disable' ), - }, - ] } + label={ label } + controls={ buttons } /> ); }; @@ -225,6 +236,11 @@ const PaymentMethods = () => { const { isUpeEnabled, status, upeType } = useContext( WcPayUpeContext ); const [ openModalIdentifier, setOpenModalIdentifier ] = useState( '' ); + const rollbackNoticeForLegacyUPE = __( + // eslint-disable-next-line max-len + 'You have been switched from the new checkout to your previous checkout experience. We will keep you posted on the new checkout availability.', + 'woocommerce-payments' + ); return ( <> @@ -260,17 +276,6 @@ const PaymentMethods = () => { 'woocommerce-payments' ) } - { upeType !== 'split' && ( - <> - { ' ' } - - { __( - 'Early access', - 'woocommerce-payments' - ) } - - - ) } { status="warning" isDismissible={ false } > - { interpolateComponents( { - mixedString: __( - 'The new WooPayments checkout experience will become the default on October 11, 2023.' + - ' {{learnMoreLink}}Learn more{{/learnMoreLink}}', - 'woocommerce-payments' - ), - components: { - learnMoreLink: ( - // eslint-disable-next-line max-len - - ), - }, - } ) } + { rollbackNoticeForLegacyUPE } ) } + + { availableMethods.map( ( { diff --git a/client/payment-methods/test/index.js b/client/payment-methods/test/index.js index 5d2ebf69ff9..70023318ec1 100644 --- a/client/payment-methods/test/index.js +++ b/client/payment-methods/test/index.js @@ -6,6 +6,7 @@ import React from 'react'; import { act, render, screen } from '@testing-library/react'; import user from '@testing-library/user-event'; +import { select } from '@wordpress/data'; /** * Internal dependencies @@ -49,6 +50,7 @@ jest.mock( '@wordpress/data', () => ( { useDispatch: jest .fn() .mockReturnValue( { updateAvailablePaymentMethodIds: jest.fn() } ), + select: jest.fn(), } ) ); describe( 'PaymentMethods', () => { @@ -81,8 +83,14 @@ describe( 'PaymentMethods', () => { useManualCapture.mockReturnValue( [ false, jest.fn() ] ); global.wcpaySettings = { accountEmail: 'admin@example.com', + capabilityRequestNotices: {}, }; useAccountDomesticCurrency.mockReturnValue( 'usd' ); + select.mockImplementation( () => ( { + getSettings: jest.fn().mockReturnValue( { + account_country: 'US', + } ), + } ) ); } ); test( 'payment methods are rendered correctly', () => { @@ -424,7 +432,7 @@ describe( 'PaymentMethods', () => { expect( disableUPEButton ).toBeInTheDocument(); expect( screen.queryByText( 'Payment methods' ).parentElement - ).toHaveTextContent( 'Payment methods Early access' ); + ).toHaveTextContent( 'Payment methods' ); } ); test( 'Does not render the feedback elements when UPE is disabled', () => { @@ -484,6 +492,69 @@ describe( 'PaymentMethods', () => { ); } ); + it( 'should only be able to leave feedback when deferred upe after migration is enabled', () => { + render( + + + + ); + const kebabMenuWithFeedbackOnly = screen.queryByRole( 'button', { + name: 'Add feedback', + } ); + + const kebabMenuWithFeedbackAndDisable = screen.queryByRole( 'button', { + name: 'Add feedback or disable', + } ); + + expect( kebabMenuWithFeedbackOnly ).toBeInTheDocument(); + expect( kebabMenuWithFeedbackAndDisable ).not.toBeInTheDocument(); + } ); + + it( 'should only be able to leave feedback and disable when deferred upe was enabled manually for legacy card stores', () => { + render( + + + + ); + const kebabMenuWithFeedbackOnly = screen.queryByRole( 'button', { + name: 'Add feedback', + } ); + + const kebabMenuWithFeedbackAndDisable = screen.queryByRole( 'button', { + name: 'Add feedback or disable', + } ); + + expect( kebabMenuWithFeedbackAndDisable ).toBeInTheDocument(); + expect( kebabMenuWithFeedbackOnly ).not.toBeInTheDocument(); + } ); + + it( 'should be able to leave feedback and disable for non-deferred-upe', () => { + render( + + + + ); + const kebabMenuWithFeedbackOnly = screen.queryByRole( 'button', { + name: 'Add feedback', + } ); + + const kebabMenuWithFeedbackAndDisable = screen.queryByRole( 'button', { + name: 'Add feedback or disable', + } ); + + expect( kebabMenuWithFeedbackAndDisable ).toBeInTheDocument(); + expect( kebabMenuWithFeedbackOnly ).not.toBeInTheDocument(); + } ); + it( 'should render the activation modal when requirements exist for the payment method', () => { useEnabledPaymentMethodIds.mockReturnValue( [ [], jest.fn() ] ); useGetAvailablePaymentMethodIds.mockReturnValue( [ 'bancontact' ] ); diff --git a/client/payment-request/index.js b/client/payment-request/index.js index 8596874a4eb..e9c77679c61 100644 --- a/client/payment-request/index.js +++ b/client/payment-request/index.js @@ -381,14 +381,37 @@ jQuery( ( $ ) => { $.when( wcpayPaymentRequest.getSelectedProductData() ) .then( ( response ) => { - $.when( - paymentRequest.update( { - total: response.total, - displayItems: response.displayItems, - } ) - ).then( () => { + // If a variation doesn't need shipping, re-init the `wcpayPaymentRequest` with response params. + if ( + wcpayPaymentRequestParams.product.needs_shipping !== + response.needs_shipping + ) { + wcpayPaymentRequestParams.product.needs_shipping = + response.needs_shipping; + wcpayPaymentRequestParams.product.total = + response.total; + wcpayPaymentRequestParams.product.displayItems = + response.displayItems; + wcpayPaymentRequest.init(); wcpayPaymentRequest.unblockPaymentRequestButton(); - } ); + } else { + const responseTotal = response.total; + + // If a variation `needs_shipping` is `false`, the `pending` param needs to be set to `false`. + // Because the additional shipping address call is not executed to set the pending to `false`. + if ( response.needs_shipping === false ) { + responseTotal.pending = false; + } + + $.when( + paymentRequest.update( { + total: responseTotal, + displayItems: response.displayItems, + } ) + ).then( () => { + wcpayPaymentRequest.unblockPaymentRequestButton(); + } ); + } } ) .catch( () => { wcpayPaymentRequest.hide(); diff --git a/client/product-details/index.js b/client/product-details/index.js index d26397f6b5e..26ff916efa8 100644 --- a/client/product-details/index.js +++ b/client/product-details/index.js @@ -7,51 +7,124 @@ import { initializeBnplSiteMessaging } from './bnpl-site-messaging'; jQuery( function ( $ ) { + /** + * Check for the existence of the `wcpayStripeSiteMessaging` variable on the window object. + * This variable holds the configuration for Stripe site messaging and contains the following keys: + * - productId: The ID of the product. + * - productVariations: Variations of the product. + * - country: The country of the customer. Defaults to the store's country. + * - publishableKey: The key used for Stripe's API calls. + * - paymentMethods: Enabled BNPL payment methods. + * + * If this variable is not set, the script will exit early to prevent further execution. + */ + if ( ! window.wcpayStripeSiteMessaging ) { + return; + } + + const { productVariations, productId } = window.wcpayStripeSiteMessaging; + const { + amount: baseProductAmount = 0, + currency: productCurrency, + } = productVariations[ productId ]; + const QUANTITY_INPUT_SELECTOR = '.quantity input[type=number]'; + const SINGLE_VARIATION_SELECTOR = '.single_variation_wrap'; + const VARIATIONS_SELECTOR = '.variations'; + const RESET_VARIATIONS_SELECTOR = '.reset_variations'; + const VARIATION_ID_SELECTOR = 'input[name="variation_id"]'; + + const quantityInput = $( QUANTITY_INPUT_SELECTOR ); const bnplPaymentMessageElement = initializeBnplSiteMessaging(); - const { productVariations } = window.wcpayStripeSiteMessaging; - let { productId } = window.wcpayStripeSiteMessaging; + const hasVariations = Object.keys( productVariations ).length > 1; + + /** + * Safely parses a given value to an integer number. + * If the parsed value is NaN, the function returns 0. + * + * @param {string|number} value - The value to be parsed to integer number. + * @return {number} The parsed number, or 0 if the parsed value is NaN. + */ + const parseIntOrReturnZero = ( value ) => { + const result = parseInt( value, 10 ); + return isNaN( result ) ? 0 : result; + }; + + /** + * Updates the BNPL payment message displayed on the page. + * The function takes an amount, a currency, and an optional quantity. + * If the amount is less than or equal to zero, or if the currency is not provided, + * the function will exit early without making updates. + * + * @param {number} amount - The total amount for the BNPL message. + * @param {string} currency - The currency code (e.g., 'USD', 'EUR') for the BNPL message. + * @param {number} [quantity=1] - The quantity of the product being purchased. Defaults to 1. + */ + const updateBnplPaymentMessage = ( amount, currency, quantity = 1 ) => { + const totalAmount = + parseIntOrReturnZero( amount ) * parseIntOrReturnZero( quantity ); + + if ( totalAmount <= 0 || ! currency ) { + return; + } + + bnplPaymentMessageElement.update( { amount: totalAmount, currency } ); + }; + /** + * Resets the BNPL payment message displayed on the page. + * The function updates the BNPL message using the global `baseProductAmount` and the current value + * from `quantityInput` by calling `updateBnplPaymentMessage`. + */ const resetBnplPaymentMessage = () => { - const quantity = $( '.quantity input[type=number]' ).val(); - productId = 'base_product'; - bnplPaymentMessageElement.update( { - amount: - parseInt( productVariations.base_product.amount, 10 ) * - quantity, - currency: productVariations.base_product.currency, - } ); + updateBnplPaymentMessage( + baseProductAmount, + productCurrency, + quantityInput.val() + ); }; - $( '.quantity input[type=number]' ).on( 'change', function ( event ) { - const newQuantity = event.target.value; - const price = productVariations[ productId ].amount; - bnplPaymentMessageElement.update( { - amount: parseInt( price, 10 ) * newQuantity, - currency: productVariations[ productId ].currency, - } ); + // Update BNPL message based on the quantity change + quantityInput.on( 'change', ( event ) => { + let amount = baseProductAmount; + const variationId = $( VARIATION_ID_SELECTOR ).val(); + + // If the product has variations, get the amount from the selected variation. + if ( + hasVariations && + productVariations.hasOwnProperty( variationId ) + ) { + amount = productVariations[ variationId ]?.amount; + } + + updateBnplPaymentMessage( amount, productCurrency, event.target.value ); } ); // Handle BNPL messaging for variable products. - if ( Object.keys( productVariations ).length > 1 ) { - $( '.single_variation_wrap' ).on( 'show_variation', function ( - event, - variation - ) { - const quantity = $( '.quantity input[type=number]' ).val(); - const variationPrice = - productVariations[ variation.variation_id ].amount; - productId = variation.variation_id; - bnplPaymentMessageElement.update( { - amount: parseInt( variationPrice, 10 ) * quantity, - currency: productVariations[ variation.variation_id ].currency, - } ); - } ); + if ( hasVariations ) { + // Update BNPL message based on product variation + $( SINGLE_VARIATION_SELECTOR ).on( + 'show_variation', + ( event, variation ) => { + if ( ! productVariations[ variation.variation_id ] ) { + return; + } + + updateBnplPaymentMessage( + productVariations[ variation.variation_id ].amount, + productCurrency, + quantityInput.val() + ); + } + ); - // If variation is changed back to default, reset BNPL messaging. - $( '.variations' ).on( 'change', function ( event ) { - if ( event.target.value === '' ) resetBnplPaymentMessage(); + // Reset BNPL message if variation is changed back to default + $( VARIATIONS_SELECTOR ).on( 'change', ( event ) => { + if ( event.target.value === '' ) { + resetBnplPaymentMessage(); + } } ); - $( '.reset_variations' ).on( 'click', resetBnplPaymentMessage ); + // Reset BNPL message on variations reset + $( RESET_VARIATIONS_SELECTOR ).on( 'click', resetBnplPaymentMessage ); } } ); diff --git a/client/settings/advanced-settings/wcpay-subscriptions-toggle.js b/client/settings/advanced-settings/wcpay-subscriptions-toggle.js index 0f1f3f2937b..687ffa723b9 100644 --- a/client/settings/advanced-settings/wcpay-subscriptions-toggle.js +++ b/client/settings/advanced-settings/wcpay-subscriptions-toggle.js @@ -35,7 +35,7 @@ const WCPaySubscriptionsToggle = () => { * for wcpay subscriptions or if wcpay subscriptions are already enabled. */ return ! wcpaySettings.isSubscriptionsActive && - ( isWCPaySubscriptionsEligible || isWCPaySubscriptionsEnabled ) ? ( + isWCPaySubscriptionsEligible ? ( { const { saveSettings, isSaving, isLoading, settings } = useSettings(); @@ -23,6 +24,15 @@ const SaveSettingsSection = ( { disabled = false } ) => { initialIsPaymentRequestEnabled, setInitialIsPaymentRequestEnabled, ] = useState( null ); + // Keep the inital value of is_woopay_enabled + // in state for showing the feedback modal on change. + const [ initialIsWooPayEnabled, setInitialIsWooPayEnabled ] = useState( + null + ); + const [ + isWooPayDisableFeedbackOpen, + setIsWooPayDisableFeedbackOpen, + ] = useState( false ); if ( initialIsPaymentRequestEnabled === null && @@ -34,15 +44,26 @@ const SaveSettingsSection = ( { disabled = false } ) => { ); } + if ( + initialIsWooPayEnabled === null && + settings && + typeof settings.is_woopay_enabled !== 'undefined' + ) { + setInitialIsWooPayEnabled( settings.is_woopay_enabled ); + } + const saveOnClick = async () => { const isSuccess = await saveSettings(); + if ( ! isSuccess ) { + return; + } + // Track the event when the value changed and the // settings were successfully saved. if ( - isSuccess && initialIsPaymentRequestEnabled !== - settings.is_payment_request_enabled + settings.is_payment_request_enabled ) { wcpayTracks.recordEvent( wcpayTracks.events.PAYMENT_REQUEST_SETTINGS_CHANGE, @@ -56,6 +77,35 @@ const SaveSettingsSection = ( { disabled = false } ) => { settings.is_payment_request_enabled ); } + + // Show the feedback modal when WooPay is disabled. + if ( initialIsWooPayEnabled && ! settings.is_woopay_enabled ) { + const { woopayLastDisableDate } = wcpaySettings; + + // Do not show feedback modal if WooPay + // was disabled in the last 7 days. + if ( woopayLastDisableDate ) { + const date1 = new Date( woopayLastDisableDate ); + const date2 = new Date(); + const diffTime = Math.abs( date2 - date1 ); + const diffDays = Math.ceil( + diffTime / ( 1000 * 60 * 60 * 24 ) + ); + + if ( diffDays < 7 ) { + return; + } + } + + setIsWooPayDisableFeedbackOpen( true ); + + // Prevent show modal again. + setInitialIsPaymentRequestEnabled( true ); + // Set last disable date to prevent feedback window opening up + // on successive "Save button" clicks. This value is overwritten + // on page refresh. + wcpaySettings.woopayLastDisableDate = new Date(); + } }; return ( @@ -68,6 +118,13 @@ const SaveSettingsSection = ( { disabled = false } ) => { > { __( 'Save changes', 'woocommerce-payments' ) } + { isWooPayDisableFeedbackOpen ? ( + + setIsWooPayDisableFeedbackOpen( false ) + } + /> + ) : null } ); }; diff --git a/client/settings/wcpay-settings-context.js b/client/settings/wcpay-settings-context.js index 0966d6e9c47..531e806ff27 100644 --- a/client/settings/wcpay-settings-context.js +++ b/client/settings/wcpay-settings-context.js @@ -9,7 +9,7 @@ const WCPaySettingsContext = createContext( { accountStatus: {}, featureFlags: { isAuthAndCaptureEnabled: false, - isDisputeOnTransactionPageEnabled: false, + isDisputeIssuerEvidenceEnabled: false, woopay: false, }, } ); diff --git a/client/settings/woopay-disable-feedback/index.js b/client/settings/woopay-disable-feedback/index.js new file mode 100644 index 00000000000..a46f093e888 --- /dev/null +++ b/client/settings/woopay-disable-feedback/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 WooPayIcon from 'assets/images/woopay.svg?asset'; + +const WooPayDisableFeedback = ( { onRequestClose } ) => { + const [ isLoading, setIsLoading ] = useState( true ); + + return ( + + } + isDismissible={ true } + shouldCloseOnClickOutside={ false } // Should be false because of the iframe. + shouldCloseOnEsc={ true } + onRequestClose={ onRequestClose } + className="woopay-disable-feedback" + > + +