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 */}
{/* eslint-enable react/no-danger */}
- {hasInitialLogo && logoBlock && logoType !== 'none' ? (
-
- {renderBlock(logoBlock)}
-
- ) : null}
+ {hasInitialLogo && logoType !== 'none' ? renderLogo(logoBlock, logoClasses) : null}
+ {logoType === 'inline' ? renderLogo(logoBlock, logoClasses) : null}
{preparedMainBlocks.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
{renderBlock(item)}
@@ -84,11 +87,7 @@ export default function V2Message({ options, v2Content }) {
))}
) : null}
- {hasRightLogo && logoBlock && logoType !== 'none' ? (
-
- {renderBlock(logoBlock)}
-
- ) : null}
+ {hasRightLogo && logoType !== 'none' ? renderLogo(logoBlock, logoClasses) : null}
);
}
diff --git a/src/server/v2/styles.js b/src/server/v2/styles.js
index 9a73ef4a6f..187d10c3f7 100644
--- a/src/server/v2/styles.js
+++ b/src/server/v2/styles.js
@@ -1,15 +1,38 @@
-export default ({
- fontFamily = '"PayPal Pro", Helvetica, Arial, "Liberation Sans", sans-serif',
- fontSize = 12,
- textAlign = 'left'
-} = {}) => `
+import { buildFontRules } from './font';
+import flexStyles from './flexStyles';
+
+const DEFAULT_FONT_FAMILY = '"PayPal Pro", Helvetica, Arial, "Liberation Sans", sans-serif';
+const FONT_FALLBACKS = 'Helvetica, Arial, "Liberation Sans", sans-serif';
+
+function buildFontConfig({ fontFamily, fontSource }) {
+ return buildFontRules({
+ fontSource,
+ fontFamily,
+ fallbackStack: FONT_FALLBACKS,
+ defaultFontFamily: DEFAULT_FONT_FAMILY,
+ fontNamePrefix: 'PP Merchant Font'
+ });
+}
+
+function textStyles({ fontFamily, fontSource, fontSize = 12, textAlign = 'left' } = {}) {
+ const { fontFaceRules, effectiveFontFamily } = buildFontConfig({ fontFamily, fontSource });
+
+ return `${fontFaceRules ? `${fontFaceRules}\n` : ''}
+body {
+ margin: 0;
+ padding: 0;
+}
+
.pp-message {
- font-family: ${fontFamily};
+ display: block;
+ width: 100%;
+ font-family: ${effectiveFontFamily};
font-weight: 450;
font-size: ${fontSize}px;
text-align: ${textAlign};
}
+.pp-message .main { vertical-align: middle; }
.pp-message .main.black { color: #000; }
.pp-message .main.monochrome { color: #000; }
.pp-message .main.grayscale { color: #000; }
@@ -19,12 +42,16 @@ export default ({
color: #0070ba;
white-space: nowrap;
}
+.pp-message .action {
+ margin-left: 0.25em;
+ vertical-align: middle;
+}
.pp-message .action.monochrome > [data-iframe-url] { color: #000; }
.pp-message .action.grayscale > [data-iframe-url] { color: #000; }
.pp-message .action.white > [data-iframe-url] { color: #fff; }
-.pp-message .logo { display: inline-block; }
-.pp-message .logo.top { display: block; }
+.pp-message .logo { display: inline-block; vertical-align: middle; }
+.pp-message .logo.top { display: block; vertical-align: initial; }
.pp-message img {
max-height: 1.25em;
@@ -33,4 +60,24 @@ export default ({
vertical-align: middle;
margin-right: 0.3125em;
}
+.pp-message .logo.right img {
+ margin-right: 0;
+ margin-left: 0.3125em;
+}
+.pp-message .logo.top img {
+ margin-right: 0;
+ margin-bottom: 0.3125em;
+}
+
+.pp-message .logo.white img { filter: brightness(0) invert(1); }
+.pp-message .logo.monochrome img { filter: grayscale(100%) brightness(0); }
+.pp-message .logo.grayscale img { filter: grayscale(100%); }
`;
+}
+
+export default ({ layout = 'text', fontFamily, fontSource, fontSize, textAlign } = {}) => {
+ if (layout === 'flex') {
+ return flexStyles({ fontFamily, fontSource });
+ }
+ return textStyles({ fontFamily, fontSource, fontSize, textAlign });
+};
diff --git a/src/server/v2/utils/buildLogoConfiguration.js b/src/server/v2/utils/buildLogoConfiguration.js
index f125b65a97..5ac73cf92f 100644
--- a/src/server/v2/utils/buildLogoConfiguration.js
+++ b/src/server/v2/utils/buildLogoConfiguration.js
@@ -1,11 +1,11 @@
-export function buildLogoConfiguration({ logoPosition, mainItems }) {
- if (logoPosition === 'inline') {
- return { hasInitialLogo: false, hasRightLogo: false, logoBlock: undefined, mainBlocks: mainItems };
- }
-
+export function buildLogoConfiguration({ logoType, logoPosition, mainItems }) {
const logoBlock = mainItems.find(item => item.type === 'IMAGE');
const mainBlocks = mainItems.filter(item => item.type !== 'IMAGE');
+ if (logoType === 'inline') {
+ return { hasInitialLogo: false, hasRightLogo: false, logoBlock, mainBlocks };
+ }
+
return {
hasInitialLogo: !!logoBlock && (logoPosition === 'left' || logoPosition === 'top'),
hasRightLogo: !!logoBlock && logoPosition === 'right',
diff --git a/src/server/v2/utils/renderBlock.js b/src/server/v2/utils/renderBlock.js
new file mode 100644
index 0000000000..32dc6aa16d
--- /dev/null
+++ b/src/server/v2/utils/renderBlock.js
@@ -0,0 +1,22 @@
+/** @jsx h */
+import { h } from 'preact';
+
+export function renderBlock(item) {
+ if (!item) return null;
+ switch (item.type) {
+ case 'IMAGE':
+ return

;
+ case 'LINK':
+ return (
+
+ {item.text}
+
+ );
+ case 'TEXT':
+ default:
+ return item.text;
+ }
+}
diff --git a/tests/unit/spec/server/v2/__snapshots__/render.test.js.snap b/tests/unit/spec/server/v2/__snapshots__/render.test.js.snap
new file mode 100644
index 0000000000..b5c0ea8d90
--- /dev/null
+++ b/tests/unit/spec/server/v2/__snapshots__/render.test.js.snap
@@ -0,0 +1,491 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`v2 render flex snapshots full render snapshot for representative case (blue/8x1) 1`] = `
+"
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('