From 1a9c122a9d16979fa250a4d72671a353c5714932 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Mon, 24 Aug 2026 19:43:50 -0300 Subject: [PATCH 1/4] Fix purchase-complete override handling for labels and paragraphs Two problems on the completion screen when marketing overrides are set: - A button/title override passed as a present-but-undefined prop slipped through the `!isEmptyString(...)` guard, because isEmptyString only treated strings as empty. The order-complete button then rendered blank. isEmptyString now treats null/undefined as empty, so the button and title overrides fall back to their translated defaults; the redundant `typeof !== 'undefined'` checks are removed. - An override paragraph was inserted verbatim, so {attendee}/{adv}/{button} tokens in custom copy printed literally. Add an interpolate() helper and run the override paragraphs through it with the same values the built-in strings use. Adds unit tests for isEmptyString and interpolate, plus regression tests for the blank button and the paragraph interpolation. --- .../__tests__/purchase-complete.test.js | 23 +++++++++++ src/components/purchase-complete/index.js | 28 ++++++------- src/utils/__tests__/utils.test.js | 39 +++++++++++++++++++ src/utils/utils.js | 15 ++++++- 4 files changed, 90 insertions(+), 15 deletions(-) create mode 100644 src/utils/__tests__/utils.test.js diff --git a/src/components/purchase-complete/__tests__/purchase-complete.test.js b/src/components/purchase-complete/__tests__/purchase-complete.test.js index e7e03ad..3bdd51a 100644 --- a/src/components/purchase-complete/__tests__/purchase-complete.test.js +++ b/src/components/purchase-complete/__tests__/purchase-complete.test.js @@ -68,6 +68,29 @@ it('renders the active CTA path when the clock seed falls inside the summit wind expect(queryByText('View My Orders/Tickets')).toBeInTheDocument(); }); +it('falls back to the default label when orderCompleteButton is passed as undefined', async () => { + mockClockNow = SUMMIT.start_date + 1000; + // A present-but-undefined prop (marketing key with no value) must not blank + // the button — isEmptyString(undefined) is false, so without the typeof + // guard the branch returned undefined and rendered an empty button. + const { queryByText } = await renderAndFlush({ orderCompleteButton: undefined }); + + expect(queryByText('View My Orders/Tickets')).toBeInTheDocument(); +}); + +it('interpolates {button} in a marketing-override paragraph', async () => { + mockClockNow = SUMMIT.start_date + 1000; + // A custom paragraph that references the button by token must print the + // resolved label, not the literal {button}. + const { queryByText } = await renderAndFlush({ + orderCompleteButton: 'Wrap Up', + initialOrderComplete1stParagraph: 'Please click the "{button}" button.', + }); + + expect(queryByText('Please click the "Wrap Up" button.')).toBeInTheDocument(); + expect(queryByText(/\{button\}/)).not.toBeInTheDocument(); +}); + it('renders the "event will start" copy when the clock seed is outside the summit window', async () => { mockClockNow = SUMMIT.end_date + 1; // one second past end const { queryByText } = await renderAndFlush(); diff --git a/src/components/purchase-complete/index.js b/src/components/purchase-complete/index.js index 5293a72..7c42dde 100644 --- a/src/components/purchase-complete/index.js +++ b/src/components/purchase-complete/index.js @@ -16,7 +16,7 @@ import styles from './index.module.scss'; import { epochToMomentTimeZone } from 'openstack-uicore-foundation/lib/utils/methods'; import { useClockSelector } from 'openstack-uicore-foundation/lib/components/clock-context'; import ContentLoader from 'react-content-loader'; -import { isEmptyString, ticketHasAccessLevel } from '../../utils/utils'; +import { isEmptyString, interpolate, ticketHasAccessLevel } from '../../utils/utils'; import { VirtualAccessLevel } from '../../utils/constants'; import T from 'i18n-react'; import RawHTML from 'openstack-uicore-foundation/lib/components/raw-html'; @@ -106,8 +106,7 @@ const PurchaseComplete = ({ let orderCompleteButtonText = ( currentTicket && requireExtraQuestions ? - rest.hasOwnProperty('initialOrderCompleteButton') && !isEmptyString(rest.initialOrderCompleteButton) - && typeof rest.initialOrderCompleteButton !== 'undefined' ? + rest.hasOwnProperty('initialOrderCompleteButton') && !isEmptyString(rest.initialOrderCompleteButton) ? rest.initialOrderCompleteButton : T.translate('purchase_complete_step.initial_order_complete_button') @@ -119,28 +118,29 @@ const PurchaseComplete = ({ ); let orderCompleteTitle = ( - rest.hasOwnProperty('orderCompleteTitle') && !isEmptyString(rest.orderCompleteTitle) - && typeof rest.orderCompleteTitle !== 'undefined' ? + rest.hasOwnProperty('orderCompleteTitle') && !isEmptyString(rest.orderCompleteTitle) ? rest.orderCompleteTitle : T.translate('purchase_complete_step.title') ); + // Shared with both the i18n default and the marketing override so custom + // copy can use the same {attendee}/{adv}/{button} tokens. + const paragraphVars = { + attendee: `${attendeeIsSomeoneElse ? ` ${currentTicket.owner.email}` : 'you'}`, + adv: `${attendeeIsSomeoneElse ? `${currentTicket.owner.email}` : 'your'}`, + button: orderCompleteButtonText + }; + let orderComplete1stParagraph = ( currentTicket ? !attendeeIsSomeoneElse && rest.hasOwnProperty('initialOrderComplete1stParagraph') && typeof rest.initialOrderComplete1stParagraph !== 'undefined' ? - rest.initialOrderComplete1stParagraph + interpolate(rest.initialOrderComplete1stParagraph, paragraphVars) : - T.translate('purchase_complete_step.initial_order_complete_1st_paragraph_label', - { - attendee: `${attendeeIsSomeoneElse ? ` ${currentTicket.owner.email}` : 'you'}`, - adv: `${attendeeIsSomeoneElse ? `${currentTicket.owner.email}` : 'your'}`, - button: orderCompleteButtonText - } - ) + T.translate('purchase_complete_step.initial_order_complete_1st_paragraph_label', paragraphVars) : rest.hasOwnProperty('orderComplete1stParagraph') && typeof rest.orderComplete1stParagraph !== 'undefined' ? - rest.orderComplete1stParagraph + interpolate(rest.orderComplete1stParagraph, paragraphVars) : T.translate('purchase_complete_step.order_complete_1st_paragraph_label') ); diff --git a/src/utils/__tests__/utils.test.js b/src/utils/__tests__/utils.test.js new file mode 100644 index 0000000..d18ca13 --- /dev/null +++ b/src/utils/__tests__/utils.test.js @@ -0,0 +1,39 @@ +import { isEmptyString, interpolate } from '../utils'; + +describe('isEmptyString', () => { + it('treats missing values (null/undefined) as empty', () => { + expect(isEmptyString(undefined)).toBe(true); + expect(isEmptyString(null)).toBe(true); + }); + + it('treats empty and whitespace-only strings as empty', () => { + expect(isEmptyString('')).toBe(true); + expect(isEmptyString(' ')).toBe(true); + }); + + it('treats a non-empty string as not empty', () => { + expect(isEmptyString('Finish Now')).toBe(false); + expect(isEmptyString(' x ')).toBe(false); + }); +}); + +describe('interpolate', () => { + it('replaces every occurrence of a {token} with its value', () => { + expect(interpolate('click the "{button}" button', { button: 'Finish Now' })) + .toBe('click the "Finish Now" button'); + expect(interpolate('{a} and {a}', { a: 'x' })).toBe('x and x'); + }); + + it('replaces multiple distinct tokens', () => { + expect(interpolate('assigned to {attendee}, complete {adv} details', { attendee: 'you', adv: 'your' })) + .toBe('assigned to you, complete your details'); + }); + + it('leaves unknown tokens untouched', () => { + expect(interpolate('hello {name}', { button: 'x' })).toBe('hello {name}'); + }); + + it('returns a non-string template unchanged', () => { + expect(interpolate(undefined, { a: '1' })).toBe(undefined); + }); +}); diff --git a/src/utils/utils.js b/src/utils/utils.js index bcceb63..d18bf7f 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -46,7 +46,20 @@ export const getCurrentUserLanguage = () => { }; export const isEmptyString = (val) => { - return typeof val === 'string' && val.trim().length == 0; + // A missing value (null/undefined) counts as empty too, so callers can guard + // an optional override prop with a single `!isEmptyString(prop)` check. + return val == null || (typeof val === 'string' && val.trim().length == 0); +} + +// Replaces {token} placeholders in a template string with values from `vars`. +// Used so marketing-override copy supports the same {attendee}/{adv}/{button} +// tokens the built-in i18n strings do. Unknown tokens are left untouched. +export const interpolate = (template, vars = {}) => { + if (typeof template !== 'string') return template; + return Object.keys(vars).reduce( + (out, key) => out.split(`{${key}}`).join(vars[key]), + template + ); } export const getTicketTaxes = (ticket, taxes) => { From 8acd6e67fb4f07fdb9035a95301aed302f192bd8 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Tue, 25 Aug 2026 12:42:10 -0300 Subject: [PATCH 2/4] Interpolate tokens in a single pass Replace the per-variable split/join loop with one regex pass. A value that contains a {token} is now inserted as written, not expanded again. --- src/utils/__tests__/utils.test.js | 13 +++++++++++++ src/utils/utils.js | 9 ++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/utils/__tests__/utils.test.js b/src/utils/__tests__/utils.test.js index d18ca13..47a1e54 100644 --- a/src/utils/__tests__/utils.test.js +++ b/src/utils/__tests__/utils.test.js @@ -36,4 +36,17 @@ describe('interpolate', () => { it('returns a non-string template unchanged', () => { expect(interpolate(undefined, { a: '1' })).toBe(undefined); }); + + it('does not substitute into a value it just inserted', () => { + // Values come from marketing overrides, so one that happens to contain + // a token must land as written rather than be expanded in turn. + expect(interpolate('{attendee} pays', { attendee: '{button}', button: 'Finish Now' })) + .toBe('{button} pays'); + }); + + it('is unaffected by the shared regex across calls', () => { + const call = () => interpolate('{a} {a}', { a: 'x' }); + expect(call()).toBe('x x'); + expect(call()).toBe('x x'); + }); }); diff --git a/src/utils/utils.js b/src/utils/utils.js index d18bf7f..80b9eac 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -54,11 +54,14 @@ export const isEmptyString = (val) => { // Replaces {token} placeholders in a template string with values from `vars`. // Used so marketing-override copy supports the same {attendee}/{adv}/{button} // tokens the built-in i18n strings do. Unknown tokens are left untouched. +// One pass over the template, so a value that itself contains a {token} is +// inserted as-is rather than being substituted again by a later pass. +const TOKEN = /\{(\w+)\}/g; + export const interpolate = (template, vars = {}) => { if (typeof template !== 'string') return template; - return Object.keys(vars).reduce( - (out, key) => out.split(`{${key}}`).join(vars[key]), - template + return template.replace(TOKEN, (match, key) => + key in vars ? String(vars[key]) : match ); } From 768de47e5ffb4c0de9e1f9da73275166365caae5 Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 16:12:01 -0300 Subject: [PATCH 3/4] Cover empty paragraph overrides A paragraph override present as null or an empty string must fall back to the default, the same as the button and title overrides. --- .../__tests__/purchase-complete.test.js | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/components/purchase-complete/__tests__/purchase-complete.test.js b/src/components/purchase-complete/__tests__/purchase-complete.test.js index 3bdd51a..bff3aa1 100644 --- a/src/components/purchase-complete/__tests__/purchase-complete.test.js +++ b/src/components/purchase-complete/__tests__/purchase-complete.test.js @@ -99,3 +99,25 @@ it('renders the "event will start" copy when the clock seed is outside the summi // CTA still renders in the inactive branch (different layout). expect(queryByText('View My Orders/Tickets')).toBeInTheDocument(); }); + +// Marketing overrides arrive unfiltered from the embedder, so a key present with +// no usable value must fall back to the default rather than render nothing. +describe.each([null, ''])('override paragraph present but empty (%p)', (emptyValue) => { + it('falls back to the default 1st paragraph', async () => { + mockClockNow = SUMMIT.start_date + 1000; + const { queryByText } = await renderAndFlush({ + initialOrderComplete1stParagraph: emptyValue, + }); + + expect(queryByText(/A ticket has been assigned to/i)).toBeInTheDocument(); + }); + + it('falls back to the default 2nd paragraph', async () => { + mockClockNow = SUMMIT.start_date + 1000; + const { queryByText } = await renderAndFlush({ + initialOrderComplete2ndParagraph: emptyValue, + }); + + expect(queryByText(/If you wish to transfer your assigned ticket/i)).toBeInTheDocument(); + }); +}); From 3df171ee36ecdb215a6cddde11f7bda86bf00b3b Mon Sep 17 00:00:00 2001 From: Gabriel Horacio Cutrini Date: Sat, 29 Aug 2026 16:12:35 -0300 Subject: [PATCH 4/4] Guard paragraph overrides with isEmptyString A paragraph override present as null or an empty string took the override branch and rendered nothing. Use the same guard as the button and title overrides, on both paragraphs and both their branches. --- src/components/purchase-complete/index.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/purchase-complete/index.js b/src/components/purchase-complete/index.js index 7c42dde..f8f0ff4 100644 --- a/src/components/purchase-complete/index.js +++ b/src/components/purchase-complete/index.js @@ -134,12 +134,12 @@ const PurchaseComplete = ({ let orderComplete1stParagraph = ( currentTicket ? - !attendeeIsSomeoneElse && rest.hasOwnProperty('initialOrderComplete1stParagraph') && typeof rest.initialOrderComplete1stParagraph !== 'undefined' ? + !attendeeIsSomeoneElse && rest.hasOwnProperty('initialOrderComplete1stParagraph') && !isEmptyString(rest.initialOrderComplete1stParagraph) ? interpolate(rest.initialOrderComplete1stParagraph, paragraphVars) : T.translate('purchase_complete_step.initial_order_complete_1st_paragraph_label', paragraphVars) : - rest.hasOwnProperty('orderComplete1stParagraph') && typeof rest.orderComplete1stParagraph !== 'undefined' ? + rest.hasOwnProperty('orderComplete1stParagraph') && !isEmptyString(rest.orderComplete1stParagraph) ? interpolate(rest.orderComplete1stParagraph, paragraphVars) : T.translate('purchase_complete_step.order_complete_1st_paragraph_label') @@ -147,12 +147,12 @@ const PurchaseComplete = ({ let orderComplete2ndParagraph = ( currentTicket ? - rest.hasOwnProperty('initialOrderComplete2ndParagraph') && typeof rest.initialOrderComplete2ndParagraph !== 'undefined' ? + rest.hasOwnProperty('initialOrderComplete2ndParagraph') && !isEmptyString(rest.initialOrderComplete2ndParagraph) ? rest.initialOrderComplete2ndParagraph : T.translate('purchase_complete_step.initial_order_footer_label') : - rest.hasOwnProperty('orderComplete2ndParagraph') && typeof rest.orderComplete2ndParagraph !== 'undefined' ? + rest.hasOwnProperty('orderComplete2ndParagraph') && !isEmptyString(rest.orderComplete2ndParagraph) ? rest.orderComplete2ndParagraph : ''