From cbeab483aaa1dec835fb46cfc8eff629ae085ac0 Mon Sep 17 00:00:00 2001 From: Mike Moore Date: Thu, 19 Dec 2024 15:02:11 -0500 Subject: [PATCH] Add payment method logos to blocks card label --- .../checkout/blocks/payment-method-label.js | 48 ++++-- .../blocks/payment-methods-logos/index.ts | 1 + .../payment-methods-logos/logo-popover.tsx | 139 +++++++++++++++++ .../payment-methods-logos.tsx | 143 ++++++++++++++++++ .../blocks/payment-methods-logos/style.scss | 47 ++++++ .../blocks/test/payment-method-logos.test.tsx | 115 ++++++++++++++ 6 files changed, 481 insertions(+), 12 deletions(-) create mode 100644 client/checkout/blocks/payment-methods-logos/index.ts create mode 100644 client/checkout/blocks/payment-methods-logos/logo-popover.tsx create mode 100644 client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx create mode 100644 client/checkout/blocks/payment-methods-logos/style.scss create mode 100644 client/checkout/blocks/test/payment-method-logos.test.tsx diff --git a/client/checkout/blocks/payment-method-label.js b/client/checkout/blocks/payment-method-label.js index 752a9b830db..38a0ccf9d88 100644 --- a/client/checkout/blocks/payment-method-label.js +++ b/client/checkout/blocks/payment-method-label.js @@ -5,6 +5,11 @@ import { Elements, PaymentMethodMessagingElement, } from '@stripe/react-stripe-js'; +import { PaymentMethodsLogos } from './payment-methods-logos'; +import Visa from 'assets/images/payment-method-icons/visa.svg?asset'; +import Mastercard from 'assets/images/payment-method-icons/mastercard.svg?asset'; +import Amex from 'assets/images/payment-method-icons/amex.svg?asset'; +import Discover from 'assets/images/payment-method-icons/discover.svg?asset'; import { normalizeCurrencyToMinorUnit } from '../utils'; import { useStripeForUPE } from 'wcpay/hooks/use-stripe-async'; import { getUPEConfig } from 'wcpay/utils/checkout'; @@ -13,6 +18,32 @@ import './style.scss'; import { useEffect, useState } from '@wordpress/element'; import { getAppearance } from 'wcpay/checkout/upe-styles'; +const paymentMethods = [ + { + name: 'visa', + component: Visa, + }, + { + name: 'mastercard', + component: Mastercard, + }, + { + name: 'amex', + component: Amex, + }, + { + name: 'discover', + component: Discover, + }, + // TODO: Missing Diners Club + // TODO: What other card payment methods should be here? +]; +const breakpointConfigs = [ + { breakpoint: 550, maxElements: 2 }, + { breakpoint: 833, maxElements: 4 }, + { breakpoint: 960, maxElements: 2 }, +]; + const bnplMethods = [ 'affirm', 'afterpay_clearpay', 'klarna' ]; const PaymentMethodMessageWrapper = ( { upeName, @@ -47,16 +78,12 @@ const PaymentMethodMessageWrapper = ( { ); }; -export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { +export default ( { api, title, countries, upeName } ) => { const cartData = wp.data.select( 'wc/store/cart' ).getCartData(); const isTestMode = getUPEConfig( 'testMode' ); const [ appearance, setAppearance ] = useState( getUPEConfig( 'wcBlocksUPEAppearance' ) ); - const [ upeAppearanceTheme, setUpeAppearanceTheme ] = useState( - getUPEConfig( 'wcBlocksUPEAppearanceTheme' ) - ); - // Stripe expects the amount to be sent as the minor unit of 2 digits. const amount = parseInt( normalizeCurrencyToMinorUnit( @@ -81,7 +108,6 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { 'blocks_checkout' ); setAppearance( upeAppearance ); - setUpeAppearanceTheme( upeAppearance.theme ); } if ( ! appearance ) { @@ -104,12 +130,10 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => { { __( 'Test Mode', 'woocommerce-payments' ) } ) } - { void; + dataTestId?: string; +} + +export const LogoPopover: React.FC< LogoPopoverProps > = ( { + id, + className, + children, + anchor, + open, + onClose, + dataTestId, +} ) => { + const popoverRef = useRef< HTMLDivElement >( null ); + const [ isPositioned, setIsPositioned ] = useState( false ); + + const updatePosition = useCallback( () => { + const popover = popoverRef.current; + if ( ! popover || ! anchor ) { + return; + } + + // Get the most up-to-date anchor rect + const anchorRect = anchor.getBoundingClientRect(); + + // Temporarily make the popover visible to get correct dimensions + popover.style.visibility = 'hidden'; + popover.style.display = 'block'; + const popoverRect = popover.getBoundingClientRect(); + popover.style.display = ''; + popover.style.visibility = ''; + + const offset = 7; + const left = anchorRect.left; + // Position the popover above the anchor + const top = anchorRect.top - popoverRect.height - offset; + + popover.style.position = 'fixed'; + popover.style.width = `${ anchorRect.width }px`; + popover.style.left = `${ left }px`; + popover.style.top = `${ top }px`; + + // Adjust position if popover goes off-screen + if ( top < 0 ) { + // If there's not enough space above, position it below the anchor + popover.style.top = `${ anchorRect.bottom + offset }px`; + } + + setIsPositioned( true ); + }, [ anchor ] ); + + useLayoutEffect( () => { + if ( open && anchor ) { + // Use requestAnimationFrame to ensure the DOM has updated before positioning + requestAnimationFrame( updatePosition ); + } + }, [ open, anchor, updatePosition ] ); + + useEffect( () => { + if ( open && anchor ) { + const observer = new MutationObserver( updatePosition ); + observer.observe( anchor, { + attributes: true, + childList: true, + subtree: true, + } ); + + window.addEventListener( 'resize', updatePosition ); + window.addEventListener( 'scroll', updatePosition ); + + const handleOutsideClick = ( event: MouseEvent ) => { + if ( + popoverRef.current && + ! popoverRef.current.contains( event.target as Node ) && + ! anchor.contains( event.target as Node ) + ) { + onClose?.(); + } + }; + + const handleEscapeKey = ( event: KeyboardEvent ) => { + if ( event.key === 'Escape' ) { + onClose?.(); + } + }; + + document.addEventListener( 'mousedown', handleOutsideClick ); + document.addEventListener( 'keydown', handleEscapeKey ); + + return () => { + observer.disconnect(); + window.removeEventListener( 'resize', updatePosition ); + window.removeEventListener( 'scroll', updatePosition ); + document.removeEventListener( 'mousedown', handleOutsideClick ); + document.removeEventListener( 'keydown', handleEscapeKey ); + }; + } + }, [ open, anchor, updatePosition, onClose ] ); + + if ( ! open ) { + return null; + } + + return ( + + ); +}; diff --git a/client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx b/client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx new file mode 100644 index 00000000000..0fb54df3755 --- /dev/null +++ b/client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx @@ -0,0 +1,143 @@ +/** + * External dependencies + */ +import React, { useState, useEffect, useCallback, useRef } from 'react'; +/** + * Internal dependencies + */ +import { LogoPopover } from './logo-popover'; +import './style.scss'; + +interface BreakpointConfig { + breakpoint: number; + maxElements: number; +} + +interface PaymentMethodsLogosProps { + maxElements: number; + paymentMethods: { name: string; component: string }[]; + breakpointConfigs?: BreakpointConfig[]; +} + +const breakpointConfigsDefault = [ + { breakpoint: 480, maxElements: 5 }, + { breakpoint: 768, maxElements: 7 }, +]; +const paymentMethodsDefault: never[] = []; +export const PaymentMethodsLogos: React.FC< PaymentMethodsLogosProps > = ( { + maxElements = 10, + paymentMethods = paymentMethodsDefault, + breakpointConfigs = breakpointConfigsDefault, +} ) => { + const [ maxShownElements, setMaxShownElements ] = useState( maxElements ); + const [ + popoverAnchor, + setPopoverAnchor, + ] = useState< HTMLDivElement | null >( null ); + const [ popoverOpen, setPopoverOpen ] = useState( false ); + const [ shouldHavePopover, setShouldHavePopover ] = useState( false ); + + const togglePopover = () => setPopoverOpen( ! popoverOpen ); + + const anchorRef = useCallback( ( node: HTMLDivElement | null ) => { + if ( node !== null ) { + setPopoverAnchor( node ); + } + }, [] ); + + const buttonRef = useRef< HTMLDivElement | null >( null ); + + const handlePopoverClose = useCallback( () => { + setPopoverOpen( false ); + buttonRef.current?.focus(); + }, [] ); + + useEffect( () => { + const updateMaxElements = () => { + const sortedConfigs = [ ...breakpointConfigs ].sort( + ( a, b ) => a.breakpoint - b.breakpoint + ); + const config = sortedConfigs.find( + ( cfg ) => window.innerWidth <= cfg.breakpoint + ); + + setMaxShownElements( config ? config.maxElements : maxElements ); + }; + + updateMaxElements(); + window.addEventListener( 'resize', updateMaxElements ); + + return () => window.removeEventListener( 'resize', updateMaxElements ); + }, [ breakpointConfigs, maxElements ] ); + + useEffect( () => { + if ( popoverAnchor ) { + buttonRef.current = popoverAnchor; + } + }, [ popoverAnchor ] ); + + useEffect( () => { + setShouldHavePopover( paymentMethods.length > maxShownElements ); + }, [ maxShownElements, paymentMethods.length ] ); + + return ( + <> +
+
{ + if ( e.key === 'Enter' || e.key === ' ' ) { + e.preventDefault(); + togglePopover(); + } + }, + role: 'button', + tabIndex: 0, + 'aria-expanded': popoverOpen, + 'aria-controls': 'payment-methods-popover', + } ) } + data-testid="payment-methods-logos" + > + { paymentMethods + .slice( 0, maxShownElements ) + .map( ( pm ) => ( + { + ) ) } + { shouldHavePopover && ( +
+ + { paymentMethods.length - maxShownElements } +
+ ) } +
+
+ { shouldHavePopover && popoverOpen && ( + + { paymentMethods.slice( maxShownElements ).map( ( pm ) => ( + { + ) ) } + + ) } + + ); +}; diff --git a/client/checkout/blocks/payment-methods-logos/style.scss b/client/checkout/blocks/payment-methods-logos/style.scss new file mode 100644 index 00000000000..b5f08d2ef8d --- /dev/null +++ b/client/checkout/blocks/payment-methods-logos/style.scss @@ -0,0 +1,47 @@ +.payment-methods--logos { + > div { + display: flex; + align-items: center; + + img { + width: 37px; + height: 24px; + margin-right: 4px; + border: 1px solid $gray-300; + border-radius: 3px; + } + } + + &-count { + width: 38px; + height: 24px; + background-color: rgba( $gray-700, 0.1 ); + color: $gray-900; + text-align: center; + line-height: 24px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + } +} + +.logo-popover { + background-color: #fff; + border: 1px solid $gray-300; + border-radius: 3px; + padding: 10px; + box-sizing: border-box; + box-shadow: 0 0 10px 0 rgba( 0, 0, 0, 0.1 ); + display: grid; + grid-template-columns: repeat( auto-fit, minmax( 38px, 1fr ) ); + gap: 10px; + justify-items: center; + align-items: center; + cursor: pointer; + + > img { + box-shadow: 0 0 0 1px rgba( 0, 0, 0, 0.1 ); + max-width: 100%; + height: auto; + } +} diff --git a/client/checkout/blocks/test/payment-method-logos.test.tsx b/client/checkout/blocks/test/payment-method-logos.test.tsx new file mode 100644 index 00000000000..2e946884312 --- /dev/null +++ b/client/checkout/blocks/test/payment-method-logos.test.tsx @@ -0,0 +1,115 @@ +/** + * External dependencies + */ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, test, expect } from '@jest/globals'; +/** + * Internal dependencies + */ +import { PaymentMethodsLogos } from '../payment-methods-logos'; + +const mockPaymentMethods = [ + { name: 'Visa', component: 'visa.png' }, + { name: 'MasterCard', component: 'mastercard.png' }, + { name: 'PayPal', component: 'paypal.png' }, + { name: 'Amex', component: 'amex.png' }, + { name: 'Discover', component: 'discover.png' }, +]; + +describe( 'PaymentMethodsLogos', () => { + test( 'renders without crashing', () => { + render( + + ); + const logoContainer = screen.getByTestId( 'payment-methods-logos' ); + expect( logoContainer ).toBeTruthy(); + } ); + + test( 'displays correct number of logos based on maxElements', () => { + render( + + ); + const logos = screen.queryAllByRole( 'img' ); + expect( logos ).toHaveLength( 3 ); + } ); + + test( 'shows popover indicator when there are more payment methods than maxElements', () => { + render( + + ); + const popoverIndicator = screen.queryByText( + `+ ${ mockPaymentMethods.length - 3 }` + ); + expect( popoverIndicator ).toBeTruthy(); + } ); + + test( 'opens popover on button click', async () => { + render( + + ); + const button = screen.getByTestId( 'payment-methods-logos' ); + + fireEvent.click( button ); + + const popover = await screen.findByTestId( 'payment-methods-popover' ); + expect( popover ).toBeTruthy(); + } ); + + test( 'handles keyboard navigation', async () => { + render( + + ); + const button = screen.getByTestId( 'payment-methods-logos' ); + + fireEvent.keyDown( button, { key: 'Enter' } ); + + const popover = await screen.findByTestId( 'payment-methods-popover' ); + expect( popover ).toBeTruthy(); + } ); + + test( 'does not show popover indicator when there are fewer payment methods than maxElements', () => { + render( + + ); + const popoverIndicator = screen.queryByText( /^\+\s*\d+$/ ); + expect( popoverIndicator ).toBeNull(); + + const logos = screen.getAllByRole( 'img' ); + expect( logos ).toHaveLength( mockPaymentMethods.length ); + } ); + + test( 'does not show popover when there are fewer payment methods than maxElements', async () => { + render( + + ); + + const button = screen.getByTestId( 'payment-methods-logos' ); + + fireEvent.click( button ); + + const popover = screen.queryByTestId( 'payment-methods-popover' ); + expect( popover ).toBeNull(); + } ); +} );