Skip to content

Commit

Permalink
Add payment method logos to blocks card label
Browse files Browse the repository at this point in the history
  • Loading branch information
mdmoore committed Dec 19, 2024
1 parent 863519d commit cbeab48
Show file tree
Hide file tree
Showing 6 changed files with 481 additions and 12 deletions.
48 changes: 36 additions & 12 deletions client/checkout/blocks/payment-method-label.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -81,7 +108,6 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => {
'blocks_checkout'
);
setAppearance( upeAppearance );
setUpeAppearanceTheme( upeAppearance.theme );
}

if ( ! appearance ) {
Expand All @@ -104,12 +130,10 @@ export default ( { api, title, countries, iconLight, iconDark, upeName } ) => {
{ __( 'Test Mode', 'woocommerce-payments' ) }
</span>
) }
<img
className="payment-methods--logos"
src={
upeAppearanceTheme === 'night' ? iconDark : iconLight
}
alt={ title }
<PaymentMethodsLogos
maxElements={ 4 }
paymentMethods={ paymentMethods }
breakpointConfigs={ breakpointConfigs }
/>
</div>
<PaymentMethodMessageWrapper
Expand Down
1 change: 1 addition & 0 deletions client/checkout/blocks/payment-methods-logos/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PaymentMethodsLogos } from './payment-methods-logos';
139 changes: 139 additions & 0 deletions client/checkout/blocks/payment-methods-logos/logo-popover.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
/**
* External dependencies
*/
import React, {
useEffect,
useLayoutEffect,
useRef,
useState,
useCallback,
} from 'react';

interface LogoPopoverProps {
id: string;
className?: string;
children: React.ReactNode;
anchor: HTMLElement | null;
open: boolean;
onClose?: () => 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 (
<div
id={ id }
ref={ popoverRef }
className={ `logo-popover ${ className || '' }` }
style={ {
position: 'fixed',
zIndex: 1000,
opacity: isPositioned ? 1 : 0,
transition: 'opacity 0.2s',
} }
role="dialog"
aria-label="Supported Credit Card Brands"
data-testid={ dataTestId }
>
{ children }
</div>
);
};
143 changes: 143 additions & 0 deletions client/checkout/blocks/payment-methods-logos/payment-methods-logos.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<div className="payment-methods--logos">
<div
ref={ anchorRef }
{ ...( shouldHavePopover && {
onClick: togglePopover,
onKeyDown: ( e ) => {
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 ) => (
<img
key={ pm.name }
alt={ pm.name }
src={ pm.component }
width={ 38 }
height={ 24 }
/>
) ) }
{ shouldHavePopover && (
<div className="payment-methods--logos-count">
+ { paymentMethods.length - maxShownElements }
</div>
) }
</div>
</div>
{ shouldHavePopover && popoverOpen && (
<LogoPopover
id="payment-methods-popover"
className="payment-methods--logos-popover"
anchor={ popoverAnchor }
open={ popoverOpen }
onClose={ handlePopoverClose }
dataTestId="payment-methods-popover"
>
{ paymentMethods.slice( maxShownElements ).map( ( pm ) => (
<img
key={ pm.name }
alt={ pm.name }
src={ pm.component }
width={ 38 }
height={ 24 }
/>
) ) }
</LogoPopover>
) }
</>
);
};
Loading

0 comments on commit cbeab48

Please sign in to comment.