Skip to content

Commit 20527e8

Browse files
dmallory42claude
andauthored
Display spotlight promotions on WooPayments and WC payment settings pages (#11147)
Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0d42bd7 commit 20527e8

File tree

13 files changed

+594
-4
lines changed

13 files changed

+594
-4
lines changed
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
Significance: patch
2+
Type: add
3+
4+
Display spotlight promotions on WooPayments pages and WooCommerce payment settings.

client/components/spotlight/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,11 @@ const Spotlight: React.FC< SpotlightProps > = ( {
5050
// Show the spotlight after a delay
5151
const timer = setTimeout( () => {
5252
setIsVisible( true );
53-
// Trigger animation slightly after visibility for smooth transition
53+
// Double RAF to ensure browser paints initial state before animating
5454
requestAnimationFrame( () => {
55-
setIsAnimatingIn( true );
55+
requestAnimationFrame( () => {
56+
setIsAnimatingIn( true );
57+
} );
5658
} );
5759
}, showDelayMs );
5860

client/components/spotlight/style.scss

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,10 @@
132132
.wcpay-spotlight__footer {
133133
padding: 16px 24px 24px;
134134
}
135+
136+
// Override WC settings page styles that remove box-shadow from cards
137+
body.woocommerce_page_wc-settings
138+
.wcpay-spotlight
139+
.wcpay-spotlight__card.components-surface.components-card {
140+
box-shadow: 0 8px 16px rgba( 0, 0, 0, 0.15 );
141+
}

client/overview/__tests__/index.test.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,13 @@ jest.mock( 'wcpay/data', () => ( {
7272
.fn()
7373
.mockReturnValue( { overviews: { currencies: [] } } ),
7474
useActiveLoanSummary: jest.fn().mockReturnValue( { isLoading: true } ),
75+
usePromotions: jest
76+
.fn()
77+
.mockReturnValue( { promotions: {}, isLoading: false } ),
78+
usePromotionActions: jest.fn().mockReturnValue( {
79+
activatePromotion: jest.fn(),
80+
dismissPromotion: jest.fn(),
81+
} ),
7582
} ) );
7683

7784
select.mockReturnValue( {

client/overview/index.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import { recordEvent } from 'wcpay/tracks';
3636
import StripeSpinner from 'wcpay/components/stripe-spinner';
3737
import { getAdminUrl, isInTestModeOnboarding } from 'wcpay/utils';
3838
import { EmbeddedConnectNotificationBanner } from 'wcpay/embedded-components';
39+
import SpotlightPromotion from 'promotions/spotlight';
3940

4041
const OverviewPageError = () => {
4142
const queryParams = getQuery();
@@ -404,6 +405,9 @@ const OverviewPage = () => {
404405
<ConnectionSuccessModal />
405406
</ErrorBoundary>
406407
) }
408+
<ErrorBoundary>
409+
<SpotlightPromotion />
410+
</ErrorBoundary>
407411
</Page>
408412
);
409413
};
Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
/** @format */
2+
3+
/**
4+
* External dependencies
5+
*/
6+
import React from 'react';
7+
import { render, screen } from '@testing-library/react';
8+
9+
/**
10+
* Internal dependencies
11+
*/
12+
import SpotlightPromotion from '../index';
13+
import { usePromotions, usePromotionActions } from 'data';
14+
15+
// Mock the dependencies
16+
jest.mock( 'data', () => ( {
17+
usePromotions: jest.fn(),
18+
usePromotionActions: jest.fn(),
19+
} ) );
20+
21+
jest.mock( 'components/spotlight', () => ( {
22+
__esModule: true,
23+
default: ( props: any ) => (
24+
<div data-testid="spotlight-mock">
25+
<div data-testid="spotlight-badge">{ props.badge }</div>
26+
<div data-testid="spotlight-heading">{ props.heading }</div>
27+
<div data-testid="spotlight-description">{ props.description }</div>
28+
{ props.disclaimer && (
29+
<div data-testid="spotlight-disclaimer">
30+
{ props.disclaimer }
31+
</div>
32+
) }
33+
<button onClick={ props.onPrimaryClick }>
34+
{ props.primaryButtonLabel }
35+
</button>
36+
<button onClick={ props.onSecondaryClick }>
37+
{ props.secondaryButtonLabel }
38+
</button>
39+
<button onClick={ props.onDismiss }>Close</button>
40+
</div>
41+
),
42+
} ) );
43+
44+
// Mock the SVG import
45+
jest.mock(
46+
'assets/images/illustrations/klarna-promotion-spotlight.svg?asset',
47+
() => 'mocked-image-url'
48+
);
49+
50+
// Mock the global wcpaySettings
51+
const mockWcpaySettings = {
52+
accountStatus: {
53+
status: 'complete',
54+
},
55+
};
56+
57+
( global as any ).wcpaySettings = mockWcpaySettings;
58+
59+
describe( 'SpotlightPromotion', () => {
60+
const mockActivatePromotion = jest.fn();
61+
const mockDismissPromotion = jest.fn();
62+
63+
const mockPromotionData = {
64+
available_promotions: [
65+
{
66+
promo_id: 'promo_123',
67+
variations: [
68+
{
69+
type: 'spotlight',
70+
badge: 'Limited time offer',
71+
heading: 'Activate Klarna',
72+
description: 'Offer your customers flexible payments',
73+
cta_label: 'Activate now',
74+
cta_url: 'https://example.com/learn-more',
75+
footnote: '*Terms apply',
76+
tc_url: 'https://example.com/terms',
77+
},
78+
],
79+
},
80+
],
81+
};
82+
83+
beforeEach( () => {
84+
jest.clearAllMocks();
85+
86+
( usePromotionActions as jest.Mock ).mockReturnValue( {
87+
activatePromotion: mockActivatePromotion,
88+
dismissPromotion: mockDismissPromotion,
89+
} );
90+
} );
91+
92+
it( 'renders spotlight when account is onboarded and promotion available', () => {
93+
( usePromotions as jest.Mock ).mockReturnValue( {
94+
promotions: mockPromotionData,
95+
isLoading: false,
96+
} );
97+
98+
render( <SpotlightPromotion /> );
99+
100+
expect( screen.getByTestId( 'spotlight-mock' ) ).toBeInTheDocument();
101+
expect( screen.getByTestId( 'spotlight-badge' ) ).toHaveTextContent(
102+
'Limited time offer'
103+
);
104+
expect( screen.getByTestId( 'spotlight-heading' ) ).toHaveTextContent(
105+
'Activate Klarna'
106+
);
107+
expect(
108+
screen.getByTestId( 'spotlight-description' )
109+
).toHaveTextContent( 'Offer your customers flexible payments' );
110+
} );
111+
112+
it( 'does not render when account is not onboarded', () => {
113+
( global as any ).wcpaySettings = {
114+
accountStatus: {
115+
status: 'pending',
116+
},
117+
};
118+
119+
( usePromotions as jest.Mock ).mockReturnValue( {
120+
promotions: mockPromotionData,
121+
isLoading: false,
122+
} );
123+
124+
const { container } = render( <SpotlightPromotion /> );
125+
126+
expect( container.firstChild ).toBeNull();
127+
128+
// Reset to original
129+
( global as any ).wcpaySettings = mockWcpaySettings;
130+
} );
131+
132+
it( 'does not render when promotions are loading', () => {
133+
( usePromotions as jest.Mock ).mockReturnValue( {
134+
promotions: mockPromotionData,
135+
isLoading: true,
136+
} );
137+
138+
const { container } = render( <SpotlightPromotion /> );
139+
140+
expect( container.firstChild ).toBeNull();
141+
} );
142+
143+
it( 'does not render when no spotlight variation available', () => {
144+
( usePromotions as jest.Mock ).mockReturnValue( {
145+
promotions: {
146+
available_promotions: [
147+
{
148+
promo_id: 'promo_123',
149+
variations: [
150+
{
151+
type: 'banner', // Not a spotlight type
152+
heading: 'Different promotion',
153+
},
154+
],
155+
},
156+
],
157+
},
158+
isLoading: false,
159+
} );
160+
161+
const { container } = render( <SpotlightPromotion /> );
162+
163+
expect( container.firstChild ).toBeNull();
164+
} );
165+
166+
it( 'does not render when no promotions available', () => {
167+
( usePromotions as jest.Mock ).mockReturnValue( {
168+
promotions: {
169+
available_promotions: [],
170+
},
171+
isLoading: false,
172+
} );
173+
174+
const { container } = render( <SpotlightPromotion /> );
175+
176+
expect( container.firstChild ).toBeNull();
177+
} );
178+
179+
it( 'calls activatePromotion when primary button is clicked', () => {
180+
( usePromotions as jest.Mock ).mockReturnValue( {
181+
promotions: mockPromotionData,
182+
isLoading: false,
183+
} );
184+
185+
render( <SpotlightPromotion /> );
186+
187+
const activateButton = screen.getByText( 'Activate now' );
188+
activateButton.click();
189+
190+
expect( mockActivatePromotion ).toHaveBeenCalledWith( 'promo_123' );
191+
} );
192+
193+
it( 'calls dismissPromotion when close button is clicked', () => {
194+
( usePromotions as jest.Mock ).mockReturnValue( {
195+
promotions: mockPromotionData,
196+
isLoading: false,
197+
} );
198+
199+
render( <SpotlightPromotion /> );
200+
201+
const closeButton = screen.getByText( 'Close' );
202+
closeButton.click();
203+
204+
expect( mockDismissPromotion ).toHaveBeenCalledWith( 'promo_123' );
205+
} );
206+
207+
it( 'opens learn more URL when secondary button is clicked', () => {
208+
( usePromotions as jest.Mock ).mockReturnValue( {
209+
promotions: mockPromotionData,
210+
isLoading: false,
211+
} );
212+
213+
const windowOpenSpy = jest
214+
.spyOn( window, 'open' )
215+
.mockImplementation( () => null );
216+
217+
render( <SpotlightPromotion /> );
218+
219+
const learnMoreButton = screen.getByText( 'Learn more' );
220+
learnMoreButton.click();
221+
222+
expect( windowOpenSpy ).toHaveBeenCalledWith(
223+
'https://example.com/learn-more',
224+
'_blank'
225+
);
226+
227+
windowOpenSpy.mockRestore();
228+
} );
229+
230+
it( 'renders disclaimer with terms link when both footnote and tc_url provided', () => {
231+
( usePromotions as jest.Mock ).mockReturnValue( {
232+
promotions: mockPromotionData,
233+
isLoading: false,
234+
} );
235+
236+
render( <SpotlightPromotion /> );
237+
238+
expect( screen.getByText( /Terms apply/i ) ).toBeInTheDocument();
239+
expect(
240+
screen.getByText( 'Terms and conditions' )
241+
).toBeInTheDocument();
242+
} );
243+
244+
it( 'renders disclaimer without link when only footnote provided', () => {
245+
const dataWithoutTcUrl = {
246+
available_promotions: [
247+
{
248+
promo_id: 'promo_123',
249+
variations: [
250+
{
251+
type: 'spotlight',
252+
badge: 'Limited time offer',
253+
heading: 'Activate Klarna',
254+
description:
255+
'Offer your customers flexible payments',
256+
cta_label: 'Activate now',
257+
cta_url: 'https://example.com/learn-more',
258+
footnote: '*Terms apply',
259+
},
260+
],
261+
},
262+
],
263+
};
264+
265+
( usePromotions as jest.Mock ).mockReturnValue( {
266+
promotions: dataWithoutTcUrl,
267+
isLoading: false,
268+
} );
269+
270+
render( <SpotlightPromotion /> );
271+
272+
expect( screen.getByText( '*Terms apply' ) ).toBeInTheDocument();
273+
expect(
274+
screen.queryByText( 'Terms and conditions' )
275+
).not.toBeInTheDocument();
276+
} );
277+
278+
it( 'renders for enabled account status', () => {
279+
( global as any ).wcpaySettings = {
280+
accountStatus: {
281+
status: 'enabled',
282+
},
283+
};
284+
285+
( usePromotions as jest.Mock ).mockReturnValue( {
286+
promotions: mockPromotionData,
287+
isLoading: false,
288+
} );
289+
290+
render( <SpotlightPromotion /> );
291+
292+
expect( screen.getByTestId( 'spotlight-mock' ) ).toBeInTheDocument();
293+
294+
// Reset to original
295+
( global as any ).wcpaySettings = mockWcpaySettings;
296+
} );
297+
} );

0 commit comments

Comments
 (0)