diff --git a/src/server/message/font.js b/src/server/message/font.js
index 794e655dcb..cb1bb3fd38 100644
--- a/src/server/message/font.js
+++ b/src/server/message/font.js
@@ -90,3 +90,25 @@ export const getFontRules = style => {
}
return rules;
};
+
+// Shared validation and @font-face generation for v2 stylesheet.
+const isSafeFontName = value => typeof value === 'string' && /^[\w\s-]+$/.test(value.trim());
+const isSafeFontSource = value => typeof value === 'string' && /^https:\/\/[^'")\s]+$/i.test(value);
+
+export function buildFontRules({ fontSource, fontFamily, fallbackStack, defaultFontFamily, fontNamePrefix }) {
+ const sources = Array.isArray(fontSource) ? fontSource.filter(isSafeFontSource) : [];
+ const families = Array.isArray(fontFamily) ? fontFamily.filter(isSafeFontName).map(formatFontFamilyName) : [];
+
+ const fontFaceRules = sources
+ .map((url, i) => `@font-face { font-family: '${fontNamePrefix} ${i + 1}'; src: url('${url}'); }`)
+ .join('\n');
+
+ const customSourceNames = sources.map((_, i) => `'${fontNamePrefix} ${i + 1}'`);
+
+ const effectiveFontFamily =
+ customSourceNames.length > 0 || families.length > 0
+ ? [...customSourceNames, ...families, fallbackStack].join(', ')
+ : defaultFontFamily;
+
+ return { fontFaceRules, effectiveFontFamily };
+}
diff --git a/src/server/v2/message.jsx b/src/server/v2/message.jsx
index 58e5fb63d0..5e43458548 100644
--- a/src/server/v2/message.jsx
+++ b/src/server/v2/message.jsx
@@ -27,10 +27,19 @@ function renderBlock(item) {
}
}
+function renderLogo(block, className) {
+ if (!block) return null;
+ return (
+
+ {renderBlock(block)}
+
+ );
+}
+
export default function V2Message({ options, v2Content }) {
const { style } = options;
- const logoPosition = style.logo?.type === 'inline' ? 'inline' : style.logo?.position ?? 'left';
const logoType = style.logo?.type ?? 'primary';
+ const logoPosition = style.logo?.position ?? 'left';
const textColor = style.layout === 'flex' ? style.color ?? 'black' : style.text?.color ?? 'black';
const mainItems = v2Content?.main_items ?? [];
@@ -38,6 +47,7 @@ export default function V2Message({ options, v2Content }) {
const disclaimerItems = v2Content?.disclaimer_items ?? [];
const { logoBlock, hasInitialLogo, hasRightLogo, mainBlocks } = buildLogoConfiguration({
+ logoType,
logoPosition,
mainItems
});
@@ -53,24 +63,30 @@ export default function V2Message({ options, v2Content }) {
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 +100,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..facdb9b01d 100644
--- a/src/server/v2/styles.js
+++ b/src/server/v2/styles.js
@@ -1,15 +1,33 @@
-export default ({
- fontFamily = '"PayPal Pro", Helvetica, Arial, "Liberation Sans", sans-serif',
- fontSize = 12,
- textAlign = 'left'
-} = {}) => `
+import { buildFontRules } from '../message/font';
+
+const DEFAULT_FONT_FAMILY = '"PayPal Pro", Helvetica, Arial, "Liberation Sans", sans-serif';
+const FONT_FALLBACKS = 'Helvetica, Arial, "Liberation Sans", sans-serif';
+
+export default ({ fontFamily, fontSource, fontSize = 12, textAlign = 'left' } = {}) => {
+ const { fontFaceRules, effectiveFontFamily } = buildFontRules({
+ fontSource,
+ fontFamily,
+ fallbackStack: FONT_FALLBACKS,
+ defaultFontFamily: DEFAULT_FONT_FAMILY,
+ fontNamePrefix: 'PP Merchant Font'
+ });
+
+ 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 +37,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 +55,17 @@ 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%); }
`;
+};
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/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..b470f886ea
--- /dev/null
+++ b/tests/unit/spec/server/v2/__snapshots__/render.test.js.snap
@@ -0,0 +1,119 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+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/render.test.js b/tests/unit/spec/server/v2/render.test.js
index 842893e63b..487d707896 100644
--- a/tests/unit/spec/server/v2/render.test.js
+++ b/tests/unit/spec/server/v2/render.test.js
@@ -1,4 +1,5 @@
import render from 'server/v2/render';
+import validateStyle from 'server/v2/validateStyle';
const mockLog = jest.fn();
@@ -134,7 +135,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 +150,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 +206,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 +236,260 @@ 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('