diff --git a/libs/blocks/modal/modal.js b/libs/blocks/modal/modal.js index d8c34964d55..c8e891665c8 100644 --- a/libs/blocks/modal/modal.js +++ b/libs/blocks/modal/modal.js @@ -176,14 +176,45 @@ export async function getModal(details, custom) { return dialog; } +export function getHashParams(hashStr) { + if (!hashStr) return {}; + return hashStr.split(':').reduce((params, part) => { + if (part.startsWith('#')) { + params.hash = part; + } else { + const [key, val] = part.split('='); + if (key === 'delay' && parseInt(val, 10) > 0) { + params.delay = parseInt(val, 10) * 1000; + } + } + return params; + }, {}); +} + +export function delayedModal(el) { + const { hash, delay } = getHashParams(el?.dataset.modalHash); + if (!delay || !hash) return false; + el.classList.add('hide-block'); + const modalOpenEvent = new Event(`${hash}:modalOpen`); + const pagesModalWasShownOn = window.sessionStorage.getItem(`shown:${hash}`); + el.dataset.modalHash = hash; + el.href = hash; + if (!pagesModalWasShownOn?.includes(window.location.pathname)) { + setTimeout(() => { + window.location.replace(hash); + sendAnalytics(modalOpenEvent); + window.sessionStorage.setItem(`shown:${hash}`, `${pagesModalWasShownOn || ''} ${window.location.pathname}`); + }, delay); + } + return true; +} + // Deep link-based export default function init(el) { const { modalHash } = el.dataset; - if (window.location.hash === modalHash && !document.querySelector(`div.dialog-modal${modalHash}`)) { - const details = findDetails(window.location.hash, el); - if (details) return getModal(details); - } - return null; + if (window.location.hash !== modalHash || document.querySelector(`div.dialog-modal${modalHash}`) || delayedModal(el)) return null; + const details = findDetails(window.location.hash, el); + return details ? getModal(details) : null; } // Click-based modal diff --git a/test/blocks/modals/modals.test.js b/test/blocks/modals/modals.test.js index 9a825b29535..daf6ddc9505 100644 --- a/test/blocks/modals/modals.test.js +++ b/test/blocks/modals/modals.test.js @@ -1,11 +1,26 @@ import { readFile, sendKeys } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; +import sinon from 'sinon'; import { delay, waitForElement, waitForRemoval } from '../../helpers/waitfor.js'; -import init, { getModal } from '../../../libs/blocks/modal/modal.js'; document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +const { + default: init, + getModal, + getHashParams, + delayedModal, +} = await import('../../../libs/blocks/modal/modal.js'); describe('Modals', () => { + beforeEach(() => { + // eslint-disable-next-line no-underscore-dangle + window._satellite = { track: sinon.spy() }; + }); + + afterEach(() => { + sinon.restore(); + }); + it('Doesnt load modals on page load with no hash', async () => { window.location.hash = ''; const modal = document.querySelector('.dialog-modal'); @@ -169,4 +184,43 @@ describe('Modals', () => { // Test passing, means there was no error thrown await hashChangeTriggered; }); + + it('validates and returns proper hash parameters', () => { + expect(getHashParams()).to.deep.equal({}); + expect(getHashParams('#delayed-modal:delay=0')).to.deep.equal({ hash: '#delayed-modal' }); + expect(getHashParams('#delayed-modal:delay=1')).to.deep.equal({ + delay: 1000, + hash: '#delayed-modal', + }); + }); + + it('shows the modal with a delay, and remembers it was shown on this page', async () => { + window.sessionStorage.removeItem('shown:#delayed-modal'); + const el = document.createElement('a'); + el.setAttribute('data-modal-hash', '#delayed-modal:delay=1'); + expect(delayedModal(el)).to.be.true; + await delay(1000); + expect(el.classList.contains('hide-block')).to.be.true; + const modal = waitForElement('#delayed-modal'); + expect(modal).to.be.not.null; + expect(window.sessionStorage.getItem('shown:#delayed-modal').includes(window.location.pathname)).to.be.true; + // eslint-disable-next-line no-underscore-dangle + expect(window._satellite.track.called).to.be.true; + window.sessionStorage.removeItem('shown:#delayed-modal'); + el.remove(); + }); + + it('does not show the modal if it was shown on this page', async () => { + const el = document.createElement('a'); + el.setAttribute('data-modal-hash', '#dm:delay=1'); + window.sessionStorage.setItem('shown:#dm', window.location.pathname); + expect(delayedModal(el)).to.be.true; + await delay(1000); + // eslint-disable-next-line no-underscore-dangle + expect(window._satellite.track.called).to.be.false; + const modal = document.querySelector('#dm'); + expect(modal).to.not.exist; + window.sessionStorage.removeItem('shown:#dm'); + el.remove(); + }); }); diff --git a/test/features/personalization/mocks/manifestInsertContentAfter.json b/test/features/personalization/mocks/manifestInsertContentAfter.json index 9c5b58f53bf..046afae7cfa 100644 --- a/test/features/personalization/mocks/manifestInsertContentAfter.json +++ b/test/features/personalization/mocks/manifestInsertContentAfter.json @@ -12,6 +12,16 @@ "firefox": "", "android": "", "ios": "" + }, + { + "action": "insertContentAfter", + "selector": "main > div", + "page filter (optional)": "", + "param-newoffer=123": "", + "chrome": "/fragments/insertafter#delayed-modal:delay=1", + "firefox": "", + "android": "", + "ios": "" } ], ":type": "sheet" diff --git a/test/features/personalization/personalization.test.js b/test/features/personalization/personalization.test.js index fad53db87f0..bd893e9bcbe 100644 --- a/test/features/personalization/personalization.test.js +++ b/test/features/personalization/personalization.test.js @@ -68,8 +68,11 @@ describe('Functional Test', () => { const fragment = document.querySelector('a[href="/fragments/insertafter"]'); expect(fragment).to.not.be.null; - expect(fragment.parentElement.previousElementSibling.className).to.equal('marquee'); + + const delayedModalFragment = document.querySelector('a[href="/fragments/insertafter#delayed-modal:delay=1"]'); + expect(delayedModalFragment).to.not.be.null; + delayedModalFragment.remove(); }); it('insertContentBefore should add fragment before target element', async () => {