diff --git a/changelog/fix-7437-refactor-order-dispute-side-effects b/changelog/fix-7437-refactor-order-dispute-side-effects new file mode 100644 index 00000000000..5e596f4d817 --- /dev/null +++ b/changelog/fix-7437-refactor-order-dispute-side-effects @@ -0,0 +1,4 @@ +Significance: patch +Type: dev + +Fire a tracks event for disputed order notice view. diff --git a/client/components/disputed-order-notice/index.js b/client/components/disputed-order-notice/index.js new file mode 100644 index 00000000000..13ea7af71e5 --- /dev/null +++ b/client/components/disputed-order-notice/index.js @@ -0,0 +1,234 @@ +import moment from 'moment'; +import React, { useEffect } from 'react'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { dateI18n } from '@wordpress/date'; +import { createInterpolateElement } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import InlineNotice from 'wcpay/components/inline-notice'; +import { formatExplicitCurrency } from 'utils/currency'; +import { reasons } from 'wcpay/disputes/strings'; +import { getDetailsURL } from 'wcpay/components/details-link'; +import { + isAwaitingResponse, + isInquiry, + isUnderReview, +} from 'wcpay/disputes/utils'; +import { useCharge } from 'wcpay/data'; +import wcpayTracks from 'tracks'; +import './style.scss'; + +const DisputedOrderNoticeHandler = ( { chargeId, onDisableOrderRefund } ) => { + const { data: charge } = useCharge( chargeId ); + const disputeDetailsUrl = getDetailsURL( chargeId, 'transactions' ); + + // Disable the refund button if there's an active dispute. + useEffect( () => { + const { dispute } = charge; + if ( ! charge?.dispute ) { + return; + } + // Refunds are only allowed if the dispute is an inquiry or if it's won. + const isRefundable = + isInquiry( dispute ) || [ 'won' ].includes( dispute.status ); + if ( ! isRefundable ) { + onDisableOrderRefund( dispute.status ); + } + }, [ charge, onDisableOrderRefund ] ); + + const { dispute } = charge; + if ( ! charge?.dispute ) { + return null; + } + const isRefundable = + isInquiry( dispute ) || [ 'won' ].includes( dispute.status ); + + // Special case the dispute "under review" notice which is much simpler. + // (And return early.) + if ( isUnderReview( dispute.status ) && ! isInquiry( dispute ) ) { + return ( + + ); + } + + // Special case lost disputes. + // (And return early.) + // I suspect this is unnecessary, as any lost disputes will have already been + // refunded as part of `charge.dispute.closed` webhook handler. + // This may be dead code. Leaving in for now as this is consistent with + // the logic before this PR. + // https://github.com/Automattic/woocommerce-payments/pull/7557 + if ( dispute.status === 'lost' && ! isRefundable ) { + return ( + + ); + } + + // Only show the notice if the dispute is awaiting a response. + if ( ! isAwaitingResponse( dispute.status ) ) { + return null; + } + + // Bail if we don't have due_by for whatever reason. + if ( ! dispute.evidence_details?.due_by ) { + return null; + } + + const now = moment(); + const dueBy = moment.unix( dispute.evidence_details?.due_by ); + + // If the dispute is due in the past, don't show notice. + if ( ! now.isBefore( dueBy ) ) { + return null; + } + + return ( + + ); +}; + +const DisputeNeedsResponseNotice = ( { + disputeReason, + formattedAmount, + isPreDisputeInquiry, + dueBy, + countdownDays, + disputeDetailsUrl, +} ) => { + useEffect( () => { + wcpayTracks.recordEvent( 'wcpay_order_dispute_notice_view', { + is_inquiry: isPreDisputeInquiry, + dispute_reason: disputeReason, + due_by_days: countdownDays, + } ); + }, [ isPreDisputeInquiry, disputeReason, countdownDays ] ); + + const titleStrings = { + // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. + dispute_default: __( + // eslint-disable-next-line max-len + 'This order has been disputed in the amount of %1$s. The customer provided the following reason: %2$s. Please respond to this dispute before %3$s.', + 'woocommerce-payments' + ), + // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. + inquiry_default: __( + // eslint-disable-next-line max-len + 'The card network involved in this order has opened an inquiry into the transaction with the following reason: %2$s. Please respond to this inquiry before %3$s, just like you would for a formal dispute.', + 'woocommerce-payments' + ), + // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. + dispute_urgent: __( + 'Please resolve the dispute on this order for %1$s labeled "%2$s" by %3$s.', + 'woocommerce-payments' + ), + // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. + inquiry_urgent: __( + 'Please resolve the inquiry on this order for %1$s labeled "%2$s" by %3$s.', + 'woocommerce-payments' + ), + }; + + let buttonLabel = __( 'Respond now', 'woocommerce-payments' ); + let suffix = ''; + + let titleText = isPreDisputeInquiry + ? titleStrings.inquiry_default + : titleStrings.dispute_default; + + // If the dispute is due within 7 days, adjust wording and highlight urgency. + if ( countdownDays < 7 ) { + titleText = isPreDisputeInquiry + ? titleStrings.inquiry_urgent + : titleStrings.dispute_urgent; + + suffix = sprintf( + // Translators: %s is the number of days left to respond to the dispute. + _n( + '(%s day left)', + '(%s days left)', + countdownDays, + 'woocommerce-payments' + ), + countdownDays + ); + } + + const title = sprintf( + titleText, + formattedAmount, + reasons[ disputeReason ].display, + dateI18n( 'M j, Y', dueBy.local().toISOString() ) + ); + + if ( countdownDays < 1 ) { + buttonLabel = __( 'Respond today', 'woocommerce-payments' ); + suffix = __( '(Last day today)', 'woocommerce-payments' ); + } + + return ( + { + wcpayTracks.recordEvent( + 'wcpay_order_dispute_notice_action_click', + { + due_by_days: countdownDays, + } + ); + window.location = disputeDetailsUrl; + }, + }, + ] } + > + { { `${ title } ${ suffix }` } } + + ); +}; + +const DisputeOrderLockedNotice = ( { message, disputeDetailsUrl } ) => { + return ( + + { message } + { createInterpolateElement( + __( ' View details', 'woocommerce-payments' ), + { + // createInterpolateElement is incompatible with this eslint rule as the is decoupled from content. + // eslint-disable-next-line jsx-a11y/anchor-has-content + a: , + } + ) } + + ); +}; + +export default DisputedOrderNoticeHandler; diff --git a/client/order/style.scss b/client/components/disputed-order-notice/style.scss similarity index 100% rename from client/order/style.scss rename to client/components/disputed-order-notice/style.scss diff --git a/client/order/index.js b/client/order/index.js index 42184ae92e8..4c983955c10 100644 --- a/client/order/index.js +++ b/client/order/index.js @@ -1,30 +1,58 @@ /* global jQuery */ -import { __, _n, sprintf } from '@wordpress/i18n'; -import { dateI18n } from '@wordpress/date'; import ReactDOM from 'react-dom'; +import React from 'react'; +import { __ } from '@wordpress/i18n'; import { dispatch } from '@wordpress/data'; -import moment from 'moment'; -import { createInterpolateElement } from '@wordpress/element'; /** * Internal dependencies */ import { getConfig } from 'utils/order'; +import { isAwaitingResponse, isUnderReview } from 'wcpay/disputes/utils'; import RefundConfirmationModal from './refund-confirm-modal'; import CancelConfirmationModal from './cancel-confirm-modal'; -import InlineNotice from 'components/inline-notice'; -import { formatExplicitCurrency } from 'utils/currency'; -import { reasons } from 'wcpay/disputes/strings'; -import { getDetailsURL } from 'wcpay/components/details-link'; -import { - isAwaitingResponse, - isInquiry, - isUnderReview, -} from 'wcpay/disputes/utils'; -import { useCharge } from 'wcpay/data'; -import wcpayTracks from 'tracks'; -import './style.scss'; +import DisputedOrderNoticeHandler from 'wcpay/components/disputed-order-notice'; + +function disableWooOrderRefundButton( disputeStatus ) { + const refundButton = document.querySelector( 'button.refund-items' ); + if ( ! refundButton ) { + return; + } + + refundButton.disabled = true; + + // Show helpful info in order edit lock icon tooltip. + + let tooltipText = ''; + if ( isAwaitingResponse( disputeStatus ) ) { + tooltipText = __( + 'Refunds and order editing are disabled during disputes.', + 'woocommerce-payments' + ); + } else if ( isUnderReview( disputeStatus ) ) { + tooltipText = __( + 'Refunds and order editing are disabled during an active dispute.', + 'woocommerce-payments' + ); + } else if ( disputeStatus === 'lost' ) { + tooltipText = __( + 'Refunds and order editing have been disabled as a result of a lost dispute.', + 'woocommerce-payments' + ); + } + + jQuery( refundButton ) + .parent() + .find( '.woocommerce-help-tip' ) + .attr( { + // jQuery.tipTip uses the title attribute to generate the tooltip. + title: tooltipText, + 'aria-label': tooltipText, + } ) + // Regenerate the tipTip tooltip. + .tipTip(); +} jQuery( function ( $ ) { const disableManualRefunds = getConfig( 'disableManualRefunds' ) ?? false; @@ -135,221 +163,12 @@ jQuery( function ( $ ) { return; } - ReactDOM.render( , container ); + ReactDOM.render( + , + container + ); } } ); - -const DisputeNotice = ( { chargeId } ) => { - const { data: charge } = useCharge( chargeId ); - - if ( ! charge?.dispute ) { - return null; - } - - const { dispute } = charge; - - let urgency = 'warning'; - let actions; - - // Refunds are only allowed if the dispute is an inquiry or if it's won. - const isRefundable = - isInquiry( dispute ) || [ 'won' ].includes( dispute.status ); - const shouldDisableRefund = ! isRefundable; - let disableRefund = false; - - let refundDisabledNotice = ''; - if ( shouldDisableRefund ) { - const refundButton = document.querySelector( 'button.refund-items' ); - if ( refundButton ) { - disableRefund = true; - - // Disable the refund button. - refundButton.disabled = true; - - const disputeDetailsLink = getDetailsURL( - chargeId, - 'transactions' - ); - - let tooltipText = ''; - - if ( isAwaitingResponse( dispute.status ) ) { - refundDisabledNotice = __( - 'Refunds and order editing are disabled during disputes.', - 'woocommerce-payments' - ); - tooltipText = refundDisabledNotice; - } else if ( isUnderReview( dispute.status ) ) { - refundDisabledNotice = createInterpolateElement( - __( - // eslint-disable-next-line max-len - 'This order has an active payment dispute. Refunds and order editing are disabled during this time. View details', - 'woocommerce-payments' - ), - { - // eslint-disable-next-line jsx-a11y/anchor-has-content - a: , - } - ); - tooltipText = __( - 'Refunds and order editing are disabled during an active dispute.', - 'woocommerce-payments' - ); - } else if ( dispute.status === 'lost' ) { - refundDisabledNotice = createInterpolateElement( - __( - 'Refunds and order editing have been disabled as a result of a lost dispute. View details', - 'woocommerce-payments' - ), - { - // eslint-disable-next-line jsx-a11y/anchor-has-content - a: , - } - ); - tooltipText = __( - 'Refunds and order editing have been disabled as a result of a lost dispute.', - 'woocommerce-payments' - ); - } - - // Change refund tooltip's text copy. - jQuery( refundButton ) - .parent() - .find( '.woocommerce-help-tip' ) - .attr( { - // jQuery.tipTip uses the title attribute to generate the tooltip. - title: tooltipText, - 'aria-label': tooltipText, - } ) - // Regenerate the tipTip tooltip. - .tipTip(); - } - } - - let showWarning = false; - let warningText = ''; - - if ( - dispute.evidence_details?.due_by && - // Only show the notice if the dispute is awaiting a response. - isAwaitingResponse( dispute.status ) - ) { - const now = moment(); - const dueBy = moment.unix( dispute.evidence_details?.due_by ); - const countdownDays = Math.floor( dueBy.diff( now, 'days', true ) ); - - // If the dispute is due in the past, we don't want to show the notice. - if ( now.isBefore( dueBy ) ) { - showWarning = true; - - const titleStrings = { - // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. - dispute_default: __( - // eslint-disable-next-line max-len - 'This order has been disputed in the amount of %1$s. The customer provided the following reason: %2$s. Please respond to this dispute before %3$s.', - 'woocommerce-payments' - ), - // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. - inquiry_default: __( - // eslint-disable-next-line max-len - 'The card network involved in this order has opened an inquiry into the transaction with the following reason: %2$s. Please respond to this inquiry before %3$s, just like you would for a formal dispute.', - 'woocommerce-payments' - ), - // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. - dispute_urgent: __( - 'Please resolve the dispute on this order for %1$s labeled "%2$s" by %3$s.', - 'woocommerce-payments' - ), - // Translators: %1$s is the formatted dispute amount, %2$s is the dispute reason, %3$s is the due date. - inquiry_urgent: __( - 'Please resolve the inquiry on this order for %1$s labeled "%2$s" by %3$s.', - 'woocommerce-payments' - ), - }; - const amountFormatted = formatExplicitCurrency( - dispute.amount, - dispute.currency - ); - - let buttonLabel = __( 'Respond now', 'woocommerce-payments' ); - let suffix = ''; - - let titleText = isInquiry( dispute ) - ? titleStrings.inquiry_default - : titleStrings.dispute_default; - - // If the dispute is due within 7 days, use different wording. - if ( countdownDays < 7 ) { - titleText = isInquiry( dispute ) - ? titleStrings.inquiry_urgent - : titleStrings.dispute_urgent; - - suffix = sprintf( - // Translators: %s is the number of days left to respond to the dispute. - _n( - '(%s day left)', - '(%s days left)', - countdownDays, - 'woocommerce-payments' - ), - countdownDays - ); - } - - const title = sprintf( - titleText, - amountFormatted, - reasons[ dispute.reason ].display, - dateI18n( 'M j, Y', dueBy.local().toISOString() ) - ); - - // If the dispute is due within 72 hours, we want to highlight it as urgent/red. - if ( countdownDays < 3 ) { - urgency = 'error'; - } - - if ( countdownDays < 1 ) { - buttonLabel = __( 'Respond today', 'woocommerce-payments' ); - suffix = __( '(Last day today)', 'woocommerce-payments' ); - } - - actions = [ - { - label: buttonLabel, - variant: 'secondary', - onClick: () => { - wcpayTracks.recordEvent( - wcpayTracks.events - .ORDER_DISPUTE_NOTICE_BUTTON_CLICK, - { - due_by_days: parseInt( countdownDays, 10 ), - } - ); - window.location = getDetailsURL( - chargeId, - 'transactions' - ); - }, - }, - ]; - - warningText = `${ title } ${ suffix }`; - } - } - - if ( ! showWarning && ! disableRefund ) { - return null; - } - - return ( - - { showWarning && { warningText } } - - { disableRefund &&
{ refundDisabledNotice }
} -
- ); -}; diff --git a/client/tracks/index.js b/client/tracks/index.js index 866b3106a46..c02615f781d 100644 --- a/client/tracks/index.js +++ b/client/tracks/index.js @@ -76,8 +76,6 @@ const events = { DISPUTE_INQUIRY_REFUND_MODAL_VIEW: 'wcpay_dispute_inquiry_refund_modal_view', GOOGLEPAY_BUTTON_CLICK: 'gpay_button_click', - ORDER_DISPUTE_NOTICE_BUTTON_CLICK: - 'wcpay_order_dispute_notice_action_click', OVERVIEW_BALANCES_CURRENCY_CLICK: 'wcpay_overview_balances_currency_tab_click', OVERVIEW_DEPOSITS_VIEW_HISTORY_CLICK: