-
Notifications
You must be signed in to change notification settings - Fork 77
feat(DTCRCMERC-5374): add flex layout support to renderV2Message #1339
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sailaya99
wants to merge
6
commits into
paypal:develop
Choose a base branch
from
sailaya99:DTCRCMERC-5374-flex-layout-render-v2-message
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
e632488
feat(DTCRCMERC-4687): implement v5 text-layout styles for renderV2Mes…
b7dc956
fix(DTCRCMERC-4687): address braluna PR review comments
461e5bf
fix(DTCRCMERC-4687): address braluna PR review comments
fc12b23
feat(DTCRCMERC-5374): add flex layout support to renderV2Message
f27248b
fix(DTCRCMERC-5374): make flex themes and ratios table-driven in flex…
b437895
fix(DTCRCMERC-5374): unify flex defaults via FLEX_DEFAULTS constant
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'; | ||
|
|
||
| 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> | ||
| ); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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; } | ||
| } | ||
| `; | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 }; | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
validOptionsdefaults flex tocolor: "blue"andratio: "1x1"because those are the first valid values, but this lower-level render path falls back toblackand8x1.That means
render({ style: { layout: "flex" } })can produceclass="pp-message pp-flex black r-8x1", while the validated path producesblue r-1x1. Sincerender,validateStyle, andgetParentStylesare all exported fromsrc/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
blueand1x1here, and ideally the flex defaults should live in one shared constant used by bothvalidOptionsandFlexMessage. Please also add a small test forrender({ style: { layout: "flex" } })so this stays aligned.There was a problem hiding this comment.
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.