Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
a81d6bf
Add Jetpack Constants package
vladolaru Nov 26, 2025
9e2d823
Use Jetpack Constants to check for constants so we can mock them
vladolaru Nov 26, 2025
20744f6
Use a more standard check
vladolaru Nov 26, 2025
2fa5549
test: Update tests to use Jetpack Constants
vladolaru Nov 26, 2025
0003079
refac: Use plural Payments when referring to Payments Settings
vladolaru Nov 26, 2025
9359dc0
Ensure reliable mount of the spotlight
vladolaru Nov 26, 2025
d82df8f
Properly clean the timeout on early unmounting
vladolaru Nov 26, 2025
c79f6ab
Accesibility improvements
vladolaru Nov 26, 2025
6d45a9b
Use explicit import
vladolaru Nov 26, 2025
f5504b0
test: Fix test
vladolaru Nov 26, 2025
84cc265
Add better type guards and more defensive handling of API errors
vladolaru Nov 26, 2025
3568e6e
Fix non-i18n strings
vladolaru Nov 26, 2025
e3e7d33
Be explicit about relying on window
vladolaru Nov 26, 2025
5d0151b
test: Use explicit types
vladolaru Nov 26, 2025
72b292d
Use standard dispatch
vladolaru Nov 26, 2025
b208ff4
Add rel noopener when opening links
vladolaru Nov 26, 2025
ef0ce9e
Unset reference
vladolaru Nov 26, 2025
7e975f5
Merge branch 'add/payment-method-promotions' into update/payment-meth…
vladolaru Nov 26, 2025
cd245e2
refac: Be explicit about these promotions being for payment methods
vladolaru Nov 26, 2025
aeb6816
Use pm shorthand instead of payment-method
vladolaru Nov 26, 2025
fbd3cb8
Psalm fixes
vladolaru Nov 27, 2025
fce6111
Cleanup
vladolaru Nov 27, 2025
25066ec
Don't autoload promotions options
vladolaru Nov 27, 2025
26ef547
refac: Introduce pm promotions service
vladolaru Nov 27, 2025
599d9a5
Merge branch 'add/payment-method-promotions' into update/payment-meth…
vladolaru Nov 27, 2025
884664b
Psalm fixes
vladolaru Nov 27, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions client/components/spotlight/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ describe( 'Spotlight Component', () => {
expect( screen.getByTestId( 'custom-image' ) ).toBeInTheDocument();
} );

