Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 76 additions & 0 deletions src/server/v2/flex.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/** @jsx h */
/** @jsxFrag Fragment */
import { h, Fragment } from 'preact';

import { buildContentLabel } from './utils/buildContentLabel';
import { renderBlock } from './utils/renderBlock';
import flexStyles from './flexStyles';

export default function FlexMessage({ style, v2Content }) {
const color = style.color ?? 'black';
const ratio = style.ratio ?? '8x1';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a correctness edge case: the direct flex renderer defaults do not match the validated flex defaults. validOptions defaults flex to color: "blue" and ratio: "1x1" because those are the first valid values, but this lower-level render path falls back to black and 8x1.

That means render({ style: { layout: "flex" } }) can produce class="pp-message pp-flex black r-8x1", while the validated path produces blue r-1x1. Since render, validateStyle, and getParentStyles are all exported from src/server/v2/index.js, callers can hit this mismatch and end up with a rendered message that does not match the wrapper/default style contract.

Can we make these defaults come from the same source? At minimum this should use blue and 1x1 here, and ideally the flex defaults should live in one shared constant used by both validOptions and FlexMessage. Please also add a small test for render({ style: { layout: "flex" } }) so this stays aligned.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Added FLEX_DEFAULTS = { color: 'blue', ratio: '1x1' } to constants.js and updated flex.jsx to use it instead of the hardcoded black/8x1. Added a test for render({ style: { layout: 'flex' } }) that asserts the output matches validateStyle's defaults — any future drift will break the test.


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 (
<div
className={`pp-message pp-flex ${color} r-${ratio}`}
data-pp-style-layout="flex"
data-pp-style-color={color}
data-pp-style-ratio={ratio}
>
{/* eslint-disable react/no-danger */}
<style
dangerouslySetInnerHTML={{
__html: flexStyles({
fontFamily: style.text?.fontFamily,
fontSource: style.text?.fontSource
})
}}
/>
{/* eslint-enable react/no-danger */}
<div className="pp-flex__background" />
<div className="pp-flex__content">
{logoBlock ? (
<div className="pp-flex__logo-container">
<span role="img" aria-label={logoBlock.alternative_text || 'PayPal'} className="pp-flex__logo">
{renderBlock(logoBlock)}
</span>
</div>
) : null}
<div className="pp-flex__messaging">
<div aria-label={mainLabel} className="pp-flex__main">
{mainBlocks.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
<Fragment key={idx}>{renderBlock(item)}</Fragment>
))}
</div>
{actionItems.length > 0 ? (
<div aria-label={actionLabel} className="pp-flex__action">
{actionItems.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
<Fragment key={idx}>{renderBlock(item)}</Fragment>
))}
</div>
) : null}
{disclaimerItems.length > 0 ? (
<div className="pp-flex__disclaimer">
{disclaimerItems.map((item, idx) => (
// eslint-disable-next-line react/no-array-index-key
<Fragment key={idx}>{renderBlock(item)}</Fragment>
))}
</div>
) : null}
</div>
</div>
</div>
);
}
246 changes: 246 additions & 0 deletions src/server/v2/flexStyles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
import { buildFontRules } from './font';

const DEFAULT_FONT_FAMILY = '"PayPal Pro", Helvetica, Arial, "Liberation Sans", sans-serif';
const FONT_FALLBACKS = 'Helvetica, Arial, "Liberation Sans", 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%)' }
];

const PORTRAIT_RATIOS = [
{
name: '1x1',
padding: '7%',
logoWidth: '50%',
logoMarginBottom: '7%',
mainFontSize: '10vw',
mainLineHeight: '1.1em',
disclaimerFontSize: '9.5px'
},
{
name: '1x4',
padding: '8%',
logoWidth: '70%',
logoMarginBottom: '12%',
mainFontSize: '1.6rem',
mainLineHeight: '1.3em',
disclaimerFontSize: '0.75rem'
}
];

const LANDSCAPE_RATIOS = ['8x1', '20x1'];

// Joins landscape selectors with an optional indent on continuation lines (for use inside @media blocks).
const lscpSel = (sub, indent = '') =>
LANDSCAPE_RATIOS.map(r => `.pp-message.pp-flex.r-${r} ${sub}`).join(`,\n${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');

// Group themes sharing the same logo filter to produce grouped selectors.
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 buildPortraitRatioRules() {
const contentSels = PORTRAIT_RATIOS.map(({ name }) => `.pp-message.pp-flex.r-${name} .pp-flex__content`).join(
',\n'
);
const messagingSels = PORTRAIT_RATIOS.map(({ name }) => `.pp-message.pp-flex.r-${name} .pp-flex__messaging`).join(
',\n'
);

const sharedColumnLayout = `${contentSels} {\n display: flex;\n flex-direction: column;\n}`;

const contentPadding = PORTRAIT_RATIOS.map(
({ name, padding }) => `.pp-message.pp-flex.r-${name} .pp-flex__content { padding: ${padding}; }`
).join('\n');

const logoContainers = PORTRAIT_RATIOS.map(
({ name, logoWidth, logoMarginBottom }) =>
`.pp-message.pp-flex.r-${name} .pp-flex__logo-container { width: ${logoWidth}; margin-bottom: ${logoMarginBottom}; }`
).join('\n');

const perRatioBase = [contentPadding, logoContainers].join('\n');

const sharedMessaging = `${messagingSels} { flex: 1; }`;

const mainBlocks = PORTRAIT_RATIOS.map(
({ name, mainFontSize, mainLineHeight }) =>
`.pp-message.pp-flex.r-${name} .pp-flex__main {\n font-size: ${mainFontSize};\n line-height: ${mainLineHeight};\n font-weight: 400;\n}`
).join('\n');

const disclaimerRules = PORTRAIT_RATIOS.map(
({ name, disclaimerFontSize }) =>
`.pp-message.pp-flex.r-${name} .pp-flex__disclaimer { font-size: ${disclaimerFontSize}; line-height: 1.1; }`
).join('\n');

return [
sharedColumnLayout,
[perRatioBase, sharedMessaging].join('\n'),
[mainBlocks, disclaimerRules].join('\n')
].join('\n\n');
}

export default function flexStyles({ fontFamily, fontSource } = {}) {
const { fontFaceRules, effectiveFontFamily } = buildFontRules({
fontSource,
fontFamily,
fallbackStack: FONT_FALLBACKS,
defaultFontFamily: DEFAULT_FONT_FAMILY,
fontNamePrefix: 'PP Merchant Font'
});
const fontFaceBlock = fontFaceRules ? `${fontFaceRules}\n` : '';

return `${fontFaceBlock}
body {
height: 100%;
margin: 0;
padding: 0;
}

.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;
}

