diff --git a/src/server/v2/constants.js b/src/server/v2/constants.js
index 32a64f541d..f48567d786 100644
--- a/src/server/v2/constants.js
+++ b/src/server/v2/constants.js
@@ -1,2 +1,20 @@
export const VARIANT = 'B';
export const PORT = process.env.PORT || 8080;
+
+export const FLEX_DEFAULTS = {
+ color: 'blue',
+ ratio: '1x1'
+};
+
+// Maps flex style.color to logo asset variant keys (see flexLogoMutations + v5 flex defaults).
+export const FLEX_COLOR_TO_LOGO_TEXT_COLOR = {
+ blue: 'white',
+ black: 'white',
+ white: 'black',
+ 'white-no-border': 'black',
+ gray: 'black',
+ grey: 'black',
+ monochrome: 'monochrome',
+ grayscale: 'grayscale',
+ greyscale: 'grayscale'
+};
diff --git a/src/server/v2/flex.jsx b/src/server/v2/flex.jsx
new file mode 100644
index 0000000000..78df68a0d1
--- /dev/null
+++ b/src/server/v2/flex.jsx
@@ -0,0 +1,107 @@
+/** @jsx h */
+/** @jsxFrag Fragment */
+import { h, Fragment } from 'preact';
+
+import { buildContentLabel } from './utils/buildContentLabel';
+import { renderBlock } from './utils/renderBlock';
+import { getLogoBrandClass, resolveLogoAssets } from './logos';
+import flexStyles from './flexStyles';
+import { FLEX_COLOR_TO_LOGO_TEXT_COLOR, FLEX_DEFAULTS } from './constants';
+
+function renderFlexLogo(logoBlock, flexColor) {
+ const textColor = FLEX_COLOR_TO_LOGO_TEXT_COLOR[flexColor] ?? 'white';
+ const brandClass = getLogoBrandClass({
+ logoName: logoBlock.name,
+ alternativeText: logoBlock.alternative_text
+ });
+ const logoClassName = ['pp-flex__logo', brandClass].filter(Boolean).join(' ');
+ const assets = resolveLogoAssets({
+ logoName: logoBlock.name,
+ effectiveLogoType: 'wordmark',
+ effectiveLogoPosition: 'left',
+ textColor
+ });
+
+ if (assets) {
+ return assets.map(({ src, dimensions: [width, height] }, idx) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+
+
+ ));
+ }
+
+ return (
+
+ {renderBlock(logoBlock)}
+
+ );
+}
+
+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(logoBlock ? [logoBlock, ...mainBlocks] : mainBlocks);
+ const actionLabel = buildContentLabel(actionItems);
+
+ return (
+
+ {/* eslint-disable react/no-danger */}
+
+ {/* eslint-enable react/no-danger */}
+
+
+ {logoBlock ? (
+
+ {renderFlexLogo(logoBlock, color)}
+
+ ) : null}
+
+
+ {mainBlocks.map((item, idx) => (
+ // eslint-disable-next-line react/no-array-index-key
+ {renderBlock(item)}
+ ))}
+
+ {actionItems.length > 0 ? (
+
+ {actionItems.map((item, idx) => (
+ // eslint-disable-next-line react/no-array-index-key
+ {renderBlock(item)}
+ ))}
+
+ ) : null}
+ {disclaimerItems.length > 0 ? (
+
+ {disclaimerItems.map((item, idx) => (
+ // eslint-disable-next-line react/no-array-index-key
+ {renderBlock(item)}
+ ))}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/src/server/v2/flexStyles.js b/src/server/v2/flexStyles.js
new file mode 100644
index 0000000000..f9234b4eb9
--- /dev/null
+++ b/src/server/v2/flexStyles.js
@@ -0,0 +1,579 @@
+import { buildFontRules } from '../message/font';
+
+const DEFAULT_FONT_FAMILY = 'Helvetica, Arial, sans-serif';
+const FONT_FALLBACKS = 'Helvetica, Arial, sans-serif';
+
+const FLEX_THEMES = [
+ { name: 'blue', background: '#023188', contentColor: '#fff', logoFilter: 'brightness(0) invert(1)' },
+ { name: 'black', background: '#000', contentColor: '#fff', logoFilter: 'brightness(0) invert(1)' },
+ { name: 'white', background: '#fff', contentColor: '#023187', border: '1px solid #009cde' },
+ { name: 'white-no-border', background: '#fff', contentColor: '#023187' },
+ { name: 'gray', background: '#eaeced', contentColor: '#023187' },
+ {
+ name: 'monochrome',
+ background: '#fff',
+ contentColor: '#000',
+ border: '1px solid #000',
+ logoFilter: 'grayscale(100%) brightness(0)'
+ },
+ { name: 'grayscale', background: '#fff', border: '1px solid #b7bcbf', logoFilter: 'grayscale(100%)' }
+];
+
+// Build ratio-scoped selectors for flex layout CSS (e.g. rs('8x1', '.pp-flex__main')).
+const rs = (ratio, sub) => `.pp-message.pp-flex.r-${ratio} ${sub}`;
+
+// Same as rs, with leading indent for rules nested inside @media blocks.
+const rsMedia = (ratio, sub, indent = ' ') => `${indent}${rs(ratio, sub)}`;
+
+// Comma-join multiple ratio-scoped selectors that share the same declarations.
+const rsPair = (ratio, ...subs) => subs.map(sub => rs(ratio, sub)).join(',\n');
+
+// Single-piece lockups (PPC/Venmo/fallback). Include :nth-of-type(1) so brand
+// selectors beat dual-PayPal monogram rules; :only-child covers unnamed fallbacks.
+const LOCKUP_LOGO_SUBS = [
+ '.pp-flex__logo.paypal-credit:nth-of-type(1)',
+ '.pp-flex__logo.venmo:nth-of-type(1)',
+ '.pp-flex__logo:only-child'
+];
+
+const rsLockup = (ratio, indent = '') => LOCKUP_LOGO_SUBS.map(sub => `${indent}${rs(ratio, sub)}`).join(',\n');
+
+const rsMediaLockup = (ratio, indent = ' ') => rsLockup(ratio, indent);
+
+function buildThemeRules() {
+ const bgAndContentRules = FLEX_THEMES.flatMap(({ name, background, contentColor, border }) => {
+ const decls = [contentColor && `color: ${contentColor}`, border && `border: ${border}`]
+ .filter(Boolean)
+ .join('; ');
+ return [
+ `.pp-message.pp-flex.${name} .pp-flex__background { background: ${background}; }`,
+ `.pp-message.pp-flex.${name} .pp-flex__content { ${decls}; }`
+ ];
+ }).join('\n');
+
+ const filterGroups = new Map();
+ FLEX_THEMES.filter(({ logoFilter }) => logoFilter).forEach(({ name, logoFilter }) => {
+ if (!filterGroups.has(logoFilter)) filterGroups.set(logoFilter, []);
+ filterGroups.get(logoFilter).push(name);
+ });
+ const logoFilterRules = Array.from(filterGroups.entries())
+ .map(([filter, names]) => {
+ const sels = names.map(n => `.pp-message.pp-flex.${n} .pp-flex__logo img`).join(',\n');
+ return `${sels} { filter: ${filter}; }`;
+ })
+ .join('\n');
+
+ return `${bgAndContentRules}\n\n${logoFilterRules}`;
+}
+
+function buildBaseRules() {
+ return `
+* {
+ box-sizing: border-box;
+}
+
+.pp-flex__logo img {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.pp-flex__logo--fallback {
+ width: 100%;
+}
+
+button:focus .pp-message.pp-flex .pp-flex__content,
+button:focus .pp-message.pp-flex .pp-flex__content span.br {
+ text-decoration: underline;
+}
+
+.pp-flex__disclaimer span,
+.pp-flex__action span {
+ text-decoration: underline;
+ font-weight: 300;
+}
+
+.pp-flex__action span {
+ white-space: nowrap;
+}
+
+.pp-flex__disclaimer {
+ white-space: normal;
+}
+
+.pp-flex__logo-container {
+ display: flex;
+ align-items: center;
+}`;
+}
+
+function buildPortrait1x1Rules() {
+ return `
+${rs('1x1', '.pp-flex__content')} {
+ padding: 7%;
+}
+
+${rs('1x1', '.pp-flex__logo-container')} {
+ width: 100%;
+ margin-bottom: 12%;
+}
+
+${rs('1x1', '.pp-flex__logo:nth-of-type(1)')} {
+ width: 29px;
+ max-width: 15%;
+}
+
+${rs('1x1', '.pp-flex__logo:nth-of-type(2)')} {
+ width: 91px;
+ max-width: 45%;
+ margin-left: 3%;
+}
+
+${rsLockup('1x1')} {
+ width: 50%;
+ max-width: 50%;
+}
+
+${rs('1x1', '.pp-flex__main')} {
+ font-size: 10vw;
+ line-height: 1.55em;
+ font-weight: 400;
+}
+
+${rsPair('1x1', '.pp-flex__disclaimer', '.pp-flex__action')} {
+ position: static;
+ width: 80%;
+ font-size: 9.5px;
+ white-space: normal;
+}
+
+${rs('1x1', '.pp-flex__main + .pp-flex__disclaimer')},
+${rs('1x1', '.pp-flex__main + .pp-flex__action')} {
+ margin-top: 3%;
+}
+
+@media (min-width: 140px) {
+ ${rsMedia('1x1', '.pp-flex__main')} { font-size: 8.4vw; }
+}
+
+@media (min-width: 170px) {
+ ${rsMedia('1x1', '.pp-flex__main')} { font-size: 8vw; }
+ ${rsMedia('1x1', '.pp-flex__disclaimer')},
+ ${rsMedia('1x1', '.pp-flex__action')} { font-size: 5.5vw; }
+}
+
+@media (min-width: 220px) {
+ ${rsMedia('1x1', '.pp-flex__disclaimer')},
+ ${rsMedia('1x1', '.pp-flex__action')} { font-size: 0.9rem; }
+}`;
+}
+
+function buildPortrait1x4Rules() {
+ return `
+${rs('1x4', '.pp-flex__content')} {
+ padding: 8%;
+}
+
+${rs('1x4', '.pp-flex__logo-container')} {
+ width: 100%;
+ margin-top: 3%;
+}
+
+${rs('1x4', '.pp-flex__messaging')} {
+ height: 100%;
+ transform: translateY(-80px);
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+}
+
+${rs('1x4', '.pp-flex__main')} {
+ font-size: 1.1rem;
+ line-height: 1.3em;
+ margin-bottom: 10%;
+ font-weight: 400;
+}
+
+${rsPair('1x4', '.pp-flex__disclaimer', '.pp-flex__action')} {
+ font-size: 0.9rem;
+ line-height: 1.1;
+}
+
+${rs('1x4', '.pp-flex__logo:nth-of-type(1)')} {
+ width: 27px;
+ display: inline-block;
+ margin-right: 10px;
+}
+
+${rs('1x4', '.pp-flex__logo:nth-of-type(2)')} {
+ width: 89px;
+ display: inline-block;
+}
+
+${rsLockup('1x4')} {
+ width: 70%;
+ max-width: none;
+ margin-right: 0;
+}
+
+@media (min-height: 500px) {
+ ${rsMedia('1x4', '.pp-flex__main')} { font-size: 1.7rem; }
+}
+
+@media (aspect-ratio: 1/2) {
+ ${rsMedia('1x4', '.pp-flex__messaging')} { transform: translateY(-40px); }
+}`;
+}
+
+function buildLandscapeMobileBase(ratio) {
+ return `
+${rs(ratio, '.pp-flex__content')} {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding-right: 1rem;
+}
+
+${rs(ratio, '.pp-flex__logo-container')} {
+ flex: 0 0 33%;
+ justify-content: center;
+}
+
+${rs(ratio, '.pp-flex__logo')} {
+ width: 60%;
+}
+
+${rs(ratio, '.pp-flex__logo:nth-of-type(2)')} {
+ display: none;
+}
+
+${rs(ratio, '.pp-flex__messaging')} {
+ flex: 1 1 100%;
+}
+
+${rs(ratio, '.pp-flex__main')} {
+ font-size: 5vw;
+ line-height: 1;
+ font-weight: 400;
+ display: block;
+}
+
+${rs(ratio, '.pp-flex__disclaimer')},
+${rs(ratio, '.pp-flex__action')} {
+ font-size: 10px;
+ line-height: 1.1;
+ display: inline;
+}
+
+@media (max-aspect-ratio: 61/10) {
+ ${rsMedia(ratio, '.pp-flex__logo-container')} {
+ flex-basis: 12%;
+ margin-bottom: -6px;
+ justify-content: flex-start;
+ margin-left: 5px;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} {
+ margin-left: 10px;
+ margin-right: 0;
+ }
+
+ ${rsMediaLockup(ratio)} {
+ margin-left: 0;
+ margin-right: 0;
+ }
+}
+
+@media (max-aspect-ratio: 61/10) and (min-width: 324px) {
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} { width: 45%; }
+
+ ${rsMediaLockup(ratio)} { width: 60%; }
+}
+
+@media (max-aspect-ratio: 61/10) and (max-width: 374px) {
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} { width: 50%; }
+
+ ${rsMediaLockup(ratio)} { width: 60%; }
+}
+
+@media (max-width: 374px) {
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} { width: 55%; }
+ ${rsMedia(ratio, '.pp-flex__logo-container')} { margin-right: 2.5%; }
+
+ ${rsMediaLockup(ratio)} { width: 60%; }
+}
+
+@media (max-aspect-ratio: 61/10) and (max-width: 323px) {
+ ${rsMedia(ratio, '.pp-flex__logo-container')} { margin-right: 7%; }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} {
+ margin: 0 5px;
+ width: 30%;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(2)')} {
+ display: inline;
+ }
+
+ ${rsMediaLockup(ratio)} {
+ margin: 0;
+ width: 60%;
+ }
+}
+
+@media (max-aspect-ratio: 61/10) and (min-width: 400px) {
+ ${rsMedia(ratio, '.pp-flex__main')} { font-size: 4vw; margin-bottom: 0.5rem; }
+}
+
+@media (max-aspect-ratio: 61/10) and (min-width: 520px) {
+ ${rsMedia(ratio, '.pp-flex__disclaimer')},
+ ${rsMedia(ratio, '.pp-flex__action')} { font-size: 0.85rem; }
+}
+
+@media (max-aspect-ratio: 61/10) and (min-width: 640px) {
+ ${rsMedia(ratio, '.pp-flex__main')} { font-size: 1.7rem; }
+}`;
+}
+
+function buildLandscape8x1Rules() {
+ const ratio = '8x1';
+
+ return `${buildLandscapeMobileBase(ratio)}
+${rs(ratio, '.pp-flex__logo-container')} {
+ padding-bottom: 2.5px;
+}
+
+@media (min-aspect-ratio: 80/11) {
+ ${rsMedia(ratio, '.pp-flex__main')} {
+ display: block;
+ line-height: 1.3em;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo-container')} {
+ flex-basis: 12%;
+ margin-bottom: -6px;
+ justify-content: flex-start;
+ margin-left: 5px;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} {
+ width: 50%;
+ margin-left: 10px;
+ }
+
+ ${rsMediaLockup(ratio)} {
+ width: 60%;
+ margin-left: 0;
+ }
+}
+
+@media (min-aspect-ratio: 80/11) and (min-width: 500px) {
+ ${rsMedia(ratio, '.pp-flex__main')} { font-size: 3vw; }
+
+ ${rsMedia(ratio, '.pp-flex__logo-container')} { flex-basis: 22%; }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} {
+ width: 18%;
+ margin-right: 5%;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(2)')} {
+ display: inline-block;
+ width: 55%;
+ }
+
+ ${rsMediaLockup(ratio)} {
+ width: 60%;
+ margin-right: 0;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__disclaimer')},
+ ${rsMedia(ratio, '.pp-flex__action')} {
+ font-size: 0.9rem;
+ }
+}`;
+}
+function buildLandscape20x1Rules() {
+ const ratio = '20x1';
+
+ return `${buildLandscapeMobileBase(ratio)}
+@media (min-aspect-ratio: 200/11) {
+ ${rsMedia(ratio, '.pp-flex__content')} {
+ justify-content: center;
+ align-items: center;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo-container')} {
+ flex: none;
+ width: auto;
+ max-width: 18%;
+ margin-right: 1.5vw;
+ align-self: center;
+ padding-top: 2.5px;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo img')} {
+ width: 100%;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} {
+ width: 20%;
+ margin-right: 3%;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(2)')} {
+ display: inline-block;
+ width: 60%;
+ }
+
+ ${rsMediaLockup(ratio)} {
+ width: 60%;
+ margin-right: 0;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__messaging')} {
+ flex: none;
+ display: flex;
+ flex-direction: row;
+ flex-wrap: nowrap;
+ align-items: center;
+ align-self: center;
+ width: auto;
+ max-width: 75%;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__main')} {
+ flex: 1 1 auto;
+ display: block;
+ margin-bottom: 0;
+ margin-right: 0.5em;
+ font-size: 0.7rem;
+ line-height: 1;
+ min-width: 0;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__disclaimer')},
+ ${rsMedia(ratio, '.pp-flex__action')} {
+ flex: 0 0 auto;
+ display: inline;
+ margin-left: 0;
+ font-size: 8px;
+ line-height: 1.1;
+ max-width: 12rem;
+ }
+}
+
+@media (min-aspect-ratio: 200/11) and (min-width: 400px) {
+ ${rsMedia(ratio, '.pp-flex__main')} { font-size: 1rem; }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(1)')} {
+ width: 22%;
+ margin-right: 5%;
+ }
+
+ ${rsMedia(ratio, '.pp-flex__logo:nth-of-type(2)')} {
+ display: inline-block;
+ width: 65%;
+ }
+
+ ${rsMediaLockup(ratio)} {
+ width: 60%;
+ margin-right: 0;
+ }
+}
+
+@media (min-aspect-ratio: 200/11) and (min-width: 600px) {
+ ${rsMedia(ratio, '.pp-flex__logo-container')} { max-width: 22%; }
+
+ ${rsMedia(ratio, '.pp-flex__main')} { font-size: 1.8vw; }
+ ${rsMedia(ratio, '.pp-flex__disclaimer')},
+ ${rsMedia(ratio, '.pp-flex__action')} { font-size: 0.75rem; }
+}
+
+@media (min-aspect-ratio: 200/11) and (min-width: 1000px) {
+ ${rsMedia(ratio, '.pp-flex__disclaimer')},
+ ${rsMedia(ratio, '.pp-flex__action')} { font-size: 0.9rem; }
+}`;
+}
+
+function buildRatioRules(ratio) {
+ switch (ratio) {
+ case '1x1':
+ return buildPortrait1x1Rules();
+ case '1x4':
+ return buildPortrait1x4Rules();
+ case '8x1':
+ return buildLandscape8x1Rules();
+ case '20x1':
+ return buildLandscape20x1Rules();
+ default:
+ return '';
+ }
+}
+
+export default function flexStyles({ fontFamily, fontSource, ratio } = {}) {
+ const { fontFaceRules, effectiveFontFamily } = buildFontRules({
+ fontSource,
+ fontFamily,
+ fallbackStack: FONT_FALLBACKS,
+ defaultFontFamily: DEFAULT_FONT_FAMILY,
+ fontNamePrefix: 'PP Merchant Font'
+ });
+ const fontFaceBlock = fontFaceRules ? `${fontFaceRules}\n` : '';
+
+ return `${fontFaceBlock}
+html,
+body,
+button {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ width: 100%;
+ border: none;
+ padding: 0;
+}
+
+html {
+ font-size: 14px;
+ overflow: hidden;
+}
+
+.pp-message.pp-flex {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ font-family: ${effectiveFontFamily};
+ font-weight: 300;
+ cursor: pointer;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.pp-message.pp-flex .pp-flex__background,
+.pp-message.pp-flex .pp-flex__content {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ box-sizing: border-box;
+}
+
+.pp-message.pp-flex .pp-flex__background {
+ z-index: -1;
+}
+
+${buildBaseRules()}
+
+${buildThemeRules()}
+
+${buildRatioRules(ratio)}
+`;
+}
diff --git a/src/server/v2/message.jsx b/src/server/v2/message.jsx
index 18213d0f42..4a0521f2ed 100644
--- a/src/server/v2/message.jsx
+++ b/src/server/v2/message.jsx
@@ -6,77 +6,30 @@ import { buildContentLabel } from './utils/buildContentLabel';
import { buildLogoConfiguration } from './utils/buildLogoConfiguration';
import { resolveLogoPresentation } from './utils/resolveLogoPresentation';
import { mapClasses } from './utils/mapClasses';
-import { getLogoBrandClass, resolveLogoAssets } from './logos';
+import { renderBlock, renderLogoImages } from './utils/renderBlock';
+import { getLogoBrandClass } from './logos';
+import FlexMessage from './flex';
import styles from './styles';
-// Renders local brand assets for first-party logos (paypal_logo, paypal_credit_logo).
-// Falls back to item.source_url for unknown image blocks.
-function renderLogoImages(item, logoPresentation) {
- const assets = resolveLogoAssets({
- logoName: item.name,
- effectiveLogoType: logoPresentation.effectiveLogoType,
- effectiveLogoPosition: logoPresentation.effectiveLogoPosition,
- textColor: logoPresentation.textColor
- });
-
- if (assets) {
- return assets.map(({ src, dimensions: [width, height] }, idx) => (
- // eslint-disable-next-line react/no-array-index-key
-
- ));
- }
-
- return
;
-}
-
// Deferred v6-parity behaviors (not yet ported — unknown block types/fields fall through
// to plain text or are dropped, never throwing or producing broken markup):
// - TEXT_VARIABLE blocks / missing-text placeholders (v6-specific content type)
// - "**bold**" marker rendering within TEXT blocks
// - Card-offer logo overrides for PAYPAL_CASHBACK_MASTERCARD / PAYPAL_DEBIT_CARD
-// renderBlock renders a single CPS content block.
-// When logoPresentation is provided, IMAGE blocks render as inline brand logos
-// in CPS order (v6 parity for logo.type:inline). Without it, IMAGE falls back
-// to a plain img — callers that extract the logo block before the loop should
-// pass null so IMAGE items are never double-rendered.
-//
-// LINK blocks are visually styled spans, not anchors: the v5 SDK message click
-// surface is canonical and click_url is not used for navigation.
-function renderBlock(item, logoPresentation) {
- if (!item) return null;
- switch (item.type) {
- case 'IMAGE':
- if (logoPresentation) {
- return (
-
- {renderLogoImages(item, logoPresentation)}
-
- );
- }
- return
;
- case 'LINK':
- return {item.text};
- case 'TEXT':
- return item.brand ? {item.text} : item.text;
- default:
- return item.text;
- }
-}
+const textLinkOptions = { linkClassName: 'action__link' };
function renderInlineMain(blocks, mainClasses, mainLabel, logoPresentation) {
return (
{blocks.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
- {renderBlock(item, item.type === 'IMAGE' ? logoPresentation : null)}
+
+ {renderBlock(item, {
+ ...textLinkOptions,
+ logoPresentation: item.type === 'IMAGE' ? logoPresentation : null
+ })}
+
))}
);
@@ -84,14 +37,15 @@ function renderInlineMain(blocks, mainClasses, mainLabel, logoPresentation) {
function renderLogoSpan(block, className, logoPresentation) {
if (!block) return null;
+ const brandClass = getLogoBrandClass({
+ logoName: block.name,
+ alternativeText: block.alternative_text
+ });
return (
{renderLogoImages(block, logoPresentation)}
@@ -100,6 +54,11 @@ function renderLogoSpan(block, className, logoPresentation) {
export default function V2Message({ options, v2Content, log }) {
const { style } = options;
+
+ if (style.layout === 'flex') {
+ return ;
+ }
+
const textColor = style.text?.color ?? 'black';
const logoPresentation = resolveLogoPresentation({
@@ -178,7 +137,7 @@ export default function V2Message({ options, v2Content, log }) {
{preparedMainBlocks.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
- {renderBlock(item, null)}
+ {renderBlock(item, textLinkOptions)}
))}
)}
@@ -188,7 +147,7 @@ export default function V2Message({ options, v2Content, log }) {
{linkActionItems.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
- {renderBlock(item, null)}
+ {renderBlock(item, textLinkOptions)}
))}
>
diff --git a/src/server/v2/utils/renderBlock.js b/src/server/v2/utils/renderBlock.js
new file mode 100644
index 0000000000..a7bfb5cb81
--- /dev/null
+++ b/src/server/v2/utils/renderBlock.js
@@ -0,0 +1,60 @@
+/** @jsx h */
+import { h } from 'preact';
+
+import { getLogoBrandClass, resolveLogoAssets } from '../logos';
+
+// Renders local brand assets for first-party logos (paypal_logo, paypal_credit_logo, venmo).
+// Falls back to item.source_url for unknown image blocks.
+export function renderLogoImages(item, logoPresentation) {
+ const assets = resolveLogoAssets({
+ logoName: item.name,
+ effectiveLogoType: logoPresentation.effectiveLogoType,
+ effectiveLogoPosition: logoPresentation.effectiveLogoPosition,
+ textColor: logoPresentation.textColor
+ });
+
+ if (assets) {
+ return assets.map(({ src, dimensions: [width, height] }, idx) => (
+ // eslint-disable-next-line react/no-array-index-key
+
+ ));
+ }
+
+ return
;
+}
+
+/**
+ * Renders a single CPS content block for text or flex layouts.
+ *
+ * @param {object} item CPS content block
+ * @param {object} [options]
+ * @param {object|null} [options.logoPresentation] When set, IMAGE blocks render as
+ * inline brand logos (text layout logo.type:inline). Pass null/omit for plain img.
+ * @param {string} [options.linkClassName] Optional class for LINK spans (e.g. action__link).
+ */
+export function renderBlock(item, { logoPresentation = null, linkClassName } = {}) {
+ if (!item) return null;
+
+ switch (item.type) {
+ case 'IMAGE':
+ if (logoPresentation) {
+ const brandClass = getLogoBrandClass({
+ logoName: item.name,
+ alternativeText: item.alternative_text
+ });
+ const className = ['logo', 'inline', 'wordmark', brandClass].filter(Boolean).join(' ');
+ return (
+
+ {renderLogoImages(item, logoPresentation)}
+
+ );
+ }
+ return
;
+ case 'LINK':
+ return linkClassName ? {item.text} : {item.text};
+ case 'TEXT':
+ return item.brand ? {item.text} : item.text;
+ default:
+ return item.text;
+ }
+}
diff --git a/tests/functional/spec/createBannerTest.js b/tests/functional/spec/createBannerTest.js
index 6fbe5921c2..a8a039f0dd 100644
--- a/tests/functional/spec/createBannerTest.js
+++ b/tests/functional/spec/createBannerTest.js
@@ -48,32 +48,79 @@ const getTestNameParts = (locale, { account, amount, style: { layout, ...style }
// returns height and width of banner in pixels
const waitForBanner = async ({ testName, timeout, config }) => {
try {
- const polling = 10;
+ const polling = 100;
+ // Must pass into the page function — closures are not available in waitForFunction.
+ const useIframeBodyDimensions = Boolean(config?.style?.text?.align);
const result = await page.waitForFunction(
- ({ bannerSelectors, _testName, _polling, _timeout }) => {
- Window.timeTaken = (Window.timeTaken || 0) + _polling;
- if (Window.timeTaken % 1000 === 0 && Window.timeTaken >= _timeout - 2000) {
+ async ({ bannerSelectors, _testName, _timeout, useIframeBodyDimensions: useBodyDims, startedAt }) => {
+ if (Date.now() - startedAt >= _timeout - 2000 && !window.__waitForBannerLogged) {
+ window.__waitForBannerLogged = true;
// eslint-disable-next-line no-console
console.info(`waitForBanner innerHTML for failed test [${_testName}]`, document.body.innerHTML);
}
+ let measureEl = null;
+ let measureDoc = null;
+
const iframe = document.querySelector(bannerSelectors.iframeByAttribute);
if (iframe) {
- const iframeBody = iframe.contentWindow.document.body;
- const banner = iframeBody.querySelector(bannerSelectors.container);
- if (config?.style?.text?.align) {
- return (
- iframeBody?.clientHeight && {
- height: iframeBody.clientHeight,
- width: iframeBody.clientWidth
- }
- );
+ // Iframe can exist before its document/body is ready; do not throw.
+ const iframeDoc = iframe.contentDocument || iframe.contentWindow?.document;
+ const iframeBody = iframeDoc?.body;
+ if (!iframeBody) {
+ return false;
}
- return banner?.clientHeight && { height: banner.clientHeight, width: banner.clientWidth };
+
+ measureDoc = iframeDoc;
+ measureEl = useBodyDims ? iframeBody : iframeBody.querySelector(bannerSelectors.container);
+ } else {
+ measureEl = document.querySelector(bannerSelectors.legacyContainer);
+ measureDoc = document;
+ }
+
+ if (!measureEl || measureEl.clientHeight <= 0) {
+ return false;
+ }
+
+ // Wait for screenshot-affecting resources before treating the banner as ready.
+ if (measureDoc.fonts?.ready) {
+ await measureDoc.fonts.ready;
+ }
+
+ const incompleteImages = Array.from(measureDoc.images || []).filter(img => !img.complete);
+ if (incompleteImages.length > 0) {
+ await Promise.all(
+ incompleteImages.map(
+ img =>
+ new Promise(resolve => {
+ img.addEventListener('load', resolve, { once: true });
+ img.addEventListener('error', resolve, { once: true });
+ })
+ )
+ );
+ }
+
+ const first = {
+ height: measureEl.clientHeight,
+ width: measureEl.clientWidth
+ };
+
+ await new Promise(resolve => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(resolve);
+ });
+ });
+
+ const second = {
+ height: measureEl.clientHeight,
+ width: measureEl.clientWidth
+ };
+
+ if (second.height <= 0 || first.height !== second.height || first.width !== second.width) {
+ return false;
}
- const legacy = document.querySelector(bannerSelectors.legacyContainer);
- return legacy?.clientHeight && { height: legacy.clientHeight, width: legacy.clientWidth };
+ return second;
},
{
polling,
@@ -82,13 +129,12 @@ const waitForBanner = async ({ testName, timeout, config }) => {
{
bannerSelectors: selectors.banner,
_testName: testName,
- _polling: polling,
- _timeout: timeout
+ _timeout: timeout,
+ useIframeBodyDimensions,
+ startedAt: Date.now()
}
);
- // Give time for fonts to load after banner is rendered
- await new Promise(resolve => setTimeout(resolve, 500));
return await result.jsonValue();
} catch (error) {
console.warn(`waitForBanner error for [${testName}]`, error); // eslint-disable-line no-console
@@ -184,7 +230,7 @@ export default function createBannerTest(locale, testPage = 'banner.html') {
await setupPageForBanner(viewport, config, testPage);
- const bannerDimensions = await waitForBanner({ testName, timeout: 2 * 1000, config });
+ const bannerDimensions = await waitForBanner({ testName, timeout: 10 * 1000, config });
expect(bannerDimensions.height).toBeGreaterThan(0);
expect(bannerDimensions.width).toBeGreaterThan(0);
diff --git a/tests/unit/spec/server/v2/__snapshots__/render.test.js.snap b/tests/unit/spec/server/v2/__snapshots__/render.test.js.snap
index 7d3b271d18..305481bc51 100644
--- a/tests/unit/spec/server/v2/__snapshots__/render.test.js.snap
+++ b/tests/unit/spec/server/v2/__snapshots__/render.test.js.snap
@@ -1,5 +1,863 @@
// 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 no-custom-font snapshot uses v5 Helvetica/Arial default stack 1`] = `
+"
+html,
+body,
+button {
+ height: 100%;
+}
+
+body {
+ margin: 0;
+ padding: 0;
+}
+
+button {
+ width: 100%;
+ border: none;
+ padding: 0;
+}
+
+html {
+ font-size: 14px;
+ overflow: hidden;
+}
+
+.pp-message.pp-flex {
+ position: relative;
+ width: 100%;
+ height: 100%;
+ font-family: Helvetica, Arial, sans-serif;
+ font-weight: 300;
+ cursor: pointer;
+ box-sizing: border-box;
+ overflow: hidden;
+}
+
+.pp-message.pp-flex .pp-flex__background,
+.pp-message.pp-flex .pp-flex__content {
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ box-sizing: border-box;
+}
+
+.pp-message.pp-flex .pp-flex__background {
+ z-index: -1;
+}
+
+
+* {
+ box-sizing: border-box;
+}
+
+.pp-flex__logo img {
+ display: block;
+ width: 100%;
+ height: auto;
+}
+
+.pp-flex__logo--fallback {
+ width: 100%;
+}
+
+button:focus .pp-message.pp-flex .pp-flex__content,
+button:focus .pp-message.pp-flex .pp-flex__content span.br {
+ text-decoration: underline;
+}
+
+.pp-flex__disclaimer span,
+.pp-flex__action span {
+ text-decoration: underline;
+ font-weight: 300;
+}
+
+.pp-flex__action span {
+ white-space: nowrap;
+}
+
+.pp-flex__disclaimer {
+ white-space: normal;
+}
+
+.pp-flex__logo-container {
+ display: flex;
+ align-items: center;
+}
+
+.pp-message.pp-flex.blue .pp-flex__background { background: #023188; }
+.pp-message.pp-flex.blue .pp-flex__content { color: #fff; }
+.pp-message.pp-flex.black .pp-flex__background { background: #000; }
+.pp-message.pp-flex.black .pp-flex__content { color: #fff; }
+.pp-message.pp-flex.white .pp-flex__background { background: #fff; }
+.pp-message.pp-flex.white .pp-flex__content { color: #023187; border: 1px solid #009cde; }
+.pp-message.pp-flex.white-no-border .pp-flex__background { background: #fff; }
+.pp-message.pp-flex.white-no-border .pp-flex__content { color: #023187; }
+.pp-message.pp-flex.gray .pp-flex__background { background: #eaeced; }
+.pp-message.pp-flex.gray .pp-flex__content { color: #023187; }
+.pp-message.pp-flex.monochrome .pp-flex__background { background: #fff; }
+.pp-message.pp-flex.monochrome .pp-flex__content { color: #000; border: 1px solid #000; }
+.pp-message.pp-flex.grayscale .pp-flex__background { background: #fff; }
+.pp-message.pp-flex.grayscale .pp-flex__content { border: 1px solid #b7bcbf; }
+
+.pp-message.pp-flex.blue .pp-flex__logo img,
+.pp-message.pp-flex.black .pp-flex__logo img { filter: brightness(0) invert(1); }
+.pp-message.pp-flex.monochrome .pp-flex__logo img { filter: grayscale(100%) brightness(0); }
+.pp-message.pp-flex.grayscale .pp-flex__logo img { filter: grayscale(100%); }
+
+
+.pp-message.pp-flex.r-8x1 .pp-flex__content {
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ padding-right: 1rem;
+}
+
+.pp-message.pp-flex.r-8x1 .pp-flex__logo-container {
+ flex: 0 0 33%;
+ justify-content: center;
+}
+
+.pp-message.pp-flex.r-8x1 .pp-flex__logo {
+ width: 60%;
+}
+
+.pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(2) {
+ display: none;
+}
+
+.pp-message.pp-flex.r-8x1 .pp-flex__messaging {
+ flex: 1 1 100%;
+}
+
+.pp-message.pp-flex.r-8x1 .pp-flex__main {
+ font-size: 5vw;
+ line-height: 1;
+ font-weight: 400;
+ display: block;
+}
+
+.pp-message.pp-flex.r-8x1 .pp-flex__disclaimer,
+.pp-message.pp-flex.r-8x1 .pp-flex__action {
+ font-size: 10px;
+ line-height: 1.1;
+ display: inline;
+}
+
+@media (max-aspect-ratio: 61/10) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo-container {
+ flex-basis: 12%;
+ margin-bottom: -6px;
+ justify-content: flex-start;
+ margin-left: 5px;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(1) {
+ margin-left: 10px;
+ margin-right: 0;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.paypal-credit:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.venmo:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:only-child {
+ margin-left: 0;
+ margin-right: 0;
+ }
+}
+
+@media (max-aspect-ratio: 61/10) and (min-width: 324px) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(1) { width: 45%; }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.paypal-credit:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.venmo:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:only-child { width: 60%; }
+}
+
+@media (max-aspect-ratio: 61/10) and (max-width: 374px) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(1) { width: 50%; }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.paypal-credit:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.venmo:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:only-child { width: 60%; }
+}
+
+@media (max-width: 374px) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(1) { width: 55%; }
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo-container { margin-right: 2.5%; }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.paypal-credit:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.venmo:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:only-child { width: 60%; }
+}
+
+@media (max-aspect-ratio: 61/10) and (max-width: 323px) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo-container { margin-right: 7%; }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(1) {
+ margin: 0 5px;
+ width: 30%;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(2) {
+ display: inline;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.paypal-credit:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.venmo:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:only-child {
+ margin: 0;
+ width: 60%;
+ }
+}
+
+@media (max-aspect-ratio: 61/10) and (min-width: 400px) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__main { font-size: 4vw; margin-bottom: 0.5rem; }
+}
+
+@media (max-aspect-ratio: 61/10) and (min-width: 520px) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__disclaimer,
+ .pp-message.pp-flex.r-8x1 .pp-flex__action { font-size: 0.85rem; }
+}
+
+@media (max-aspect-ratio: 61/10) and (min-width: 640px) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__main { font-size: 1.7rem; }
+}
+.pp-message.pp-flex.r-8x1 .pp-flex__logo-container {
+ padding-bottom: 2.5px;
+}
+
+@media (min-aspect-ratio: 80/11) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__main {
+ display: block;
+ line-height: 1.3em;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo-container {
+ flex-basis: 12%;
+ margin-bottom: -6px;
+ justify-content: flex-start;
+ margin-left: 5px;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(1) {
+ width: 50%;
+ margin-left: 10px;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.paypal-credit:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.venmo:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:only-child {
+ width: 60%;
+ margin-left: 0;
+ }
+}
+
+@media (min-aspect-ratio: 80/11) and (min-width: 500px) {
+ .pp-message.pp-flex.r-8x1 .pp-flex__main { font-size: 3vw; }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo-container { flex-basis: 22%; }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(1) {
+ width: 18%;
+ margin-right: 5%;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:nth-of-type(2) {
+ display: inline-block;
+ width: 55%;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.paypal-credit:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo.venmo:nth-of-type(1),
+ .pp-message.pp-flex.r-8x1 .pp-flex__logo:only-child {
+ width: 60%;
+ margin-right: 0;
+ }
+
+ .pp-message.pp-flex.r-8x1 .pp-flex__disclaimer,
+ .pp-message.pp-flex.r-8x1 .pp-flex__action {
+ font-size: 0.9rem;
+ }
+}
+"
+`;
+
+exports[`v2 render flex snapshots renders flex stylesheet once 1`] = `
+""
+`;
+
exports[`v2 render snapshots full render snapshot for representative case 1`] = `
"