it( 'calls onPrimaryClick and onDismiss when primary button is clicked', () => {
it( 'calls onPrimaryClick and onDismiss when primary button is clicked', async () => {
const onPrimaryClick = jest.fn();
const onDismiss = jest.fn();

Expand All @@ -127,10 +127,17 @@ describe( 'Spotlight Component', () => {
);

const primaryButton = screen.getByText( 'Activate' );
userEvent.click( primaryButton );
await userEvent.click( primaryButton );

expect( onPrimaryClick ).toHaveBeenCalledTimes( 1 );
// onDismiss is called after animation timeout

// onDismiss is called after animation timeout (300ms)
await waitFor(
() => {
expect( onDismiss ).toHaveBeenCalledTimes( 1 );
},
{ timeout: 500 }
);
} );

it( 'calls onSecondaryClick when secondary button is clicked', () => {
Expand Down
94 changes: 88 additions & 6 deletions client/components/spotlight/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* External dependencies
*/
import React, { useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
Card,
CardBody,
Expand Down Expand Up @@ -40,6 +40,11 @@ const Spotlight: React.FC< SpotlightProps > = ( {
} ) => {
const [ isVisible, setIsVisible ] = useState( false );
const [ isAnimatingIn, setIsAnimatingIn ] = useState( false );
const closeTimeoutRef = useRef< ReturnType< typeof setTimeout > | null >(
null
);
const dialogRef = useRef< HTMLDivElement >( null );
const previouslyFocusedElementRef = useRef< HTMLElement | null >( null );

useEffect( () => {
if ( showImmediately ) {
Expand All @@ -62,6 +67,15 @@ const Spotlight: React.FC< SpotlightProps > = ( {
return () => clearTimeout( timer );
}, [ showImmediately ] );

// Cleanup close timeout on unmount
useEffect( () => {
return () => {
if ( closeTimeoutRef.current ) {
clearTimeout( closeTimeoutRef.current );
}
};
}, [] );

// Call onView when spotlight becomes visible
useEffect( () => {
if ( isAnimatingIn && onView ) {
Expand All @@ -70,14 +84,72 @@ const Spotlight: React.FC< SpotlightProps > = ( {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isAnimatingIn ] );

const handleClose = () => {
const handleClose = useCallback( () => {
setIsAnimatingIn( false );
// Wait for animation to complete before hiding
setTimeout( () => {
closeTimeoutRef.current = setTimeout( () => {
setIsVisible( false );
onDismiss();
}, 300 );
};
}, [ onDismiss ] );

// Focus management: save previous focus, focus dialog, handle Escape, trap focus, restore on close
useEffect( () => {
if ( ! isVisible || ! dialogRef.current ) {
return;
}

const dialog = dialogRef.current;
const ownerDocument = dialog.ownerDocument;

// Save the currently focused element to restore later
previouslyFocusedElementRef.current = ownerDocument.activeElement as HTMLElement;

// Focus the dialog
dialog.focus();

const handleKeyDown = ( event: KeyboardEvent ) => {
if ( event.key === 'Escape' ) {
event.preventDefault();
handleClose();
return;
}

// Focus trapping
if ( event.key === 'Tab' ) {
const focusableElements = dialog.querySelectorAll<
HTMLElement
>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[ 0 ];
const lastElement =
focusableElements[ focusableElements.length - 1 ];
const activeElement = ownerDocument.activeElement;

if ( event.shiftKey && activeElement === firstElement ) {
// Shift + Tab: if on first element, wrap to last
event.preventDefault();
lastElement?.focus();
} else if (
! event.shiftKey &&
activeElement === lastElement
) {
// Tab: if on last element, wrap to first
event.preventDefault();
firstElement?.focus();
}
}
};

ownerDocument.addEventListener( 'keydown', handleKeyDown );

return () => {
ownerDocument.removeEventListener( 'keydown', handleKeyDown );
// Restore focus to previously focused element
previouslyFocusedElementRef.current?.focus();
};
}, [ isVisible, handleClose ] );

const handlePrimaryClick = () => {
onPrimaryClick();
Expand All @@ -94,7 +166,14 @@ const Spotlight: React.FC< SpotlightProps > = ( {
isAnimatingIn ? 'wcpay-spotlight--visible' : ''
}` }
>
<div className="wcpay-spotlight__container">
<div
ref={ dialogRef }
role="dialog"
aria-modal="true"
aria-labelledby="spotlight-heading"
tabIndex={ -1 }
className="wcpay-spotlight__container"
>
<Card
className={ `wcpay-spotlight__card ${
image ? 'has-image' : ''
Expand Down Expand Up @@ -147,7 +226,10 @@ const Spotlight: React.FC< SpotlightProps > = ( {
<Chip message={ badge } type="primary" />
</div>
) }
<h2 className="wcpay-spotlight__heading">
<h2
id="spotlight-heading"
className="wcpay-spotlight__heading"
>
{ heading }
</h2>
<div className="wcpay-spotlight__description">
Expand Down
4 changes: 4 additions & 0 deletions client/components/spotlight/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
margin: 0;
}
}

@media ( prefers-reduced-motion: reduce ) {
transition: none;
}
}

.wcpay-spotlight__container {
Expand Down
11 changes: 8 additions & 3 deletions client/components/spotlight/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
/** @format */

/**
* External dependencies
*/
import type { ReactNode } from 'react';

/**
* Props for the Spotlight component.
*/
Expand All @@ -17,17 +22,17 @@ export interface SpotlightProps {
/**
* Description content (can be a string or React component).
*/
description: React.ReactNode;
description: ReactNode;

/**
* Optional disclaimer content shown at the bottom (can be a string or React component).
*/
disclaimer?: React.ReactNode;
disclaimer?: ReactNode;

/**
* Image element or URL to display in the spotlight.
*/
image?: React.ReactNode | string;
image?: ReactNode | string;

/**
* Primary button label.
Expand Down
40 changes: 36 additions & 4 deletions client/data/promotions/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,30 @@ import {
import { ApiError } from '../../types/errors';
import { NAMESPACE } from '../constants';

/**
* Type guard to check if an error is an ApiError.
*/
function isApiError( error: unknown ): error is ApiError {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
typeof ( error as ApiError ).code === 'string'
);
}

/**
* Normalizes an unknown error to an ApiError.
*/
function normalizeError( error: unknown ): ApiError {
if ( isApiError( error ) ) {
return error;
}
return {
code: 'unknown_error',
};
}

export function updatePromotions(
data: PromotionsData
): UpdatePromotionsAction {
Expand Down Expand Up @@ -47,7 +71,7 @@ export function* activatePromotion(
identifier: string,
acceptTerms = true
): unknown {
const path = `${ NAMESPACE }/payment-method-promotions/${ identifier }/activate`;
const path = `${ NAMESPACE }/pm-promotions/${ identifier }/activate`;

try {
yield apiFetch( {
Expand Down Expand Up @@ -78,7 +102,11 @@ export function* activatePromotion(
'woocommerce-payments'
)
);
yield updateErrorForPromotions( e as ApiError );
yield controls.dispatch(
'wc/payments',
'updateErrorForPromotions',
normalizeError( e )
);
}
}

Expand All @@ -92,7 +120,7 @@ export function* dismissPromotion(
identifier: string,
variationId: string
): unknown {
const path = `${ NAMESPACE }/payment-method-promotions/${ identifier }/dismiss`;
const path = `${ NAMESPACE }/pm-promotions/${ identifier }/dismiss`;

try {
yield apiFetch( {
Expand Down Expand Up @@ -123,6 +151,10 @@ export function* dismissPromotion(
'woocommerce-payments'
)
);
yield updateErrorForPromotions( e as ApiError );
yield controls.dispatch(
'wc/payments',
'updateErrorForPromotions',
normalizeError( e )
);
}
}
79 changes: 74 additions & 5 deletions client/data/promotions/resolvers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,84 @@ import { __ } from '@wordpress/i18n';
* Internal dependencies
*/
import { NAMESPACE } from '../constants';
import { updatePromotions, updateErrorForPromotions } from './actions';
import { PromotionsData } from './types';
import { Promotion, PromotionVariation, PromotionsData } from './types';
import { ApiError } from '../../types/errors';

/**
* Type guard to check if an object is a valid PromotionVariation.
*/
function isPromotionVariation( value: unknown ): value is PromotionVariation {
if ( typeof value !== 'object' || value === null ) {
return false;
}
const obj = value as Record< string, unknown >;
return (
typeof obj.id === 'string' &&
typeof obj.type === 'string' &&
typeof obj.heading === 'string' &&
typeof obj.description === 'string' &&
typeof obj.cta_label === 'string' &&
typeof obj.cta_url === 'string'
);
}

/**
* Type guard to check if an object is a valid Promotion.
*/
function isPromotion( value: unknown ): value is Promotion {
if ( typeof value !== 'object' || value === null ) {
return false;
}
const obj = value as Record< string, unknown >;
return (
typeof obj.promo_id === 'string' &&
typeof obj.discount_rate === 'string' &&
typeof obj.duration_days === 'number' &&
Array.isArray( obj.variations ) &&
obj.variations.every( isPromotionVariation )
);
}

/**
* Type guard to check if a value is valid PromotionsData.
*/
function isPromotionsData( value: unknown ): value is PromotionsData {
return Array.isArray( value ) && value.every( isPromotion );
}

/**
* Type guard to check if an error is an ApiError.
*/
function isApiError( error: unknown ): error is ApiError {
return typeof error === 'object' && error !== null && 'code' in error;
}

/**
* Normalizes an unknown error to an ApiError.
*/
function normalizeError( error: unknown ): ApiError {
if ( isApiError( error ) ) {
return error;
}
return {
code: 'unknown_error',
};
}

/**
* Retrieve promotions data.
*/
export function* getPromotions(): unknown {
const path = `${ NAMESPACE }/payment-method-promotions`;
const path = `${ NAMESPACE }/pm-promotions`;

try {
const result = yield apiFetch( { path } );
yield updatePromotions( result as PromotionsData );

if ( ! isPromotionsData( result ) ) {
throw new Error( 'Invalid promotions data received from API' );
}

yield controls.dispatch( 'wc/payments', 'updatePromotions', result );
} catch ( e ) {
yield controls.dispatch(
'core/notices',
Expand All @@ -33,6 +98,10 @@ export function* getPromotions(): unknown {
'woocommerce-payments'
)
);
yield updateErrorForPromotions( e as ApiError );
yield controls.dispatch(
'wc/payments',
'updateErrorForPromotions',
normalizeError( e )
);
}
}
Loading
Loading