diff --git a/src/server/v2/constants.js b/src/server/v2/constants.js index 32a64f541d..583a5188b1 100644 --- a/src/server/v2/constants.js +++ b/src/server/v2/constants.js @@ -1,2 +1,7 @@ export const VARIANT = 'B'; export const PORT = process.env.PORT || 8080; + +export const FLEX_DEFAULTS = { + color: 'blue', + ratio: '1x1' +}; diff --git a/src/server/v2/flex.jsx b/src/server/v2/flex.jsx new file mode 100644 index 0000000000..ba50505a57 --- /dev/null +++ b/src/server/v2/flex.jsx @@ -0,0 +1,77 @@ +/** @jsx h */ +/** @jsxFrag Fragment */ +import { h, Fragment } from 'preact'; + +import { buildContentLabel } from './utils/buildContentLabel'; +import { renderBlock } from './utils/renderBlock'; +import flexStyles from './flexStyles'; +import { FLEX_DEFAULTS } from './constants'; + +export default function FlexMessage({ style, v2Content }) { + const color = style.color ?? FLEX_DEFAULTS.color; + const ratio = style.ratio ?? FLEX_DEFAULTS.ratio; + + const mainItems = v2Content?.main_items ?? []; + const actionItems = v2Content?.action_items ?? []; + const disclaimerItems = v2Content?.disclaimer_items ?? []; + + const logoBlock = mainItems.find(item => item.type === 'IMAGE'); + const mainBlocks = mainItems.filter(item => item.type !== 'IMAGE'); + + const mainLabel = buildContentLabel(mainBlocks); + const actionLabel = buildContentLabel(actionItems); + + return ( +
+ {/* eslint-disable react/no-danger */} +
\\"PayPal\\"
Pay Later.
Learn more
Subject to approval.
" +`; + +exports[`v2 render flex snapshots renders flex stylesheet once 1`] = ` +"" +`; + +exports[`v2 render snapshots full render snapshot for representative case 1`] = ` +"
Pay Later. Subject to approval.Learn more
" +`; + +exports[`v2 render snapshots renders the v2 stylesheet once 1`] = ` +"" +`; diff --git a/tests/unit/spec/server/v2/getParentStyles.test.js b/tests/unit/spec/server/v2/getParentStyles.test.js new file mode 100644 index 0000000000..54a86999b4 --- /dev/null +++ b/tests/unit/spec/server/v2/getParentStyles.test.js @@ -0,0 +1,101 @@ +import getParentStyles from 'server/v2/getParentStyles'; + +describe('v2 getParentStyles flex', () => { + test('returns empty string when ratio is not provided', () => { + expect(getParentStyles({ layout: 'flex' })).toBe(''); + }); + + test('returns empty string for text layout with no ratio', () => { + expect(getParentStyles({ layout: 'text' })).toBe(''); + }); + + test.each([['1x1'], ['1x4'], ['8x1'], ['20x1']])('generates wrapper class pp-flex--%s for flex ratio %s', ratio => { + const result = getParentStyles({ layout: 'flex', ratio }); + expect(result).toContain(`pp-flex--${ratio}`); + }); + + test.each([['1x1'], ['1x4'], ['8x1'], ['20x1']])('includes iframe positioning rules for ratio %s', ratio => { + const result = getParentStyles({ layout: 'flex', ratio }); + expect(result).toContain('position: absolute'); + expect(result).toContain('width: 100%'); + expect(result).toContain('height: 100%'); + expect(result).toContain('iframe'); + }); + + test.each([['1x1'], ['1x4'], ['8x1'], ['20x1']])( + 'includes ::before padding-top aspect ratio rule for ratio %s', + ratio => { + const result = getParentStyles({ layout: 'flex', ratio }); + expect(result).toContain('::before'); + expect(result).toContain('padding-top'); + } + ); + + test('1x1 uses 100% padding-top (square)', () => { + const result = getParentStyles({ layout: 'flex', ratio: '1x1' }); + expect(result).toContain('padding-top: 100%'); + }); + + test('1x1 constrains width between 120px and 300px', () => { + const result = getParentStyles({ layout: 'flex', ratio: '1x1' }); + expect(result).toContain('min-width: 120px'); + expect(result).toContain('max-width: 300px'); + }); + + test('1x4 uses 200% initial padding-top then 400% at breakpoint', () => { + const result = getParentStyles({ layout: 'flex', ratio: '1x4' }); + expect(result).toContain('padding-top: 200%'); + expect(result).toContain('padding-top: 400%'); + }); + + test('1x4 switches to 400% padding-top at 768px breakpoint', () => { + const result = getParentStyles({ layout: 'flex', ratio: '1x4' }); + expect(result).toContain('@media (min-width: 768px)'); + const afterBreakpoint = result.slice(result.indexOf('@media (min-width: 768px)')); + expect(afterBreakpoint).toContain('padding-top: 400%'); + }); + + test('8x1 uses ~16.67% initial padding-top then 12.5% at breakpoint', () => { + const result = getParentStyles({ layout: 'flex', ratio: '8x1' }); + expect(result).toContain('padding-top: 12.5%'); + }); + + test('8x1 switches aspect ratio at 768px breakpoint', () => { + const result = getParentStyles({ layout: 'flex', ratio: '8x1' }); + expect(result).toContain('@media (min-width: 768px)'); + }); + + test('20x1 switches to 5% padding-top at 768px breakpoint', () => { + const result = getParentStyles({ layout: 'flex', ratio: '20x1' }); + expect(result).toContain('padding-top: 5%'); + expect(result).toContain('@media (min-width: 768px)'); + }); + + test('20x1 constrains width at breakpoint between 350px and 1169px', () => { + const result = getParentStyles({ layout: 'flex', ratio: '20x1' }); + expect(result).toContain('min-width: 350px'); + expect(result).toContain('max-width: 1169px'); + }); + + test('wrapper has display block and box-sizing border-box', () => { + const result = getParentStyles({ layout: 'flex', ratio: '8x1' }); + expect(result).toContain('display: block'); + expect(result).toContain('box-sizing: border-box'); + }); + + test('wrapper has position relative for iframe stacking context', () => { + const result = getParentStyles({ layout: 'flex', ratio: '8x1' }); + expect(result).toContain('position: relative'); + }); + + test('does not mix flex ratioMap into non-flex layout', () => { + // For text layout with a ratio string, it should not use the flex ratioMap + const flexResult = getParentStyles({ layout: 'flex', ratio: '8x1' }); + const textResult = getParentStyles({ layout: 'text', ratio: '8x1' }); + // text layout parses '8x1' as a plain ratio string, not the flex ratioMap + // both produce output but the flex one uses the full responsive ratioMap config + expect(flexResult).toContain('@media (min-width: 768px)'); + // text layout treats '8x1' as a single ratio entry with no breakpoint + expect(textResult).not.toContain('@media (min-width: 768px)'); + }); +}); diff --git a/tests/unit/spec/server/v2/render.test.js b/tests/unit/spec/server/v2/render.test.js index 842893e63b..c2b7024b48 100644 --- a/tests/unit/spec/server/v2/render.test.js +++ b/tests/unit/spec/server/v2/render.test.js @@ -1,4 +1,6 @@ import render from 'server/v2/render'; +import validateStyle from 'server/v2/validateStyle'; +import { FLEX_DEFAULTS } from 'server/v2/constants'; const mockLog = jest.fn(); @@ -134,7 +136,7 @@ describe('v2 render', () => { expect(result).toMatch(/class="logo[^"]*top[^"]*"/); }); - test('inline logo type keeps logo in main blocks without a standalone logo span', () => { + test('inline logo type renders logo inside main span via logo-aware path', () => { const options = { style: { ...baseOptions.style, logo: { type: 'inline', position: 'left' } } }; const content = { ...baseV2Content, @@ -149,8 +151,12 @@ describe('v2 render', () => { ] }; const result = render(options, content, mockLog); - // no standalone logo span — logo rendered inline within main blocks - expect(result).not.toMatch(/role="img"/); + // logo is wrapped in .logo.inline span for color-filter CSS to apply + expect(result).toMatch(/class="logo[^"]*inline[^"]*"/); + // logo span is nested inside the main span, not a standalone sibling + const mainIdx = result.indexOf('class="main'); + const logoIdx = result.indexOf('class="logo'); + expect(logoIdx).toBeGreaterThan(mainIdx); }); test('logo type none suppresses logo span even when IMAGE item is present', () => { @@ -201,6 +207,18 @@ describe('v2 render', () => { expect(result).toMatch(/class="main[^"]*white[^"]*"/); }); + test('greyscale alias normalizes to grayscale class after validation', () => { + const raw = { + layout: 'text', + logo: { type: 'primary', position: 'left' }, + text: { color: 'greyscale', size: 12 } + }; + const validatedStyle = validateStyle(mockLog, raw); + const result = render({ style: validatedStyle }, baseV2Content, mockLog); + expect(result).toMatch(/class="main[^"]*grayscale[^"]*"/); + expect(result).not.toContain('greyscale'); + }); + test('handles empty main_items gracefully', () => { const content = { ...baseV2Content, main_items: [] }; const result = render(baseOptions, content, mockLog); @@ -219,4 +237,500 @@ describe('v2 render', () => { expect(result).not.toContain('message__headline'); expect(result).not.toContain('message__foreground'); }); + + test('maps validated v5 text style options to root data attributes', () => { + const options = { + style: { + layout: 'text', + logo: { type: 'primary', position: 'top' }, + text: { align: 'center', color: 'white', size: 16 } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toContain('data-pp-style-layout="text"'); + expect(result).toContain('data-pp-style-logo-position="top"'); + expect(result).toContain('data-pp-style-logo-type="primary"'); + expect(result).toContain('data-pp-style-text-align="center"'); + expect(result).toContain('data-pp-style-text-color="white"'); + expect(result).toContain('data-pp-style-text-size="16"'); + }); +}); + +describe('v2 render fontSource', () => { + test('generates @font-face rule for a single fontSource URL', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontSource: ['https://example.com/font.woff2'] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toContain('@font-face'); + expect(result).toContain("url('https://example.com/font.woff2')"); + expect(result).toContain("font-family: 'PP Merchant Font 1'"); + }); + + test('prepends custom font name to font-family stack', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontSource: ['https://example.com/font.woff2'] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toMatch(/font-family:\s*'PP Merchant Font 1',/); + expect(result).not.toContain('"PayPal Pro"'); + }); + + test('generates one @font-face rule per fontSource URL', () => { + const options = { + style: { + ...baseOptions.style, + text: { + color: 'black', + size: 12, + fontSource: ['https://example.com/font1.woff2', 'https://example.com/font2.woff2'] + } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toContain("url('https://example.com/font1.woff2')"); + expect(result).toContain("url('https://example.com/font2.woff2')"); + expect(result).toContain("'PP Merchant Font 1'"); + expect(result).toContain("'PP Merchant Font 2'"); + }); + + test('includes explicit fontFamily after fontSource names in font-family stack', () => { + const options = { + style: { + ...baseOptions.style, + text: { + color: 'black', + size: 12, + fontSource: ['https://example.com/font.woff2'], + fontFamily: ['MyFont'] + } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toMatch(/'PP Merchant Font 1',\s*'MyFont',/); + }); + + test('includes explicit fontFamily without fontSource', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontFamily: ['Impact', 'sans-serif'] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).toContain("font-family: 'Impact', sans-serif, Helvetica"); + }); + + test('uses PayPal Pro default font-family when fontSource is not provided', () => { + const result = render(baseOptions, baseV2Content, mockLog); + expect(result).not.toContain('@font-face'); + expect(result).toContain('"PayPal Pro"'); + }); + + test('uses PayPal Pro default font-family when fontSource is empty array', () => { + const options = { + style: { ...baseOptions.style, text: { color: 'black', size: 12, fontSource: [] } } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).not.toContain('@font-face'); + expect(result).toContain('"PayPal Pro"'); + }); + + test('ignores unsafe fontSource URLs', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontSource: ['./font.woff2'] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).not.toContain('@font-face'); + expect(result).not.toContain('./font.woff2'); + }); + + test('ignores unsafe fontFamily values', () => { + const options = { + style: { + ...baseOptions.style, + text: { color: 'black', size: 12, fontFamily: [""] } + } + }; + const result = render(options, baseV2Content, mockLog); + expect(result).not.toContain('