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
diff --git a/test/blocks/video/mocks/body.html b/test/blocks/video/mocks/body.html
index b3f1963c85..1c59b91e82 100644
--- a/test/blocks/video/mocks/body.html
+++ b/test/blocks/video/mocks/body.html
@@ -16,7 +16,7 @@
-