Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
41 changes: 41 additions & 0 deletions client/components/spotlight/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -230,4 +230,45 @@ describe( 'Spotlight Component', () => {
container.querySelector( '.wcpay-spotlight__card' )
).toBeInTheDocument();
} );

it( 'calls onView when spotlight becomes visible with showImmediately', () => {
const onView = jest.fn();

render( <Spotlight { ...defaultProps } onView={ onView } /> );

expect( onView ).toHaveBeenCalledTimes( 1 );
} );

it( 'calls onView after delay when showImmediately is false', async () => {
jest.useFakeTimers();
const onView = jest.fn();

render(
<Spotlight
{ ...defaultProps }
showImmediately={ false }
onView={ onView }
/>
);

// onView should not be called initially
expect( onView ).not.toHaveBeenCalled();

// Fast forward time by 4 seconds
act( () => {
jest.advanceTimersByTime( 4000 );
} );

// Flush requestAnimationFrame calls
await act( async () => {
await Promise.resolve();
} );

// onView should now be called
await waitFor( () => {
expect( onView ).toHaveBeenCalledTimes( 1 );
} );

jest.useRealTimers();
} );
} );
9 changes: 9 additions & 0 deletions client/components/spotlight/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const Spotlight: React.FC< SpotlightProps > = ( {
secondaryButtonLabel,
onSecondaryClick,
onDismiss,
onView,
showImmediately = false,
} ) => {
const [ isVisible, setIsVisible ] = useState( false );
Expand All @@ -61,6 +62,14 @@ const Spotlight: React.FC< SpotlightProps > = ( {
return () => clearTimeout( timer );
}, [ showImmediately ] );

// Call onView when spotlight becomes visible
useEffect( () => {
if ( isAnimatingIn && onView ) {
onView();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isAnimatingIn ] );

const handleClose = () => {
setIsAnimatingIn( false );
// Wait for animation to complete before hiding
Expand Down
6 changes: 6 additions & 0 deletions client/components/spotlight/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ export interface SpotlightProps {
*/
onDismiss: () => void;

/**
* Callback when the spotlight becomes visible (after delay and animation starts).
* Useful for tracking view events.
*/
onView?: () => void;

/**
* Whether to show the spotlight immediately without delay (for testing).
*
Expand Down
1 change: 1 addition & 0 deletions client/data/promotions/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface PromotionConfig {

export interface Promotion {
promo_id: string;
payment_method: string;
discount_rate: string;
duration_days: number;
config?: PromotionConfig;
Expand Down
90 changes: 90 additions & 0 deletions client/promotions/spotlight/__tests__/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,18 @@ import { render, screen } from '@testing-library/react';
*/
import SpotlightPromotion from '../index';
import { usePromotions, usePromotionActions } from 'data';
import { recordEvent } from 'tracks';

// Mock the dependencies
jest.mock( 'data', () => ( {
usePromotions: jest.fn(),
usePromotionActions: jest.fn(),
} ) );

jest.mock( 'tracks', () => ( {
recordEvent: jest.fn(),
} ) );

jest.mock( 'components/spotlight', () => ( {
__esModule: true,
default: ( props: any ) => (
Expand All @@ -37,6 +42,7 @@ jest.mock( 'components/spotlight', () => ( {
{ props.secondaryButtonLabel }
</button>
<button onClick={ props.onDismiss }>Close</button>
<button onClick={ props.onView }>View</button>
</div>
),
} ) );
Expand All @@ -63,6 +69,7 @@ describe( 'SpotlightPromotion', () => {
const mockPromotionData = [
{
promo_id: 'promo_123',
payment_method: 'klarna',
discount_rate: '100%',
duration_days: 90,
variations: [
Expand Down Expand Up @@ -300,4 +307,87 @@ describe( 'SpotlightPromotion', () => {
// Reset to original
( global as any ).wcpaySettings = mockWcpaySettings;
} );

describe( 'tracks events', () => {
const expectedBaseProperties = {
promotion_id: 'promo_123',
payment_method: 'klarna',
variation_id: 'promo_123__spotlight_1',
display_context: 'spotlight',
source: 'unknown',
path: '/',
};

beforeEach( () => {
( usePromotions as jest.Mock ).mockReturnValue( {
promotions: mockPromotionData,
isLoading: false,
} );
} );

it( 'records view event when spotlight becomes visible', () => {
render( <SpotlightPromotion /> );

const viewButton = screen.getByText( 'View' );
viewButton.click();

expect( recordEvent ).toHaveBeenCalledWith(
'wcpay_payment_method_promotion_view',
expectedBaseProperties
);
} );

it( 'records activate_click event when primary button is clicked', () => {
render( <SpotlightPromotion /> );

const activateButton = screen.getByText( 'Activate now' );
activateButton.click();

expect( recordEvent ).toHaveBeenCalledWith(
'wcpay_payment_method_promotion_activate_click',
expectedBaseProperties
);
} );

it( 'records secondary_click event when secondary button is clicked', () => {
jest.spyOn( window, 'open' ).mockImplementation( () => null );

render( <SpotlightPromotion /> );

const learnMoreButton = screen.getByText( 'Learn more' );
learnMoreButton.click();

expect( recordEvent ).toHaveBeenCalledWith(
'wcpay_payment_method_promotion_secondary_click',
expectedBaseProperties
);
} );

it( 'records dismiss event when close button is clicked', () => {
render( <SpotlightPromotion /> );

const closeButton = screen.getByText( 'Close' );
closeButton.click();

expect( recordEvent ).toHaveBeenCalledWith(
'wcpay_payment_method_promotion_dismiss',
expectedBaseProperties
);
} );

it( 'records link_click event when terms link is clicked', () => {
render( <SpotlightPromotion /> );

const termsLink = screen.getByText( 'Terms and conditions' );
termsLink.click();

expect( recordEvent ).toHaveBeenCalledWith(
'wcpay_payment_method_promotion_link_click',
{
...expectedBaseProperties,
link_type: 'terms',
}
);
} );
} );
} );
110 changes: 89 additions & 21 deletions client/promotions/spotlight/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import React from 'react';
*/
import Spotlight from 'components/spotlight';
import { usePromotions, usePromotionActions } from 'data';
import { PromotionVariation } from 'data/promotions/types';
import { Promotion, PromotionVariation } from 'data/promotions/types';
import { recordEvent } from 'tracks';
import KlarnaIllustration from 'assets/images/illustrations/klarna-promotion-spotlight.svg?asset';

/**
Expand All @@ -23,6 +24,30 @@ const spotlightImages: Record< string, string > = {
// Add more promotion images here as needed
};

/**
* Determine a human-readable source identifier based on the current page.
*
* @return {string} Source identifier for tracking.
*/
const getPageSource = (): string => {
const path = window.location.pathname + window.location.search;

if ( path.includes( 'path=%2Fpayments%2Foverview' ) ) {
return 'wcpay-overview';
}
if ( path.includes( 'path=%2Fpayments%2Fsettings' ) ) {
return 'wcpay-settings';
}
if (
path.includes( 'page=wc-settings' ) &&
path.includes( 'tab=checkout' )
) {
return 'wc-settings-payments';
}

return 'unknown';
};

/**
* Container component that fetches promotions and renders the Spotlight component.
*
Expand Down Expand Up @@ -53,24 +78,82 @@ const SpotlightPromotion: React.FC = () => {

// Find the first available promotion with a 'spotlight' variation
let spotlightVariation: PromotionVariation | null = null;
let promotionId: string | null = null;
let activePromotion: Promotion | null = null;

for ( const promotion of promotions ) {
const variation = promotion.variations.find(
( v ) => v.type === 'spotlight'
);
if ( variation ) {
spotlightVariation = variation;
promotionId = promotion.promo_id;
activePromotion = promotion;
break;
}
}

// No spotlight promotion available
if ( ! spotlightVariation || ! promotionId ) {
if ( ! spotlightVariation || ! activePromotion ) {
return null;
}

// Extract values after null check for TypeScript
const promotionId = activePromotion.promo_id;
const paymentMethod = activePromotion.payment_method;
const variationId = spotlightVariation.id;
const ctaUrl = spotlightVariation.cta_url;

/**
* Get common event properties for tracking.
*/
const getEventProperties = () => ( {
promotion_id: promotionId,
payment_method: paymentMethod,
variation_id: variationId,
display_context: 'spotlight',
source: getPageSource(),
path: window.location.pathname + window.location.search,
} );

const handleView = () => {
recordEvent(
'wcpay_payment_method_promotion_view',
getEventProperties()
);
};

const handlePrimaryClick = () => {
recordEvent(
'wcpay_payment_method_promotion_activate_click',
getEventProperties()
);
activatePromotion( promotionId );
};

const handleSecondaryClick = () => {
recordEvent(
'wcpay_payment_method_promotion_secondary_click',
getEventProperties()
);
if ( ctaUrl ) {
window.open( ctaUrl, '_blank' );
}
};

const handleDismiss = () => {
recordEvent(
'wcpay_payment_method_promotion_dismiss',
getEventProperties()
);
dismissPromotion( promotionId, variationId as string );
};

const handleTermsClick = () => {
recordEvent( 'wcpay_payment_method_promotion_link_click', {
...getEventProperties(),
link_type: 'terms',
} );
};

// Build disclaimer content if footnote and tc_url exist
let disclaimer: React.ReactNode | undefined;
if ( spotlightVariation.footnote && spotlightVariation.tc_url ) {
Expand All @@ -81,6 +164,7 @@ const SpotlightPromotion: React.FC = () => {
href={ spotlightVariation.tc_url }
target="_blank"
rel="noopener noreferrer"
onClick={ handleTermsClick }
>
Terms and conditions
</a>
Expand All @@ -90,23 +174,6 @@ const SpotlightPromotion: React.FC = () => {
disclaimer = spotlightVariation.footnote;
}

const handlePrimaryClick = () => {
activatePromotion( promotionId as string );
};

const handleSecondaryClick = () => {
if ( spotlightVariation?.cta_url ) {
window.open( spotlightVariation.cta_url, '_blank' );
}
};

const handleDismiss = () => {
if ( ! promotionId || ! spotlightVariation ) {
return;
}
dismissPromotion( promotionId, spotlightVariation.id as string );
};

// Get the image for this promotion (undefined if not mapped)
const image = spotlightImages[ promotionId ];

Expand All @@ -122,6 +189,7 @@ const SpotlightPromotion: React.FC = () => {
secondaryButtonLabel="Learn more"
onSecondaryClick={ handleSecondaryClick }
onDismiss={ handleDismiss }
onView={ handleView }
/>
);
};
Expand Down
5 changes: 5 additions & 0 deletions client/tracks/event.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,4 +127,9 @@ export type MerchantEvent =
| 'payments_transactions_details_refund_full'
| 'payments_transactions_risk_review_list_review_button_click'
| 'payments_transactions_uncaptured_list_capture_charge_button_click'
| 'wcpay_payment_method_promotion_view'
| 'wcpay_payment_method_promotion_dismiss'
| 'wcpay_payment_method_promotion_activate_click'
| 'wcpay_payment_method_promotion_secondary_click'
| 'wcpay_payment_method_promotion_link_click'
| string;
Loading
Loading