${buildThemeRules()}

${buildPortraitRatioRules()}

@media (min-width: 170px) {
.pp-message.pp-flex.r-1x1 .pp-flex__main { font-size: 8vw; }
.pp-message.pp-flex.r-1x1 .pp-flex__disclaimer { font-size: 5.5vw; }
}

@media (min-width: 220px) {
.pp-message.pp-flex.r-1x1 .pp-flex__disclaimer { font-size: 4.5vw; }
}

${lscpSel('.pp-flex__content')} {
display: flex;
flex-direction: row;
align-items: center;
padding-right: 1rem;
}

${lscpSel('.pp-flex__logo-container')} {
flex: 0 0 33%;
display: flex;
justify-content: center;
align-items: center;
}

${lscpSel('.pp-flex__logo img')} { width: 60%; }

${lscpSel('.pp-flex__messaging')} {
flex: 1 1 100%;
display: block;
}

${lscpSel('.pp-flex__main')} {
font-size: 5vw;
line-height: 1;
margin-bottom: 0.2em;
font-weight: 400;
}

${lscpSel('.pp-flex__disclaimer')} {
font-size: 10px;
line-height: 1.1;
display: inline;
}

@media (max-aspect-ratio: 61/10) and (min-width: 400px) {
${lscpSel('.pp-flex__logo-container', ' ')} { flex: 0 0 25%; }
${lscpSel('.pp-flex__main', ' ')} { font-size: 4vw; margin-bottom: 0.5rem; }
}

@media (max-aspect-ratio: 61/10) and (min-width: 520px) {
${lscpSel('.pp-flex__disclaimer', ' ')} { font-size: 0.85rem; }
}

@media (max-aspect-ratio: 61/10) and (min-width: 640px) {
${lscpSel('.pp-flex__main', ' ')} { font-size: 1.7rem; }
}

@media (min-aspect-ratio: 200/11) {
${lscpSel('.pp-flex__logo-container', ' ')} { flex: 1 0 20%; }
${lscpSel('.pp-flex__logo img', ' ')} { width: 50%; }
${lscpSel('.pp-flex__messaging', ' ')} {
flex: 1 1 85%;
display: flex;
align-items: center;
}
${lscpSel('.pp-flex__main', ' ')} { flex: 1 1 60%; font-size: 0.7rem; line-height: 1; }
${lscpSel('.pp-flex__disclaimer', ' ')} { flex: 0 0 auto; font-size: 8px; line-height: 1.1; }
}

@media (min-aspect-ratio: 200/11) and (min-width: 400px) {
${lscpSel('.pp-flex__main', ' ')} { font-size: 1rem; }
}

@media (min-aspect-ratio: 200/11) and (min-width: 600px) {
${lscpSel('.pp-flex__logo-container', ' ')} { flex: 1 0 10%; }
${lscpSel('.pp-flex__logo img', ' ')} { width: 60%; }
${lscpSel('.pp-flex__main', ' ')} { font-size: 1.8vw; }
${lscpSel('.pp-flex__disclaimer', ' ')} { font-size: 0.75rem; }
}

@media (min-aspect-ratio: 200/11) and (min-width: 1000px) {
${lscpSel('.pp-flex__disclaimer', ' ')} { font-size: 0.9rem; }
}
`;
}
35 changes: 35 additions & 0 deletions src/server/v2/font.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
const isSafeFontName = value => typeof value === 'string' && /^[\w\s-]+$/.test(value.trim());
const isSafeFontSource = value => typeof value === 'string' && /^https:\/\/[^'")\s]+$/i.test(value);

const formatFontFamilyName = val => {
const genericFamilies = {
serif: 'serif',
'sans-serif': 'sans-serif',
monospace: 'monospace',
cursive: 'cursive',
fantasy: 'fantasy',
'system-ui': 'system-ui',
'ui-serif': 'ui-serif',
'ui-sans-serif': 'ui-sans-serif',
'ui-monospace': 'ui-monospace'
};
return genericFamilies[val] ?? `'${val}'`;
};

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 };
}
Loading
Loading