diff --git a/src/components/purchase-complete/__tests__/purchase-complete.test.js b/src/components/purchase-complete/__tests__/purchase-complete.test.js index e7e03ad..bff3aa1 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(); @@ -76,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(); + }); +}); diff --git a/src/components/purchase-complete/index.js b/src/components/purchase-complete/index.js index 5293a72..f8f0ff4 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,40 +118,41 @@ 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 + !attendeeIsSomeoneElse && rest.hasOwnProperty('initialOrderComplete1stParagraph') && !isEmptyString(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 + rest.hasOwnProperty('orderComplete1stParagraph') && !isEmptyString(rest.orderComplete1stParagraph) ? + interpolate(rest.orderComplete1stParagraph, paragraphVars) : T.translate('purchase_complete_step.order_complete_1st_paragraph_label') ); 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 : '' diff --git a/src/utils/__tests__/utils.test.js b/src/utils/__tests__/utils.test.js new file mode 100644 index 0000000..47a1e54 --- /dev/null +++ b/src/utils/__tests__/utils.test.js @@ -0,0 +1,52 @@ +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); + }); + + 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 bcceb63..80b9eac 100644 --- a/src/utils/utils.js +++ b/src/utils/utils.js @@ -46,7 +46,23 @@ 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. +// 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 template.replace(TOKEN, (match, key) => + key in vars ? String(vars[key]) : match + ); } export const getTicketTaxes = (ticket, taxes) => {