diff --git a/.eslintrc-code-compatibility.js b/.eslintrc-code-compatibility.js index 92080c6c42..c2a8c26aca 100644 --- a/.eslintrc-code-compatibility.js +++ b/.eslintrc-code-compatibility.js @@ -14,6 +14,7 @@ module.exports = { ], ignorePatterns: [ '/libs/deps/*', + '/libs/navigation/dist/*', '/tools/loc/*', ], }; diff --git a/.eslintrc.js b/.eslintrc.js index f24adfa387..e7c17dc5fb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -62,6 +62,7 @@ module.exports = { '/libs/features/mas/*', '/tools/loc/*', '/libs/features/spectrum-web-components/*', + '/libs/navigation/dist/*', ], plugins: [ 'chai-friendly', diff --git a/.github/workflows/merge-to-stage.js b/.github/workflows/merge-to-stage.js index 71f290ace4..7c85415da1 100644 --- a/.github/workflows/merge-to-stage.js +++ b/.github/workflows/merge-to-stage.js @@ -20,19 +20,20 @@ const LABELS = { zeroImpact: 'zero-impact', }; const TEAM_MENTIONS = [ - '@adobecom/miq-sot', '@adobecom/bacom-sot', - '@adobecom/homepage-sot', '@adobecom/creative-cloud-sot', '@adobecom/document-cloud-sot', + '@adobecom/express-sot', + '@adobecom/homepage-sot', + '@adobecom/miq-sot', ]; const SLACK = { merge: ({ html_url, number, title, prefix = '' }) => `:merged: PR merged to stage: ${prefix} <${html_url}|${number}: ${title}>.`, openedSyncPr: ({ html_url, number }) => `:fast_forward: Created <${html_url}|Stage to Main PR ${number}>`, }; -let github; -let owner; +let github; +let owner; let repo; let body = ` diff --git a/.github/workflows/release-standalone-feds.yml b/.github/workflows/release-standalone-feds.yml new file mode 100644 index 0000000000..89a11a1886 --- /dev/null +++ b/.github/workflows/release-standalone-feds.yml @@ -0,0 +1,55 @@ +name: Create a Release for Standalone Feds GlobalNav and Footer +on: + workflow_dispatch: + inputs: + version: + description: 'Version' + required: true + type: string + +permissions: + contents: write + +jobs: + release-feds: + name: Release Standalone Feds + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [20.x] + defaults: + run: + working-directory: ./libs/navigation + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 2 + + - name: Set up Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build Files + run: node ./build.mjs + + - name: Generate tarball + run: npm pack + + - name: Create Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "feds-standalone-v${{ inputs.version }}" \ + --repo="$GITHUB_REPOSITORY" \ + --title="@adobecom/standalone-feds v${{ inputs.version }} Release" \ + --generate-notes + + - name: Upload Files to Release + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: gh release upload "feds-standalone-v${{ inputs.version }}" "adobecom-standalone-feds-${{ inputs.version }}.tgz" diff --git a/.gitignore b/.gitignore index 168ba7ef67..4952087964 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ logs/* test-html-results/ test-results/ test-a11y-results/ +libs/navigation/dist/ diff --git a/libs/blocks/adobetv/adobetv.css b/libs/blocks/adobetv/adobetv.css index 7907e053ca..28322d4585 100644 --- a/libs/blocks/adobetv/adobetv.css +++ b/libs/blocks/adobetv/adobetv.css @@ -1,4 +1,5 @@ @import url('../../styles/iframe.css'); +@import url('../video/video.css'); a[href*='.mp4'].hide-video { visibility: hidden !important; diff --git a/libs/blocks/aside/aside.css b/libs/blocks/aside/aside.css index 7feb805ada..54615ee978 100644 --- a/libs/blocks/aside/aside.css +++ b/libs/blocks/aside/aside.css @@ -298,6 +298,7 @@ } .aside.rounded-corners .foreground .image img, +.aside.rounded-corners .foreground .image:not(:has(.video-container)) .pause-play-wrapper, .aside.rounded-corners .foreground .image video { border-radius: 16px; } diff --git a/libs/blocks/aside/aside.js b/libs/blocks/aside/aside.js index 7519967de3..92ea2500d4 100644 --- a/libs/blocks/aside/aside.js +++ b/libs/blocks/aside/aside.js @@ -197,8 +197,8 @@ function decorateLayout(el) { } const foregroundImage = foreground.querySelector(':scope > div:not(.text) img')?.closest('div'); const bgImage = el.querySelector(':scope > div:not(.text):not(.foreground) img')?.closest('div'); - const foregroundMedia = foreground.querySelector(':scope > div:not(.text) video, :scope > div:not(.text) a:is([href*=".mp4"], [href*="tv.adobe.com"]), :scope > div:not(.text) iframe[src*="tv.adobe.com"]')?.closest('div'); - + const foregroundMedia = foreground.querySelector(':scope > div:not(.text) :is(.video-container, video, a[href*=".mp4"], a[href*="tv.adobe.com"]), :scope > div:not(.text) iframe[src*="tv.adobe.com"]') + ?.closest('div:not(.video-container)'); const bgMedia = el.querySelector(':scope > div:not(.text):not(.foreground) video, :scope > div:not(.text):not(.foreground) a:is([href*=".mp4"], [href*="tv.adobe.com"])')?.closest('div'); const image = foregroundImage ?? bgImage; const asideMedia = foregroundMedia ?? bgMedia ?? image; diff --git a/libs/blocks/brick/brick.css b/libs/blocks/brick/brick.css index 43cd31584d..4ed08eb815 100644 --- a/libs/blocks/brick/brick.css +++ b/libs/blocks/brick/brick.css @@ -98,6 +98,10 @@ margin: 0; } +.brick .foreground div > .video-container { + margin: 0; +} + .brick .foreground div > * { margin-top: var(--spacing-xs); } @@ -342,6 +346,11 @@ position: absolute; } + .brick.split.row .foreground .brick-media .video-container img, + .brick.split.row .foreground .brick-media .video-container video { + width: 100%; + } + .brick .foreground .brick-media video, .brick.split.row .foreground .brick-media video { object-fit: fill; diff --git a/libs/blocks/carousel/carousel.css b/libs/blocks/carousel/carousel.css index 41e70a1ef2..08bffdf704 100644 --- a/libs/blocks/carousel/carousel.css +++ b/libs/blocks/carousel/carousel.css @@ -397,7 +397,7 @@ html[dir="rtl"] .carousel-slides .section.carousel-slide { overflow: hidden; } -.carousel .carousel-slide > div p > video { +.carousel .carousel-slide > div p :is(.video-holder, video) { width: 100%; height: auto; } diff --git a/libs/blocks/carousel/carousel.js b/libs/blocks/carousel/carousel.js index 1bd73281f6..4bb57f7180 100644 --- a/libs/blocks/carousel/carousel.js +++ b/libs/blocks/carousel/carousel.js @@ -168,7 +168,7 @@ function moveSlides(event, carouselElements, jumpToIndex) { referenceSlide.classList.remove('reference-slide'); referenceSlide.style.order = null; activeSlide.classList.remove('active'); - activeSlide.querySelectorAll('a').forEach((focusableElement) => { focusableElement.setAttribute('tabindex', -1); }); + activeSlide.querySelectorAll('a, video').forEach((focusableElement) => focusableElement.setAttribute('tabindex', -1)); activeSlideIndicator.classList.remove('active'); activeSlideIndicator.setAttribute('tabindex', -1); @@ -230,10 +230,12 @@ function moveSlides(event, carouselElements, jumpToIndex) { if (index < show) { tabIndex = 0; } - slide.querySelectorAll('a').forEach((focusableElement) => { focusableElement.setAttribute('tabindex', tabIndex); }); + slide.querySelectorAll('a,:not(.video-container, .pause-play-wrapper) > video') + .forEach((focusableElement) => { focusableElement.setAttribute('tabindex', tabIndex); }); }); } else { - activeSlide.querySelectorAll('a').forEach((focusableElement) => { focusableElement.setAttribute('tabindex', 0); }); + activeSlide.querySelectorAll('a,:not(.video-container, .pause-play-wrapper) > video') + .forEach((focusableElement) => { focusableElement.setAttribute('tabindex', 0); }); } activeSlideIndicator.classList.add('active'); activeSlideIndicator.setAttribute('tabindex', 0); diff --git a/libs/blocks/figure/figure.js b/libs/blocks/figure/figure.js index cd0d80cd88..40c9e40c28 100644 --- a/libs/blocks/figure/figure.js +++ b/libs/blocks/figure/figure.js @@ -1,4 +1,4 @@ -import { applyHoverPlay, decorateAnchorVideo } from '../../utils/decorate.js'; +import { applyHoverPlay, decorateAnchorVideo, applyAccessibilityEvents, decoratePausePlayWrapper, isVideoAccessible } from '../../utils/decorate.js'; import { createTag } from '../../utils/utils.js'; function buildCaption(pEl) { @@ -31,7 +31,11 @@ function decorateVideo(clone, figEl) { ); } applyHoverPlay(videoTag); - figEl.prepend(videoTag); + if (!videoTag.controls && isVideoAccessible(anchorTag)) { + applyAccessibilityEvents(videoTag); + decoratePausePlayWrapper(videoTag, 'autoplay'); + } + figEl.prepend(clone.querySelector('.video-container, .pause-play-wrapper, video')); } } @@ -68,7 +72,7 @@ export function buildFigure(blockEl) { const link = clone.querySelector('a'); if (link) { const img = figEl.querySelector('picture') || figEl.querySelector('video'); - if (img) { + if (img && !link.classList.contains('pause-play-wrapper')) { // wrap picture or video in A tag link.textContent = ''; link.append(img); diff --git a/libs/blocks/global-footer/global-footer.css b/libs/blocks/global-footer/global-footer.css index 5f32030e01..951a1d72d3 100644 --- a/libs/blocks/global-footer/global-footer.css +++ b/libs/blocks/global-footer/global-footer.css @@ -252,6 +252,20 @@ height: 12px; } +@media (min-width: 600px) { + dialog.feds-dialog { + max-width: 80vw; + width: fit-content; + } +} + +@media (min-width: 1200px) { + dialog.feds-dialog { + width: 1200px; + max-width: calc((100% - 6px) - 2em); + } +} + @media (min-width: 900px) { /* If there is too much content, float it on multiple rows */ .feds-footer-wrapper .feds-menu-content { diff --git a/libs/blocks/global-footer/global-footer.js b/libs/blocks/global-footer/global-footer.js index 2cd9f35559..21e18208cd 100644 --- a/libs/blocks/global-footer/global-footer.js +++ b/libs/blocks/global-footer/global-footer.js @@ -4,12 +4,11 @@ import { decorateLinks, getMetadata, getConfig, - loadBlock, localizeLink, + loadStyle, } from '../../utils/utils.js'; import { - getFedsPlaceholderConfig, getExperienceName, getAnalyticsValue, loadDecorateMenu, @@ -23,7 +22,7 @@ import { isDarkMode, } from '../global-navigation/utilities/utilities.js'; -import { getFederatedUrl } from '../../utils/federated.js'; +import { getFederatedUrl, getFedsPlaceholderConfig } from '../../utils/federated.js'; import { replaceKey } from '../../features/placeholders.js'; @@ -218,6 +217,8 @@ class Footer { ${regionPickerTextElem} `; + regionPickerElem.dataset.modalPath = `${url.pathname}#_inline`; + regionPickerElem.dataset.modalHash = url.hash; const regionPickerWrapperClass = 'feds-regionPicker-wrapper'; this.elements.regionPicker = toFragment`
${regionPickerElem} @@ -231,24 +232,48 @@ class Footer { // Hash -> region selector opens a modal decorateAutoBlock(regionPickerElem); // add modal-specific attributes // TODO remove logs after finding the root cause for the region picker 404s -> MWPW-143627 + regionPickerElem.href = url.hash; if (regionPickerElem.classList[0] !== 'modal') { lanaLog({ message: `Modal block class missing from region picker pre loading the block; locale: ${locale}; regionPickerElem: ${regionPickerElem.outerHTML}`, tags: 'errorType=warn,module=global-footer', }); } - await loadBlock(regionPickerElem); // load modal logic and styles + loadStyle(`${base}/blocks/modal/modal.css`); + const { default: initModal } = await import('../modal/modal.js'); + const modal = await initModal(regionPickerElem); + + const loadRegionNav = async () => { + const block = document.querySelector('.region-nav'); + if (block && getConfig().standaloneGnav) { + // on standalone the region-nav will fail to load automatically through + // the modal calling fragment.js. In that case we will have data-failed=true + // and we should manually load region nav + // If that's not the case then we're not a standalone gnav + // and we mustn't load region-nav twice. + if (block.getAttribute('data-failed') !== 'true') return; + block.classList.add('hide'); + loadStyle(`${base}/blocks/region-nav/region-nav.css`); + const { default: initRegionNav } = await import('../region-nav/region-nav.js'); + initRegionNav(block); + // decoratePlaceholders(block, getConfig()); + block.classList.remove('hide'); + } + }; + + if (modal) await loadRegionNav(); // just in case the modal is already open + if (regionPickerElem.classList[0] !== 'modal') { lanaLog({ message: `Modal block class missing from region picker post loading the block; locale: ${locale}; regionPickerElem: ${regionPickerElem.outerHTML}`, tags: 'errorType=warn,module=global-footer', }); } - // 'decorateAutoBlock' logic replaces class name entirely, need to add it back - regionPickerElem.classList.add(regionPickerClass); regionPickerElem.addEventListener('click', () => { if (!isRegionPickerExpanded()) { regionPickerElem.setAttribute('aria-expanded', 'true'); + // wait for the modal to load before we load the region nav + window.addEventListener('milo:modal:loaded', loadRegionNav, { once: true }); } }); // Set aria-expanded to false when region modal is closed @@ -263,7 +288,8 @@ class Footer { regionSelector.href = localizeLink(regionSelector.href); decorateAutoBlock(regionSelector); // add fragment-specific class(es) this.elements.regionPicker.append(regionSelector); // add fragment after regionPickerElem - await loadBlock(regionSelector); // load fragment and replace original link + const { default: initFragment } = await import('../fragment/fragment.js'); + await initFragment(regionSelector); // load fragment and replace original link // Update aria-expanded on click regionPickerElem.addEventListener('click', (e) => { e.preventDefault(); @@ -272,6 +298,7 @@ class Footer { }); // Close region picker dropdown on outside click document.addEventListener('click', (e) => { + e.preventDefault(); if (isRegionPickerExpanded() && !e.target.closest(`.${regionPickerWrapperClass}`)) { regionPickerElem.setAttribute('aria-expanded', false); @@ -279,7 +306,7 @@ class Footer { }); } - return this.regionPicker; + return this.elements.regionPicker; }; decorateSocial = () => { diff --git a/libs/blocks/global-navigation/base.css b/libs/blocks/global-navigation/base.css index 1eb4d98d29..15a7925834 100644 --- a/libs/blocks/global-navigation/base.css +++ b/libs/blocks/global-navigation/base.css @@ -118,8 +118,8 @@ align-items: center; } -header.global-navigation { - visibility: visible; +header.global-navigation.ready { + visibility: visible !important; } /* Desktop styles */ diff --git a/libs/blocks/global-navigation/features/profile/dropdown.js b/libs/blocks/global-navigation/features/profile/dropdown.js index 836f9cd834..d874963e82 100644 --- a/libs/blocks/global-navigation/features/profile/dropdown.js +++ b/libs/blocks/global-navigation/features/profile/dropdown.js @@ -1,6 +1,7 @@ import { getConfig } from '../../../../utils/utils.js'; -import { toFragment, getFedsPlaceholderConfig, trigger, closeAllDropdowns, logErrorFor } from '../../utilities/utilities.js'; +import { toFragment, trigger, closeAllDropdowns, logErrorFor } from '../../utilities/utilities.js'; import { replaceKeyArray } from '../../../../features/placeholders.js'; +import { getFedsPlaceholderConfig } from '../../../../utils/federated.js'; const getLanguage = (ietfLocale) => { if (!ietfLocale.length) return 'en'; diff --git a/libs/blocks/global-navigation/features/search/gnav-search.js b/libs/blocks/global-navigation/features/search/gnav-search.js index ca6df552b8..76532c5d31 100644 --- a/libs/blocks/global-navigation/features/search/gnav-search.js +++ b/libs/blocks/global-navigation/features/search/gnav-search.js @@ -1,6 +1,5 @@ import { toFragment, - getFedsPlaceholderConfig, isDesktop, setCurtainState, trigger, @@ -10,6 +9,7 @@ import { import { replaceKeyArray } from '../../../../features/placeholders.js'; import { getConfig } from '../../../../utils/utils.js'; import { debounce } from '../../../../utils/action.js'; +import { getFedsPlaceholderConfig } from '../../../../utils/federated.js'; const CONFIG = { suggestions: { diff --git a/libs/blocks/global-navigation/global-navigation.js b/libs/blocks/global-navigation/global-navigation.js index 4a1903171a..9d13051e86 100644 --- a/libs/blocks/global-navigation/global-navigation.js +++ b/libs/blocks/global-navigation/global-navigation.js @@ -1,3 +1,4 @@ +/* eslint import/no-relative-packages: 0 */ /* eslint-disable no-async-promise-executor */ import { getConfig, @@ -13,7 +14,6 @@ import { getActiveLink, getAnalyticsValue, getExperienceName, - getFedsPlaceholderConfig, hasActiveLink, isActiveLink, icons, @@ -21,7 +21,6 @@ import { isTangentToViewport, lanaLog, loadBaseStyles, - loadBlock, loadDecorateMenu, rootPath, loadStyles, @@ -40,6 +39,7 @@ import { setDisableAEDState, getDisableAEDState, } from './utilities/utilities.js'; +import { getFedsPlaceholderConfig } from '../../utils/federated.js'; import { replaceKey, replaceKeyArray } from '../../features/placeholders.js'; @@ -222,7 +222,7 @@ const decorateProfileTrigger = async ({ avatar }) => { let keyboardNav; const setupKeyboardNav = async () => { keyboardNav = keyboardNav || new Promise(async (resolve) => { - const KeyboardNavigation = await loadBlock('./keyboard/index.js'); + const { default: KeyboardNavigation } = await import('./utilities/keyboard/index.js'); const instance = new KeyboardNavigation(); resolve(instance); }); @@ -425,17 +425,17 @@ class Gnav { this.block.removeEventListener('keydown', this.loadDelayed); if (this.searchPresent()) { const [ - Search, + { default: Search }, ] = await Promise.all([ - loadBlock('../features/search/gnav-search.js'), + import('./features/search/gnav-search.js'), loadStyles(rootPath('features/search/gnav-search.css')), ]); this.Search = Search; } if (!this.useUniversalNav) { - const [ProfileDropdown] = await Promise.all([ - loadBlock('../features/profile/dropdown.js'), + const [{ default: ProfileDropdown }] = await Promise.all([ + import('./features/profile/dropdown.js'), loadStyles(rootPath('features/profile/dropdown.css')), ]); this.ProfileDropdown = ProfileDropdown; @@ -540,7 +540,7 @@ class Gnav { const unavVersion = new URLSearchParams(window.location.search).get('unavVersion') || '1.3'; await Promise.all([ loadScript(`https://${environment}.adobeccstatic.com/unav/${unavVersion}/UniversalNav.js`), - loadStyles(`https://${environment}.adobeccstatic.com/unav/${unavVersion}/UniversalNav.css`), + loadStyles(`https://${environment}.adobeccstatic.com/unav/${unavVersion}/UniversalNav.css`, true), ]); const getChildren = () => { @@ -679,7 +679,7 @@ class Gnav { return this.loadDelayed().then(() => { this.blocks.search.instance = new this.Search(this.blocks.search.config); - }).catch(() => {}); + }).catch(() => { }); }; isToggleExpanded = () => this.elements.mobileToggle?.getAttribute('aria-expanded') === 'true'; @@ -773,7 +773,7 @@ class Gnav { if (allSvgImgs.length === 2) return allSvgImgs[1]; const images = blockLinks.filter((blockLink) => imgRegex.test(blockLink.href) - || imgRegex.test(blockLink.textContent)); + || imgRegex.test(blockLink.textContent)); if (images.length === 2) return getBrandImage(images[1], isBrandImage); } const svgImg = rawBlock.querySelector('picture img[src$=".svg"]'); @@ -910,7 +910,7 @@ class Gnav { const menuLogic = await loadDecorateMenu(); - menuLogic.decorateMenu({ + await menuLogic.decorateMenu({ item, template, type: itemType, @@ -1021,7 +1021,7 @@ class Gnav { const breadcrumbsElem = this.block.querySelector('.breadcrumbs'); // Breadcrumbs are not initially part of the nav, need to decorate the links if (breadcrumbsElem) decorateLinks(breadcrumbsElem); - const createBreadcrumbs = await loadBlock('../features/breadcrumbs/breadcrumbs.js'); + const { default: createBreadcrumbs } = await import('./features/breadcrumbs/breadcrumbs.js'); this.elements.breadcrumbsWrapper = await createBreadcrumbs(breadcrumbsElem); return this.elements.breadcrumbsWrapper; }; @@ -1091,5 +1091,6 @@ export default async function init(block) { const mepMartech = mep?.martech || ''; block.setAttribute('daa-lh', `gnav|${getExperienceName()}${mepMartech}`); if (isDarkMode()) block.classList.add('feds--dark'); + block.classList.add('ready'); return gnav; } diff --git a/libs/blocks/global-navigation/utilities/getUserEntitlements.js b/libs/blocks/global-navigation/utilities/getUserEntitlements.js index 1166146179..b9812236b6 100644 --- a/libs/blocks/global-navigation/utilities/getUserEntitlements.js +++ b/libs/blocks/global-navigation/utilities/getUserEntitlements.js @@ -1,3 +1,4 @@ +/* eslint import/no-relative-packages: 0 */ /* eslint-disable camelcase */ import { getConfig } from '../../../utils/utils.js'; diff --git a/libs/blocks/global-navigation/utilities/getUserEventHistory.js b/libs/blocks/global-navigation/utilities/getUserEventHistory.js index e1fcc23fee..25937e34a2 100644 --- a/libs/blocks/global-navigation/utilities/getUserEventHistory.js +++ b/libs/blocks/global-navigation/utilities/getUserEventHistory.js @@ -1,3 +1,4 @@ +/* eslint import/no-relative-packages: 0 */ /* eslint-disable no-promise-executor-return, no-async-promise-executor */ import { getConfig } from '../../../utils/utils.js'; diff --git a/libs/blocks/global-navigation/utilities/utilities.js b/libs/blocks/global-navigation/utilities/utilities.js index 79104db68c..f880d14ae1 100644 --- a/libs/blocks/global-navigation/utilities/utilities.js +++ b/libs/blocks/global-navigation/utilities/utilities.js @@ -1,7 +1,8 @@ +/* eslint import/no-relative-packages: 0 */ import { getConfig, getMetadata, loadStyle, loadLana, decorateLinks, localizeLink, } from '../../../utils/utils.js'; -import { getFederatedContentRoot, getFederatedUrl } from '../../../utils/federated.js'; +import { getFederatedContentRoot, getFederatedUrl, getFedsPlaceholderConfig } from '../../../utils/federated.js'; import { processTrackingLabels } from '../../../martech/attributes.js'; import { replaceText } from '../../../features/placeholders.js'; @@ -107,24 +108,6 @@ export const federatePictureSources = ({ section, forceFederate } = {}) => { }); }; -let fedsPlaceholderConfig; -export const getFedsPlaceholderConfig = ({ useCache = true } = {}) => { - if (useCache && fedsPlaceholderConfig) return fedsPlaceholderConfig; - - const { locale, placeholders } = getConfig(); - const libOrigin = getFederatedContentRoot(); - - fedsPlaceholderConfig = { - locale: { - ...locale, - contentRoot: `${libOrigin}${locale.prefix}/federal/globalnav`, - }, - placeholders, - }; - - return fedsPlaceholderConfig; -}; - export function getAnalyticsValue(str, index) { if (typeof str !== 'string' || !str.length) return str; @@ -152,7 +135,9 @@ export function rootPath(path) { return url; } -export function loadStyles(url) { +export function loadStyles(url, override = false) { + const { standaloneGnav } = getConfig(); + if (standaloneGnav && !override) return; loadStyle(url, (e) => { if (e === 'error') { lanaLog({ @@ -173,6 +158,8 @@ export function isDarkMode() { // since they can be independent of each other. // CSS imports were not used due to duplication of file include export async function loadBaseStyles() { + const { standaloneGnav } = getConfig(); + if (standaloneGnav) return; if (isDarkMode()) { new Promise((resolve) => { loadStyle(rootPath('base.css'), resolve); }) .then(() => loadStyles(rootPath('dark-nav.css'))); @@ -182,10 +169,6 @@ export async function loadBaseStyles() { } } -export function loadBlock(path) { - return import(path).then((module) => module.default); -} - let cachedDecorateMenu; export async function loadDecorateMenu() { if (cachedDecorateMenu) return cachedDecorateMenu; @@ -195,15 +178,12 @@ export async function loadDecorateMenu() { resolve = _resolve; }); - const [{ decorateMenu, decorateLinkGroup }] = await Promise.all([ - loadBlock('./menu/menu.js'), + const [menu] = await Promise.all([ + import('./menu/menu.js'), loadStyles(rootPath('utilities/menu/menu.css')), ]); - resolve({ - decorateMenu, - decorateLinkGroup, - }); + resolve(menu.default); return cachedDecorateMenu; } diff --git a/libs/blocks/hero-marquee/hero-marquee.js b/libs/blocks/hero-marquee/hero-marquee.js index c9f409757d..f747559e29 100644 --- a/libs/blocks/hero-marquee/hero-marquee.js +++ b/libs/blocks/hero-marquee/hero-marquee.js @@ -187,7 +187,7 @@ export default async function init(el) { foreground.classList.add('foreground', `cols-${fRows.length}`); let copy = fRows[0]; const anyTag = foreground.querySelector('p, h1, h2, h3, h4, h5, h6'); - const asset = foreground.querySelector('div > picture, div > video, div > a[href*=".mp4"], div > a.image-link'); + const asset = foreground.querySelector('div > picture, :is(.video-container, .pause-play-wrapper), div > video, div > a[href*=".mp4"], div > a.image-link'); const allRows = foreground.querySelectorAll('div > div'); copy = anyTag.closest('div'); copy.classList.add('copy'); diff --git a/libs/blocks/how-to/how-to.js b/libs/blocks/how-to/how-to.js index ce7cf146df..8080319fd0 100644 --- a/libs/blocks/how-to/how-to.js +++ b/libs/blocks/how-to/how-to.js @@ -47,7 +47,7 @@ const setJsonLd = (heading, description, mainImage, stepsLd) => { }; const getImage = (el) => el.querySelector('picture') || el.querySelector('a[href$=".svg"'); -const getVideo = (el) => el.querySelector('video') || el.querySelector('.milo-video'); +const getVideo = (el) => el.querySelector('.video-container, .pause-play-wrapper, video, .milo-video'); const getHowToInfo = (el) => { const infoDiv = el.querySelector(':scope > div > div'); diff --git a/libs/blocks/marquee/marquee.js b/libs/blocks/marquee/marquee.js index d747586fd3..21c3e8c8a5 100644 --- a/libs/blocks/marquee/marquee.js +++ b/libs/blocks/marquee/marquee.js @@ -88,7 +88,7 @@ function decorateSplit(el, foreground, media) { let mediaCreditInner; const txtContent = media?.lastChild?.textContent?.trim(); - if (txtContent?.match(/^http.*\.mp4/) || media?.lastChild?.tagName === 'VIDEO') return; + if (txtContent?.match(/^http.*\.mp4/) || media?.lastChild?.tagName === 'VIDEO' || media.querySelector('.video-holder video')) return; if (txtContent) { mediaCreditInner = createTag('p', { class: 'body-s' }, txtContent); } else if (media.lastElementChild?.tagName !== 'PICTURE') { diff --git a/libs/blocks/media/media.js b/libs/blocks/media/media.js index 62b3af490c..65a91d8c7f 100644 --- a/libs/blocks/media/media.js +++ b/libs/blocks/media/media.js @@ -79,9 +79,7 @@ export default async function init(el) { decorateBlockText(text, blockTypeSizes[size], blockType); } const image = row.querySelector(':scope > div:not([class])'); - if (image) image.classList.add('image'); - const img = image?.querySelector(':scope img'); - if (header && img?.alt === '') img.alt = header.textContent; + image?.classList.add('image'); const imageVideo = image?.querySelector('video'); if (imageVideo) applyHoverPlay(imageVideo); diff --git a/libs/blocks/region-nav/region-nav.css b/libs/blocks/region-nav/region-nav.css index b3a9859c0b..a996434f33 100644 --- a/libs/blocks/region-nav/region-nav.css +++ b/libs/blocks/region-nav/region-nav.css @@ -56,6 +56,10 @@ column-count: 1; } +.region-nav.hide { + display: none; +} + @media (min-width: 600px) { .region-nav > div:nth-of-type(2) { column-count: 3; diff --git a/libs/blocks/table/table.css b/libs/blocks/table/table.css index 87d889c3f0..19eb5beed9 100644 --- a/libs/blocks/table/table.css +++ b/libs/blocks/table/table.css @@ -47,6 +47,15 @@ justify-content: center; } +.table.top .section-row .col { + flex-direction: column; + justify-content: start; +} + +.table.top.left .section-row .col { + align-items: start; +} + .table:not(.merch) .col-1:not(:only-child) { background-color: var(--color-gray-100); } diff --git a/libs/blocks/video/video.css b/libs/blocks/video/video.css index f6ec58c065..6538e0596a 100644 --- a/libs/blocks/video/video.css +++ b/libs/blocks/video/video.css @@ -5,4 +5,153 @@ a[href*='.mp4'].hide-video { video { max-width: 100%; height: auto; + object-fit: cover; +} + +:is(.marquee, .aside.split) .pause-play-wrapper img.accessibility-control { + min-height: 40px; +} + +.video-container { + display: flex; + position: relative; + height: 100%; + width: fit-content; + margin: auto; +} + +:is(.aside, .marquee, .quiz-marquee) .video-container { + width: auto; +} + +.brick-media .video-container { + width: 100%; + height: 100%; + border-radius: inherit; +} + +.pause-play-wrapper { + display: flex; + width: fit-content; + margin: auto; +} + +.video-container .pause-play-wrapper { + position: absolute; + bottom: 2%; + right: 2%; + margin: 0; + justify-content: center; + align-items: center; + border-radius: 50%; + z-index: 2; + padding: 3px; + cursor: pointer; +} + +.video-container .pause-play-wrapper .offset-filler { + display: inherit; + justify-content: center; + align-items: center; + width: 40px; + height: 40px; + border-radius: inherit; + background: var(--color-gray-800); +} + +:is(.marquee:not(.light), .dark) .video-container .pause-play-wrapper { + padding: 1px; +} + +:is(.marquee:not(.light), .dark) .video-container .pause-play-wrapper:focus-visible { + background: #000; +} + +:is(.marquee:not(.light), .dark) .video-container .pause-play-wrapper .offset-filler { + border: 2px solid #fff; +} + +.video-container .pause-play-wrapper:focus-visible { + background: #fff; +} + +.video-container .pause-play-wrapper .offset-filler:hover { + background: #000; +} + +.video-container .pause-play-wrapper:focus-visible { + outline: var(--color-accent-focus-ring) solid 2px; +} + +.video-container .pause-play-wrapper .offset-filler.is-playing .play-icon, +.video-container .pause-play-wrapper .offset-filler:not(.is-playing) .pause-icon { + display: none; +} + +:is(.editorial-card, .hero-marquee, .marquee):not(:has(.video-container)) .pause-play-wrapper, +.editorial-card .video-container { + width: auto; + height: 100%; +} + +.brick .brick-media:not(:has(.video-container)) .pause-play-wrapper { + border-radius: 0; + border-top-right-radius: inherit; + border-bottom-right-radius: inherit; + width: auto; + height: 100%; + margin: 0; +} + +[dir="rtl"] .brick .brick-media:not(:has(.video-container)) .pause-play-wrapper { + border-top-left-radius: inherit; + border-bottom-left-radius: inherit; +} + +.hero-marquee .background .video-container { + position: inherit; +} + +:is(.video-container .pause-play-wrapper, .aside.split.split-left .split-image) img.accessibility-control { + width: auto; +} + +.video-container .pause-play-wrapper img.hidden { + display: none; +} + +.marquee .background .video-container { + display: contents; +} + +.how-to .how-to-media .video-container { + height: fit-content; +} + +@media (min-width: 600px) { + .media:not(.media-reverse-mobile, .media-reversed) .video-container .pause-play-wrapper, + :is(.marquee.row-reversed .asset, .marquee-anchors, .hero-marquee.asset-left) .video-container .pause-play-wrapper, + :is(.aside:not(.split), .aside.split.split-right) .video-container .pause-play-wrapper { + left: 2%; + } + + :is(.section[class*="-up"] .media .foreground .image:first-child, .aside .foreground .image:nth-last-child(1)) + .video-container .pause-play-wrapper { + left: auto; + right: 2%; + } + + [dir="rtl"] :is(.marquee.split:not(.row-reversed), .media:is(.media-reverse-mobile, .media-reversed), + .hero-marquee:not(.asset-left) :is([class*="foreground"]), .aside.split:not(.split-right), + .aside .foreground.container .image:nth-last-child(1), .brick.media-right, .how-to) .video-container .pause-play-wrapper { + left: 2%; + right: auto; + } +} + +@media (min-width: 600px) and (max-width: 1199px) { + .hero-marquee.asset-left .video-container.video-holder .pause-play-wrapper { + left: auto; + right: 2%; + } } diff --git a/libs/features/personalization/personalization.js b/libs/features/personalization/personalization.js index 9472ec413f..cbb87ba508 100644 --- a/libs/features/personalization/personalization.js +++ b/libs/features/personalization/personalization.js @@ -1,7 +1,9 @@ /* eslint-disable no-underscore-dangle */ /* eslint-disable no-console */ -import { createTag, getConfig, loadLink, loadScript, localizeLink } from '../../utils/utils.js'; +import { + createTag, getConfig, loadLink, loadScript, localizeLink, enablePersonalizationV2, +} from '../../utils/utils.js'; import { getFederatedUrl } from '../../utils/federated.js'; /* c8 ignore start */ @@ -467,7 +469,7 @@ function getSelectedElements(sel, rootEl, forceRootEl) { try { els = root.querySelectorAll(modifiedSelector); } catch (e) { - /* eslint-disable-next-line no-console */ + /* eslint-disable-next-line no-console */ log('Invalid selector: ', selector); return null; } @@ -773,7 +775,11 @@ async function getPersonalizationVariant(manifestPath, variantNames = [], varian let userEntitlements = []; if (hasEntitlementTag) { - userEntitlements = await config.entitlements(); + if (enablePersonalizationV2()) { + userEntitlements = []; + } else { + userEntitlements = await config.entitlements(); + } } const hasMatch = (name) => { @@ -1111,9 +1117,7 @@ export const combineMepSources = async (persEnabled, promoEnabled, mepParam) => return persManifests; }; -async function callMartech(config) { - const { getTargetPersonalization } = await import('../../martech/martech.js'); - const { targetManifests, targetPropositions } = await getTargetPersonalization(); +function updateManifestsAndPropositions({ config, targetManifests, targetPropositions }) { config.mep.targetManifests = targetManifests; if (targetPropositions?.length && window._satellite) { window._satellite.track('propositionDisplay', targetPropositions); @@ -1124,6 +1128,97 @@ async function callMartech(config) { } return targetManifests; } + +function roundToQuarter(num) { + return Math.ceil(num / 250) / 4; +} + +function calculateResponseTime(responseStart) { + const responseTime = Date.now() - responseStart; + return roundToQuarter(responseTime); +} + +function sendTargetResponseAnalytics(failure, responseStart, timeoutLocal, message) { + // temporary solution until we can decide on a better timeout value + const responseTime = calculateResponseTime(responseStart); + const timeoutTime = roundToQuarter(timeoutLocal); + let val = `target response time ${responseTime}:timed out ${failure}:timeout ${timeoutTime}`; + if (message) val += `:${message}`; + // eslint-disable-next-line no-underscore-dangle + window._satellite?.track?.('event', { + documentUnloading: true, + xdm: { + eventType: 'web.webinteraction.linkClicks', + web: { + webInteraction: { + linkClicks: { value: 1 }, + type: 'other', + name: val, + }, + }, + }, + data: { _adobe_corpnew: { digitalData: { primaryEvent: { eventInfo: { eventName: val } } } } }, + }); +} + +const handleAlloyResponse = (response) => ((response.propositions || response.decisions)) + ?.map((i) => i.items) + ?.flat() + ?.map((item) => { + const content = item?.data?.content; + if (!content || !(content.manifestLocation || content.manifestContent)) return null; + + return { + manifestPath: content.manifestLocation || content.manifestPath, + manifestUrl: content.manifestLocation, + manifestData: content.manifestContent?.experiences?.data || content.manifestContent?.data, + manifestPlaceholders: content.manifestContent?.placeholders?.data, + manifestInfo: content.manifestContent?.info.data, + name: item.meta['activity.name'], + variantLabel: item.meta['experience.name'] && `target-${item.meta['experience.name']}`, + meta: item.meta, + }; + }) + ?.filter(Boolean) ?? []; + +async function handleMartechTargetInteraction( + { config, targetInteractionPromise, calculatedTimeout }, +) { + let targetManifests = []; + let targetPropositions = []; + if (enablePersonalizationV2() && targetInteractionPromise) { + try { + const { targetInteractionData, respTime, respStartTime } = await targetInteractionPromise; + sendTargetResponseAnalytics(false, respStartTime, calculatedTimeout); + + const roundedResponseTime = roundToQuarter(respTime); + performance.clearMarks(); + performance.clearMeasures(); + try { + window.lana.log(`target response time: ${roundedResponseTime}`, { tags: 'martech', errorType: 'i' }); + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error logging target response time:', e); + } + targetManifests = handleAlloyResponse(targetInteractionData.result); + targetPropositions = targetInteractionData.result?.propositions || []; + } catch (err) { + console.log('Oops!! Interact Call didnt go through', err); + } + } + + return updateManifestsAndPropositions({ config, targetManifests, targetPropositions }); +} + +async function callMartech(config) { + const { getTargetPersonalization } = await import('../../martech/martech.js'); + const { + targetManifests, + targetPropositions, + } = await getTargetPersonalization({ handleAlloyResponse, sendTargetResponseAnalytics }); + return updateManifestsAndPropositions({ config, targetManifests, targetPropositions }); +} + const awaitMartech = () => new Promise((resolve) => { const listener = (event) => resolve(event.detail); window.addEventListener(MARTECH_RETURNED_EVENT, listener, { once: true }); @@ -1132,7 +1227,8 @@ const awaitMartech = () => new Promise((resolve) => { export async function init(enablements = {}) { let manifests = []; const { - mepParam, mepHighlight, mepButton, pzn, promo, target, postLCP, + mepParam, mepHighlight, mepButton, pzn, promo, + target, targetInteractionPromise, calculatedTimeout, postLCP, } = enablements; const config = getConfig(); if (postLCP) { @@ -1157,11 +1253,17 @@ export async function init(enablements = {}) { if (pzn) loadLink(getXLGListURL(config), { as: 'fetch', crossorigin: 'anonymous', rel: 'preload' }); } - if (target === true) manifests = manifests.concat(await callMartech(config)); - if (target === 'postlcp') callMartech(config); - if (postLCP) { - if (!config.mep.targetManifests) await awaitMartech(); - manifests = config.mep.targetManifests; + if (enablePersonalizationV2()) { + manifests = manifests.concat(await handleMartechTargetInteraction( + { config, targetInteractionPromise, calculatedTimeout }, + )); + } else { + if (target === true) manifests = manifests.concat(await callMartech(config)); + if (target === 'postlcp') callMartech(config); + if (postLCP) { + if (!config.mep.targetManifests) await awaitMartech(); + manifests = config.mep.targetManifests; + } } if (!manifests || !manifests.length) return; try { diff --git a/libs/features/webapp-prompt/webapp-prompt.js b/libs/features/webapp-prompt/webapp-prompt.js index ea9bc072d0..894d010d0b 100644 --- a/libs/features/webapp-prompt/webapp-prompt.js +++ b/libs/features/webapp-prompt/webapp-prompt.js @@ -1,5 +1,4 @@ import { - getFedsPlaceholderConfig, getUserProfile, icons, lanaLog, @@ -7,6 +6,7 @@ import { } from '../../blocks/global-navigation/utilities/utilities.js'; import { getConfig, decorateSVG } from '../../utils/utils.js'; import { replaceKey, replaceText } from '../placeholders.js'; +import { getFedsPlaceholderConfig } from '../../utils/federated.js'; export const DISMISSAL_CONFIG = { animationCount: 2, diff --git a/libs/martech/helpers.js b/libs/martech/helpers.js new file mode 100644 index 0000000000..25760ba57a --- /dev/null +++ b/libs/martech/helpers.js @@ -0,0 +1,424 @@ +/** + * Generates a random UUIDv4 using cryptographically secure random values. + * This implementation follows the RFC 4122 specification for UUIDv4. + * It uses the `crypto` API for secure randomness without any bitwise operators. + * + * @returns {string} A random UUIDv4 string, e.g., 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx' + * where: + * - 'x' is any hexadecimal digit (0-9, a-f) + * - 'y' is one of 8, 9, A, or B, ensuring that the UUID conforms to version 4. + * + * @example + * const myUuid = generateUUIDv4(); + * console.log(myUuid); // Outputs: 'e8b57e2f-8cb1-4d0f-804b-e1a45bce2d90' + */ +function generateUUIDv4() { + // Generate an array of 16 random values using the crypto API for better randomness + const randomValues = new Uint8Array(16); + crypto.getRandomValues(randomValues); + + // Set the version (4) at the 13th position + randomValues[6] = (randomValues[6] % 16) + 64; // '4' for version 4 + // Set the variant (8, 9, A, or B) at the 17th position + randomValues[8] = (randomValues[8] % 16) + 128; // One of 8, 9, A, or B + + // Accumulate the UUID string in a separate variable (to avoid modifying the parameter directly) + let uuid = ''; + + // Convert the random values to a UUID string (xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx) + randomValues.forEach((byte, index) => { + const hex = byte.toString(16).padStart(2, '0'); // Convert byte to hex + if (index === 4 || index === 6 || index === 8 || index === 10) { + uuid += '-'; // Add dashes at appropriate positions + } + uuid += hex; + }); + + return uuid; +} + +/** + * Determines the Adobe Target property value based on the page's region. + * + * @param {string} env - The environment (e.g., 'prod' for production, 'dev' for development). + * @returns {string} Adobe Target property value. + */ +function getTargetPropertyBasedOnPageRegion(env) { + const { pathname } = window.location; + + if (env !== 'prod') return 'bc8dfa27-29cc-625c-22ea-f7ccebfc6231'; // Default for non-prod environments + + // EMEA & LATAM + if ( + pathname.search( + /(\/africa\/|\/be_en\/|\/be_fr\/|\/be_nl\/|\/cis_en\/|\/cy_en\/|\/dk\/|\/de\/|\/ee\/|\/es\/|\/fr\/|\/gr_en\/|\/ie\/|\/il_en\/|\/it\/|\/lv\/|\/lu_de\/|\/lu_en\/|\/lu_fr\/|\/hu\/|\/mt\/|\/mena_en\/|\/nl\/|\/no\/|\/pl\/|\/pt\/|\/ro\/|\/ch_de\/|\/si\/|\/sk\/|\/ch_fr\/|\/fi\/|\/se\/|\/ch_it\/|\/tr\/|\/uk\/|\/at\/|\/cz\/|\/bg\/|\/ru\/|\/cis_ru\/|\/ua\/|\/il_he\/|\/mena_ar\/|\/lt\/|\/sa_en\/|\/ae_en\/|\/ae_ar\/|\/sa_ar\/|\/ng\/|\/za\/|\/qa_ar\/|\/eg_en\/|\/eg_ar\/|\/kw_ar\/|\/eg_ar\/|\/qa_en\/|\/kw_en\/|\/gr_el\/|\/br\/|\/cl\/|\/la\/|\/mx\/|\/co\/|\/ar\/|\/pe\/|\/gt\/|\/pr\/|\/ec\/|\/cr\/)/, + ) !== -1 + ) { + return '488edf5f-3cbe-f410-0953-8c0c5c323772'; + } + if ( // APAC + pathname.search( + /(\/au\/|\/hk_en\/|\/in\/|\/nz\/|\/sea\/|\/cn\/|\/hk_zh\/|\/tw\/|\/kr\/|\/sg\/|\/th_en\/|\/th_th\/|\/my_en\/|\/my_ms\/|\/ph_en\/|\/ph_fil\/|\/vn_en\/|\/vn_vi\/|\/in_hi\/|\/id_id\/|\/id_en\/)/, + ) !== -1 + ) { + return '3de509ee-bbc7-58a3-0851-600d1c2e2918'; + } + // JP + if (pathname.indexOf('/jp/') !== -1) { + return 'ba5bc9e8-8fb4-037a-12c8-682384720007'; + } + + return 'bc8dfa27-29cc-625c-22ea-f7ccebfc6231'; // Default +} + +/** + * Retrieves device-related information such as screen and viewport dimensions. + * + * @returns {Object} Object containing device and viewport information. + */ +function getDeviceInfo() { + return { + screenWidth: window.screen.width, + screenHeight: window.screen.height, + screenOrientation: window.innerWidth > window.innerHeight ? 'landscape' : 'portrait', + viewportWidth: window.innerWidth, + viewportHeight: window.innerHeight, + }; +} + +/** + * Retrieves the value of a specific cookie by its key. + * + * @param {string} key - The cookie key. + * @returns {string|null} The cookie value, or null if the cookie doesn't exist. + */ +function getCookie(key, sendFullCookie) { + const cookie = document.cookie.split(';') + .map((x) => x.trim().split('=')) + .find(([k]) => k === key); + + if (sendFullCookie) { + return cookie; + } + return cookie ? cookie[1] : null; +} + +/** + * Sets a cookie with a specified expiration time (default 730 days). + * + * @param {string} key - The cookie key. + * @param {string} value - The cookie value. + * @param {Object} [options={}] - Optional settings for cookie properties. + * Defaults to an expiration of 730 days. + */ +function setCookie(key, value, options = {}) { + // Default expiration (24 months) + const expires = options.expires || 730; + const date = new Date(); + date.setTime(date.getTime() + expires * 24 * 60 * 60 * 1000); + const expiresString = `expires=${date.toUTCString()}`; + + document.cookie = `${key}=${value}; ${expiresString}; path=/`; +} + +/** + * Retrieves the ECID (Experience Cloud ID) from the browser's cookies or + * generates a new FPID (First Party ID) if the ECID is not found. Returns + * the ID in a structured object, depending on which ID is available. + * + * @returns {Object} An object containing either the ECID or FPID. + * - If ECID is found, the object will be: + * { ECID: [{ id: string, authenticatedState: string, primary: boolean }] } + * - If ECID is not found, the object will be: + * { FPID: [{ id: string, authenticatedState: string, primary: boolean }] } + */ +function getOrGenerateUserId() { + const experienceCloudCookieName = 'AMCV_9E1005A551ED61CA0A490D45%40AdobeOrg'; + const amcvCookieValue = getCookie(experienceCloudCookieName); + + // If ECID is not found, generate and return FPID + if (!amcvCookieValue) { + const fpidValue = generateUUIDv4(); + return { + FPID: [{ + id: fpidValue, + authenticatedState: 'ambiguous', + primary: true, + }], + }; + } + + // ECID found, return structured ECID object + const extractedEcid = amcvCookieValue.substring(6); // Extract the ECID value from the cookie + return { + ECID: [{ + id: extractedEcid, + authenticatedState: 'ambiguous', + primary: true, + }], + }; +} + +/** + * Retrieves the page name for analytics, modified for the current locale. + * + * @param {Object} params - The parameters. + * @param {Object} params.locale - The locale object containing + * language/region info (e.g., { ietf: 'en-US', prefix: 'us' }). + * @returns {string} The modified page name. + */ +function getPageNameForAnalytics({ locale }) { + const { host, pathname } = new URL(window.location.href); + const [modifiedPath] = pathname.split('/').filter((x) => x !== locale.prefix).join(':').split('.'); + return `${host.replace('www.', '')}:${modifiedPath}`; +} + +/** + * Creates the updated context for the request payload for analytics or personalization requests. + * + * @param {number} screenWidth - Screen width. + * @param {number} screenHeight - Screen height. + * @param {string} screenOrientation - Orientation of the screen. + * @param {number} viewportWidth - Viewport width. + * @param {number} viewportHeight - Viewport height. + * @param {string} localTime - The local time in ISO format. + * @param {number} timezoneOffset - The timezone offset. + * @returns {Object} The updated context for the request payload. + */ +function getUpdatedContext({ + screenWidth, screenHeight, screenOrientation, + viewportWidth, viewportHeight, localTime, timezoneOffset, +}) { + return { + device: { + screenHeight, + screenWidth, + screenOrientation, + }, + environment: { + type: 'browser', + browserDetails: { + viewportWidth, + viewportHeight, + }, + }, + placeContext: { + localTime, + localTimezoneOffset: timezoneOffset, + }, + }; +} + +/** + * Retrieves specific MarTech cookies by their keys. + * + * @returns {Array} List of MarTech cookies with each + * object containing 'key' and 'value' properties. + */ +const getMarctechCookies = () => { + const KNDCTR_COOKIE_KEYS = [ + 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_identity', + 'kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_cluster', + ]; + return document.cookie.split(';') + .map((x) => x.trim().split('=')) + .filter(([key]) => KNDCTR_COOKIE_KEYS.includes(key)) + .map(([key, value]) => ({ key, value })); +}; + +/** + * Creates the request payload for Adobe Analytics and Target. + * + * @param {Object} params - Parameters required to create the payload. + * @param {Object} params.updatedContext - The updated context for the request. + * @param {string} params.pageName - The page name for the analytics request. + * @param {Object} params.locale - The locale object containing language/region info. + * @param {string} params.env - The environment (e.g., 'prod' for production). + * @returns {Object} The request payload for Adobe Analytics and Target. + */ +function createRequestPayload({ updatedContext, pageName, locale, env }) { + const prevPageName = getCookie('gpv'); + + const REPORT_SUITES_ID = env === 'prod' ? ['adbadobenonacdcprod'] : ['adbadobenonacdcqa']; + const AT_PROPERTY_VAL = getTargetPropertyBasedOnPageRegion(env); + + return { + event: { + xdm: { + ...updatedContext, + identityMap: getOrGenerateUserId(), + web: { + webPageDetails: { + URL: window.location.href, + siteSection: 'www.adobe.com', + server: 'www.adobe.com', + isErrorPage: false, + isHomePage: false, + name: pageName, + pageViews: { value: 0 }, + }, + webInteraction: { + name: 'Martech-API', + type: 'other', + linkClicks: { value: 1 }, + }, + webReferrer: { URL: document.referrer }, + }, + timestamp: new Date().toISOString(), + eventType: 'decisioning.propositionFetch', + }, + data: { + __adobe: { + target: { + is404: false, authState: 'loggedOut', hitType: 'propositionFetch', isMilo: true, adobeLocale: locale.ietf, hasGnav: true, + }, + }, + _adobe_corpnew: { + marketingtech: { adobe: { alloy: { approach: 'martech-API' } } }, + digitalData: { + page: { pageInfo: { language: locale.ietf } }, + diagnostic: { franklin: { implementation: 'milo' } }, + previousPage: { pageInfo: { pageName: prevPageName } }, + primaryUser: { primaryProfile: { profileInfo: { authState: 'loggedOut', returningStatus: 'Repeat' } } }, + }, + }, + }, + }, + query: { + identity: { fetch: ['ECID'] }, + personalization: { + schemas: [ + 'https://ns.adobe.com/personalization/default-content-item', + 'https://ns.adobe.com/personalization/html-content-item', + 'https://ns.adobe.com/personalization/json-content-item', + 'https://ns.adobe.com/personalization/redirect-item', + 'https://ns.adobe.com/personalization/dom-action', + ], + decisionScopes: ['__view__'], + }, + }, + meta: { + target: { migration: true }, + configOverrides: { + com_adobe_analytics: { reportSuites: REPORT_SUITES_ID }, + com_adobe_target: { propertyToken: AT_PROPERTY_VAL }, + }, + state: { + domain: 'localhost', + cookiesEnabled: true, + entries: getMarctechCookies(), + }, + }, + }; +} + +/** + * Extracts the ECID (Experience Cloud ID) from the API response data. + * + * @param {Object} data - The response data from the API. + * @returns {string|null} The ECID value, or null if not found. + */ +function extractECID(data) { + return data.handle + .flatMap((item) => item.payload) + .find((p) => p.namespace?.code === 'ECID')?.id || null; +} + +/** + * Updates the AMCV cookie with the new ECID. + * + * @param {string} ECID - The Experience Cloud ID (ECID). + */ +function updateAMCVCookie(ECID) { + const cookieName = 'AMCV_9E1005A551ED61CA0A490D45%40AdobeOrg'; + const cookieValue = `MCMID|${ECID}`; + + if (getCookie(cookieName) !== cookieValue) { + setCookie(cookieName, `MCMID|${ECID}`); + } +} + +/** + * Loads analytics and interaction data based on the user and page context. + * Sends the data to Adobe Analytics and Adobe Target for personalization. + * + * @param {Object} params - The parameters for the function. + * @param {Object} params.locale - The locale object containing language/region info. + * @param {string} params.env - The environment (e.g., 'prod' for production). + * @param {string} [params.calculatedTimeout] - timeout value for the request in milliseconds. + * + * @returns {Promise} A promise that resolves to the + * personalization propositions fetched from Adobe Target. + */ +export const loadAnalyticsAndInteractionData = async ({ locale, env, calculatedTimeout }) => { + const value = getCookie('kndctr_9E1005A551ED61CA0A490D45_AdobeOrg_consent', true); + if (value?.[1] === 'general' && value?.[2] === 'out') { + return Promise.reject(new Error('Consent Cookie doesnt allow interact')); + } + + // Define constants based on environment + const DATA_STREAM_ID = env === 'prod' ? '5856abb0-95d8-4f9a-bb92-37f99d2bd492' : '87f9b644-5fd3-4015-81d5-f68ad81c3561'; + const TARGET_API_URL = 'https://edge.adobedc.net/ee/v2/interact'; + + // Device and viewport information + const { + screenWidth, screenHeight, + screenOrientation, viewportWidth, viewportHeight, + } = getDeviceInfo(); + + // Date and Time Constants + const CURRENT_DATE = new Date(); + const LOCAL_TIME = CURRENT_DATE.toISOString(); + const LOCAL_TIMEZONE_OFFSET = CURRENT_DATE.getTimezoneOffset(); + + const pageName = getPageNameForAnalytics({ locale }); + + const updatedContext = getUpdatedContext({ + screenWidth, + screenHeight, + screenOrientation, + viewportWidth, + viewportHeight, + LOCAL_TIME, + LOCAL_TIMEZONE_OFFSET, + }); + + // Prepare the body for the request + const requestBody = createRequestPayload({ + updatedContext, + pageName, + locale, + env, + }); + + try { + const targetResp = await Promise.race([ + fetch(`${TARGET_API_URL}?dataStreamId=${DATA_STREAM_ID}&requestId=${generateUUIDv4()}`, { + method: 'POST', + body: JSON.stringify(requestBody), + }), + new Promise((_, reject) => { setTimeout(() => reject(new Error('Request timed out')), calculatedTimeout); }), + ]); + + if (!targetResp.ok) { + throw new Error('Failed to fetch interact call'); + } + const targetRespJson = await targetResp.json(); + const ECID = extractECID(targetRespJson); + + // Update the AMCV cookie with ECID + updateAMCVCookie(ECID); + + // Resolve or reject based on propositions + const resultPayload = targetRespJson?.handle?.find((d) => d.type === 'personalization:decisions')?.payload; + if (resultPayload.length === 0) throw new Error('No propositions found'); + return { + type: 'propositionFetch', + result: { propositions: resultPayload }, + }; + } catch (err) { + throw new Error(err); + } +}; + +export default { loadAnalyticsAndInteractionData }; diff --git a/libs/martech/martech.js b/libs/martech/martech.js index 74df28252c..9cddce3358 100644 --- a/libs/martech/martech.js +++ b/libs/martech/martech.js @@ -1,12 +1,17 @@ import { - getConfig, getMetadata, loadIms, loadLink, loadScript, getMepEnablement, + getConfig, loadIms, loadLink, loadScript, getMepEnablement, getMetadata, } from '../utils/utils.js'; const ALLOY_SEND_EVENT = 'alloy_sendEvent'; const ALLOY_SEND_EVENT_ERROR = 'alloy_sendEvent_error'; -const TARGET_TIMEOUT_MS = 4000; const ENTITLEMENT_TIMEOUT = 3000; +const TARGET_TIMEOUT_MS = 4000; +const params = new URL(window.location.href).searchParams; +const timeout = parseInt(params.get('target-timeout'), 10) + || parseInt(getMetadata('target-timeout'), 10) + || TARGET_TIMEOUT_MS; + const setDeep = (obj, path, value) => { const pathArr = path.split('.'); let currentObj = obj; @@ -22,7 +27,7 @@ const setDeep = (obj, path, value) => { }; // eslint-disable-next-line max-len -const waitForEventOrTimeout = (eventName, timeout, returnValIfTimeout) => new Promise((resolve) => { +const waitForEventOrTimeout = (eventName, timeoutLocal, returnValIfTimeout) => new Promise((resolve) => { const listener = (event) => { // eslint-disable-next-line no-use-before-define clearTimeout(timer); @@ -42,40 +47,12 @@ const waitForEventOrTimeout = (eventName, timeout, returnValIfTimeout) => new Pr } else { resolve({ timeout: true }); } - }, timeout); + }, timeoutLocal); window.addEventListener(eventName, listener, { once: true }); window.addEventListener(ALLOY_SEND_EVENT_ERROR, errorListener, { once: true }); }); -const handleAlloyResponse = (response) => { - const items = ( - (response.propositions?.length && response.propositions) - || (response.decisions?.length && response.decisions) - || [] - ).map((i) => i.items).flat(); - - if (!items?.length) return []; - - return items - .map((item) => { - const content = item?.data?.content; - if (!content || !(content.manifestLocation || content.manifestContent)) return null; - - return { - manifestPath: content.manifestLocation || content.manifestPath, - manifestUrl: content.manifestLocation, - manifestData: content.manifestContent?.experiences?.data || content.manifestContent?.data, - manifestPlaceholders: content.manifestContent?.placeholders?.data, - manifestInfo: content.manifestContent?.info.data, - name: item.meta['activity.name'], - variantLabel: item.meta['experience.name'] && `target-${item.meta['experience.name']}`, - meta: item.meta, - }; - }) - .filter(Boolean); -}; - function roundToQuarter(num) { return Math.ceil(num / 250) / 4; } @@ -85,36 +62,9 @@ function calculateResponseTime(responseStart) { return roundToQuarter(responseTime); } -function sendTargetResponseAnalytics(failure, responseStart, timeout, message) { - // temporary solution until we can decide on a better timeout value - const responseTime = calculateResponseTime(responseStart); - const timeoutTime = roundToQuarter(timeout); - let val = `target response time ${responseTime}:timed out ${failure}:timeout ${timeoutTime}`; - if (message) val += `:${message}`; - // eslint-disable-next-line no-underscore-dangle - window._satellite?.track?.('event', { - documentUnloading: true, - xdm: { - eventType: 'web.webinteraction.linkClicks', - web: { - webInteraction: { - linkClicks: { value: 1 }, - type: 'other', - name: val, - }, - }, - }, - data: { _adobe_corpnew: { digitalData: { primaryEvent: { eventInfo: { eventName: val } } } } }, - }); -} - -export const getTargetPersonalization = async () => { - const params = new URL(window.location.href).searchParams; - - const timeout = parseInt(params.get('target-timeout'), 10) - || parseInt(getMetadata('target-timeout'), 10) - || TARGET_TIMEOUT_MS; - +export const getTargetPersonalization = async ( + { handleAlloyResponse, sendTargetResponseAnalytics }, +) => { const responseStart = Date.now(); window.addEventListener(ALLOY_SEND_EVENT, () => { const responseTime = calculateResponseTime(responseStart); @@ -128,6 +78,7 @@ export const getTargetPersonalization = async () => { let targetManifests = []; let targetPropositions = []; + const response = await waitForEventOrTimeout(ALLOY_SEND_EVENT, timeout); if (response.error) { try { @@ -179,7 +130,7 @@ const setupEntitlementCallback = () => { }; function isProxied() { - return /^(www|milo|business|blog)(\.stage)?\.adobe\.com$/.test(window.location.hostname); + return /^(www|milo|business|blog|news)(\.stage)?\.adobe\.com$/.test(window.location.hostname); } let filesLoadedPromise = false; @@ -194,7 +145,7 @@ const loadMartechFiles = async (config) => { .then(() => { if (window.adobeIMS.isSignedInUser()) setupEntitlementCallback(); }) - .catch(() => {}); + .catch(() => { }); } setDeep( diff --git a/libs/navigation/base.css b/libs/navigation/base.css new file mode 100644 index 0000000000..6df2730955 --- /dev/null +++ b/libs/navigation/base.css @@ -0,0 +1 @@ +@import '../blocks/global-navigation/base.css'; diff --git a/libs/navigation/bootstrapper.js b/libs/navigation/bootstrapper.js index 4f19bb3ffd..55778464cc 100644 --- a/libs/navigation/bootstrapper.js +++ b/libs/navigation/bootstrapper.js @@ -1,10 +1,7 @@ -export default async function bootstrapBlock(miloLibs, blockConfig) { +/* eslint import/no-relative-packages: 0 */ +export default async function bootstrapBlock(initBlock, blockConfig) { const { name, targetEl, layout, noBorder, jarvis } = blockConfig; - const { getConfig, createTag, loadLink, loadScript } = await import(`${miloLibs}/utils/utils.js`); - const { default: initBlock } = await import(`${miloLibs}/blocks/${name}/${name}.js`); - - const styles = [`${miloLibs}/blocks/${name}/${name}.css`, `${miloLibs}/navigation/navigation.css`]; - styles.forEach((url) => loadLink(url, { rel: 'stylesheet' })); + const { getConfig, createTag, loadScript } = await import('../utils/utils.js'); const setNavLayout = () => { const element = document.querySelector(targetEl); @@ -41,7 +38,7 @@ export default async function bootstrapBlock(miloLibs, blockConfig) { await initBlock(document.querySelector(targetEl)); if (blockConfig.targetEl === 'footer') { - const { loadPrivacy } = await import(`${miloLibs}/scripts/delayed.js`); + const { loadPrivacy } = await import('../scripts/delayed.js'); setTimeout(() => { loadPrivacy(getConfig, loadScript); }, blockConfig.delay); diff --git a/libs/navigation/build.mjs b/libs/navigation/build.mjs new file mode 100755 index 0000000000..f94fb1f657 --- /dev/null +++ b/libs/navigation/build.mjs @@ -0,0 +1,57 @@ +import * as esbuild from 'esbuild'; // eslint-disable-line +import fs from 'node:fs'; + +fs.rmSync('./dist/', { recursive: true, force: true }); + +await esbuild.build({ + entryPoints: ['navigation.css', 'footer.css', 'dark-nav.css', 'base.css'], + bundle: true, + minify: true, + outdir: './dist/', +}); + +// This function behaves slightly different +// than the built in split function in +// that it only splits the array xs into two arrays +// on the first occurence of y only +const splitAt = (xs, y) => { + if (!xs.length) return null; + const splitInternal = (before, after) => { + if (!after.length) return [before, []]; + const [x, ...rest] = after; + if (x === y) return [before, rest]; + return splitInternal(before.concat([x]), rest); + }; + return splitInternal([], xs); +}; + +const StyleLoader = { + name: 'inline-style', + setup({ onLoad }) { + const template = (css) => ` + typeof document<'u'&& + document.head + .appendChild(document.createElement('style')) + .appendChild(document.createTextNode(${JSON.stringify(css)}))`; + onLoad({ filter: /\.css$/ }, async (args) => { + const { path } = args; + const [before, after] = splitAt(path.split('/'), 'navigation'); + const newPath = before + .concat(['navigation', 'dist']) + .concat(after) + .join('/'); + const css = await fs.promises.readFile(newPath, 'utf8'); + return { contents: template(css) }; + }); + }, +}; + +await esbuild.build({ + entryPoints: ['navigation.js'], + bundle: true, + splitting: true, + format: 'esm', + sourcemap: true, + outdir: './dist/', + plugins: [StyleLoader], +}); diff --git a/libs/navigation/dark-nav.css b/libs/navigation/dark-nav.css new file mode 100644 index 0000000000..8cf31dba0e --- /dev/null +++ b/libs/navigation/dark-nav.css @@ -0,0 +1 @@ +@import '../blocks/global-navigation/dark-nav.css'; diff --git a/libs/navigation/footer.css b/libs/navigation/footer.css new file mode 100644 index 0000000000..802e676252 --- /dev/null +++ b/libs/navigation/footer.css @@ -0,0 +1,2 @@ +@import '../blocks/global-footer/global-footer.css'; +@import '../blocks/modal/modal.css'; diff --git a/libs/navigation/navigation.css b/libs/navigation/navigation.css index cca872ed0b..ce0aae2d06 100644 --- a/libs/navigation/navigation.css +++ b/libs/navigation/navigation.css @@ -1,3 +1,8 @@ +@import '../blocks/global-navigation/global-navigation.css'; +@import '../blocks/global-navigation/features/profile/dropdown.css'; +@import '../blocks/global-navigation/features/search/gnav-search.css'; +@import '../blocks/global-navigation/utilities/menu/menu.css'; + /* Extracting the essential styles required for rendering the component independently */ :root { --navigation-link-color: #035FE6; diff --git a/libs/navigation/navigation.js b/libs/navigation/navigation.js index d54e439eaa..3c26aabb6d 100644 --- a/libs/navigation/navigation.js +++ b/libs/navigation/navigation.js @@ -1,3 +1,5 @@ +import { loadStyle } from '../utils/utils.js'; + const blockConfig = [ { key: 'header', @@ -39,6 +41,9 @@ const getStageDomainsMap = (stageDomainsMap) => ( } ); +// Production Domain +const prodDomains = ['milo.adobe.com', 'business.adobe.com', 'www.adobe.com', 'adobecom.github.io']; + function getParamsConfigs(configs) { return blockConfig.reduce((acc, block) => { block.params.forEach((param) => { @@ -51,6 +56,7 @@ function getParamsConfigs(configs) { }, {}); } +/* eslint import/no-relative-packages: 0 */ export default async function loadBlock(configs, customLib) { const { header, @@ -59,23 +65,43 @@ export default async function loadBlock(configs, customLib) { env = 'prod', locale = '', theme, + allowedOrigins, stageDomainsMap = {}, } = configs || {}; - const branch = new URLSearchParams(window.location.search).get('navbranch'); - const miloLibs = branch ? `https://${branch}--milo--adobecom.aem.page` : customLib || envMap[env]; if (!header && !footer) { // eslint-disable-next-line no-console console.error('Global navigation Error: header and footer configurations are missing.'); return; } - // Relative path can't be used, as the script will run on consumer's app + const branch = new URLSearchParams(window.location.search).get('navbranch'); + const miloLibs = branch ? `https://${branch}--milo--adobecom.aem.page` : customLib || envMap[env]; + + // The below css imports will fail when using the non-bundled standalone gnav + // and fallback to using loadStyle. On the other hand, the bundler will rewrite + // the css imports to attach the styles to the head (and point to the dist folder + // using the custom StyleLoader plugin found in build.mjs + try { + await import('./base.css'); + if (theme === 'dark') { + await import('./dark-nav.css'); + } + await import('./navigation.css'); + } catch (e) { + if (theme === 'dark') { + loadStyle(`${miloLibs}/libs/navigation/base.css`, () => loadStyle(`${miloLibs}/libs/navigation/dark-nav.css`)); + } else { + loadStyle(`${miloLibs}/libs/navigation/base.css`); + } + loadStyle(`${miloLibs}/libs/navigation/navigation.css`); + } + + // Relative paths work just fine since they exist in the context of this file's origin const [{ default: bootstrapBlock }, { default: locales }, { setConfig }] = await Promise.all([ - import(`${miloLibs}/libs/navigation/bootstrapper.js`), - import(`${miloLibs}/libs/utils/locales.js`), - import(`${miloLibs}/libs/utils/utils.js`), + import('./bootstrapper.js'), + import('../utils/locales.js'), + import('../utils/utils.js'), ]); - - const paramConfigs = getParamsConfigs(configs, miloLibs); + const paramConfigs = getParamsConfigs(configs); const clientConfig = { clientEnv: env, origin: `https://main--federal--adobecom.aem.${env === 'prod' ? 'live' : 'page'}`, @@ -85,6 +111,9 @@ export default async function loadBlock(configs, customLib) { contentRoot: authoringPath || footer.authoringPath, theme, ...paramConfigs, + prodDomains, + allowedOrigins, + standaloneGnav: true, stageDomainsMap: getStageDomainsMap(stageDomainsMap), }; setConfig(clientConfig); @@ -92,16 +121,25 @@ export default async function loadBlock(configs, customLib) { const configBlock = configs[block.key]; try { if (configBlock) { - await bootstrapBlock(`${miloLibs}/libs`, { - ...block, - ...(block.key === 'header' && { + if (block.key === 'header') { + const { default: init } = await import('../blocks/global-navigation/global-navigation.js'); + await bootstrapBlock(init, { + ...block, unavComponents: configBlock.unav?.unavComponents, redirect: configBlock.redirect, layout: configBlock.layout, noBorder: configBlock.noBorder, jarvis: configBlock.jarvis, - }), - }); + }); + } else if (block.key === 'footer') { + try { + await import('./footer.css'); + } catch (e) { + loadStyle(`${miloLibs}/libs/navigation/footer.css`); + } + const { default: init } = await import('../blocks/global-footer/global-footer.js'); + await bootstrapBlock(init, { ...block }); + } configBlock.onReady?.(); } } catch (e) { diff --git a/libs/navigation/package-lock.json b/libs/navigation/package-lock.json new file mode 100644 index 0000000000..f29d7ac225 --- /dev/null +++ b/libs/navigation/package-lock.json @@ -0,0 +1,439 @@ +{ + "name": "navigation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "navigation", + "version": "1.0.0", + "license": "ISC", + "devDependencies": { + "esbuild": "0.24.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.24.0.tgz", + "integrity": "sha512-WtKdFM7ls47zkKHFVzMz8opM7LkcsIp9amDUBIAWirg70RM71WRSjdILPsY5Uv1D42ZpUfaPILDlfactHgsRkw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.24.0.tgz", + "integrity": "sha512-arAtTPo76fJ/ICkXWetLCc9EwEHKaeya4vMrReVlEIUCAUncH7M4bhMQ+M9Vf+FFOZJdTNMXNBrWwW+OXWpSew==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.24.0.tgz", + "integrity": "sha512-Vsm497xFM7tTIPYK9bNTYJyF/lsP590Qc1WxJdlB6ljCbdZKU9SY8i7+Iin4kyhV/KV5J2rOKsBQbB77Ab7L/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.24.0.tgz", + "integrity": "sha512-t8GrvnFkiIY7pa7mMgJd7p8p8qqYIz1NYiAoKc75Zyv73L3DZW++oYMSHPRarcotTKuSs6m3hTOa5CKHaS02TQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.24.0.tgz", + "integrity": "sha512-CKyDpRbK1hXwv79soeTJNHb5EiG6ct3efd/FTPdzOWdbZZfGhpbcqIpiD0+vwmpu0wTIL97ZRPZu8vUt46nBSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.24.0.tgz", + "integrity": "sha512-rgtz6flkVkh58od4PwTRqxbKH9cOjaXCMZgWD905JOzjFKW+7EiUObfd/Kav+A6Gyud6WZk9w+xu6QLytdi2OA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.24.0.tgz", + "integrity": "sha512-6Mtdq5nHggwfDNLAHkPlyLBpE5L6hwsuXZX8XNmHno9JuL2+bg2BX5tRkwjyfn6sKbxZTq68suOjgWqCicvPXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.24.0.tgz", + "integrity": "sha512-D3H+xh3/zphoX8ck4S2RxKR6gHlHDXXzOf6f/9dbFt/NRBDIE33+cVa49Kil4WUjxMGW0ZIYBYtaGCa2+OsQwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.24.0.tgz", + "integrity": "sha512-gJKIi2IjRo5G6Glxb8d3DzYXlxdEj2NlkixPsqePSZMhLudqPhtZ4BUrpIuTjJYXxvF9njql+vRjB2oaC9XpBw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.24.0.tgz", + "integrity": "sha512-TDijPXTOeE3eaMkRYpcy3LarIg13dS9wWHRdwYRnzlwlA370rNdZqbcp0WTyyV/k2zSxfko52+C7jU5F9Tfj1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.24.0.tgz", + "integrity": "sha512-K40ip1LAcA0byL05TbCQ4yJ4swvnbzHscRmUilrmP9Am7//0UjPreh4lpYzvThT2Quw66MhjG//20mrufm40mA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.24.0.tgz", + "integrity": "sha512-0mswrYP/9ai+CU0BzBfPMZ8RVm3RGAN/lmOMgW4aFUSOQBjA31UP8Mr6DDhWSuMwj7jaWOT0p0WoZ6jeHhrD7g==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.24.0.tgz", + "integrity": "sha512-hIKvXm0/3w/5+RDtCJeXqMZGkI2s4oMUGj3/jM0QzhgIASWrGO5/RlzAzm5nNh/awHE0A19h/CvHQe6FaBNrRA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.24.0.tgz", + "integrity": "sha512-HcZh5BNq0aC52UoocJxaKORfFODWXZxtBaaZNuN3PUX3MoDsChsZqopzi5UupRhPHSEHotoiptqikjN/B77mYQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.24.0.tgz", + "integrity": "sha512-bEh7dMn/h3QxeR2KTy1DUszQjUrIHPZKyO6aN1X4BCnhfYhuQqedHaa5MxSQA/06j3GpiIlFGSsy1c7Gf9padw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.24.0.tgz", + "integrity": "sha512-ZcQ6+qRkw1UcZGPyrCiHHkmBaj9SiCD8Oqd556HldP+QlpUIe2Wgn3ehQGVoPOvZvtHm8HPx+bH20c9pvbkX3g==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.24.0.tgz", + "integrity": "sha512-vbutsFqQ+foy3wSSbmjBXXIJ6PL3scghJoM8zCL142cGaZKAdCZHyf+Bpu/MmX9zT9Q0zFBVKb36Ma5Fzfa8xA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.24.0.tgz", + "integrity": "sha512-hjQ0R/ulkO8fCYFsG0FZoH+pWgTTDreqpqY7UnQntnaKv95uP5iW3+dChxnx7C3trQQU40S+OgWhUVwCjVFLvg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.24.0.tgz", + "integrity": "sha512-MD9uzzkPQbYehwcN583yx3Tu5M8EIoTD+tUgKF982WYL9Pf5rKy9ltgD0eUgs8pvKnmizxjXZyLt0z6DC3rRXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.24.0.tgz", + "integrity": "sha512-4ir0aY1NGUhIC1hdoCzr1+5b43mw99uNwVzhIq1OY3QcEwPDO3B7WNXBzaKY5Nsf1+N11i1eOfFcq+D/gOS15Q==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.24.0.tgz", + "integrity": "sha512-jVzdzsbM5xrotH+W5f1s+JtUy1UWgjU0Cf4wMvffTB8m6wP5/kx0KiaLHlbJO+dMgtxKV8RQ/JvtlFcdZ1zCPA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.24.0.tgz", + "integrity": "sha512-iKc8GAslzRpBytO2/aN3d2yb2z8XTVfNV0PjGlCxKo5SgWmNXx82I/Q3aG1tFfS+A2igVCY97TJ8tnYwpUWLCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.24.0.tgz", + "integrity": "sha512-vQW36KZolfIudCcTnaTpmLQ24Ha1RjygBo39/aLkM2kmjkWmZGEJ5Gn9l5/7tzXA42QGIoWbICfg6KLLkIw6yw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.24.0.tgz", + "integrity": "sha512-7IAFPrjSQIJrGsK6flwg7NFmwBoSTyF3rl7If0hNUFQU4ilTsEPL6GuMuU9BfIWVVGuRnuIidkSMC+c0Otu8IA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.24.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.24.0.tgz", + "integrity": "sha512-FuLPevChGDshgSicjisSooU0cemp/sGXR841D5LHMB7mTVOmsEHcAxaH3irL53+8YDIeVNQEySh4DaYU/iuPqQ==", + "dev": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.24.0", + "@esbuild/android-arm": "0.24.0", + "@esbuild/android-arm64": "0.24.0", + "@esbuild/android-x64": "0.24.0", + "@esbuild/darwin-arm64": "0.24.0", + "@esbuild/darwin-x64": "0.24.0", + "@esbuild/freebsd-arm64": "0.24.0", + "@esbuild/freebsd-x64": "0.24.0", + "@esbuild/linux-arm": "0.24.0", + "@esbuild/linux-arm64": "0.24.0", + "@esbuild/linux-ia32": "0.24.0", + "@esbuild/linux-loong64": "0.24.0", + "@esbuild/linux-mips64el": "0.24.0", + "@esbuild/linux-ppc64": "0.24.0", + "@esbuild/linux-riscv64": "0.24.0", + "@esbuild/linux-s390x": "0.24.0", + "@esbuild/linux-x64": "0.24.0", + "@esbuild/netbsd-x64": "0.24.0", + "@esbuild/openbsd-arm64": "0.24.0", + "@esbuild/openbsd-x64": "0.24.0", + "@esbuild/sunos-x64": "0.24.0", + "@esbuild/win32-arm64": "0.24.0", + "@esbuild/win32-ia32": "0.24.0", + "@esbuild/win32-x64": "0.24.0" + } + } + } +} diff --git a/libs/navigation/package.json b/libs/navigation/package.json new file mode 100644 index 0000000000..9323d847cd --- /dev/null +++ b/libs/navigation/package.json @@ -0,0 +1,16 @@ +{ + "name": "@adobecom/standalone-feds", + "version": "0.0.1", + "description": "", + "main": "dist/navigation.js", + "type": "module", + "scripts": { + "build": "node ./build.mjs" + }, + "files": ["dist"], + "author": "", + "license": "ISC", + "devDependencies": { + "esbuild": "0.24.0" + } +} diff --git a/libs/utils/decorate.js b/libs/utils/decorate.js index 36e23dd055..580d2fcc1f 100644 --- a/libs/utils/decorate.js +++ b/libs/utils/decorate.js @@ -1,6 +1,17 @@ import { createTag, loadStyle, getConfig, createIntersectionObserver } from './utils.js'; +import { getFederatedContentRoot, getFedsPlaceholderConfig } from './federated.js'; const { miloLibs, codeRoot } = getConfig(); +const HIDE_CONTROLS = '_hide-controls'; +let firstVideo = null; +let videoLabels = { + playMotion: 'Play', + pauseMotion: 'Pause', + pauseIcon: 'Pause icon', + playIcon: 'Play icon', + hasFetched: false, +}; +let videoCounter = 0; export function decorateButtons(el, size) { const buttons = el.querySelectorAll('em a, strong a, p > a strong'); @@ -209,7 +220,7 @@ export function getImgSrc(pic) { return source?.srcset ? `poster='${source.srcset}'` : ''; } -function getVideoAttrs(hash, dataset) { +export function getVideoAttrs(hash, dataset) { const isAutoplay = hash?.includes('autoplay'); const isAutoplayOnce = hash?.includes('autoplay1'); const playOnHover = hash?.includes('hoverplay'); @@ -234,12 +245,80 @@ function getVideoAttrs(hash, dataset) { return `${globalAttrs} controls`; } +export function syncPausePlayIcon(video) { + if (!video.getAttributeNames().includes('data-hoverplay')) { + const offsetFiller = video.closest('.video-holder').querySelector('.offset-filler'); + const anchorTag = video.closest('.video-holder').querySelector('a'); + offsetFiller?.classList.toggle('is-playing'); + const isPlaying = offsetFiller?.classList.contains('is-playing'); + const indexOfVideo = (anchorTag.getAttribute('video-index') === '1' && videoCounter === 1) ? '' : anchorTag.getAttribute('video-index'); + const changedLabel = `${isPlaying ? videoLabels?.pauseMotion : videoLabels?.playMotion}`; + const oldLabel = `${!isPlaying ? videoLabels?.pauseMotion : videoLabels?.playMotion}`; + const ariaLabel = `${changedLabel} ${indexOfVideo}`.trim(); + anchorTag?.setAttribute('aria-label', `${ariaLabel} `); + anchorTag?.setAttribute('aria-pressed', isPlaying ? 'true' : 'false'); + const daaLL = anchorTag.getAttribute('daa-ll'); + if (daaLL) anchorTag.setAttribute('daa-ll', daaLL.replace(oldLabel, changedLabel)); + } +} + +export function addAccessibilityControl(videoString, videoAttrs, indexOfVideo, tabIndex = 0) { + if (videoAttrs.includes('controls')) return videoString; + const fedRoot = getFederatedContentRoot(); + if (videoAttrs.includes('hoverplay')) { + return `${videoString}`; + } + return ` +
${videoString} + +
+ + +
+
+
+ `; +} + +export function handlePause(event) { + event.stopPropagation(); + if (event.code !== 'Enter' && event.code !== 'Space' && !['focus', 'click', 'blur'].includes(event.type)) { + return; + } + event.preventDefault(); + const video = event.target.closest('.video-holder').parentElement.querySelector('video'); + if (event.type === 'blur') { + video.pause(); + } else if (video.paused || video.ended || event.type === 'focus') { + video.play(); + } else { + video.pause(); + } + syncPausePlayIcon(video); +} + export function applyHoverPlay(video) { if (!video) return; - if (video.hasAttribute('data-hoverplay') && !video.hasAttribute('data-mouseevent')) { - video.addEventListener('mouseenter', () => { video.play(); }); - video.addEventListener('mouseleave', () => { video.pause(); }); - video.setAttribute('data-mouseevent', true); + if (video.hasAttribute('data-hoverplay')) { + video.parentElement.addEventListener('focus', handlePause); + video.parentElement.addEventListener('blur', handlePause); + if (!video.hasAttribute('data-mouseevent')) { + video.addEventListener('mouseenter', () => { video.play(); }); + video.addEventListener('mouseleave', () => { video.pause(); }); + video.addEventListener('ended', () => { syncPausePlayIcon(video); }); + video.setAttribute('data-mouseevent', true); + } + } +} + +export function applyAccessibilityEvents(videoEl) { + const pausePlayWrapper = videoEl.parentElement.querySelector('.pause-play-wrapper') || videoEl.closest('.pause-play-wrapper'); + if (pausePlayWrapper?.querySelector('.accessibility-control')) { + pausePlayWrapper.addEventListener('click', handlePause); + pausePlayWrapper.addEventListener('keydown', handlePause); + } + if (videoEl.hasAttribute('autoplay')) { + videoEl.addEventListener('ended', () => { syncPausePlayIcon(videoEl); }); } } @@ -275,7 +354,7 @@ function getVideoIntersectionObserver() { const isHaveLoopAttr = video.getAttributeNames().includes('loop'); const { playedOnce = false } = video.dataset; const isPlaying = video.currentTime > 0 && !video.paused && !video.ended - && video.readyState > video.HAVE_CURRENT_DATA; + && video.readyState > video.HAVE_CURRENT_DATA; if (intersectionRatio <= 0.8) { video.pause(); @@ -331,13 +410,72 @@ export async function loadCDT(el, classList) { } } +export function isVideoAccessible(anchorTag) { + return !anchorTag?.hash.includes(HIDE_CONTROLS); +} + +function updateFirstVideo() { + if (firstVideo != null && firstVideo?.controls === false && videoCounter > 1) { + let videoHolder = document.querySelector('[video-index="1"]') || firstVideo.closest('.video-holder'); + if (videoHolder.nodeName !== 'A') videoHolder = videoHolder.querySelector('a.pause-play-wrapper'); + const firstVideoLabel = videoHolder.getAttribute('aria-label'); + videoHolder.setAttribute('aria-label', `${firstVideoLabel} 1`); + firstVideo = null; + } +} + +function updateAriaLabel(videoEl, videoAttrs) { + if (!videoEl.getAttributeNames().includes('data-hoverplay')) { + const pausePlayWrapper = videoEl.parentElement.querySelector('.pause-play-wrapper') || videoEl.closest('.pause-play-wrapper'); + const pauseIcon = pausePlayWrapper.querySelector('.pause-icon'); + const playIcon = pausePlayWrapper.querySelector('.play-icon'); + const indexOfVideo = pausePlayWrapper.getAttribute('video-index'); + let ariaLabel = `${videoAttrs.includes('autoplay') ? videoLabels.pauseMotion : videoLabels.playMotion}`; + ariaLabel = ariaLabel.concat(` ${indexOfVideo === '1' && videoCounter === 1 ? '' : indexOfVideo}`); + pausePlayWrapper.setAttribute('aria-label', ariaLabel); + pauseIcon.setAttribute('alt', videoLabels.pauseMotion); + playIcon.setAttribute('alt', videoLabels.playMotion); + updateFirstVideo(); + } +} + +export function decoratePausePlayWrapper(videoEl, videoAttrs) { + if (!videoLabels.hasFetched) { + import('../features/placeholders.js').then(({ replaceKeyArray }) => { + replaceKeyArray(['pause-motion', 'play-motion', 'pause-icon', 'play-icon'], getFedsPlaceholderConfig()) + .then(([pauseMotion, playMotion, pauseIcon, playIcon]) => { + videoLabels = { playMotion, pauseMotion, pauseIcon, playIcon }; + videoLabels.hasFetched = true; + updateAriaLabel(videoEl, videoAttrs); + }); + }); + } else { + updateAriaLabel(videoEl, videoAttrs); + } +} + export function decorateAnchorVideo({ src = '', anchorTag }) { if (!src.length || !(anchorTag instanceof HTMLElement)) return; + const accessibilityEnabled = isVideoAccessible(anchorTag); + anchorTag.hash = anchorTag.hash.replace(`#${HIDE_CONTROLS}`, ''); if (anchorTag.closest('.marquee, .aside, .hero-marquee, .quiz-marquee') && !anchorTag.hash) anchorTag.hash = '#autoplay'; const { dataset, parentElement } = anchorTag; - const video = ``; + const attrs = getVideoAttrs(anchorTag.hash, dataset); + const tabIndex = anchorTag.tabIndex || 0; + const videoIndex = (tabIndex === -1) ? 'tabindex=-1' : ''; + let video = ``; + if (!attrs.includes('controls') && !attrs.includes('hoverplay') && accessibilityEnabled) { + videoCounter += 1; + } + const indexOfVideo = videoCounter; + if (accessibilityEnabled) { + video = addAccessibilityControl(video, attrs, indexOfVideo, tabIndex); + } anchorTag.insertAdjacentHTML('afterend', video); const videoEl = parentElement.querySelector('video'); + if (indexOfVideo === 1) { + firstVideo = videoEl; + } createIntersectionObserver({ el: videoEl, options: { rootMargin: '1000px' }, @@ -345,6 +483,12 @@ export function decorateAnchorVideo({ src = '', anchorTag }) { videoEl?.appendChild(createTag('source', { src, type: 'video/mp4' })); }, }); + if (accessibilityEnabled) { + applyAccessibilityEvents(videoEl); + if (!videoEl.controls) { + decoratePausePlayWrapper(videoEl, attrs); + } + } applyHoverPlay(videoEl); applyInViewPortPlay(videoEl); anchorTag.remove(); diff --git a/libs/utils/federated.js b/libs/utils/federated.js index 759f518eb0..c3e3d3d24e 100644 --- a/libs/utils/federated.js +++ b/libs/utils/federated.js @@ -39,3 +39,21 @@ export const getFederatedUrl = (url = '') => { } return url; }; + +let fedsPlaceholderConfig; +export const getFedsPlaceholderConfig = ({ useCache = true } = {}) => { + if (useCache && fedsPlaceholderConfig) return fedsPlaceholderConfig; + + const { locale, placeholders } = getConfig(); + const libOrigin = getFederatedContentRoot(); + + fedsPlaceholderConfig = { + locale: { + ...locale, + contentRoot: `${libOrigin}${locale.prefix}/federal/globalnav`, + }, + placeholders, + }; + + return fedsPlaceholderConfig; +}; diff --git a/libs/utils/utils.js b/libs/utils/utils.js index 13ecb1d7fb..40355460ac 100644 --- a/libs/utils/utils.js +++ b/libs/utils/utils.js @@ -140,6 +140,7 @@ ENVS.local = { }; export const MILO_EVENTS = { DEFERRED: 'milo:deferred' }; +const TARGET_TIMEOUT_MS = 4000; const LANGSTORE = 'langstore'; const PREVIEW = 'target-preview'; @@ -148,7 +149,7 @@ export const SLD = PAGE_URL.hostname.includes('.aem.') ? 'aem' : 'hlx'; const PROMO_PARAM = 'promo'; -function getEnv(conf) { +export function getEnv(conf) { const { host } = window.location; const query = PAGE_URL.searchParams.get('env'); @@ -197,7 +198,7 @@ export function getMetadata(name, doc = document) { const handleEntitlements = (() => { const { martech } = Object.fromEntries(PAGE_URL.searchParams); - if (martech === 'off') return () => {}; + if (martech === 'off') return () => { }; let entResolve; const entPromise = new Promise((resolve) => { entResolve = resolve; @@ -311,7 +312,7 @@ export function localizeLink( const isLocalizedLink = path.startsWith(`/${LANGSTORE}`) || path.startsWith(`/${PREVIEW}`) || Object.keys(locales).some((loc) => loc !== '' && (path.startsWith(`/${loc}/`) - || path.endsWith(`/${loc}`))); + || path.endsWith(`/${loc}`))); if (isLocalizedLink) return processedHref; const urlPath = `${locale.prefix}${path}${url.search}${hash}`; return relative ? urlPath : `${url.origin}${urlPath}`; @@ -764,7 +765,7 @@ function decorateHeader() { } header.className = headerMeta || 'global-navigation'; const metadataConfig = getMetadata('breadcrumbs')?.toLowerCase() - || getConfig().breadcrumbs; + || getConfig().breadcrumbs; if (metadataConfig === 'off') return; const baseBreadcrumbs = getMetadata('breadcrumbs-base')?.length; @@ -819,15 +820,15 @@ const findReplaceableNodes = (area) => { }; let placeholderRequest; -async function decoratePlaceholders(area, config) { +export async function decoratePlaceholders(area, config) { if (!area) return; const nodes = findReplaceableNodes(area); if (!nodes.length) return; area.dataset.hasPlaceholders = 'true'; const placeholderPath = `${config.locale?.contentRoot}/placeholders.json`; placeholderRequest = placeholderRequest - || customFetch({ resource: placeholderPath, withCacheRules: true }) - .catch(() => ({})); + || customFetch({ resource: placeholderPath, withCacheRules: true }) + .catch(() => ({})); const { decoratePlaceholderArea } = await import('../features/placeholders.js'); await decoratePlaceholderArea({ placeholderPath, placeholderRequest, nodes }); } @@ -1023,7 +1024,7 @@ export async function loadMartech({ } window.targetGlobalSettings = { bodyHidingEnabled: false }; - loadIms().catch(() => {}); + loadIms().catch(() => { }); const { default: initMartech } = await import('../martech/martech.js'); await initMartech({ persEnabled, persManifests, postLCP }); @@ -1031,6 +1032,30 @@ export async function loadMartech({ return true; } +/** + * Checks if the user is signed out based on the server timing and navigation performance. + * + * @returns {boolean} True if the user is signed out, otherwise false. + */ +function isSignedOut() { + const serverTiming = window.performance?.getEntriesByType('navigation')?.[0]?.serverTiming?.reduce( + (acc, { name, description }) => ({ ...acc, [name]: description }), + {}, + ); + + return !Object.keys(serverTiming || {}).length || serverTiming?.sis === '0'; +} + +/** + * Enables personalization (V2) for the page. + * + * @returns {boolean} True if personalization is enabled, otherwise false. + */ +export function enablePersonalizationV2() { + const enablePersV2 = document.head.querySelector('meta[name="personalization-v2"]'); + return !!enablePersV2 && isSignedOut(); +} + async function checkForPageMods() { const { mep: mepParam, @@ -1038,13 +1063,51 @@ async function checkForPageMods() { mepButton, martech, } = Object.fromEntries(PAGE_URL.searchParams); + let targetInteractionPromise = null; if (mepParam === 'off') return; const pzn = getMepEnablement('personalization'); const promo = getMepEnablement('manifestnames', PROMO_PARAM); const target = martech === 'off' ? false : getMepEnablement('target'); const xlg = martech === 'off' ? false : getMepEnablement('xlg'); + if (!(pzn || target || promo || mepParam || mepHighlight || mepButton || mepParam === '' || xlg)) return; + + const enablePersV2 = enablePersonalizationV2(); + if (martech !== 'off' && (target || xlg || pzn) && enablePersV2) { + const params = new URL(window.location.href).searchParams; + const calculatedTimeout = parseInt(params.get('target-timeout'), 10) + || parseInt(getMetadata('target-timeout'), 10) + || TARGET_TIMEOUT_MS; + + const { locale } = getConfig(); + targetInteractionPromise = (async () => { + const { loadAnalyticsAndInteractionData } = await import('../martech/helpers.js'); + const now = performance.now(); + performance.mark('interaction-start'); + const data = await loadAnalyticsAndInteractionData( + { locale, env: getEnv({})?.name, calculatedTimeout }, + ); + performance.mark('interaction-end'); + performance.measure('total-time', 'interaction-start', 'interaction-end'); + const respTime = performance.getEntriesByName('total-time')[0]; + + return { targetInteractionData: data, respTime, respStartTime: now }; + })(); + + const { init } = await import('../features/personalization/personalization.js'); + await init({ + mepParam, + mepHighlight, + mepButton, + pzn, + promo, + target, + targetInteractionPromise, + calculatedTimeout, + }); + return; + } if (target || xlg) { loadMartech(); } else if (pzn && martech !== 'off') { @@ -1058,7 +1121,7 @@ async function checkForPageMods() { const { init } = await import('../features/personalization/personalization.js'); await init({ - mepParam, mepHighlight, mepButton, pzn, promo, target, + mepParam, mepHighlight, mepButton, pzn, promo, target, targetInteractionPromise, }); } @@ -1070,9 +1133,13 @@ async function loadPostLCP(config) { /* c8 ignore next 2 */ const { init } = await import('../features/personalization/personalization.js'); await init({ postLCP: true }); + if (enablePersonalizationV2()) { + loadMartech(); + } } else { loadMartech(); } + const georouting = getMetadata('georouting') || config.geoRouting; if (georouting === 'on') { const { default: loadGeoRouting } = await import('../features/georoutingv2/georoutingv2.js'); diff --git a/nala/blocks/marquee/marquee.test.js b/nala/blocks/marquee/marquee.test.js index 246503a78b..9f9905e41d 100644 --- a/nala/blocks/marquee/marquee.test.js +++ b/nala/blocks/marquee/marquee.test.js @@ -543,7 +543,7 @@ test.describe('Milo Marquee Block test suite', () => { await test.step('step-3: Verify analytic attributes', async () => { await expect(await marquee.marqueeSmallDark).toHaveAttribute('daa-lh', await webUtil.getBlockDaalh('marquee', 1)); - await expect(await marquee.blueButtonL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 1, data.h2Text)); + await expect(await marquee.blueButtonL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 2, data.h2Text)); }); await test.step('step-4: Verify the accessibility test on the Marquee (small) background video playsinline block', async () => { @@ -570,7 +570,7 @@ test.describe('Milo Marquee Block test suite', () => { await expect(await marquee.headingXXL).toContainText(data.h2Text); await expect(await marquee.bodyXL).toContainText(data.bodyText); await expect(await marquee.blueButtonXL).toContainText(data.blueButtonText); - await expect(await marquee.actionLink2).toContainText(data.linkText); + await expect(await marquee.actionLink3).toContainText(data.linkText); await expect(await marquee.backgroundVideoDesktop).toBeVisible(); expect(await webUtil.verifyAttributes(marquee.backgroundVideoDesktop, marquee.attributes['backgroundVideo.inline'])).toBeTruthy(); @@ -581,8 +581,8 @@ test.describe('Milo Marquee Block test suite', () => { await test.step('step-3: Verify analytic attributes', async () => { await expect(await marquee.marqueeLargeLight).toHaveAttribute('daa-lh', await webUtil.getBlockDaalh('marquee', 1)); - await expect(await marquee.blueButtonXL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 1, data.h2Text)); - await expect(await marquee.actionLink2).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.linkText, 2, data.h2Text)); + await expect(await marquee.blueButtonXL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 2, data.h2Text)); + await expect(await marquee.actionLink3).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.linkText, 3, data.h2Text)); }); }); @@ -615,7 +615,7 @@ test.describe('Milo Marquee Block test suite', () => { await test.step('step-3: Verify analytic attributes', async () => { await expect(await marquee.marqueeLargeDark).toHaveAttribute('daa-lh', await webUtil.getBlockDaalh('marquee', 2)); - await expect(await marquee.blueButtonXL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 1, data.h2Text)); + await expect(await marquee.blueButtonXL).toHaveAttribute('daa-ll', await webUtil.getLinkDaall(data.blueButtonText, 2, data.h2Text)); }); }); diff --git a/test/blocks/adobetv/adobetv.test.js b/test/blocks/adobetv/adobetv.test.js index a54528e496..223622c7bd 100644 --- a/test/blocks/adobetv/adobetv.test.js +++ b/test/blocks/adobetv/adobetv.test.js @@ -1,8 +1,10 @@ import { readFile } from '@web/test-runner-commands'; import { expect } from '@esm-bundle/chai'; import { waitForElement } from '../../helpers/waitfor.js'; +import { setConfig } from '../../../libs/utils/utils.js'; document.body.innerHTML = await readFile({ path: './mocks/body.html' }); +setConfig({}); const { default: init } = await import('../../../libs/blocks/adobetv/adobetv.js'); describe('adobetv autoblock', () => { @@ -10,7 +12,7 @@ describe('adobetv autoblock', () => { const wrapper = document.body.querySelector('.adobe-tv'); const a = wrapper.querySelector(':scope > a'); - init(a); + await init(a); const iframe = await waitForElement('.adobe-tv iframe'); expect(wrapper.querySelector(':scope > a')).to.be.null; expect(iframe).to.be.exist; @@ -20,7 +22,7 @@ describe('adobetv autoblock', () => { const wrapper = document.body.querySelector('#adobetvAsBg'); const a = wrapper.querySelector(':scope a[href*=".mp4"]'); - init(a); + await init(a); const video = await waitForElement('#adobetvAsBg video'); expect(wrapper.querySelector(':scope a[href*=".mp4"]')).to.be.null; expect(video).to.be.exist; diff --git a/test/blocks/figure/figure.test.js b/test/blocks/figure/figure.test.js index 61ee37b909..6f9d7ab0bf 100644 --- a/test/blocks/figure/figure.test.js +++ b/test/blocks/figure/figure.test.js @@ -1,10 +1,11 @@ import { expect } from '@esm-bundle/chai'; import { readFile } from '@web/test-runner-commands'; +import { setConfig } from '../../../libs/utils/utils.js'; document.body.innerHTML = await readFile({ path: './mocks/body.html' }); const ogDocument = document.body.innerHTML; - const { default: init } = await import('../../../libs/blocks/figure/figure.js'); +setConfig({}); describe('init', () => { afterEach(() => { diff --git a/test/blocks/global-footer/global-footer.test.js b/test/blocks/global-footer/global-footer.test.js index 3ab306b9ce..8e8ed697d8 100644 --- a/test/blocks/global-footer/global-footer.test.js +++ b/test/blocks/global-footer/global-footer.test.js @@ -187,6 +187,11 @@ describe('global footer', () => { const regionPickerElem = document.querySelector(allSelectors.regionPicker); regionPickerElem.dispatchEvent(new Event('click')); + const regionNavModal = document.createElement('div'); + regionNavModal.classList.add('region-nav'); // pretend that the modal was added to the body + // since clicking on the regionpicker elem apparently doesnt set the hash + document.body.append(regionNavModal); + window.dispatchEvent(new Event('milo:modal:loaded')); expect(regionPickerElem.getAttribute('href') === '#langnav').to.equal(true); expect(regionPickerElem.getAttribute('aria-expanded')).to.equal('true'); @@ -433,4 +438,27 @@ describe('global footer', () => { expect(document.querySelector('footer').classList.contains('feds--dark')).to.be.true; }); }); + describe('standalone footer', async () => { + it('should still load the regionnav if it\'s a standalone footer', async () => { + await createFullGlobalFooter({ + waitForDecoration: true, + customConfig: { standaloneGnav: true }, + }); + + const regionPickerElem = document.querySelector(allSelectors.regionPicker); + regionPickerElem.dispatchEvent(new Event('click')); + const regionNavModal = document.createElement('div'); + regionNavModal.classList.add('region-nav'); // pretend that the modal was added to the body + regionNavModal.setAttribute('data-failed', 'true'); + // since clicking on the regionpicker elem apparently doesnt set the hash + document.body.append(regionNavModal); + window.dispatchEvent(new Event('milo:modal:loaded')); + + expect(regionPickerElem.getAttribute('href') === '#langnav').to.equal(true); + expect(regionPickerElem.getAttribute('aria-expanded')).to.equal('true'); + + window.dispatchEvent(new Event('milo:modal:closed')); + expect(regionPickerElem.getAttribute('aria-expanded')).to.equal('false'); + }); + }); }); diff --git a/test/blocks/global-navigation/keyboard/mocks/global-nav-mobile.html b/test/blocks/global-navigation/keyboard/mocks/global-nav-mobile.html index 4b4c984091..0d3eb2cb36 100644 --- a/test/blocks/global-navigation/keyboard/mocks/global-nav-mobile.html +++ b/test/blocks/global-navigation/keyboard/mocks/global-nav-mobile.html @@ -1,5 +1,5 @@