diff --git a/libs/blocks/hero-marquee/hero-marquee.js b/libs/blocks/hero-marquee/hero-marquee.js index 91a0499e68..0d30c3d82d 100644 --- a/libs/blocks/hero-marquee/hero-marquee.js +++ b/libs/blocks/hero-marquee/hero-marquee.js @@ -259,5 +259,11 @@ export default async function init(el) { } }); decorateTextOverrides(el, ['-heading', '-body', '-detail'], mainCopy); + + if (el.classList.contains('countdown-timer')) { + const { default: initCDT } = await import('../../features/cdt/cdt.js'); + await initCDT(copy, el.classList); + } + await Promise.all(promiseArr); } diff --git a/libs/blocks/marquee/marquee.js b/libs/blocks/marquee/marquee.js index 39f52449c6..5153f6b415 100644 --- a/libs/blocks/marquee/marquee.js +++ b/libs/blocks/marquee/marquee.js @@ -136,4 +136,9 @@ export default async function init(el) { if (el.classList.contains('mnemonic-list') && foreground) { await loadMnemonicList(foreground); } + + if (el.classList.contains('countdown-timer')) { + const { default: initCDT } = await import('../../features/cdt/cdt.js'); + await initCDT(text, el.classList); + } } diff --git a/libs/blocks/media/media.js b/libs/blocks/media/media.js index 2fcc018576..e38c39b54d 100644 --- a/libs/blocks/media/media.js +++ b/libs/blocks/media/media.js @@ -33,7 +33,7 @@ function decorateQr(el) { qrImage.classList.add('qr-code-img'); } -export default function init(el) { +export default async function init(el) { if (el.className.includes('rounded-corners')) { const { miloLibs, codeRoot } = getConfig(); const base = miloLibs || codeRoot; @@ -105,4 +105,9 @@ export default function init(el) { const mediaRowReversed = el.querySelector(':scope > .foreground > .media-row > div').classList.contains('text'); if (mediaRowReversed) el.classList.add('media-reverse-mobile'); decorateTextOverrides(el); + + if (el.classList.contains('countdown-timer')) { + const { default: initCDT } = await import('../../features/cdt/cdt.js'); + await initCDT(container, el.classList); + } } diff --git a/libs/features/cdt/cdt.css b/libs/features/cdt/cdt.css new file mode 100644 index 0000000000..c67183c43a --- /dev/null +++ b/libs/features/cdt/cdt.css @@ -0,0 +1,86 @@ +.horizontal, +.vertical { + display: flex; + padding: 20px 0; +} + +.vertical { + flex-direction: column; +} + +.center { + align-items: center; + justify-content: center; +} + +.timer-label { + font-size: var(--type-body-s-size); + font-weight: 700; + height: 27px; +} + +.light .timer-label { + color: #000; +} + +.dark .timer-label { + color: #FFF; +} + +.horizontal .timer-label { + margin: 0 2px 27px; +} + +.timer-block { + display: flex; +} + +.horizontal .timer-block { + margin-left: 10px; +} + +.timer-fragment { + display: flex; + flex-direction: column; + align-items: center; +} + +.timer-box { + padding: 0 9px; + width: 10px; + border-radius: 5px; + font-size: var(--type-body-m-size); + font-weight: 700; + text-align: center; +} + +.light .timer-box { + background-color: #222; + color: #FFF; +} + +.dark .timer-box { + background-color: #EBEBEB; + color: #1D1D1D; +} + +.timer-unit-container { + display: flex; + column-gap: 2px; + align-items: center; +} + +.timer-unit-label { + width: 100%; + font-size: var(--type-body-xs-size); + font-weight: 400; + text-align: start; +} + +.light .timer-unit-label { + color: #464646; +} + +.dark .timer-unit-label { + color: #D1D1D1; +} diff --git a/libs/features/cdt/cdt.js b/libs/features/cdt/cdt.js new file mode 100644 index 0000000000..bcfb354df8 --- /dev/null +++ b/libs/features/cdt/cdt.js @@ -0,0 +1,127 @@ +import { getMetadata, getConfig, loadStyle, createTag } from '../../utils/utils.js'; +import { replaceKey } from '../placeholders.js'; + +const replacePlaceholder = async (key) => replaceKey(key, getConfig()); + +function loadCountdownTimer( + container, + cdtLabel, + cdtDays, + cdtHours, + cdtMins, + timeRangesEpoch, +) { + let isVisible = false; + let interval; + + function appendTimerBox(parent, value, label) { + const fragment = createTag('div', { class: 'timer-fragment' }, null, { parent }); + const unitContainer = createTag('div', { class: 'timer-unit-container' }, null, { parent: fragment }); + createTag('div', { class: 'timer-unit-label' }, label, { parent: fragment }); + + createTag('div', { class: 'timer-box' }, Math.floor(value / 10).toString(), { parent: unitContainer }); + createTag('div', { class: 'timer-box' }, (value % 10).toString(), { parent: unitContainer }); + } + + function appendSeparator(parent) { + createTag('div', { class: 'timer-separator' }, ':', { parent }); + } + + function appendTimerBlock(parent, daysLeft, hoursLeft, minutesLeft) { + const timerBlock = createTag('div', { class: 'timer-block' }, null, { parent }); + appendTimerBox(timerBlock, daysLeft, cdtDays); + appendSeparator(timerBlock); + appendTimerBox(timerBlock, hoursLeft, cdtHours); + appendSeparator(timerBlock); + appendTimerBox(timerBlock, minutesLeft, cdtMins); + } + + function appendTimerLabel(parent, label) { + createTag('div', { class: 'timer-label' }, label, { parent }); + } + + function removeCountdown() { + container.innerHTML = ''; + } + + function render(daysLeft, hoursLeft, minutesLeft) { + if (!isVisible) return; + + removeCountdown(); + + appendTimerLabel(container, cdtLabel); + appendTimerBlock(container, daysLeft, hoursLeft, minutesLeft); + } + + function updateCountdown() { + const currentTime = Date.now(); + + for (let i = 0; i < timeRangesEpoch.length; i += 2) { + const startTime = timeRangesEpoch[i]; + const endTime = timeRangesEpoch[i + 1]; + + if (currentTime >= startTime && currentTime <= endTime) { + isVisible = true; + const diffTime = endTime - currentTime; + const daysLeft = Math.floor(diffTime / (1000 * 60 * 60 * 24)); + const hoursLeft = Math.floor((diffTime % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60)); + const minutesLeft = Math.floor((diffTime % (1000 * 60 * 60)) / (1000 * 60)); + render(daysLeft, hoursLeft, minutesLeft); + return; + } + } + + isVisible = false; + clearInterval(interval); + removeCountdown(); + } + + function startCountdown() { + const oneMinuteinMs = 60000; + updateCountdown(); + interval = setInterval(updateCountdown, oneMinuteinMs); + } + + startCountdown(); +} + +const isMobileDevice = () => /Android|webOS|iPhone|iPad|iPod/i.test(navigator.userAgent); + +export default async function initCDT(el, classList) { + try { + const { miloLibs, codeRoot } = getConfig(); + loadStyle(`${miloLibs || codeRoot}/features/cdt/cdt.css`); + + const placeholders = ['cdt-ends-in', 'cdt-days', 'cdt-hours', 'cdt-mins']; + const [cdtLabel, cdtDays, cdtHours, cdtMins] = await Promise.all( + placeholders.map(replacePlaceholder), + ); + + const cdtMetadata = getMetadata('countdown-timer'); + if (cdtMetadata === null) { + throw new Error('Metadata for countdown-timer is not available'); + } + + const cdtRange = cdtMetadata.split(','); + if (cdtRange.length % 2 !== 0) { + throw new Error('Invalid countdown timer range'); + } + + const timeRangesEpoch = cdtRange.map((time) => { + const parsedTime = Date.parse(time?.trim()); + return Number.isNaN(parsedTime) ? null : parsedTime; + }); + if (timeRangesEpoch.includes(null)) { + throw new Error('Invalid format for countdown timer range'); + } + + const cdtDiv = createTag('div', { class: 'countdown-timer' }, null, { parent: el }); + cdtDiv.classList.add(isMobileDevice() ? 'vertical' : 'horizontal'); + cdtDiv.classList.add(classList.contains('dark') ? 'dark' : 'light'); + if (classList.contains('center')) cdtDiv.classList.add('center'); + + loadCountdownTimer(cdtDiv, cdtLabel, cdtDays, cdtHours, cdtMins, timeRangesEpoch); + } catch (error) { + window.lana?.log(`Failed to load countdown timer module: ${error}`, { tags: 'countdown-timer' }); + } +} diff --git a/test/blocks/hero-marquee/hero-marquee.test.js b/test/blocks/hero-marquee/hero-marquee.test.js index 063f9b7631..93636bc840 100644 --- a/test/blocks/hero-marquee/hero-marquee.test.js +++ b/test/blocks/hero-marquee/hero-marquee.test.js @@ -13,6 +13,8 @@ setConfig(conf); describe('Hero Marquee', () => { before(async () => { document.body.innerHTML = await readFile({ path: './mocks/body.html' }); + const meta = Object.assign(document.createElement('meta'), { name: 'countdown-timer', content: '2024-08-26 12:00:00 PST,2026-08-30 00:00:00 PST' }); + document.head.appendChild(meta); const { default: init } = await import('../../../libs/blocks/hero-marquee/hero-marquee.js'); const marquees = document.querySelectorAll('.hero-marquee'); marquees.forEach(async (marquee) => { @@ -35,4 +37,26 @@ describe('Hero Marquee', () => { const hr = await waitForElement('.has-divider'); expect(hr).to.exist; }); + + it('Embedding countdown-timer inside hero-marquee', async () => { + const marquee = document.getElementById('hero-cdt'); + expect(marquee.getElementsByClassName('timer-label')).to.exist; + + // update the meta tag for negative cases of countdown-timer + const meta = document.querySelector('meta[name="countdown-timer"]'); + meta.setAttribute('content', '2024-08-26 12:00:00 PST'); + const { default: init } = await import('../../../libs/blocks/hero-marquee/hero-marquee.js'); + await init(marquee); + expect(marquee.getElementsByClassName('timer-label')).to.not.exist; + + // update the meta tag for invalid countdown-timer + meta.setAttribute('content', 'random,invalid'); + await init(marquee); + expect(marquee.getElementsByClassName('timer-label')).to.not.exist; + + // update the meta tag for invalid countdown-timer + document.head.removeChild(meta); + await init(marquee); + expect(marquee.getElementsByClassName('timer-label')).to.not.exist; + }); }); diff --git a/test/blocks/hero-marquee/mocks/body.html b/test/blocks/hero-marquee/mocks/body.html index 72166c8f81..7fca64dab7 100644 --- a/test/blocks/hero-marquee/mocks/body.html +++ b/test/blocks/hero-marquee/mocks/body.html @@ -117,3 +117,44 @@

Hero w/ Adobe.tv link

https://video.tv.adobe.com/v/3427744
+ +
+
+
+ + + +
+
+
+
+

After Effects

+

DETAIL TEXT

+

This Hero has all row types

+

lockup, list, qrcode, text, background

+

See more Other options you say?

+
+
+
+
con-block-row-list (max-width-6-tablet)
+
+
    +
  • Small
  • +
  • Medium length text
  • +
  • Long length text that may break onto a new line, what will happen, keep it going so this is even longer and really wraps?
  • +
  • Another list
  • +
+
+
+
+
--- white
+
+
+
con-block-row-text (xs-body, m-button)
+
See plans for students and teachers or small and medium business.
+
+
+
con-block-row-text (body-m)
+
Text with no button class
+
+
diff --git a/test/blocks/hero-marquee/mocks/placeholders.json b/test/blocks/hero-marquee/mocks/placeholders.json new file mode 100644 index 0000000000..3c7e61d0af --- /dev/null +++ b/test/blocks/hero-marquee/mocks/placeholders.json @@ -0,0 +1,24 @@ +{ + "total": 21, + "offset": 0, + "limit": 21, + "data": [ + { + "key": "cdt-ends-in", + "value": "ENDS IN" + }, + { + "key": "cdt-days", + "value": "days" + }, + { + "key": "cdt-hours", + "value": "hours" + }, + { + "key": "cdt-mins", + "value": "mins" + } + ], + ":type": "sheet" +} diff --git a/test/blocks/marquee/marquee.test.js b/test/blocks/marquee/marquee.test.js index 5874bd6bd5..1496ea95cf 100644 --- a/test/blocks/marquee/marquee.test.js +++ b/test/blocks/marquee/marquee.test.js @@ -16,6 +16,8 @@ const video = await readFile({ path: './mocks/video.html' }); const multipleIcons = await readFile({ path: './mocks/multiple-icons.html' }); describe('marquee', () => { + const meta = Object.assign(document.createElement('meta'), { name: 'countdown-timer', content: '2024-08-26 12:00:00 PST,2026-08-30 00:00:00 PST' }); + document.head.appendChild(meta); const marquees = document.querySelectorAll('.marquee'); marquees.forEach((marquee) => { init(marquee); @@ -163,4 +165,9 @@ describe('marquee', () => { expect(log.calledOnceWith(`Failed to load mnemonic marquee module: ${error}`)).to.be.false; }); }); + + describe('Embedding countdown-timer inside marquee', () => { + const marquee = document.getElementById('countdown-timer'); + expect(marquee.getElementsByClassName('timer-label')).to.exist; + }); }); diff --git a/test/blocks/marquee/mocks/body.html b/test/blocks/marquee/mocks/body.html index 2d1d583423..7f5cae26ac 100644 --- a/test/blocks/marquee/mocks/body.html +++ b/test/blocks/marquee/mocks/body.html @@ -381,3 +381,17 @@

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod temp +

Catalog Marquee with Countdown timer

+
+
+
linear-gradient(90deg, rgba(226,106,96,1) 0%, rgba(228,170,166,1) 51%, rgba(163,243,120,1) 100%)
+
+
+
+

Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.

+

Body M Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.

+


Includes:

+

Acrobat Pro Acrobat

+
+
+
diff --git a/test/blocks/marquee/mocks/placeholders.json b/test/blocks/marquee/mocks/placeholders.json new file mode 100644 index 0000000000..3c7e61d0af --- /dev/null +++ b/test/blocks/marquee/mocks/placeholders.json @@ -0,0 +1,24 @@ +{ + "total": 21, + "offset": 0, + "limit": 21, + "data": [ + { + "key": "cdt-ends-in", + "value": "ENDS IN" + }, + { + "key": "cdt-days", + "value": "days" + }, + { + "key": "cdt-hours", + "value": "hours" + }, + { + "key": "cdt-mins", + "value": "mins" + } + ], + ":type": "sheet" +} diff --git a/test/blocks/media/media.test.js b/test/blocks/media/media.test.js index 2a5ff0eaba..966b159b43 100644 --- a/test/blocks/media/media.test.js +++ b/test/blocks/media/media.test.js @@ -5,6 +5,8 @@ document.head.innerHTML = " { + const meta = Object.assign(document.createElement('meta'), { name: 'countdown-timer', content: '2024-08-26 12:00:00 PST,2026-08-30 00:00:00 PST' }); + document.head.appendChild(meta); const medias = document.querySelectorAll('.media'); medias.forEach((media) => { init(media); @@ -132,5 +134,8 @@ describe('media', () => { const detail = medias[8].querySelector('.detail-l'); expect(detail).to.exist; }); + it('has a cdt', () => { + expect(medias[8].getElementsByClassName('timer-label')).to.exist; + }); }); }); diff --git a/test/blocks/media/mocks/body.html b/test/blocks/media/mocks/body.html index 662303a823..78445c8a28 100644 --- a/test/blocks/media/mocks/body.html +++ b/test/blocks/media/mocks/body.html @@ -170,7 +170,7 @@

Heading XS 18/22 Media (merch -
+
diff --git a/test/blocks/media/mocks/placeholders.json b/test/blocks/media/mocks/placeholders.json new file mode 100644 index 0000000000..3c7e61d0af --- /dev/null +++ b/test/blocks/media/mocks/placeholders.json @@ -0,0 +1,24 @@ +{ + "total": 21, + "offset": 0, + "limit": 21, + "data": [ + { + "key": "cdt-ends-in", + "value": "ENDS IN" + }, + { + "key": "cdt-days", + "value": "days" + }, + { + "key": "cdt-hours", + "value": "hours" + }, + { + "key": "cdt-mins", + "value": "mins" + } + ], + ":type": "sheet" +}