diff --git a/.eslintrc b/.eslintrc index 98c80c96018..4b37af1e0ff 100644 --- a/.eslintrc +++ b/.eslintrc @@ -31,7 +31,7 @@ "typescript": {} } }, - "ignorePatterns": [ "docs/rest-api/source/**/*.js" ], + "ignorePatterns": [ "docs/rest-api/source/**/*.js", "playwright-report/**/*.js" ], "rules": { "camelcase": [ "error", diff --git a/assets/css/admin.css b/assets/css/admin.css index 7d120b47f08..38a9de18a9d 100644 --- a/assets/css/admin.css +++ b/assets/css/admin.css @@ -139,6 +139,10 @@ background-image: url( '../images/payment-methods/klarna.svg' ); } +.payment-method__brand--multibanco { + background-image: url( '../images/payment-methods/multibanco-icon.svg' ); +} + .payment-method__brand--grabpay { background-image: url( '../images/payment-methods/grabpay.svg' ); } diff --git a/assets/css/admin.rtl.css b/assets/css/admin.rtl.css index 193b24527f8..0691ba264a0 100644 --- a/assets/css/admin.rtl.css +++ b/assets/css/admin.rtl.css @@ -67,6 +67,10 @@ background-image: url( '../images/cards/visa.svg' ); } +.payment-method__brand--alipay { + background-image: url( '../images/payment-methods/alipay-logo.svg' ); +} + .payment-method__brand--cartes_bancaires { background-image: url( '../images/cards/cartes_bancaires.svg' ); } @@ -135,6 +139,18 @@ background-image: url( '../images/payment-methods/klarna.svg' ); } +.payment-method__brand--multibanco { + background-image: url( '../images/payment-methods/multibanco-icon.svg' ); +} + +.payment-method__brand--grabpay { + background-image: url( '../images/payment-methods/grabpay.svg' ); +} + +.payment-method__brand--wechat_pay { + background-image: url( '../images/payment-methods/wechat-pay.svg' ); +} + .wc_gateways tr[data-gateway_id='woocommerce_payments'] .payment-method__icon { border: 1px solid #ddd; border-radius: 2px; diff --git a/assets/css/success.css b/assets/css/success.css index b014a6d0e44..dce20be305b 100644 --- a/assets/css/success.css +++ b/assets/css/success.css @@ -18,3 +18,169 @@ .wc-payment-gateway-method-logo-wrapper.wc-payment-card-logo img { max-height: 1em; } + +#wc-payment-gateway-multibanco-instructions-container { + /* Default values */ + --woopayments-multibanco-text-color: rgb( 109, 109, 109 ); + --woopayments-multibanco-bg-color: rgba( 109, 109, 109, 0.06 ); + --woopayments-multibanco-border-color: rgba( 109, 109, 109, 0.16 ); + --woopayments-multibanco-card-bg-color: rgb( 255, 255, 255 ); + + display: flex; + justify-content: center; + margin: 2em 0; + background-color: var( --woopayments-multibanco-bg-color ); +} + +#wc-payment-gateway-multibanco-instructions-container .card { + background-color: var( --woopayments-multibanco-card-bg-color ); + border: 1px solid var( --woopayments-multibanco-border-color ); + border-radius: 4px; + padding: 20px; + max-width: 500px; + width: 100%; + margin: 30px 0; +} + +#wc-payment-gateway-multibanco-instructions-container .print-btn { + width: 100%; +} + +#wc-payment-gateway-multibanco-instructions-container .copy-link-btn { + width: 100%; + background: transparent; + border: none; + color: var( --woopayments-multibanco-text-color ); + font-weight: normal; +} + +#wc-payment-gateway-multibanco-instructions-container .copy-link-btn:focus { + outline: none; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-instructions p { + margin-bottom: 0; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-instructions ol { + margin: 0; + padding-left: 20px; +} + +#wc-payment-gateway-multibanco-instructions-container .card-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 24px; +} + +#wc-payment-gateway-multibanco-instructions-container .logo-container { + flex-shrink: 0; + width: 57px; + height: 57px; + padding: 10px; + background-color: #f6f7f7; + border: 1px solid var( --woopayments-multibanco-border-color ); + border-radius: 4px; + box-sizing: border-box; +} + +#wc-payment-gateway-multibanco-instructions-container .logo-container img { + width: 100%; + height: 100%; + object-fit: contain; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-details { + flex-grow: 1; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-header { + font-size: 1.5rem; + font-weight: 400; + line-height: 1.2; + margin-bottom: 5px; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box { + background-color: var( --woopayments-multibanco-bg-color ); + border: 1px solid var( --woopayments-multibanco-border-color ); + border-radius: 4px; + margin: 15px 0; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box-row { + padding: 15px; + display: flex; + justify-content: space-between; + align-items: center; +} + +#wc-payment-gateway-multibanco-instructions-container + .payment-box-row:not( :last-child ) { + border-bottom: 1px solid var( --woopayments-multibanco-border-color ); +} + +#wc-payment-gateway-multibanco-instructions-container i.copy-icon { + display: inline-block; + width: 1.2em; + height: 1.2em; + mask-image: url( '../images/icons/copy.svg' ); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + background-color: currentColor; + vertical-align: middle; + margin-left: 5px; +} + +#wc-payment-gateway-multibanco-instructions-container .copied i.copy-icon { + mask-image: url( '../images/icons/check-green.svg' ); +} + +#wc-payment-gateway-multibanco-instructions-container .badge { + background-color: #fff2d7; + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: #4d3716; + justify-self: start; + width: max-content; + margin-left: 5px; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box-value { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + font-weight: 600; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box-value:focus { + outline: none; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box-value:hover { + background-color: transparent; + opacity: 0.7; + + i { + opacity: 0.7; + } +} + +@media screen and ( max-width: 568px ) { + #wc-payment-gateway-multibanco-instructions-container { + margin: 0; + width: 100%; + background: none; + } +} diff --git a/assets/css/success.rtl.css b/assets/css/success.rtl.css index 811cc09ba1f..42e87b13938 100644 --- a/assets/css/success.rtl.css +++ b/assets/css/success.rtl.css @@ -18,3 +18,169 @@ .wc-payment-gateway-method-logo-wrapper.wc-payment-card-logo img { max-height: 1em; } + +#wc-payment-gateway-multibanco-instructions-container { + /* Default values */ + --woopayments-multibanco-text-color: rgb( 109, 109, 109 ); + --woopayments-multibanco-bg-color: rgba( 109, 109, 109, 0.06 ); + --woopayments-multibanco-border-color: rgba( 109, 109, 109, 0.16 ); + --woopayments-multibanco-card-bg-color: rgb( 255, 255, 255 ); + + display: flex; + justify-content: center; + margin: 2em 0; + background-color: var( --woopayments-multibanco-bg-color ); +} + +#wc-payment-gateway-multibanco-instructions-container .card { + background-color: var( --woopayments-multibanco-card-bg-color ); + border: 1px solid var( --woopayments-multibanco-border-color ); + border-radius: 4px; + padding: 20px; + max-width: 500px; + width: 100%; + margin: 30px 0; +} + +#wc-payment-gateway-multibanco-instructions-container .print-btn { + width: 100%; +} + +#wc-payment-gateway-multibanco-instructions-container .copy-link-btn { + width: 100%; + background: transparent; + border: none; + color: var( --woopayments-multibanco-text-color ); + font-weight: normal; +} + +#wc-payment-gateway-multibanco-instructions-container .copy-link-btn:focus { + outline: none; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-instructions p { + margin-bottom: 0; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-instructions ol { + margin: 0; + padding-right: 20px; +} + +#wc-payment-gateway-multibanco-instructions-container .card-header { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 24px; +} + +#wc-payment-gateway-multibanco-instructions-container .logo-container { + flex-shrink: 0; + width: 57px; + height: 57px; + padding: 10px; + background-color: #f6f7f7; + border: 1px solid var( --woopayments-multibanco-border-color ); + border-radius: 4px; + box-sizing: border-box; +} + +#wc-payment-gateway-multibanco-instructions-container .logo-container img { + width: 100%; + height: 100%; + object-fit: contain; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-details { + flex-grow: 1; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-header { + font-size: 1.5rem; + font-weight: 400; + line-height: 1.2; + margin-bottom: 5px; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box { + background-color: var( --woopayments-multibanco-bg-color ); + border: 1px solid var( --woopayments-multibanco-border-color ); + border-radius: 4px; + margin: 15px 0; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box-row { + padding: 15px; + display: flex; + justify-content: space-between; + align-items: center; +} + +#wc-payment-gateway-multibanco-instructions-container + .payment-box-row:not( :last-child ) { + border-bottom: 1px solid var( --woopayments-multibanco-border-color ); +} + +#wc-payment-gateway-multibanco-instructions-container i.copy-icon { + display: inline-block; + width: 1.2em; + height: 1.2em; + mask-image: url( '../images/icons/copy.svg' ); + mask-size: contain; + mask-repeat: no-repeat; + mask-position: center; + background-color: currentColor; + vertical-align: middle; + margin-right: 5px; +} + +#wc-payment-gateway-multibanco-instructions-container .copied i.copy-icon { + mask-image: url( '../images/icons/check-green.svg' ); +} + +#wc-payment-gateway-multibanco-instructions-container .badge { + background-color: #fff2d7; + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; + font-weight: 400; + line-height: 16px; + color: #4d3716; + justify-self: start; + width: max-content; + margin-left: 5px; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box-value { + background: none; + border: none; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + cursor: pointer; + display: inline-flex; + align-items: center; + font-weight: 600; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box-value:focus { + outline: none; +} + +#wc-payment-gateway-multibanco-instructions-container .payment-box-value:hover { + background-color: transparent; + opacity: 0.7; + + i { + opacity: 0.7; + } +} + +@media screen and ( max-width: 568px ) { + #wc-payment-gateway-multibanco-instructions-container { + margin: 0; + width: 100%; + background: none; + } +} diff --git a/assets/images/payment-methods/alipay-logo.svg b/assets/images/payment-methods/alipay-logo.svg index f9ce0c8b603..5afb06ad1e9 100644 --- a/assets/images/payment-methods/alipay-logo.svg +++ b/assets/images/payment-methods/alipay-logo.svg @@ -1 +1 @@ - + diff --git a/assets/images/payment-methods/multibanco-icon.svg b/assets/images/payment-methods/multibanco-icon.svg new file mode 100644 index 00000000000..3dc26b20f7d --- /dev/null +++ b/assets/images/payment-methods/multibanco-icon.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/images/payment-methods/multibanco-instructions.svg b/assets/images/payment-methods/multibanco-instructions.svg new file mode 100644 index 00000000000..942ab5ec25e --- /dev/null +++ b/assets/images/payment-methods/multibanco-instructions.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/payment-methods/multibanco-logo-dark.svg b/assets/images/payment-methods/multibanco-logo-dark.svg new file mode 100644 index 00000000000..df1adef88f3 --- /dev/null +++ b/assets/images/payment-methods/multibanco-logo-dark.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/payment-methods/multibanco-logo.svg b/assets/images/payment-methods/multibanco-logo.svg new file mode 100644 index 00000000000..cd174df55a3 --- /dev/null +++ b/assets/images/payment-methods/multibanco-logo.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/payment-methods/multibanco.svg b/assets/images/payment-methods/multibanco.svg new file mode 100644 index 00000000000..262cf708366 --- /dev/null +++ b/assets/images/payment-methods/multibanco.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/changelog.txt b/changelog.txt index cb0041d3386..672d1d9d3ef 100644 --- a/changelog.txt +++ b/changelog.txt @@ -1,5 +1,48 @@ *** WooPayments Changelog *** += 9.1.0 - 2025-03-19 = +* Add - Add a prompt to encourage merchants to provide feedback or leave a WordPress.org review for WooPayments +* Add - Add order failure message on Order Received page. +* Add - Add support to change payment method to 3DS card for subscriptions. +* Add - Add WeChat Pay response handling. +* Add - Implement Multibanco payment method +* Add - Implement specific handling for insufficient_balance_for_refund when refunding the admin order management +* Add - Inform Stripe when a store switch from coming soon to live mode +* Add - Show failure reason in details page for failed payout. This will help merchants get better support, or understand the next steps needed to fix the failing payouts. +* Fix - Add an order note when a recurring payment fails or when updating the payment method fails due to a missing or invalid payment token. +* Fix - Bypass enabled at checkout checks for rendering payment methods configs. +* Fix - Correct the dispute notice for Klarna Inquiries +* Fix - fix: GooglePay/ApplePay fail when there are more than 9 shipping options. +* Fix - fix: GooglePay/ApplePay script dependencies with WooCommerce 9.7 +* Fix - Fix issue where survey modal is not scrollable on smaller screen sizes. +* Fix - Fix validation for support phone numbers for Singapore +* Fix - Improve payout failure messages for better clarity and accuracy +* Fix - Inconsitent Safe Mode notice with the latest Jetpack version +* Fix - Init PMME container in cart block so that it can be dynamically rendered once the requirements are met. +* Fix - Make sure that WooPayments gateways follow the main WooPayments card gateway in gateway ordering on the page. +* Fix - Manual capture fails in the transaction detail screen with a customized order number +* Fix - Properly extract styles when using the site editor. +* Fix - Renamed function parameters to avoid reserved keyword conflicts +* Fix - Resolved an issue on stores that had the Stripe Billing feature enabled (US-only) and then changed their store location to an ineligible country. +* Fix - Scoped CSS selectors for WP components to prevent unintended styling on other pages +* Fix - Show Express Checkout button previews in template editor +* Fix - Skip email input search in pay for order flow and use email provided in order data for WooPay iframe. +* Fix - Skip limits per currency check on admin pages +* Fix - Tests: Suppressed unexpected JSON output in maybe_handle_onboarding test by wrapping execution with ob_start() and ob_end_clean() +* Update - Better handling of HTTPs errors in embedded components. +* Update - Change wording for Sales Channel, Online Store, In-Person, and In-Person (POS) +* Update - Enhancements to country select field in onboarding. +* Update - feat: add compatibility notice for Google Pay with live mode accounts. +* Update - Jetpack packages in composer +* Update - Remove the 60 day survey admin note, since it will be redundant after we add the reviews prompt. +* Update - Track action complete event in Stripe Notification embedded component. +* Update - update: tokenize ECE initialization and update flow on pricing change. +* Dev - Add centralized payment method definitions to streamline implementation and maintenance of payment methods. +* Dev - Exclude playwright-report from eslint. +* Dev - Include transaction ID when requesting card reader fee charges summary. +* Dev - Refactors to the embedded compoennts logic. +* Dev - Update @wordpress/icons version to latest version 10.19.0 + = 9.0.0 - 2025-02-26 = * Add - Add E2E tests for currency switching at checkout. * Add - Add GrabPay payment method details to the View Transaction page. diff --git a/client/additional-methods-setup/constants.js b/client/additional-methods-setup/constants.js index a5ca281fe1d..f33be757bbd 100644 --- a/client/additional-methods-setup/constants.js +++ b/client/additional-methods-setup/constants.js @@ -12,6 +12,7 @@ export const upeMethods = [ 'afterpay_clearpay', 'jcb', 'klarna', + 'multibanco', 'grabpay', 'wechat_pay', ]; diff --git a/client/additional-methods-setup/upe-preview-methods-selector/test/add-payment-methods-task.test.js b/client/additional-methods-setup/upe-preview-methods-selector/__tests__/add-payment-methods-task.test.js similarity index 100% rename from client/additional-methods-setup/upe-preview-methods-selector/test/add-payment-methods-task.test.js rename to client/additional-methods-setup/upe-preview-methods-selector/__tests__/add-payment-methods-task.test.js diff --git a/client/additional-methods-setup/upe-preview-methods-selector/test/currency-information-for-methods.test.js b/client/additional-methods-setup/upe-preview-methods-selector/__tests__/currency-information-for-methods.test.js similarity index 100% rename from client/additional-methods-setup/upe-preview-methods-selector/test/currency-information-for-methods.test.js rename to client/additional-methods-setup/upe-preview-methods-selector/__tests__/currency-information-for-methods.test.js diff --git a/client/additional-methods-setup/upe-preview-methods-selector/test/setup-complete-task.test.js b/client/additional-methods-setup/upe-preview-methods-selector/__tests__/setup-complete-task.test.js similarity index 88% rename from client/additional-methods-setup/upe-preview-methods-selector/test/setup-complete-task.test.js rename to client/additional-methods-setup/upe-preview-methods-selector/__tests__/setup-complete-task.test.js index 81ae65f67af..cfb14e78e66 100644 --- a/client/additional-methods-setup/upe-preview-methods-selector/test/setup-complete-task.test.js +++ b/client/additional-methods-setup/upe-preview-methods-selector/__tests__/setup-complete-task.test.js @@ -10,6 +10,7 @@ import WizardTaskContext from '../../wizard/task/context'; import SetupComplete from '../setup-complete-task'; import WizardContext from '../../wizard/wrapper/context'; import { useEnabledPaymentMethodIds } from '../../../data'; +import WCPaySettingsContext from 'wcpay/settings/wcpay-settings-context'; jest.mock( '@wordpress/data', () => ( { useDispatch: jest.fn().mockReturnValue( { updateOptions: jest.fn() } ), @@ -18,16 +19,24 @@ jest.mock( '../../../data', () => ( { useEnabledPaymentMethodIds: jest.fn(), } ) ); +const renderWithSettingsProvider = ( ui ) => + render( + + { ui } + + ); + describe( 'SetupComplete', () => { beforeEach( () => { useEnabledPaymentMethodIds.mockReturnValue( [ [ 'card', 'bancontact', 'eps', 'ideal', 'p24', 'sepa_debit' ], () => null, ] ); + global.wcpaySettings = { featureFlags: { multiCurrency: true } }; } ); it( 'renders setup complete messaging when context value is undefined', () => { - render( + renderWithSettingsProvider( @@ -41,9 +50,11 @@ describe( 'SetupComplete', () => { } ); it( 'renders setup complete messaging when context value is `true`', () => { - render( + renderWithSettingsProvider( @@ -57,7 +68,7 @@ describe( 'SetupComplete', () => { } ); it( 'renders setup complete messaging when context value says that methods have not changed', () => { - render( + renderWithSettingsProvider( { [ 'card', 'ideal' ], () => null, ] ); - render( + renderWithSettingsProvider( { [ 'card', 'ideal' ], () => null, ] ); - render( + renderWithSettingsProvider( { [ 'card', ...additionalMethods ], () => null, ] ); - render( + renderWithSettingsProvider( { useEffect( () => { availablePaymentMethods - .filter( ( method ) => upeMethods.includes( method ) ) + .filter( + ( method ) => paymentMethodsMap[ method ] && method !== 'card' + ) .forEach( ( method ) => { handlePaymentMethodChange( method, false ); } ); @@ -214,7 +216,6 @@ const AddPaymentMethodsTask = () => { const prepareUpePaymentMethods = ( upeMethodIds ) => { return upeMethodIds.map( ( key ) => { - // TODO : fix in https://github.com/Automattic/woocommerce-payments/issues/10182 to remove duplicated logic const { label, currencies } = paymentMethodsMap[ key ]; if ( availablePaymentMethods.includes( key ) ) { @@ -257,10 +258,15 @@ const AddPaymentMethodsTask = () => { } ); }; - const availableBuyNowPayLaterUpeMethods = upeMethods.filter( - ( id ) => - paymentMethodsMap[ id ].allows_pay_later && - availablePaymentMethods.includes( id ) + const availableUpeMethods = availablePaymentMethods.filter( + ( method ) => + paymentMethodsMap[ method ] && + ! paymentMethodsMap[ method ].allows_pay_later && + method !== 'card' // Exclude the card payment method since it's rendered separately + ); + + const availableBuyNowPayLaterUpeMethods = availablePaymentMethods.filter( + ( method ) => paymentMethodsMap[ method ]?.allows_pay_later ); return ( @@ -343,11 +349,7 @@ const AddPaymentMethodsTask = () => { name="card" /> { prepareUpePaymentMethods( - upeMethods.filter( - ( id ) => - ! paymentMethodsMap[ id ] - .allows_pay_later - ) + availableUpeMethods ) } diff --git a/client/additional-methods-setup/upe-preview-methods-selector/currency-information-for-methods.js b/client/additional-methods-setup/upe-preview-methods-selector/currency-information-for-methods.js index b19c1e50281..9639a729c8b 100644 --- a/client/additional-methods-setup/upe-preview-methods-selector/currency-information-for-methods.js +++ b/client/additional-methods-setup/upe-preview-methods-selector/currency-information-for-methods.js @@ -44,7 +44,6 @@ const CurrencyInformationForMethods = ( { selectedMethods } ) => { const paymentMethodInformation = PaymentMethodsMap[ paymentMethod ]; if ( ! paymentMethodInformation ) return; - // TODO : fix in https://github.com/Automattic/woocommerce-payments/issues/10182 to remove duplicated logic let currencies = paymentMethodInformation.currencies || []; if ( paymentMethodInformation.accepts_only_domestic_payment ) { currencies = [ stripeAccountDomesticCurrency ]; diff --git a/client/capital/index.tsx b/client/capital/index.tsx index 2a4afa04ff7..be3e49aa372 100644 --- a/client/capital/index.tsx +++ b/client/capital/index.tsx @@ -25,6 +25,7 @@ import { useLoans } from 'wcpay/data'; import { getAdminUrl } from 'wcpay/utils'; import './style.scss'; import { formatDateTimeFromString } from 'wcpay/utils/date-time'; +import { MaybeShowMerchantFeedbackPrompt } from 'wcpay/merchant-feedback-prompt'; const columns = [ { @@ -209,6 +210,7 @@ const CapitalPage = (): JSX.Element => { return ( + { wcpaySettings.accountLoans.has_active_loan && ( diff --git a/client/capital/test/index.test.tsx b/client/capital/test/index.test.tsx index 41ea917902a..c2a870256dc 100644 --- a/client/capital/test/index.test.tsx +++ b/client/capital/test/index.test.tsx @@ -15,6 +15,13 @@ jest.mock( 'wcpay/data', () => ( { useActiveLoanSummary: jest.fn(), } ) ); +jest.mock( '@woocommerce/data', () => ( { + useUserPreferences: jest.fn( () => ( { + updateUserPreferences: jest.fn(), + wc_payments_wporg_review_2025_prompt_dismissed: false, + } ) ), +} ) ); + declare const global: { wcpaySettings: { zeroDecimalCurrencies: string[]; diff --git a/client/cart/blocks/index.js b/client/cart/blocks/index.js index 1e95ed42e13..d00195c1abe 100644 --- a/client/cart/blocks/index.js +++ b/client/cart/blocks/index.js @@ -2,26 +2,10 @@ * Internal dependencies */ import { renderBNPLCartMessaging } from './product-details'; -import { getUPEConfig } from 'wcpay/utils/checkout'; const { registerPlugin } = window.wp.plugins; -const paymentMethods = getUPEConfig( 'paymentMethodsConfig' ) || {}; - -const BNPL_PAYMENT_METHODS = { - AFFIRM: 'affirm', - AFTERPAY: 'afterpay_clearpay', - KLARNA: 'klarna', -}; - -const bnplPaymentMethods = Object.values( BNPL_PAYMENT_METHODS ).filter( - ( method ) => method in paymentMethods -); - -if ( bnplPaymentMethods.length ) { - // Register BNPL site messaging on the cart block. - registerPlugin( 'bnpl-site-messaging', { - render: renderBNPLCartMessaging, - scope: 'woocommerce-checkout', - } ); -} +registerPlugin( 'bnpl-site-messaging', { + render: renderBNPLCartMessaging, + scope: 'woocommerce-checkout', +} ); diff --git a/client/checkout/api/index.js b/client/checkout/api/index.js index e70765c551b..5d88ecbc42e 100644 --- a/client/checkout/api/index.js +++ b/client/checkout/api/index.js @@ -153,7 +153,6 @@ export default class WCPayAPI { let orderId = partials[ 2 ]; const clientSecret = partials[ 3 ]; const nonce = partials[ 4 ]; - const orderPayIndex = redirectUrl.indexOf( 'order-pay' ); const isOrderPage = orderPayIndex > -1; @@ -211,6 +210,20 @@ export default class WCPayAPI { confirmPaymentOrSetup() // ToDo: Switch to an async function once it works with webpack. .then( ( result ) => { + let paymentError = null; + if ( result.paymentIntent?.last_payment_error ) { + paymentError = { + message: + result.paymentIntent.last_payment_error.message, + }; + } + // If a wallet iframe is closed, Stripe doesn't throw an error, but the intent status will be requires_action. + if ( result.paymentIntent?.status === 'requires_action' ) { + paymentError = { + message: 'Payment requires additional action.', + }; + } + const intentId = ( result.paymentIntent && result.paymentIntent.id ) || ( result.setupIntent && result.setupIntent.id ) || @@ -226,6 +239,8 @@ export default class WCPayAPI { getExpressCheckoutConfig( 'ajax_url' ) ?? getConfig( 'ajaxUrl' ); + const isChangingPayment = getConfig( 'isChangingPayment' ); + const ajaxCall = this.request( ajaxUrl, { action: 'update_order_status', order_id: orderId, @@ -236,13 +251,16 @@ export default class WCPayAPI { should_save_payment_method: shouldSavePaymentMethod ? 'true' : 'false', + is_changing_payment: isChangingPayment + ? 'true' + : 'false', } ); - return [ ajaxCall, result.error ]; + return [ ajaxCall, paymentError, result.error ]; } ) - .then( ( [ verificationCall, originalError ] ) => { - if ( originalError ) { - throw originalError; + .then( ( [ verificationCall, paymentError, resultError ] ) => { + if ( resultError ) { + throw resultError; } return verificationCall.then( ( response ) => { @@ -255,6 +273,10 @@ export default class WCPayAPI { throw result.error; } + if ( paymentError ) { + throw paymentError; + } + return result.return_url; } ); } ) diff --git a/client/checkout/blocks/hooks.js b/client/checkout/blocks/hooks.js index 93aa91557f8..76b24cfeab8 100644 --- a/client/checkout/blocks/hooks.js +++ b/client/checkout/blocks/hooks.js @@ -14,8 +14,6 @@ import { export const usePaymentCompleteHandler = ( api, - stripe, - elements, onCheckoutSuccess, emitResponse, shouldSavePayment @@ -33,7 +31,7 @@ export const usePaymentCompleteHandler = ( ), // not sure if we need to disable this, but kept it as-is to ensure nothing breaks. Please consider passing all the deps. // eslint-disable-next-line react-hooks/exhaustive-deps - [ elements, stripe, api, shouldSavePayment ] + [ api, shouldSavePayment ] ); }; diff --git a/client/checkout/blocks/index.js b/client/checkout/blocks/index.js index 190b1c605ae..3f9a949d46d 100644 --- a/client/checkout/blocks/index.js +++ b/client/checkout/blocks/index.js @@ -41,6 +41,7 @@ import { PAYMENT_METHOD_NAME_AFFIRM, PAYMENT_METHOD_NAME_AFTERPAY, PAYMENT_METHOD_NAME_KLARNA, + PAYMENT_METHOD_NAME_MULTIBANCO, PAYMENT_METHOD_NAME_GRABPAY, PAYMENT_METHOD_NAME_WECHAT_PAY, } from '../constants.js'; @@ -65,6 +66,7 @@ const upeMethods = { affirm: PAYMENT_METHOD_NAME_AFFIRM, afterpay_clearpay: PAYMENT_METHOD_NAME_AFTERPAY, klarna: PAYMENT_METHOD_NAME_KLARNA, + multibanco: PAYMENT_METHOD_NAME_MULTIBANCO, grabpay: PAYMENT_METHOD_NAME_GRABPAY, wechat_pay: PAYMENT_METHOD_NAME_WECHAT_PAY, }; diff --git a/client/checkout/blocks/payment-elements.js b/client/checkout/blocks/payment-elements.js index f782ba13545..3d89aec45bc 100644 --- a/client/checkout/blocks/payment-elements.js +++ b/client/checkout/blocks/payment-elements.js @@ -1,7 +1,7 @@ /** * External dependencies */ -import { useEffect, useState, RawHTML } from '@wordpress/element'; +import { useEffect, useState, RawHTML, useRef } from '@wordpress/element'; import { Elements } from '@stripe/react-stripe-js'; // eslint-disable-next-line import/no-unresolved import { StoreNotice } from '@woocommerce/blocks-checkout'; @@ -20,6 +20,7 @@ import { getPaymentMethodTypes } from 'wcpay/checkout/utils/upe'; const PaymentElements = ( { api, ...props } ) => { const stripeForUPE = useStripeForUPE( api, props.paymentMethodId ); + const containerRef = useRef( null ); const [ errorMessage, setErrorMessage ] = useState( null ); const [ @@ -29,7 +30,8 @@ const PaymentElements = ( { api, ...props } ) => { const [ appearance, setAppearance ] = useState( getUPEConfig( 'wcBlocksUPEAppearance' ) ); - const [ fontRules ] = useState( getFontRulesFromPage() ); + const [ fontRules, setFontRules ] = useState( [] ); + const [ fingerprint, fingerprintErrorMessage ] = useFingerprint(); const amount = Number( getUPEConfig( 'cartTotal' ) ); const currency = getUPEConfig( 'currency' ).toLowerCase(); @@ -37,8 +39,18 @@ const PaymentElements = ( { api, ...props } ) => { useEffect( () => { async function generateUPEAppearance() { + if ( ! containerRef.current ) { + return; + } + setFontRules( + getFontRulesFromPage( containerRef.current.ownerDocument ) + ); // Generate UPE input styles. - let upeAppearance = getAppearance( 'blocks_checkout', false ); + let upeAppearance = getAppearance( + 'blocks_checkout', + false, + containerRef.current.ownerDocument + ); upeAppearance = await api.saveUPEAppearance( upeAppearance, 'blocks_checkout' @@ -61,46 +73,48 @@ const PaymentElements = ( { api, ...props } ) => { props.paymentMethodId, ] ); - if ( ! stripeForUPE ) { - return ; - } - return ( - - + - { paymentProcessorLoadErrorMessage?.error?.message && ( -
- - - { - paymentProcessorLoadErrorMessage.error - .message - } - - -
- ) } - -
-
+ + { paymentProcessorLoadErrorMessage?.error?.message && ( +
+ + + { + paymentProcessorLoadErrorMessage.error + .message + } + + +
+ ) } + +
+ +
+ ); }; diff --git a/client/checkout/blocks/payment-method-label.js b/client/checkout/blocks/payment-method-label.js index ecc339b0a32..7de075a51de 100644 --- a/client/checkout/blocks/payment-method-label.js +++ b/client/checkout/blocks/payment-method-label.js @@ -15,7 +15,7 @@ import { useStripeForUPE } from 'wcpay/hooks/use-stripe-async'; import { getUPEConfig } from 'wcpay/utils/checkout'; import { __ } from '@wordpress/i18n'; import './style.scss'; -import { useEffect, useMemo, useState } from '@wordpress/element'; +import { useEffect, useState, useRef } from '@wordpress/element'; import { getAppearance, getFontRulesFromPage } from 'wcpay/checkout/upe-styles'; const paymentMethods = [ @@ -78,6 +78,7 @@ const PaymentMethodMessageWrapper = ( { }; export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { + const containerRef = useRef( null ); const cartData = wp.data.select( 'wc/store/cart' ).getCartData(); const isTestMode = getUPEConfig( 'testMode' ); const [ appearance, setAppearance ] = useState( @@ -88,7 +89,7 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { getUPEConfig( 'wcBlocksUPEAppearanceTheme' ) ); - const fontRules = useMemo( () => getFontRulesFromPage(), [] ); + const [ fontRules, setFontRules ] = useState( [] ); // Stripe expects the amount to be sent as the minor unit of 2 digits. const amount = parseInt( @@ -107,8 +108,18 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { useEffect( () => { async function generateUPEAppearance() { + if ( ! containerRef.current ) { + return; + } + setFontRules( + getFontRulesFromPage( containerRef.current.ownerDocument ) + ); // Generate UPE input styles. - let upeAppearance = getAppearance( 'blocks_checkout', false ); + let upeAppearance = getAppearance( + 'blocks_checkout', + false, + containerRef.current.ownerDocument + ); upeAppearance = await api.saveUPEAppearance( upeAppearance, 'blocks_checkout' @@ -130,7 +141,7 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { return ( <> -
+
{ title } { isTestMode && ( diff --git a/client/checkout/blocks/payment-processor.js b/client/checkout/blocks/payment-processor.js index 952470aa46b..44c9613691c 100644 --- a/client/checkout/blocks/payment-processor.js +++ b/client/checkout/blocks/payment-processor.js @@ -1,11 +1,7 @@ /** * External dependencies */ -import { - PaymentElement, - useElements, - useStripe, -} from '@stripe/react-stripe-js'; +import { PaymentElement, useElements } from '@stripe/react-stripe-js'; import { getPaymentMethods, // eslint-disable-next-line import/no-unresolved @@ -70,7 +66,6 @@ const PaymentProcessor = ( { onLoadError = noop, theme, } ) => { - const stripe = useStripe(); const elements = useElements(); const hasLoadErrorRef = useRef( false ); const linkCleanupRef = useRef( null ); @@ -269,8 +264,6 @@ const PaymentProcessor = ( { usePaymentCompleteHandler( api, - stripe, - elements, onCheckoutSuccess, emitResponse, shouldSavePayment diff --git a/client/checkout/blocks/saved-token-handler.js b/client/checkout/blocks/saved-token-handler.js index 3d12595d8e6..c5234c1f5c8 100644 --- a/client/checkout/blocks/saved-token-handler.js +++ b/client/checkout/blocks/saved-token-handler.js @@ -8,8 +8,6 @@ import { removeLinkButton } from '../stripe-link'; export const SavedTokenHandler = ( { api, - stripe, - elements, eventRegistration: { onPaymentSetup, onCheckoutSuccess }, emitResponse, } ) => { @@ -38,8 +36,6 @@ export const SavedTokenHandler = ( { // Once the server has completed payment processing, confirm the intent of necessary. usePaymentCompleteHandler( api, - stripe, - elements, onCheckoutSuccess, emitResponse, false // No need to save a payment that has already been saved. diff --git a/client/checkout/constants.js b/client/checkout/constants.js index ba573bfbd3f..588e74c5885 100644 --- a/client/checkout/constants.js +++ b/client/checkout/constants.js @@ -12,6 +12,7 @@ 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_MULTIBANCO = 'woocommerce_payments_multibanco'; export const PAYMENT_METHOD_NAME_GRABPAY = 'woocommerce_payments_grabpay'; export const PAYMENT_METHOD_NAME_WECHAT_PAY = 'woocommerce_payments_wechat_pay'; export const PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT = @@ -36,6 +37,7 @@ export function getPaymentMethodsConstants() { PAYMENT_METHOD_NAME_AFTERPAY, PAYMENT_METHOD_NAME_CARD, PAYMENT_METHOD_NAME_KLARNA, + PAYMENT_METHOD_NAME_MULTIBANCO, PAYMENT_METHOD_NAME_GRABPAY, PAYMENT_METHOD_NAME_WECHAT_PAY, ]; diff --git a/client/checkout/upe-styles/index.js b/client/checkout/upe-styles/index.js index 502cf370382..440c55fc5c6 100644 --- a/client/checkout/upe-styles/index.js +++ b/client/checkout/upe-styles/index.js @@ -50,7 +50,7 @@ export const appearanceSelectors = { pmmeRelativeTextSizeSelector: '.wc_payment_method > label', }, blocksCheckout: { - appendTarget: '#contact-fields', + appendTarget: '.wc-block-checkout__contact-fields', upeThemeInputSelector: '.wc-block-components-text-input #email', upeThemeLabelSelector: '.wc-block-components-text-input label', upeThemeTextSelectors: [ @@ -176,16 +176,17 @@ export const appearanceSelectors = { * Update selectors to use alternate if not present on DOM. * * @param {Object} selectors Object of selectors for updation. + * @param {Object} scope The document scope to search in. * * @return {Object} Updated selectors. */ - updateSelectors: function ( selectors ) { + updateSelectors: function ( selectors, scope ) { if ( selectors.hasOwnProperty( 'alternateSelectors' ) ) { Object.entries( selectors.alternateSelectors ).forEach( ( altSelector ) => { const [ key, value ] = altSelector; - if ( ! document.querySelector( selectors[ key ] ) ) { + if ( ! scope.querySelector( selectors[ key ] ) ) { selectors[ key ] = value; } } @@ -201,10 +202,11 @@ export const appearanceSelectors = { * Returns selectors based on checkout type. * * @param {boolean} elementsLocation The location of the elements. + * @param {Object} scope The document scope to search in. * * @return {Object} Selectors for checkout type specified. */ - getSelectors: function ( elementsLocation ) { + getSelectors: function ( elementsLocation, scope ) { let appearanceSelector = this.blocksCheckout; switch ( elementsLocation ) { @@ -230,7 +232,7 @@ export const appearanceSelectors = { return { ...this.default, - ...this.updateSelectors( appearanceSelector ), + ...this.updateSelectors( appearanceSelector, scope ), }; }, }; @@ -240,11 +242,12 @@ const hiddenElementsForUPE = { * Create hidden container for generating UPE styles. * * @param {string} elementID ID of element to create. + * @param {Object} scope The document scope to search in. * * @return {Object} Object of the created hidden container element. */ - getHiddenContainer: function ( elementID ) { - const hiddenDiv = document.createElement( 'div' ); + getHiddenContainer: function ( elementID, scope ) { + const hiddenDiv = scope.createElement( 'div' ); hiddenDiv.setAttribute( 'id', this.getIDFromSelector( elementID ) ); hiddenDiv.style.border = 0; hiddenDiv.style.clip = 'rect(0 0 0 0)'; @@ -261,12 +264,13 @@ const hiddenElementsForUPE = { * Create invalid element row for generating UPE styles. * * @param {string} elementType Type of element to create. - * @param {Array} classes Array of classes to be added to the element. Default: empty array. + * @param {Array} classes Array of classes to be added to the element. Default: empty array. + * @param {Object} scope The document scope to search in. * * @return {Object} Object of the created invalid row element. */ - createRow: function ( elementType, classes = [] ) { - const newRow = document.createElement( elementType ); + createRow: function ( elementType, classes = [], scope ) { + const newRow = scope.createElement( elementType ); if ( classes.length ) { newRow.classList.add( ...classes ); } @@ -276,12 +280,18 @@ const hiddenElementsForUPE = { /** * Append elements to target container. * - * @param {Object} appendTarget Element object where clone should be appended. + * @param {Object} appendTarget Element object where clone should be appended. * @param {string} elementToClone Selector of the element to be cloned. - * @param {string} newElementID Selector for the cloned element. + * @param {string} newElementID Selector for the cloned element. + * @param {Object} scope The document scope to search in. */ - appendClone: function ( appendTarget, elementToClone, newElementID ) { - const cloneTarget = document.querySelector( elementToClone ); + appendClone: function ( + appendTarget, + elementToClone, + newElementID, + scope + ) { + const cloneTarget = scope.querySelector( elementToClone ); if ( cloneTarget ) { const clone = cloneTarget.cloneNode( true ); clone.id = this.getIDFromSelector( newElementID ); @@ -309,11 +319,12 @@ const hiddenElementsForUPE = { * Initialize hidden fields to generate UPE styles. * * @param {boolean} elementsLocation The location of the elements. + * @param {Object} scope The scope of the elements. */ - init: function ( elementsLocation ) { + init: function ( elementsLocation, scope ) { const selectors = appearanceSelectors.getSelectors( elementsLocation ), - appendTarget = document.querySelector( selectors.appendTarget ), - elementToClone = document.querySelector( + appendTarget = scope.querySelector( selectors.appendTarget ), + elementToClone = scope.querySelector( selectors.upeThemeInputSelector ); @@ -323,70 +334,77 @@ const hiddenElementsForUPE = { } // Remove hidden container is already present on DOM. - if ( document.querySelector( selectors.hiddenContainer ) ) { - this.cleanup(); + if ( scope.querySelector( selectors.hiddenContainer ) ) { + this.cleanup( scope ); } // Create hidden container & append to target. const hiddenContainer = this.getHiddenContainer( - selectors.hiddenContainer + selectors.hiddenContainer, + scope ); appendTarget.appendChild( hiddenContainer ); // Create hidden valid row & append to hidden container. const hiddenValidRow = this.createRow( selectors.rowElement, - selectors.validClasses + selectors.validClasses, + scope ); hiddenContainer.appendChild( hiddenValidRow ); // Create hidden invalid row & append to hidden container. const hiddenInvalidRow = this.createRow( selectors.rowElement, - selectors.invalidClasses + selectors.invalidClasses, + scope ); hiddenContainer.appendChild( hiddenInvalidRow ); - // Clone & append target input to hidden valid row. + // Clone & append target input to hidden valid row. this.appendClone( hiddenValidRow, selectors.upeThemeInputSelector, - selectors.hiddenInput + selectors.hiddenInput, + scope ); // Clone & append target label to hidden valid row. this.appendClone( hiddenValidRow, selectors.upeThemeLabelSelector, - selectors.hiddenValidActiveLabel + selectors.hiddenValidActiveLabel, + scope ); - // Clone & append target input to hidden invalid row. + // Clone & append target input to hidden invalid row. this.appendClone( hiddenInvalidRow, selectors.upeThemeInputSelector, - selectors.hiddenInvalidInput + selectors.hiddenInvalidInput, + scope ); // Clone & append target label to hidden invalid row. this.appendClone( hiddenInvalidRow, selectors.upeThemeLabelSelector, - selectors.hiddenInvalidInput + selectors.hiddenInvalidInput, + scope ); // Remove transitions & focus on hidden element. - const wcpayHiddenInput = document.querySelector( - selectors.hiddenInput - ); + const wcpayHiddenInput = scope.querySelector( selectors.hiddenInput ); wcpayHiddenInput.style.transition = 'none'; }, /** - * Remove hidden container from DROM. + * Remove hidden container from DOM. + * + * @param {Object} scope The scope of the elements. */ - cleanup: function () { - const element = document.querySelector( + cleanup: function ( scope ) { + const element = scope.querySelector( appearanceSelectors.default.hiddenContainer ); if ( element ) { @@ -398,17 +416,20 @@ const hiddenElementsForUPE = { export const getFieldStyles = ( selector, upeElement, - backgroundColor = null + backgroundColor = null, + scope ) => { - if ( ! document.querySelector( selector ) ) { + if ( ! scope.querySelector( selector ) ) { return {}; } + const windowObject = scope.defaultView || window; + const validProperties = upeRestrictedProperties[ upeElement ]; - const elem = document.querySelector( selector ); + const elem = scope.querySelector( selector ); - const styles = window.getComputedStyle( elem ); + const styles = windowObject.getComputedStyle( elem ); const filteredStyles = {}; for ( let i = 0; i < styles.length; i++ ) { @@ -455,9 +476,9 @@ export const getFieldStyles = ( return filteredStyles; }; -export const getFontRulesFromPage = () => { +export const getFontRulesFromPage = ( scope = document ) => { const fontRules = [], - sheets = document.styleSheets, + sheets = scope.styleSheets, fontDomains = [ 'fonts.googleapis.com', 'fonts.gstatic.com', @@ -485,13 +506,15 @@ export const getFontRulesFromPage = () => { * @param {string} selector Selector of the element to be checked. * @param {string} fontSize Pre-computed font size. * @param {number} percentage Percentage (0-1) to be used relative to the font size of the target element. + * @param {Object} scope The scope of the elements. * * @return {string} Font size of the element. */ function ensureFontSizeSmallerThan( selector, fontSize, - percentage = PMME_RELATIVE_TEXT_SIZE + percentage = PMME_RELATIVE_TEXT_SIZE, + scope ) { const fontSizeNumber = parseFloat( fontSize ); @@ -500,7 +523,7 @@ function ensureFontSizeSmallerThan( } // If the element is not found, return the font size number multiplied by the percentage. - const elem = document.querySelector( selector ); + const elem = scope.querySelector( selector ); if ( ! elem ) { return `${ fontSizeNumber * percentage }px`; } @@ -520,21 +543,37 @@ function ensureFontSizeSmallerThan( return `${ fontSizeNumber }px`; } -export const getAppearance = ( elementsLocation, forWooPay = false ) => { - const selectors = appearanceSelectors.getSelectors( elementsLocation ); +export const getAppearance = ( + elementsLocation, + forWooPay = false, + scope = document +) => { + const selectors = appearanceSelectors.getSelectors( + elementsLocation, + scope + ); // Add hidden fields to DOM for generating styles. - hiddenElementsForUPE.init( elementsLocation ); + hiddenElementsForUPE.init( elementsLocation, scope ); - const inputRules = getFieldStyles( selectors.hiddenInput, '.Input' ); + const inputRules = getFieldStyles( + selectors.hiddenInput, + '.Input', + null, + scope + ); const inputInvalidRules = getFieldStyles( selectors.hiddenInvalidInput, - '.Input' + '.Input', + null, + scope ); const labelRules = getFieldStyles( selectors.upeThemeLabelSelector, - '.Label' + '.Label', + null, + scope ); const labelRestingRules = { @@ -543,13 +582,22 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { const paragraphRules = getFieldStyles( selectors.upeThemeTextSelectors, - '.Text' + '.Text', + null, + scope ); - const tabRules = getFieldStyles( selectors.upeThemeInputSelector, '.Tab' ); + const tabRules = getFieldStyles( + selectors.upeThemeInputSelector, + '.Tab', + null, + scope + ); const selectedTabRules = getFieldStyles( selectors.hiddenInput, - '.Tab--selected' + '.Tab--selected', + null, + scope ); const tabHoverRules = generateHoverRules( tabRules ); @@ -560,24 +608,57 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { color: selectedTabRules.color, }; - const backgroundColor = getBackgroundColor( selectors.backgroundSelectors ); - const headingRules = getFieldStyles( selectors.headingSelectors, '.Label' ); + const backgroundColor = getBackgroundColor( + selectors.backgroundSelectors, + scope + ); + const headingRules = getFieldStyles( + selectors.headingSelectors, + '.Label', + null, + scope + ); const blockRules = getFieldStyles( selectors.upeThemeLabelSelector, '.Block', - backgroundColor + backgroundColor, + scope + ); + const buttonRules = getFieldStyles( + selectors.buttonSelectors, + '.Input', + null, + scope + ); + const linkRules = getFieldStyles( + selectors.linkSelectors, + '.Label', + null, + scope ); - const buttonRules = getFieldStyles( selectors.buttonSelectors, '.Input' ); - const linkRules = getFieldStyles( selectors.linkSelectors, '.Label' ); const containerRules = getFieldStyles( selectors.containerSelectors, - '.Container' + '.Container', + null, + scope + ); + const headerRules = getFieldStyles( + selectors.headerSelectors, + '.Header', + null, + scope + ); + const footerRules = getFieldStyles( + selectors.footerSelectors, + '.Footer', + null, + scope ); - const headerRules = getFieldStyles( selectors.headerSelectors, '.Header' ); - const footerRules = getFieldStyles( selectors.footerSelectors, '.Footer' ); const footerLinkRules = getFieldStyles( selectors.footerLink, - '.Footer--link' + '.Footer--link', + null, + scope ); const globalRules = { colorBackground: backgroundColor, @@ -589,7 +670,9 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { if ( selectors.pmmeRelativeTextSizeSelector && globalRules.fontSizeBase ) { globalRules.fontSizeBase = ensureFontSizeSmallerThan( selectors.pmmeRelativeTextSizeSelector, - paragraphRules.fontSize + paragraphRules.fontSize, + PMME_RELATIVE_TEXT_SIZE, + scope ); } @@ -623,7 +706,9 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { appearance, getFieldStyles( selectors.hiddenValidActiveLabel, - '.Label--floating' + '.Label--floating', + null, + scope ) ); } @@ -642,6 +727,6 @@ export const getAppearance = ( elementsLocation, forWooPay = false ) => { } // Remove hidden fields from DOM. - hiddenElementsForUPE.cleanup(); + hiddenElementsForUPE.cleanup( scope ); return appearance; }; diff --git a/client/checkout/upe-styles/test/index.js b/client/checkout/upe-styles/test/index.js index 4af3358484c..3df8e81ba1f 100644 --- a/client/checkout/upe-styles/test/index.js +++ b/client/checkout/upe-styles/test/index.js @@ -32,16 +32,18 @@ describe( 'Getting styles for automated theming', () => { }; test( 'getFieldStyles returns correct styles for inputs', () => { - jest.spyOn( document, 'querySelector' ).mockImplementation( () => { - return mockElement; - } ); - jest.spyOn( window, 'getComputedStyle' ).mockImplementation( () => { - return mockCSStyleDeclaration; - } ); + const scope = { + querySelector: jest.fn( () => mockElement ), + defaultView: { + getComputedStyle: jest.fn( () => mockCSStyleDeclaration ), + }, + }; const fieldStyles = upeStyles.getFieldStyles( '.woocommerce-checkout .form-row input', - '.Input' + '.Input', + null, + scope ); expect( fieldStyles ).toEqual( { backgroundColor: 'rgba(0, 0, 0, 0)', @@ -55,13 +57,15 @@ describe( 'Getting styles for automated theming', () => { } ); test( 'getFieldStyles returns empty object if it can not find the element', () => { - jest.spyOn( document, 'querySelector' ).mockImplementation( () => { - return undefined; - } ); + const scope = { + querySelector: jest.fn( () => undefined ), + }; const fieldStyles = upeStyles.getFieldStyles( '.i-do-not-exist', - '.Input' + '.Input', + null, + scope ); expect( fieldStyles ).toEqual( {} ); } ); @@ -103,25 +107,31 @@ describe( 'Getting styles for automated theming', () => { }, 1: { href: null }, }; - jest.spyOn( document, 'styleSheets', 'get' ).mockReturnValue( - mockStyleSheets - ); + const scope = { + styleSheets: { + get: jest.fn( () => mockStyleSheets ), + }, + }; - const fontRules = upeStyles.getFontRulesFromPage(); + const fontRules = upeStyles.getFontRulesFromPage( scope ); expect( fontRules ).toEqual( [] ); } ); test( 'getAppearance returns the object with filtered CSS rules for UPE theming', () => { - jest.spyOn( document, 'querySelector' ).mockImplementation( () => { - return mockElement; - } ); - jest.spyOn( window, 'getComputedStyle' ).mockImplementation( () => { - return mockCSStyleDeclaration; - } ); + const scope = { + querySelector: jest.fn( () => mockElement ), + createElement: jest.fn( ( htmlTag ) => + document.createElement( htmlTag ) + ), + defaultView: { + getComputedStyle: jest.fn( () => mockCSStyleDeclaration ), + }, + }; const appearance = upeStyles.getAppearance( 'shortcode_checkout', - true + true, + scope ); expect( appearance ).toEqual( { variables: { @@ -285,23 +295,24 @@ describe( 'Getting styles for automated theming', () => { ], }, ].forEach( ( { elementsLocation, expectedSelectors } ) => { - afterEach( () => { - document.querySelector.mockClear(); - } ); - describe( `when elementsLocation is ${ elementsLocation }`, () => { test( 'getAppearance uses the correct appearanceSelectors based on the elementsLocation', () => { - jest.spyOn( document, 'querySelector' ).mockImplementation( - () => mockElement - ); - jest.spyOn( window, 'getComputedStyle' ).mockImplementation( - () => mockCSStyleDeclaration - ); + const scope = { + querySelector: jest.fn( () => mockElement ), + createElement: jest.fn( ( htmlTag ) => + document.createElement( htmlTag ) + ), + defaultView: { + getComputedStyle: jest.fn( + () => mockCSStyleDeclaration + ), + }, + }; - upeStyles.getAppearance( elementsLocation ); + upeStyles.getAppearance( elementsLocation, false, scope ); expectedSelectors.forEach( ( selector ) => { - expect( document.querySelector ).toHaveBeenCalledWith( + expect( scope.querySelector ).toHaveBeenCalledWith( selector ); } ); diff --git a/client/checkout/upe-styles/utils.js b/client/checkout/upe-styles/utils.js index 7546a899653..0f02995b5fb 100644 --- a/client/checkout/upe-styles/utils.js +++ b/client/checkout/upe-styles/utils.js @@ -105,21 +105,25 @@ export const dashedToCamelCase = ( string ) => { /** * Searches through array of CSS selectors and returns first visible background color. * - * @param {Array} selectors List of CSS selectors to check. + * @param {Array} selectors List of CSS selectors to check. + * @param {Object} scope The document scope to search in. * @return {string} CSS color value. */ -export const getBackgroundColor = ( selectors ) => { +export const getBackgroundColor = ( selectors, scope = document ) => { const defaultColor = '#ffffff'; let color = null; let i = 0; while ( ! color && i < selectors.length ) { - const element = document.querySelector( selectors[ i ] ); + const element = scope.querySelector( selectors[ i ] ); if ( ! element ) { i++; continue; } - const bgColor = window.getComputedStyle( element ).backgroundColor; + const windowObject = scope.defaultView || window; + + const bgColor = windowObject.getComputedStyle( element ) + .backgroundColor; // If backgroundColor property present and alpha > 0. if ( bgColor && tinycolor( bgColor ).getAlpha() > 0 ) { color = bgColor; diff --git a/client/checkout/woopay/email-input-iframe.js b/client/checkout/woopay/email-input-iframe.js index 1ecd86ca031..7279a502ce2 100644 --- a/client/checkout/woopay/email-input-iframe.js +++ b/client/checkout/woopay/email-input-iframe.js @@ -22,6 +22,12 @@ export const handleWooPayEmailInput = async ( api, isBlocksCheckout = false ) => { + const isPayForOrder = window.wcpayConfig?.pay_for_order === 'true'; + + if ( isPayForOrder ) { + return; + } + let timer; const waitTime = 500; const woopayEmailInput = await getTargetElement( field ); diff --git a/client/checkout/woopay/express-button/express-checkout-iframe.js b/client/checkout/woopay/express-button/express-checkout-iframe.js index 296b40aed17..ad5d3d4227c 100644 --- a/client/checkout/woopay/express-button/express-checkout-iframe.js +++ b/client/checkout/woopay/express-button/express-checkout-iframe.js @@ -20,8 +20,19 @@ import { getTracksIdentity } from 'tracks'; import { getAppearance } from 'wcpay/checkout/upe-styles'; import { getAppearanceType } from 'wcpay/checkout/utils'; +const getEmailValue = async ( emailSelector ) => { + const isPayForOrder = window.wcpayConfig?.pay_for_order === 'true'; + + if ( isPayForOrder ) { + return window.wcpayCustomerData?.email; + } + + const emailInput = await getTargetElement( emailSelector ); + + return emailInput?.value; +}; + export const expressCheckoutIframe = async ( api, context, emailSelector ) => { - const woopayEmailInput = await getTargetElement( emailSelector ); const tracksUserID = await getTracksIdentity(); let userEmail = ''; @@ -289,5 +300,7 @@ export const expressCheckoutIframe = async ( api, context, emailSelector ) => { } } - openIframe( woopayEmailInput?.value || getConfig( 'woopaySessionEmail' ) ); + const email = await getEmailValue( emailSelector ); + + openIframe( email || getConfig( 'woopaySessionEmail' ) ); }; diff --git a/client/checkout/woopay/express-button/test/express-checkout-iframe.test.js b/client/checkout/woopay/express-button/test/express-checkout-iframe.test.js index af09260a2d4..bdf6f6775ed 100644 --- a/client/checkout/woopay/express-button/test/express-checkout-iframe.test.js +++ b/client/checkout/woopay/express-button/test/express-checkout-iframe.test.js @@ -27,6 +27,12 @@ describe( 'expressCheckoutIframe', () => { getConfig.mockReturnValue( 'http://example.com' ); } ); + afterEach( () => { + document.body.removeChild( + document.querySelector( '.woopay-otp-iframe-wrapper' ) + ); + } ); + test( 'should open the iframe', async () => { expressCheckoutIframe( api, null, '#email' ); @@ -37,4 +43,85 @@ describe( 'expressCheckoutIframe', () => { expect( woopayIframe.src ).toContain( 'http://example.com/otp/' ); } ); } ); + + describe( 'when an email input is present', () => { + beforeEach( () => { + const emailInput = document.createElement( 'input' ); + emailInput.setAttribute( 'id', 'email' ); + emailInput.value = 'test@example.com'; + document.body.appendChild( emailInput ); + } ); + + afterEach( () => { + document.body.removeChild( document.querySelector( '#email' ) ); + } ); + + test( 'should open the iframe when an email is present', async () => { + expressCheckoutIframe( api, null, '#email' ); + + await waitFor( () => { + const woopayIframe = document.querySelector( 'iframe' ); + + expect( woopayIframe.className ).toContain( + 'woopay-otp-iframe' + ); + expect( woopayIframe.src ).toContain( + 'http://example.com/otp/' + ); + expect( woopayIframe.src ).toContain( + 'email=test%40example.com' + ); + } ); + } ); + } ); + + describe( 'pay for order flow is used', () => { + let oldWcpayConfig; + let oldWcpayCustomerData; + beforeEach( () => { + oldWcpayConfig = window.wcpayConfig; + oldWcpayCustomerData = window.wcpayCustomerData; + window.wcpayConfig = { + pay_for_order: 'true', + }; + window.wcpayCustomerData = { + email: 'payfororder@example.com', + }; + } ); + + afterEach( () => { + window.wcpayConfig = oldWcpayConfig; + window.wcpayCustomerData = oldWcpayCustomerData; + } ); + + test( 'should open the iframe when an email is present', async () => { + expressCheckoutIframe( api, null, '#email' ); + + await waitFor( () => { + const woopayIframe = document.querySelector( 'iframe' ); + + expect( woopayIframe.className ).toContain( + 'woopay-otp-iframe' + ); + expect( woopayIframe.src ).toContain( + 'http://example.com/otp/' + ); + expect( woopayIframe.src ).toContain( + 'email=payfororder%40example.com' + ); + } ); + } ); + + test( 'should handle missing email in wcpayCustomerData', async () => { + // Remove email from customer data + window.wcpayCustomerData = {}; + + expressCheckoutIframe( api, null, '#email' ); + + await waitFor( () => { + const woopayIframe = document.querySelector( 'iframe' ); + expect( woopayIframe.src ).not.toContain( 'email=' ); + } ); + } ); + } ); } ); diff --git a/client/components/account-balances/strings.ts b/client/components/account-balances/strings.ts index 2d0a9fde456..0193e4b3cef 100644 --- a/client/components/account-balances/strings.ts +++ b/client/components/account-balances/strings.ts @@ -3,22 +3,6 @@ */ import { __ } from '@wordpress/i18n'; -export const greetingStrings = { - withName: { - /** translators: %s name of the person being greeted. */ - morning: __( 'Good morning, %s', 'woocommerce-payments' ), - /** translators: %s name of the person being greeted. */ - afternoon: __( 'Good afternoon, %s', 'woocommerce-payments' ), - /** translators: %s name of the person being greeted. */ - evening: __( 'Good evening, %s', 'woocommerce-payments' ), - }, - withoutName: { - morning: __( 'Good morning', 'woocommerce-payments' ), - afternoon: __( 'Good afternoon', 'woocommerce-payments' ), - evening: __( 'Good evening', 'woocommerce-payments' ), - }, -}; - export const fundLabelStrings = { available: __( 'Available funds', 'woocommerce-payments' ), total: __( 'Total balance', 'woocommerce-payments' ), diff --git a/client/components/deposits-overview/deposit-notices.tsx b/client/components/deposits-overview/deposit-notices.tsx index 7a65a9a8e6f..9ccf655b6ae 100644 --- a/client/components/deposits-overview/deposit-notices.tsx +++ b/client/components/deposits-overview/deposit-notices.tsx @@ -46,33 +46,6 @@ export const SuspendedDepositNotice: React.FC = () => { ); }; -/** - * Renders a notice informing the user that the next deposit will include funds from a loan disbursement. - */ -export const DepositIncludesLoanPayoutNotice: React.FC = () => ( - - { interpolateComponents( { - mixedString: __( - 'This payout will include funds from your WooCommerce Capital loan. {{learnMoreLink}}Learn more{{/learnMoreLink}}', - 'woocommerce-payments' - ), - components: { - learnMoreLink: ( - // Link content is in the format string above. Consider disabling jsx-a11y/anchor-has-content. - // eslint-disable-next-line jsx-a11y/anchor-has-content - - ), - }, - } ) } - -); - /** * Renders a notice informing the user of the new account deposit waiting period. */ @@ -205,7 +178,7 @@ export const DepositFailureNotice: React.FC< { /** * The link to update the account details. */ - updateAccountLink: string; + updateAccountLink?: string; } > = ( { updateAccountLink } ) => { const accountLinkWithSource = updateAccountLink ? addQueryArgs( updateAccountLink, { diff --git a/client/components/deposits-overview/test/__snapshots__/index.tsx.snap b/client/components/deposits-overview/test/__snapshots__/index.tsx.snap index f80d3f68edf..912fe15ef12 100644 --- a/client/components/deposits-overview/test/__snapshots__/index.tsx.snap +++ b/client/components/deposits-overview/test/__snapshots__/index.tsx.snap @@ -208,9 +208,8 @@ exports[`Deposits Overview information Component Renders 1`] = ` { children } ), }; } ); +const renderWithSettingsProvider = ( ui ) => + render( + + { ui } + + ); + describe( 'PaymentMethodsCheckboxes', () => { + beforeEach( () => { + global.wcpaySettings = { + accountFees: {}, + }; + } ); + it( 'triggers the onChange when clicking the checkbox', () => { const handleChange = jest.fn(); @@ -40,14 +48,14 @@ describe( 'PaymentMethodsCheckboxes', () => { [ 'sepa_debit', false ], ]; - render( + renderWithSettingsProvider( - { upeMethods.map( ( key ) => ( + { upeMethods.map( ( [ name, checked ] ) => ( { it( 'can click the checkbox on payment methods with pending statuses', () => { const handleChange = jest.fn(); - render( + renderWithSettingsProvider( { it( 'shows the required label on payment methods which are required', () => { const handleChange = jest.fn(); - const page = render( + const page = renderWithSettingsProvider( { it( 'shows the disabled notice pill on payment methods with disabled statuses', () => { const handleChange = jest.fn(); - const page = render( + const page = renderWithSettingsProvider( { it( 'can not click the payment methods checkbox that are locked', () => { const handleChange = jest.fn(); - render( + renderWithSettingsProvider( { it( 'can not click the payment methods checkbox with disabled statuses', () => { const handleChange = jest.fn(); - render( + renderWithSettingsProvider( { it( "doesn't show the disabled notice pill on payment methods with active and unrequested statuses", () => { const handleChange = jest.fn(); - render( + renderWithSettingsProvider( { ]; }; -export const useAccountBusinessName = () => { - const { updateAccountBusinessName } = useDispatch( STORE_NAME ); - - const accountBusinessName = useSelect( ( select ) => - select( STORE_NAME ).getAccountBusinessName() - ); - - return [ accountBusinessName, updateAccountBusinessName ]; -}; - -export const useAccountBusinessURL = () => { - const { updateAccountBusinessURL } = useDispatch( STORE_NAME ); - - const accountBusinessUrl = useSelect( ( select ) => - select( STORE_NAME ).getAccountBusinessURL() - ); - - return [ accountBusinessUrl, updateAccountBusinessURL ]; -}; - -export const useAccountBusinessSupportAddress = () => { - const { updateAccountBusinessSupportAddress } = useDispatch( STORE_NAME ); - - const data = useSelect( ( select ) => { - const { - getAccountBusinessSupportAddress, - getAccountBusinessSupportAddressCountry, - getAccountBusinessSupportAddressLine1, - getAccountBusinessSupportAddressLine2, - getAccountBusinessSupportAddressCity, - getAccountBusinessSupportAddressState, - getAccountBusinessSupportAddressPostalCode, - } = select( STORE_NAME ); - - return [ - getAccountBusinessSupportAddress(), - getAccountBusinessSupportAddressCountry(), - getAccountBusinessSupportAddressLine1(), - getAccountBusinessSupportAddressLine2(), - getAccountBusinessSupportAddressCity(), - getAccountBusinessSupportAddressState(), - getAccountBusinessSupportAddressPostalCode(), - ]; - } ); - - return [ ...data, updateAccountBusinessSupportAddress ]; -}; - export const useAccountBusinessSupportEmail = () => { const { updateAccountBusinessSupportEmail } = useDispatch( STORE_NAME ); @@ -225,16 +177,6 @@ export const useAccountBusinessSupportPhone = () => { return [ accountBusinessSupportPhone, updateAccountBusinessSupportPhone ]; }; -export const useAccountBrandingLogo = () => { - const { updateAccountBrandingLogo } = useDispatch( STORE_NAME ); - - const accountBrandingLogo = useSelect( ( select ) => - select( STORE_NAME ).getAccountBrandingLogo() - ); - - return [ accountBrandingLogo, updateAccountBrandingLogo ]; -}; - export const useDepositScheduleInterval = () => { const { updateDepositScheduleInterval } = useDispatch( STORE_NAME ); diff --git a/client/data/settings/selectors.js b/client/data/settings/selectors.js index f36f402527a..9d98cffaa58 100644 --- a/client/data/settings/selectors.js +++ b/client/data/settings/selectors.js @@ -21,10 +21,6 @@ export const getSettings = ( state ) => { return getSettingsState( state ).data || EMPTY_OBJ; }; -const getSupportAddressState = ( state ) => { - return getSettings( state ).account_business_support_address || EMPTY_OBJ; -}; - export const getDuplicatedPaymentMethodIds = ( state ) => { return getSettings( state ).duplicated_payment_method_ids || EMPTY_OBJ; }; @@ -65,42 +61,6 @@ export const getAccountStatementDescriptorKana = ( state ) => { return getSettings( state ).account_statement_descriptor_kana || ''; }; -export const getAccountBusinessName = ( state ) => { - return getSettings( state ).account_business_name || ''; -}; - -export const getAccountBusinessURL = ( state ) => { - return getSettings( state ).account_business_url || ''; -}; - -export const getAccountBusinessSupportAddress = ( state ) => { - return getSettings( state ).account_business_support_address || ''; -}; - -export const getAccountBusinessSupportAddressCountry = ( state ) => { - return getSupportAddressState( state ).country || ''; -}; - -export const getAccountBusinessSupportAddressLine1 = ( state ) => { - return getSupportAddressState( state ).line1 || ''; -}; - -export const getAccountBusinessSupportAddressLine2 = ( state ) => { - return getSupportAddressState( state ).line2 || ''; -}; - -export const getAccountBusinessSupportAddressCity = ( state ) => { - return getSupportAddressState( state ).city || ''; -}; - -export const getAccountBusinessSupportAddressState = ( state ) => { - return getSupportAddressState( state ).state || ''; -}; - -export const getAccountBusinessSupportAddressPostalCode = ( state ) => { - return getSupportAddressState( state ).postal_code || ''; -}; - export const getAccountBusinessSupportEmail = ( state ) => { return getSettings( state ).account_business_support_email || ''; }; @@ -109,10 +69,6 @@ export const getAccountBusinessSupportPhone = ( state ) => { return getSettings( state ).account_business_support_phone || ''; }; -export const getAccountBrandingLogo = ( state ) => { - return getSettings( state ).account_branding_logo || ''; -}; - export const getAccountDomesticCurrency = ( state ) => { return getSettings( state ).account_domestic_currency || ''; }; diff --git a/client/data/settings/test/reducer.js b/client/data/settings/test/reducer.js index 159a8eb12e4..b80bf6a2284 100644 --- a/client/data/settings/test/reducer.js +++ b/client/data/settings/test/reducer.js @@ -9,12 +9,8 @@ import { updateAccountStatementDescriptor, updatePaymentRequestLocations, updateIsPaymentRequestEnabled, - updateAccountBusinessName, - updateAccountBusinessURL, - updateAccountBusinessSupportAddress, updateAccountBusinessSupportEmail, updateAccountBusinessSupportPhone, - updateAccountBrandingLogo, updateIsWooPayEnabled, updateWooPayCustomMessage, updateWooPayStoreLogo, @@ -310,21 +306,6 @@ describe( 'Settings reducer tests', () => { describe( 'SET_MERCHANT_SETTINGS', () => { const merchantSettings = [ - { - updateFunc: updateAccountBusinessName, - stateKey: 'account_business_name', - settingValue: 'Business name', - }, - { - updateFunc: updateAccountBusinessURL, - stateKey: 'account_business_url', - settingValue: 'Business url', - }, - { - updateFunc: updateAccountBusinessSupportAddress, - stateKey: 'account_business_support_address', - settingValue: 'Business address', - }, { updateFunc: updateAccountBusinessSupportEmail, stateKey: 'account_business_support_email', @@ -335,11 +316,6 @@ describe( 'Settings reducer tests', () => { stateKey: 'account_business_support_phone', settingValue: 'Business phone', }, - { - updateFunc: updateAccountBrandingLogo, - stateKey: 'account_branding_logo', - settingValue: 'Branding logo', - }, ]; test.each( merchantSettings )( 'toggles `%j`', ( setting ) => { diff --git a/client/data/settings/test/selectors.js b/client/data/settings/test/selectors.js index dc7cfaba6b6..2d6a61392b8 100644 --- a/client/data/settings/test/selectors.js +++ b/client/data/settings/test/selectors.js @@ -10,12 +10,8 @@ import { isSavingSettings, getPaymentRequestLocations, getIsPaymentRequestEnabled, - getAccountBusinessName, - getAccountBusinessURL, - getAccountBusinessSupportAddress, getAccountBusinessSupportEmail, getAccountBusinessSupportPhone, - getAccountBrandingLogo, getIsWooPayEnabled, getWooPayCustomMessage, getWooPayStoreLogo, @@ -282,12 +278,6 @@ describe( 'Settings selectors tests', () => { } ); describe.each( [ - { getFunc: getAccountBusinessName, setting: 'account_business_name' }, - { getFunc: getAccountBusinessURL, setting: 'account_business_url' }, - { - getFunc: getAccountBusinessSupportAddress, - setting: 'account_business_support_address', - }, { getFunc: getAccountBusinessSupportEmail, setting: 'account_business_support_email', @@ -296,7 +286,6 @@ describe( 'Settings selectors tests', () => { getFunc: getAccountBusinessSupportPhone, setting: 'account_business_support_phone', }, - { getFunc: getAccountBrandingLogo, setting: 'account_branding_logo' }, ] )( 'Test get method: %j', ( setting ) => { test( 'returns the value of state.settings.data.${setting.setting}', () => { const state = { diff --git a/client/deposits/details/index.tsx b/client/deposits/details/index.tsx index c4c83154b0a..f13eb53608c 100644 --- a/client/deposits/details/index.tsx +++ b/client/deposits/details/index.tsx @@ -32,14 +32,16 @@ import { CopyButton } from 'components/copy-button'; import Page from 'components/page'; import ErrorBoundary from 'components/error-boundary'; import { TestModeNotice } from 'components/test-mode-notice'; +import BannerNotice from 'components/banner-notice'; import InlineNotice from 'components/inline-notice'; import { formatCurrency, formatExplicitCurrency, } from 'multi-currency/interface/functions'; -import { depositStatusLabels } from '../strings'; +import { depositStatusLabels, payoutFailureMessages } from '../strings'; import './style.scss'; import { formatDateTimeFromString } from 'wcpay/utils/date-time'; +import { MaybeShowMerchantFeedbackPrompt } from 'wcpay/merchant-feedback-prompt'; /** * Renders the deposit status indicator UI, re-purposing the OrderStatus component from @woocommerce/components. @@ -227,6 +229,20 @@ export const DepositOverview: React.FC< DepositOverviewProps > = ( { ] } ) } + { deposit.status === 'failed' && ( + + + { __( 'Failure reason: ', 'woocommerce-payments' ) } + + { payoutFailureMessages[ deposit.failure_code ] || + deposit.failure_message || + __( 'Unknown', 'woocommerce-payments' ) } + + ) } @@ -300,6 +316,7 @@ export const DepositDetails: React.FC< DepositDetailsProps > = ( { return ( + { isLoading ? ( diff --git a/client/deposits/details/test/index.tsx b/client/deposits/details/test/index.tsx index fa9a4dbf042..c7dc9816625 100644 --- a/client/deposits/details/test/index.tsx +++ b/client/deposits/details/test/index.tsx @@ -104,4 +104,79 @@ describe( 'Deposit overview', () => { ); expect( overview ).toMatchSnapshot(); } ); + + test( 'renders failure reason when deposit has a known failure code', () => { + const failedDeposit = { + ...mockDeposit, + status: 'failed', + failure_code: 'insufficient_funds', + } as CachedDeposit; + + const { getByText } = render( + + ); + + expect( getByText( 'Failure reason:' ) ).toBeInTheDocument(); + expect( + getByText( + 'Your account has insufficient funds to cover your negative balance.' + ) + ).toBeInTheDocument(); + } ); + + test( 'renders failure_message when failure_code is new and not included in our mapping', () => { + // @ts-expect-error Testing invalid failure code scenario + const failedDeposit = { + ...mockDeposit, + status: 'failed', + failure_code: 'unknown_failure_code', + failure_message: + 'Failure error message originally captured from the Stripe Payout object', + } as CachedDeposit; + + const { getByText } = render( + + ); + + expect( getByText( 'Failure reason:' ) ).toBeInTheDocument(); + expect( + getByText( + 'Failure error message originally captured from the Stripe Payout object' + ) + ).toBeInTheDocument(); + } ); + + test( 'renders failure_message when no failure_code exists', () => { + const failedDeposit = { + ...mockDeposit, + status: 'failed', + failure_message: + 'Failure error message originally captured from the Stripe Payout object', + } as CachedDeposit; + + const { getByText } = render( + + ); + + expect( getByText( 'Failure reason:' ) ).toBeInTheDocument(); + expect( + getByText( + 'Failure error message originally captured from the Stripe Payout object' + ) + ).toBeInTheDocument(); + } ); + + test( 'renders Unknown when no failure_code nor failure_message exist - edge case', () => { + const failedDeposit = { + ...mockDeposit, + status: 'failed', + } as CachedDeposit; + + const { getByText } = render( + + ); + + expect( getByText( 'Failure reason:' ) ).toBeInTheDocument(); + expect( getByText( 'Unknown' ) ).toBeInTheDocument(); + } ); } ); diff --git a/client/deposits/index.tsx b/client/deposits/index.tsx index 60e18ec86b8..cc8aeb4b51d 100644 --- a/client/deposits/index.tsx +++ b/client/deposits/index.tsx @@ -22,6 +22,7 @@ import { useSettings } from 'wcpay/data'; import DepositsList from './list'; import { hasAutomaticScheduledDeposits } from 'wcpay/deposits/utils'; import { recordEvent } from 'wcpay/tracks'; +import { MaybeShowMerchantFeedbackPrompt } from 'wcpay/merchant-feedback-prompt'; const useNextDepositNoticeState = () => { const { updateOptions } = useDispatch( 'wc/admin/options' ); @@ -148,6 +149,7 @@ const DepositsPage: React.FC = () => { return ( + diff --git a/client/deposits/strings.ts b/client/deposits/strings.ts index 7dbf7df2dad..253cc7b9405 100644 --- a/client/deposits/strings.ts +++ b/client/deposits/strings.ts @@ -9,7 +9,7 @@ import { __ } from '@wordpress/i18n'; * Internal dependencies */ -import type { DepositStatus } from 'wcpay/types/deposits'; +import type { DepositStatus, PayoutFailureCode } from 'wcpay/types/deposits'; export const displayType = { deposit: __( 'Payout', 'woocommerce-payments' ), @@ -32,3 +32,118 @@ export const depositStatusLabels: Record< canceled: __( 'Canceled', 'woocommerce-payments' ), failed: __( 'Failed', 'woocommerce-payments' ), }; + +/** + * Mapping of payout failure code to display string. + */ +export const payoutFailureMessages: Record< PayoutFailureCode, string > = { + insufficient_funds: __( + 'Your account has insufficient funds to cover your negative balance.', + 'woocommerce-payments' + ), + bank_account_restricted: __( + 'The bank account has restrictions on either the type or number of transfers allowed. This normally indicates that the bank account is a savings or other non-checking account.', + 'woocommerce-payments' + ), + debit_not_authorized: __( + 'Debit transactions are not approved on your bank account. Bank accounts need to be set up for both credit and debit transfers.', + 'woocommerce-payments' + ), + invalid_card: __( + 'The card used was invalid. This usually means the card number is invalid or the account has been closed.', + 'woocommerce-payments' + ), + declined: __( + 'The bank has declined this transfer. Please contact the bank for more information.', + 'woocommerce-payments' + ), + invalid_transaction: __( + 'The transfer was refused by the issuing bank because this type of payment is not permitted for this card. Please contact the issuing bank for clarification.', + 'woocommerce-payments' + ), + refer_to_card_issuer: __( + 'The transfer was refused by the card issuer. Please contact the issuing bank for clarification.', + 'woocommerce-payments' + ), + unsupported_card: __( + 'The bank no longer supports transfers to this card.', + 'woocommerce-payments' + ), + lost_or_stolen_card: __( + 'The card used has been reported lost or stolen. Please contact the issuing bank for clarification.', + 'woocommerce-payments' + ), + invalid_issuer: __( + 'The issuer specified by the card number does not exist. Please verify card details.', + 'woocommerce-payments' + ), + expired_card: __( + 'The card used has expired. Please switch to a different card or payment method. Contact the issuing bank for clarification.', + 'woocommerce-payments' + ), + could_not_process: __( + // The same failure code is used if processing is failed by the bank or Stripe. + 'The bank or the payment processor could not process this transfer.', + 'woocommerce-payments' + ), + invalid_account_number: __( + 'The bank account details on file are probably incorrect. While the routing number appears correct, the account number is invalid.', + 'woocommerce-payments' + ), + incorrect_account_holder_name: __( + 'The bank account holder name on file appears to be incorrect.', + 'woocommerce-payments' + ), + account_closed: __( + 'The bank account has been closed.', + 'woocommerce-payments' + ), + no_account: __( + 'The bank account details on file are probably incorrect. No bank account could be located with those details.', + 'woocommerce-payments' + ), + exceeds_amount_limit: __( + 'The card issuer has declined the transaction as it will exceed the card limit. Please switch to a different card or payment method. Contact the issuing bank for clarification.', + 'woocommerce-payments' + ), + account_frozen: __( + 'The bank account has been frozen.', + 'woocommerce-payments' + ), + issuer_unavailable: __( + 'The issuing bank is currently unavailable. Our system will automatically try again on your next payout date, or you can switch to a different payout method.', + 'woocommerce-payments' + ), + invalid_currency: __( + 'The bank was unable to process this transfer because of its currency. This is probably because the bank account cannot accept payments in that currency.', + 'woocommerce-payments' + ), + incorrect_account_type: __( + 'The bank account type is incorrect. This value can only be checking or savings in most countries. In Japan, it can only be futsu or toza.', + 'woocommerce-payments' + ), + incorrect_account_holder_details: __( + 'The bank could not process this transfer. Please check that the entered bank account details match the corresponding account bank statement exactly.', + 'woocommerce-payments' + ), + bank_ownership_changed: __( + 'The destination bank account is no longer valid because its branch has changed ownership.', + 'woocommerce-payments' + ), + exceeds_count_limit: __( + 'The selected card has exceeded its card usage frequency limit. Please switch to a different card or payment method. Contact the issuing bank for clarification.', + 'woocommerce-payments' + ), + incorrect_account_holder_address: __( + 'Your bank notified us that the bank account holder address on file is incorrect.', + 'woocommerce-payments' + ), + incorrect_account_holder_tax_id: __( + 'Your bank notified us that the bank account holder tax ID on file is incorrect.', + 'woocommerce-payments' + ), + invalid_account_number_length: __( + 'Your bank notified us that the bank account number is too long.', + 'woocommerce-payments' + ), +}; diff --git a/client/disputes/index.tsx b/client/disputes/index.tsx index 84f87c70ae2..f1a8426570f 100644 --- a/client/disputes/index.tsx +++ b/client/disputes/index.tsx @@ -44,6 +44,7 @@ import { formatDateTimeFromString } from 'wcpay/utils/date-time'; import { usePersistedColumnVisibility } from 'wcpay/hooks/use-persisted-table-column-visibility'; import { useReportExport } from 'wcpay/hooks/use-report-export'; import { useDispatch } from '@wordpress/data'; +import { MaybeShowMerchantFeedbackPrompt } from 'wcpay/merchant-feedback-prompt'; const getHeaders = ( sortColumn?: string ): DisputesTableHeader[] => [ { @@ -446,6 +447,7 @@ export const DisputesList = (): JSX.Element => { return ( + { return ( + diff --git a/client/utils/embedded-components/appearance.ts b/client/embedded-components/appearance.ts similarity index 100% rename from client/utils/embedded-components/appearance.ts rename to client/embedded-components/appearance.ts diff --git a/client/embedded-components/hooks.tsx b/client/embedded-components/hooks.tsx new file mode 100644 index 00000000000..c9c725fbff5 --- /dev/null +++ b/client/embedded-components/hooks.tsx @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import { addQueryArgs } from '@wordpress/url'; +import apiFetch from '@wordpress/api-fetch'; + +/** + * Internal dependencies + */ +import { NAMESPACE } from 'data/constants'; +import { AccountSession } from './types'; +import { OnboardingFields } from 'wcpay/onboarding/types'; +import { fromDotNotation } from 'wcpay/onboarding/utils'; + +/** + * Make an API request to create an account session. + */ +export const createAccountSession = async (): Promise< AccountSession > => { + return await apiFetch< AccountSession >( { + path: addQueryArgs( `${ NAMESPACE }/accounts/session`, {} ), + method: 'GET', + } ); +}; + +/** + * Make an API request to create an KYC account session. + * + * @param data The form data. + * @param isPoEligible Whether the user is eligible for a PO account. + */ +export const createKycAccountSession = async ( + data: OnboardingFields, + isPoEligible: boolean +): Promise< AccountSession > => { + const urlParams = new URLSearchParams( window.location.search ); + return await apiFetch< AccountSession >( { + path: addQueryArgs( `${ NAMESPACE }/onboarding/kyc/session`, { + self_assessment: fromDotNotation( data ), + capabilities: urlParams.get( 'capabilities' ) || '', + progressive: isPoEligible, + } ), + method: 'GET', + } ); +}; diff --git a/client/embedded-components/index.tsx b/client/embedded-components/index.tsx new file mode 100644 index 00000000000..524c5dcf748 --- /dev/null +++ b/client/embedded-components/index.tsx @@ -0,0 +1,230 @@ +/** + * External dependencies + */ +import React, { useState, useEffect } from 'react'; +import { + loadConnectAndInitialize, + LoadError, + LoaderStart, + StripeConnectInstance, +} from '@stripe/connect-js'; +import { + ConnectAccountOnboarding, + ConnectComponentsProvider, + ConnectNotificationBanner, +} from '@stripe/react-connect-js'; + +/** + * Internal dependencies + */ +import { createKycAccountSession, createAccountSession } from './hooks'; +import appearance from './appearance'; +import { OnboardingFields } from 'wcpay/onboarding/types'; +import StripeSpinner from 'wcpay/components/stripe-spinner'; +import BannerNotice from 'wcpay/components/banner-notice'; +import { AccountSession } from 'wcpay/embedded-components/types'; +import { trackRedirected } from 'wcpay/onboarding/tracking'; + +interface EmbeddedComponentProps { + onLoaderStart?: ( { elementTagName }: LoaderStart ) => void; + onLoadError?: ( { error, elementTagName }: LoadError ) => void; +} + +interface EmbeddedAccountOnboardingProps extends EmbeddedComponentProps { + onboardingData: OnboardingFields; + onExit: () => void; + onStepChange?: ( step: string ) => void; + collectPayoutRequirements?: boolean; + isPoEligible?: boolean; +} + +interface EmbeddedAccountNotificationBannerProps + extends EmbeddedComponentProps { + onNotificationsChange: ( { + total, + actionRequired, + }: { + total: number; + actionRequired: number; + } ) => void; +} + +/** + * Hook to initialize Stripe Connect. + * + * @param isOnboarding - Whether this is an onboarding flow. + * @param onboardingData - Data required for onboarding. + * @param isPoEligible - Whether the user is eligible for progressive onboarding. + * + * @return Returns stripeConnectInstance, error, and loading state. + */ +const useInitializeStripe = ( + isOnboarding: boolean, + onboardingData: OnboardingFields | null, + isPoEligible: boolean +) => { + const [ + stripeConnectInstance, + setStripeConnectInstance, + ] = useState< StripeConnectInstance | null >( null ); + const [ initializationError, setInitializationError ] = useState< + string | null + >( null ); + const [ loading, setLoading ] = useState< boolean >( true ); + + useEffect( () => { + const initializeStripe = async () => { + try { + let session: AccountSession; + + if ( isOnboarding && onboardingData ) { + session = await createKycAccountSession( + onboardingData, + isPoEligible + ); + + // Track the embedded component redirection event. + trackRedirected( isPoEligible, true ); + } else { + session = await createAccountSession(); + } + + const { clientSecret, publishableKey } = session; + + if ( ! publishableKey ) { + throw new Error( + 'Missing publishable key in session response' + ); + } + + const instance = loadConnectAndInitialize( { + publishableKey, + fetchClientSecret: async () => clientSecret, + appearance: { + overlays: 'drawer', + ...appearance, + }, + locale: session.locale.replace( '_', '-' ), + } ); + + setStripeConnectInstance( instance ); + } catch ( err ) { + setInitializationError( + err instanceof Error ? err.message : 'Unknown error' + ); + } finally { + setLoading( false ); + } + }; + + initializeStripe(); + }, [ isOnboarding, onboardingData, isPoEligible ] ); + + return { stripeConnectInstance, initializationError, loading }; +}; + +/** + * Embedded Stripe Account Onboarding Component. + * + * @param onboardingData - Data required for onboarding. + * @param onExit - Callback function when the onboarding flow is exited. + * @param onLoaderStart - Callback function when the onboarding loader starts. + * @param onLoadError - Callback function when the onboarding load error occurs. + * @param [onStepChange] - Callback function when the onboarding step changes. + * @param [collectPayoutRequirements=false] - Whether to collect payout requirements. + * @param [isPoEligible=false] - Whether the user is eligible for progressive onboarding. + * + * @return Rendered Account Onboarding component. + */ +export const EmbeddedAccountOnboarding: React.FC< EmbeddedAccountOnboardingProps > = ( { + onboardingData, + onExit, + onLoaderStart, + onLoadError, + onStepChange, + isPoEligible = false, + collectPayoutRequirements = false, +} ) => { + const { stripeConnectInstance, initializationError } = useInitializeStripe( + true, + onboardingData, + isPoEligible + ); + + return ( + <> + { initializationError && ( + + { initializationError } + + ) } + { stripeConnectInstance && ( + + + onStepChange?.( stepChange.step ) + } + collectionOptions={ { + fields: collectPayoutRequirements + ? 'eventually_due' + : 'currently_due', + futureRequirements: 'omit', + } } + /> + + ) } + + ); +}; + +/** + * Embedded Stripe Notification Banner Component. + * + * @param onLoaderStart - Callback when Stripe component starts rendering. + * @param onLoadError - Callback when Stripe component load error occurs. + * @param onNotificationsChange - Callback triggered when notifications change. + * + * @return Rendered Notification Banner component. + */ +export const EmbeddedConnectNotificationBanner: React.FC< EmbeddedAccountNotificationBannerProps > = ( { + onLoaderStart, + onLoadError, + onNotificationsChange, +} ) => { + const { + stripeConnectInstance, + initializationError, + loading, + } = useInitializeStripe( false, null, false ); + + return ( + <> + { ( loading || ! stripeConnectInstance ) && } + { initializationError && ( + + { initializationError } + + ) } + { stripeConnectInstance && ( + + + + ) } + + ); +}; diff --git a/client/embedded-components/test/index.tsx b/client/embedded-components/test/index.tsx new file mode 100644 index 00000000000..03923a3e1e5 --- /dev/null +++ b/client/embedded-components/test/index.tsx @@ -0,0 +1,114 @@ +/** + * Internal dependencies + */ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; + +/** + * External dependencies + */ +import { + EmbeddedAccountOnboarding, + EmbeddedConnectNotificationBanner, +} from 'wcpay/embedded-components'; + +// Mock dependencies +jest.mock( '@stripe/connect-js', () => ( { + loadConnectAndInitialize: jest.fn( () => ( { + on: jest.fn(), + off: jest.fn(), + destroy: jest.fn(), + } ) ), +} ) ); +jest.mock( '@stripe/react-connect-js', () => ( { + ConnectComponentsProvider: ( { + children, + }: { + children: React.ReactNode; + } ) => <>{ children }, + ConnectAccountOnboarding: () => ( +
Stripe Onboarding
+ ), + ConnectNotificationBanner: () => ( +
Stripe Notification
+ ), +} ) ); + +jest.mock( '../hooks', () => ( { + createKycAccountSession: jest.fn().mockResolvedValue( { + clientSecret: 'test-secret', + publishableKey: 'test-key', + locale: 'en_US', + } ), + createAccountSession: jest.fn().mockResolvedValue( { + clientSecret: 'test-secret', + publishableKey: 'test-key', + locale: 'en_US', + } ), +} ) ); + +// Mock onboarding data +const mockOnboardingData = { + businessType: 'individual', + country: 'US', +}; + +// Tests for EmbeddedAccountOnboarding +describe( 'EmbeddedAccountOnboarding', () => { + it( 'renders ConnectAccountOnboarding after initialization', async () => { + const mockOnExit = jest.fn(); + const mockOnStepChange = jest.fn(); + + render( + + ); + + expect( + await screen.findByTestId( 'connect-account-onboarding' ) + ).toBeInTheDocument(); + expect( mockOnExit ).not.toHaveBeenCalled(); + expect( mockOnStepChange ).not.toHaveBeenCalled(); + } ); + + it( 'passes correct props to ConnectAccountOnboarding', async () => { + const mockOnExit = jest.fn(); + const mockOnStepChange = jest.fn(); + + render( + + ); + + expect( + await screen.findByTestId( 'connect-account-onboarding' ) + ).toBeInTheDocument(); + expect( mockOnExit ).not.toHaveBeenCalled(); + expect( mockOnStepChange ).not.toHaveBeenCalled(); + } ); +} ); + +// Tests for EmbeddedConnectNotificationBanner +describe( 'EmbeddedConnectNotificationBanner', () => { + it( 'renders ConnectNotificationBanner after initialization', async () => { + render( + + ); + expect( + await screen.findByTestId( 'connect-notification-banner' ) + ).toBeInTheDocument(); + } ); +} ); diff --git a/client/embedded-components/types.ts b/client/embedded-components/types.ts new file mode 100644 index 00000000000..675fb2fcf35 --- /dev/null +++ b/client/embedded-components/types.ts @@ -0,0 +1,16 @@ +/** + * Internal dependencies + */ + +/** + * Account session. + */ +export interface AccountSession { + clientSecret: string; + expiresAt: number; + accountId: string; + isLive: boolean; + accountCreated: boolean; + publishableKey: string; + locale: string; +} diff --git a/client/express-checkout/blocks/components/express-checkout-button-preview.js b/client/express-checkout/blocks/components/express-checkout-button-preview.js new file mode 100644 index 00000000000..f7103875833 --- /dev/null +++ b/client/express-checkout/blocks/components/express-checkout-button-preview.js @@ -0,0 +1,137 @@ +/** + * External dependencies + */ +import { useEffect, useRef } from 'react'; + +/** + * Internal dependencies + */ +import { getExpressCheckoutButtonAppearance } from '../../utils'; + +export const SUPPORTED_PREVIEW_PAYMENT_METHODS = [ 'googlePay', 'applePay' ]; + +const GooglePayButtonPreview = ( { options, buttonAttributes, theme } ) => { + const googlePlayContainerRef = useRef( null ); + const hasStartedLoadingGooglePlayButton = useRef( null ); + const appearance = getExpressCheckoutButtonAppearance( buttonAttributes ); + const borderRadius = appearance.variables.borderRadius; + + useEffect( () => { + if ( + googlePlayContainerRef.current && + ! hasStartedLoadingGooglePlayButton.current + ) { + hasStartedLoadingGooglePlayButton.current = true; + ( async () => { + // The container may be inside an iframe, so we need to retrieve a reference to the document and window objects. + const targetDocument = + googlePlayContainerRef.current.ownerDocument; + const targetWindow = targetDocument.defaultView; + if ( ! targetWindow.google?.payments?.api?.PaymentsClient ) { + await new Promise( ( resolve ) => { + const script = document.createElement( 'script' ); + script.src = 'https://pay.google.com/gp/p/js/pay.js'; + script.onload = resolve; + targetDocument.head.appendChild( script ); + } ); + } + + const googlePayClient = new targetWindow.google.payments.api.PaymentsClient( + { + environment: 'TEST', + } + ); + + const buttonColor = theme === 'black' ? 'black' : 'white'; // There is no 'outline' theme in Google Pay. + + const button = googlePayClient.createButton( { + buttonType: options.buttonType.googlePay, + buttonColor, + buttonRadius: parseFloat( borderRadius ), + buttonSizeMode: 'fill', + onClick: () => {}, + } ); + googlePlayContainerRef.current.appendChild( button ); + } )(); + } + }, [ theme, borderRadius, options.buttonType.googlePay ] ); + + useEffect( () => { + googlePlayContainerRef.current + ?.querySelector( 'button' ) + ?.style?.setProperty( 'border-radius', borderRadius ); + }, [ borderRadius ] ); + + return ( +
+ ); +}; + +const ApplePayButtonPreview = ( { options, buttonAttributes, theme } ) => { + const appearance = getExpressCheckoutButtonAppearance( buttonAttributes ); + const borderRadius = appearance.variables.borderRadius; + + const buttonStyle = { + height: `${ options.buttonHeight }px`, + borderRadius, + ApplePayButtonType: options.buttonType.applePay, + WebkitAppearance: '-apple-pay-button', + width: '100%', + }; + + if ( [ 'black', 'white', 'white-outline' ].includes( theme ) ) { + buttonStyle.ApplePayButtonStyle = theme; + } else { + buttonStyle.ApplePayButtonStyle = 'white'; + } + + return ( +
+
+ ); +}; + +const ExpressCheckoutButtonPreview = ( { + expressPaymentMethod, + options, + buttonAttributes, +} ) => { + const theme = options.buttonTheme[ expressPaymentMethod ]; + + if ( expressPaymentMethod === 'googlePay' ) { + return ( + + ); + } + + if ( expressPaymentMethod === 'applePay' ) { + return ( + + ); + } + + return null; +}; + +export default ExpressCheckoutButtonPreview; diff --git a/client/express-checkout/blocks/components/express-checkout-component.js b/client/express-checkout/blocks/components/express-checkout-component.js index bccfee2eb93..02d99524acd 100644 --- a/client/express-checkout/blocks/components/express-checkout-component.js +++ b/client/express-checkout/blocks/components/express-checkout-component.js @@ -11,6 +11,9 @@ import { } from '../../event-handlers'; import { useExpressCheckout } from '../hooks/use-express-checkout'; import { PAYMENT_METHOD_NAME_EXPRESS_CHECKOUT_ELEMENT } from 'wcpay/checkout/constants'; +import ExpressCheckoutButtonPreview, { + SUPPORTED_PREVIEW_PAYMENT_METHODS, +} from './express-checkout-button-preview'; const getPaymentMethodsOverride = ( enabledPaymentMethod ) => { const allDisabled = { @@ -131,16 +134,28 @@ const ExpressCheckoutComponent = ( { }; }; + const checkoutElementOptions = { + ...withBlockOverride(), + ...adjustButtonHeights( withBlockOverride(), expressPaymentMethod ), + ...getPaymentMethodsOverride( expressPaymentMethod ), + }; + + if ( + isPreview && + SUPPORTED_PREVIEW_PAYMENT_METHODS.includes( expressPaymentMethod ) + ) { + return ( + + ); + } + return ( { + shippingRates = shippingData.shippingRates[ 0 ].shipping_rates + .map( ( rate ) => { return { id: rate.rate_id, amount: parseInt( rate.price, 10 ), displayName: rate.name, }; - } - ); + } ) + .slice( 0, SHIPPING_RATES_UPPER_LIMIT_COUNT ); } else { shippingRates = [ { diff --git a/client/express-checkout/constants.js b/client/express-checkout/constants.js new file mode 100644 index 00000000000..6c58c715386 --- /dev/null +++ b/client/express-checkout/constants.js @@ -0,0 +1,4 @@ +// This const defines the max number of shipping options that can be handled by the ECE. +// More than 9 options will prevent the UI from behaving correctly. +// See https://github.com/Automattic/woocommerce-payments/issues/10490 . +export const SHIPPING_RATES_UPPER_LIMIT_COUNT = 9; diff --git a/client/express-checkout/event-handlers.js b/client/express-checkout/event-handlers.js index da224669771..e64610ac110 100644 --- a/client/express-checkout/event-handlers.js +++ b/client/express-checkout/event-handlers.js @@ -19,6 +19,7 @@ import { trackExpressCheckoutButtonClick, trackExpressCheckoutButtonLoad, } from './tracking'; +import { SHIPPING_RATES_UPPER_LIMIT_COUNT } from 'wcpay/express-checkout/constants'; let lastSelectedAddress = null; @@ -36,7 +37,10 @@ export const shippingAddressChangeHandler = async ( api, event, elements ) => { } ); event.resolve( { - shippingRates: response.shipping_options, + shippingRates: response.shipping_options?.slice( + 0, + SHIPPING_RATES_UPPER_LIMIT_COUNT + ), lineItems: normalizeLineItems( response.displayItems ), } ); } else { diff --git a/client/express-checkout/index.js b/client/express-checkout/index.js index f37bd453a6b..75e42953485 100644 --- a/client/express-checkout/index.js +++ b/client/express-checkout/index.js @@ -25,6 +25,7 @@ import { shippingRateChangeHandler, } from './event-handlers'; import expressCheckoutButtonUi from './button-ui'; +import { SHIPPING_RATES_UPPER_LIMIT_COUNT } from 'wcpay/express-checkout/constants'; jQuery( ( $ ) => { // Don't load if blocks checkout is being loaded. @@ -224,7 +225,10 @@ jQuery( ( $ ) => { } ) ); }; - const shippingRates = getShippingRates(); + const shippingRates = getShippingRates().slice( + 0, + SHIPPING_RATES_UPPER_LIMIT_COUNT + ); // This is a bit of a hack, but we need some way to get the shipping information before rendering the button, and // since we don't have any address information at this point it seems best to rely on what came with the cart response. diff --git a/client/globals.d.ts b/client/globals.d.ts index 0b61d866700..36e76e188f3 100644 --- a/client/globals.d.ts +++ b/client/globals.d.ts @@ -13,11 +13,16 @@ declare global { isSubscriptionsActive: boolean; featureFlags: { customSearch: boolean; + woopay: boolean; + documents: boolean; + woopayExpressCheckout: boolean; isAuthAndCaptureEnabled: boolean; paymentTimeline: boolean; isDisputeIssuerEvidenceEnabled: boolean; isPaymentOverviewWidgetEnabled?: boolean; + multiCurrency?: boolean; }; + accountFees: Record< string, any >; fraudServices: unknown[]; testMode: boolean; testModeOnboarding: boolean; @@ -26,9 +31,10 @@ declare global { isJetpackIdcActive: boolean; isAccountConnected: boolean; isAccountValid: boolean; - accountStatus: { + accountStatus: Partial< { email?: string; created: string; + isLive?: boolean; error?: boolean; status?: string; country?: string; @@ -69,7 +75,17 @@ declare global { declineOnAVSFailure: boolean; declineOnCVCFailure: boolean; }; - }; + /** + * Campaigns are temporary flags that are used to enable/disable features for a limited time. + */ + campaigns: { + /** + * The flag for the WordPress.org merchant review campaign in 2025. + * Eligibility is determined per-account on transact-platform-server. + */ + wporgReview2025: boolean; + }; + } >; accountLoans: { has_active_loan: boolean; has_past_loans: boolean; @@ -155,6 +171,21 @@ declare global { timeFormat: string; }; + const wooPaymentsPaymentMethodDefinitions: Record< + string, + { + id: string; + stripe_key: string; + title: string; + description: string; + settings_icon_url: string; + currencies: string[]; + allows_manual_capture: boolean; + allows_pay_later: boolean; + accepts_only_domestic_payment: boolean; + } + >; + const wc: { wcSettings: typeof wcSettingsModule; tracks: { @@ -219,6 +250,10 @@ declare global { siteTitle: string; }; + const wcpayPluginSettings: { + exitSurveyLastShown: string | null; + }; + interface WcSettings { ece_data?: WCPayExpressCheckoutParams; woocommerce_payments_data: typeof wcpaySettings; @@ -239,5 +274,6 @@ declare global { wc: typeof wc; wcTracks: typeof wcTracks; wcSettings: typeof wcSettings; + wcpayPluginSettings?: typeof wcpayPluginSettings; } } diff --git a/client/hooks/use-api-fetch.ts b/client/hooks/use-api-fetch.ts deleted file mode 100644 index ab0faf8995c..00000000000 --- a/client/hooks/use-api-fetch.ts +++ /dev/null @@ -1,57 +0,0 @@ -/** - * External dependencies - */ -import { createContext, useContext, useEffect, useState } from 'react'; -import { dispatch } from '@wordpress/data'; -import apiFetch from '@wordpress/api-fetch'; - -const cacheContext = createContext< { [ key: string ]: any } >( {} ); - -interface UseApiFetchParams { - path: string; - errorMessage?: string; -} - -interface ApiResponse< DataType > { - data: DataType; -} - -export function useApiFetch< DataType >( { - path, - errorMessage, -}: UseApiFetchParams ): { - data?: DataType; - isLoading: boolean; -} { - const cache = useContext( cacheContext ); - const [ data, setData ] = useState< DataType >(); - const [ isLoading, setLoading ] = useState( false ); - - useEffect( () => { - const fetch = async () => { - try { - setLoading( true ); - if ( cache[ path ] ) { - setData( cache[ path ] ); - } else { - const result = await apiFetch< ApiResponse< DataType > >( { - path, - } ); - cache[ path ] = result.data; - setData( result.data ); - } - } catch ( error ) { - if ( errorMessage ) - dispatch( 'core/notices' ).createErrorNotice( - errorMessage - ); - } finally { - setLoading( false ); - } - }; - fetch(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [ path, errorMessage ] ); - - return { data, isLoading }; -} diff --git a/client/merchant-feedback-prompt/hooks.ts b/client/merchant-feedback-prompt/hooks.ts new file mode 100644 index 00000000000..a534a9492dc --- /dev/null +++ b/client/merchant-feedback-prompt/hooks.ts @@ -0,0 +1,47 @@ +/** + * External dependencies + */ +import { useUserPreferences } from '@woocommerce/data'; + +/** + * Extend the user preferences returned from useUserPreferences to include the WooPayments merchant feedback prompt dismissed state. + * See WC_Payments::add_user_data_fields() in includes/class-wc-payments.php for the PHP implementation. + */ +interface UserPreferences extends ReturnType< typeof useUserPreferences > { + /** The unix timestamp of the dismissal of the merchant feedback prompt. */ + wc_payments_wporg_review_2025_prompt_dismissed?: number; +} + +/** + * A hook for managing the merchant feedback prompt visibility state. + * It returns the current visibility state and a function to update the state. + */ +export const useMerchantFeedbackPromptState = () => { + const { + updateUserPreferences, + ...userPrefs + } = useUserPreferences() as UserPreferences; + + const isAccountEligible = + wcpaySettings?.accountStatus?.campaigns?.wporgReview2025; + + const hasUserDismissedPrompt = + userPrefs?.wc_payments_wporg_review_2025_prompt_dismissed; + + const dismissPrompt = () => { + // Stored as a unix timestamp in case we want to let this expire in the future. + const unixTimestamp = Date.now(); + updateUserPreferences( { + wc_payments_wporg_review_2025_prompt_dismissed: unixTimestamp, + } ); + }; + + return { + /** Whether the account is eligible to be presented with the merchant feedback prompt. */ + isAccountEligible, + /** Whether the user has dismissed the merchant feedback prompt. */ + hasUserDismissedPrompt, + /** A function to dismiss the merchant feedback prompt. Stores the current timestamp of dismissal in user preferences. */ + dismissPrompt, + }; +}; diff --git a/client/merchant-feedback-prompt/index.tsx b/client/merchant-feedback-prompt/index.tsx new file mode 100644 index 00000000000..59de28f464e --- /dev/null +++ b/client/merchant-feedback-prompt/index.tsx @@ -0,0 +1,263 @@ +/** + * External dependencies + */ +import React, { useEffect, useState } from 'react'; +import ReactDOM from 'react-dom'; +import { + Button, + Flex, + FlexItem, + NoticeList, + SnackbarList, +} from '@wordpress/components'; +import { Icon, thumbsUp, thumbsDown } from '@wordpress/icons'; +import { useSelect } from '@wordpress/data'; +import { __ } from '@wordpress/i18n'; + +/** + * Internal dependencies + */ +import { recordEvent } from 'wcpay/tracks'; +import { PositiveFeedbackModal } from './positive-modal'; +import { NegativeFeedbackModal } from './negative-modal'; +import { useMerchantFeedbackPromptState } from './hooks'; +import './style.scss'; + +/** + * HACK: This file contains temporary workarounds to allow us to render a snackbar with two actions and a dismiss button. + * + * Workarounds include: + * - Using a React portal to render a custom Snackbar within a SnackbarList consistently with other core WP-admin notices – needed because `core/notices` `createNotice()` doesn't accept two actions. + * - See https://github.com/WordPress/gutenberg/blob/c300edfebb48f79f6f0f6643ce04dd73303c5fcb/packages/components/src/snackbar/index.tsx#L119-L126 + * - Adding a dismiss button to the custom Snackbar component – needed because the Snackbar component bundled by WooPayments doesn't have an `explicitDismiss` prop. + * - See https://github.com/WordPress/gutenberg/blob/c300edfebb48f79f6f0f6643ce04dd73303c5fcb/packages/components/src/snackbar/index.tsx#L166-L177 + * - Checking for the presence of WP-admin core notices to ensure that this prompt is not rendered if there are other notices being displayed. + * + * These temporary workarounds will remain in place until either: + * - This code is removed at the end of the campaign (paJDYF-gvt-p2), or + * - Gutenberg Snackbar component is updated to accept two actions and we can use `core/notices` `createNotice()` to render the snackbar. + */ + +/** + * A react portal for the merchant feedback prompt. + * This is used to render the custom snackbar prompt in the WC footer component, consistent with where WC notices (snackbars) are rendered. + */ +const WCFooterPortal = ( { children }: { children: React.ReactNode } ) => { + const portalRoot = document.getElementsByClassName( + 'woocommerce-layout__footer' + )[ 0 ]; + + if ( ! portalRoot ) { + return null; + } + + return ReactDOM.createPortal( children, portalRoot ); +}; + +interface MerchantFeedbackPromptProps { + /** A function to be called when the user dismisses the prompt and it is to be removed. */ + dismissPrompt: () => void; + /** A function to be called when the user clicks the "Yes" button and the positive feedback modal is to be shown. */ + showPositiveFeedbackModal: () => void; + /** A function to be called when the user clicks the "No" button and the negative feedback modal is to be shown. */ + showNegativeFeedbackModal: () => void; +} + +/** + * Renders the merchant feedback prompt (snackbar) in the WC footer. + * + * This is used to gather feedback from merchants about their experience with WooPayments. + * Only renders if there are no core notices and the prompt has not been dismissed. + */ +const MerchantFeedbackPrompt: React.FC< MerchantFeedbackPromptProps > = ( { + dismissPrompt, + showPositiveFeedbackModal, + showNegativeFeedbackModal, +} ) => { + // Get the core notices, which we'll use to ensure we're not rendering the prompt if there are other notices being displayed. + const coreNotices = useSelect( + ( select ) => + select( 'core/notices' ).getNotices() as NoticeList.Notice[] + ); + + // Only render the prompt if there are no core notices. + const shouldShowPrompt = coreNotices?.length === 0; + + useEffect( () => { + // Record the 'view' event when the prompt is rendered. + if ( shouldShowPrompt ) { + recordEvent( 'wcpay_merchant_feedback_prompt_view' ); + } + }, [ shouldShowPrompt ] ); + + if ( ! shouldShowPrompt ) { + return null; + } + + return ( + + + + { __( + 'Are you satisfied with WooPayments?', + 'woocommerce-payments' + ) } + + + + + + + + + + { + recordEvent( + 'wcpay_merchant_feedback_prompt_dismiss' + ); + dismissPrompt(); + } } + onKeyPress={ () => { + recordEvent( + 'wcpay_merchant_feedback_prompt_dismiss' + ); + dismissPrompt(); + } } + > + { /* Unicode character for "close" icon */ } + ✕ + + + + ), + }, + ] } + /> + + ); +}; + +/** + * A wrapper component that conditionally renders the merchant feedback prompt, including the positive and negative feedback modals. + * + * This is used to ensure the prompt is only rendered if the account is eligible for the campaign and the user has not dismissed the prompt. + */ +export function MaybeShowMerchantFeedbackPrompt() { + const { + isAccountEligible, + hasUserDismissedPrompt, + dismissPrompt, + } = useMerchantFeedbackPromptState(); + + const [ + isPositiveFeedbackModalOpen, + setIsPositiveFeedbackModalOpen, + ] = useState( false ); + + const [ + isNegativeFeedbackModalOpen, + setIsNegativeFeedbackModalOpen, + ] = useState( false ); + + if ( isPositiveFeedbackModalOpen ) { + return ( + setIsPositiveFeedbackModalOpen( false ) } + /> + ); + } + + if ( isNegativeFeedbackModalOpen ) { + return ( + setIsNegativeFeedbackModalOpen( false ) } + /> + ); + } + + if ( hasUserDismissedPrompt || ! isAccountEligible ) { + return null; + } + + return ( + + setIsPositiveFeedbackModalOpen( true ) + } + showNegativeFeedbackModal={ () => { + if ( window.wcTracks.isEnabled ) { + setIsNegativeFeedbackModalOpen( true ); + } else { + dismissPrompt(); + } + } } + /> + ); +} diff --git a/client/merchant-feedback-prompt/negative-modal.tsx b/client/merchant-feedback-prompt/negative-modal.tsx new file mode 100644 index 00000000000..ce14465c3eb --- /dev/null +++ b/client/merchant-feedback-prompt/negative-modal.tsx @@ -0,0 +1,133 @@ +/** + * External dependencies + */ +import React, { useEffect, useRef } from 'react'; +import { __ } from '@wordpress/i18n'; +import { Modal } from '@wordpress/components'; +import { dispatch } from '@wordpress/data'; +import interpolateComponents from '@automattic/interpolate-components'; + +/** + * Internal dependencies + */ +import { recordEvent } from 'wcpay/tracks'; +import './style.scss'; + +interface NegativeFeedbackModalProps { + onRequestClose: () => void; +} + +export const NegativeFeedbackModal: React.FC< NegativeFeedbackModalProps > = ( { + onRequestClose, +} ) => { + const textareaRef = useRef< HTMLTextAreaElement >( null ); + // Record tracks event when the modal is opened. + useEffect( () => { + recordEvent( 'wcpay_merchant_feedback_prompt_negative_modal_view' ); + }, [] ); + + return ( + { + recordEvent( + 'wcpay_merchant_feedback_prompt_negative_modal_close_click' + ); + onRequestClose(); + } } + > +
+

+ { __( + 'Thanks for sharing your feedback on WooPayments! Your feedback helps us to continue to improve and deliver the best tools for your business.', + 'woocommerce-payments' + ) } +

+

+ { __( + 'Would you mind sharing more about why you chose that option?', + 'woocommerce-payments' + ) } +

+