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 @@
After Effects
+DETAIL TEXT
+lockup, list, qrcode, text, background
+ +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
+