diff --git a/express/blocks/floating-button/floating-button.css b/express/blocks/floating-button/floating-button.css
new file mode 100644
index 00000000..e69de29b
diff --git a/express/blocks/floating-button/floating-button.js b/express/blocks/floating-button/floating-button.js
new file mode 100644
index 00000000..ed443a34
--- /dev/null
+++ b/express/blocks/floating-button/floating-button.js
@@ -0,0 +1,35 @@
+import { addTempWrapperDeprecated } from '../../scripts/utils/decorate.js';
+import {
+ createFloatingButton,
+ collectFloatingButtonData,
+} from '../../scripts/widgets/floating-cta.js';
+import { formatDynamicCartLink } from '../../scripts/utils/pricing.js';
+
+export default function decorate(block) {
+ addTempWrapperDeprecated(block, 'floating-button');
+ if (!block.classList.contains('metadata-powered')) {
+ block.parentElement?.remove();
+ return;
+ }
+
+ const audience = block.querySelector(':scope > div').textContent.trim();
+ if (audience === 'mobile') {
+ block.closest('.section')?.remove();
+ }
+
+ const parentSection = block.closest('.section');
+ const data = collectFloatingButtonData(block);
+
+ const blockWrapper = createFloatingButton(
+ block,
+ parentSection ? audience : null,
+ data,
+ );
+
+ const blockLinks = blockWrapper.querySelectorAll('a');
+ if (blockLinks && blockLinks.length > 0) {
+ formatDynamicCartLink(blockLinks[0]);
+ const linksPopulated = new CustomEvent('linkspopulated', { detail: blockLinks });
+ document.dispatchEvent(linksPopulated);
+ }
+}
diff --git a/express/scripts/utils.js b/express/scripts/utils.js
index 76450488..91db59d3 100644
--- a/express/scripts/utils.js
+++ b/express/scripts/utils.js
@@ -477,3 +477,22 @@ export function decorateArea(area = document) {
transpileMarquee(area);
overrideMiloColumns(area);
}
+
+export function getMobileOperatingSystem() {
+ const userAgent = navigator.userAgent || navigator.vendor || window.opera;
+
+ // Windows Phone must come first because its UA also contains "Android"
+ if (/windows phone/i.test(userAgent)) {
+ return 'Windows Phone';
+ }
+
+ if (/android/i.test(userAgent)) {
+ return 'Android';
+ }
+
+ if (/iPad|iPhone|iPod/.test(userAgent) && !window.MSStream) {
+ return 'iOS';
+ }
+
+ return 'unknown';
+}
diff --git a/express/scripts/utils/pricing.js b/express/scripts/utils/pricing.js
new file mode 100644
index 00000000..616a3c60
--- /dev/null
+++ b/express/scripts/utils/pricing.js
@@ -0,0 +1,400 @@
+import { getCountry } from './location-utils.js';
+import { getLibs } from '../utils.js';
+
+const { getConfig } = await import(`${getLibs()}/utils/utils.js`);
+const currencies = {
+ ar: 'ARS',
+ at: 'EUR',
+ au: 'AUD',
+ be: 'EUR',
+ bg: 'EUR',
+ br: 'BRL',
+ ca: 'CAD',
+ ch: 'CHF',
+ cl: 'CLP',
+ co: 'COP',
+ cr: 'USD',
+ cy: 'EUR',
+ cz: 'EUR',
+ de: 'EUR',
+ dk: 'DKK',
+ ec: 'USD',
+ ee: 'EUR',
+ es: 'EUR',
+ fi: 'EUR',
+ fr: 'EUR',
+ gb: 'GBP',
+ uk: 'GBP',
+ gr: 'EUR',
+ gt: 'USD',
+ hk: 'HKD',
+ hu: 'EUR',
+ id: 'IDR',
+ ie: 'EUR',
+ il: 'ILS',
+ in: 'INR',
+ it: 'EUR',
+ jp: 'JPY',
+ kr: 'KRW',
+ lt: 'EUR',
+ lu: 'EUR',
+ lv: 'EUR',
+ mt: 'EUR',
+ mx: 'MXN',
+ my: 'MYR',
+ nl: 'EUR',
+ no: 'NOK',
+ nz: 'AUD',
+ pe: 'PEN',
+ ph: 'PHP',
+ pl: 'EUR',
+ pt: 'EUR',
+ ro: 'EUR',
+ ru: 'RUB',
+ se: 'SEK',
+ sg: 'SGD',
+ si: 'EUR',
+ sk: 'EUR',
+ th: 'THB',
+ tw: 'TWD',
+ us: 'USD',
+ ve: 'USD',
+ za: 'USD',
+ ae: 'USD',
+ bh: 'BHD',
+ eg: 'EGP',
+ jo: 'JOD',
+ kw: 'KWD',
+ om: 'OMR',
+ qa: 'USD',
+ sa: 'SAR',
+ ua: 'USD',
+ dz: 'USD',
+ lb: 'LBP',
+ ma: 'USD',
+ tn: 'USD',
+ ye: 'USD',
+ am: 'USD',
+ az: 'USD',
+ ge: 'USD',
+ md: 'USD',
+ tm: 'USD',
+ by: 'USD',
+ kz: 'USD',
+ kg: 'USD',
+ tj: 'USD',
+ uz: 'USD',
+ bo: 'USD',
+ do: 'USD',
+ hr: 'EUR',
+ ke: 'USD',
+ lk: 'USD',
+ mo: 'HKD',
+ mu: 'USD',
+ ng: 'USD',
+ pa: 'USD',
+ py: 'USD',
+ sv: 'USD',
+ tt: 'USD',
+ uy: 'USD',
+ vn: 'USD',
+ tr: 'TRY',
+};
+
+export function getCurrency(country) {
+ return currencies[country];
+}
+
+function getCurrencyDisplay(currency) {
+ if (currency === 'JPY') {
+ return 'name';
+ }
+ if (['SEK', 'DKK', 'NOK'].includes(currency)) {
+ return 'code';
+ }
+ return 'symbol';
+}
+
+export async function formatPrice(price, currency) {
+ if (price === '') return null;
+ const customSymbols = {
+ SAR: 'SR',
+ CA: 'CAD',
+ EGP: 'LE',
+ ARS: 'Ar$',
+ };
+ const locale = ['USD', 'TWD'].includes(currency)
+ ? 'en-GB' // use en-GB for intl $ symbol formatting
+ : (getConfig().locales[await getCountry() || '']?.ietf ?? 'en-US');
+ const currencyDisplay = getCurrencyDisplay(currency);
+ let formattedPrice = new Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency,
+ currencyDisplay,
+ }).format(price);
+
+ Object.entries(customSymbols).forEach(([symbol, replacement]) => {
+ formattedPrice = formattedPrice.replace(symbol, replacement);
+ });
+
+ return formattedPrice;
+}
+
+export const getOfferOnePlans = (() => {
+ let json;
+ return async (offerId) => {
+ let country = await getCountry();
+ if (!country) country = 'us';
+
+ let currency = getCurrency(country);
+ if (!currency) {
+ country = 'us';
+ currency = 'USD';
+ }
+
+ if (!json) {
+ const resp = await fetch('/express/system/offers-one.json?limit=5000');
+ if (!resp.ok) return {};
+ json = await resp.json();
+ }
+
+ const upperCountry = country.toUpperCase();
+ let offer = json.data.find((e) => (e.o === offerId) && (e.c === upperCountry));
+ if (!offer) offer = json.data.find((e) => (e.o === offerId) && (e.c === 'US'));
+ if (!offer) return {};
+ const lang = getConfig().locale.ietf.split('-')[0];
+ const unitPrice = offer.p;
+ const customOfferId = offer.oo || offerId;
+ const ooAvailable = offer.oo || false;
+ const showVat = offer.showVat || false;
+ return {
+ country,
+ currency,
+ lang,
+ unitPrice: offer.p,
+ unitPriceCurrencyFormatted: await formatPrice(unitPrice, currency),
+ commerceURL: `https://commerce.adobe.com/checkout?cli=spark&co=${country}&items%5B0%5D%5Bid%5D=${customOfferId}&items%5B0%5D%5Bcs%5D=0&rUrl=https%3A%2F%express.adobe.com%2Fsp%2F&lang=${lang}`,
+ vatInfo: offer.vat,
+ prefix: offer.pre,
+ suffix: offer.suf,
+ basePrice: offer.bp,
+ basePriceCurrencyFormatted: await formatPrice(offer.bp, currency),
+ priceSuperScript: offer.sup,
+ customOfferId,
+ savePer: offer.savePer,
+ ooAvailable,
+ showVat,
+ y2p: await formatPrice(offer.y2p, currency),
+ };
+ };
+})();
+
+export async function fetchPlanOnePlans(planUrl) {
+ if (!window.pricingPlans) {
+ window.pricingPlans = {};
+ }
+
+ let plan = window.pricingPlans[planUrl];
+ if (!plan) {
+ plan = {};
+ const link = new URL(planUrl);
+ const params = link.searchParams;
+
+ plan.url = planUrl;
+ plan.country = 'us';
+ plan.language = 'en';
+ plan.price = '9.99';
+ plan.currency = 'US';
+ plan.symbol = '$';
+
+ // TODO: Remove '/sp/ once confirmed with stakeholders
+ const allowedHosts = ['new.express.adobe.com', 'express.adobe.com', 'adobesparkpost.app.link', 'adobesparkpost-web.app.link'];
+ const { host } = new URL(planUrl);
+ if (allowedHosts.includes(host) || planUrl.includes('/sp/')) {
+ plan.offerId = 'FREE0';
+ plan.frequency = 'monthly';
+ plan.name = 'Free';
+ plan.stringId = 'free-trial';
+ } else {
+ plan.offerId = params.get('items[0][id]');
+ plan.frequency = null;
+ plan.name = 'Premium';
+ plan.stringId = '3-month-trial';
+ }
+
+ if (plan.offerId === '70C6FDFC57461D5E449597CC8F327CF1' || plan.offerId === 'CFB1B7F391F77D02FE858C43C4A5C64F') {
+ plan.frequency = 'Monthly';
+ } else if (plan.offerId === 'E963185C442F0C5EEB3AE4F4AAB52C24' || plan.offerId === 'BADDACAB87D148A48539B303F3C5FA92') {
+ plan.frequency = 'Annual';
+ } else {
+ plan.frequency = null;
+ }
+
+ const offer = await getOfferOnePlans(plan.offerId);
+
+ if (offer) {
+ plan.currency = offer.currency;
+ plan.price = offer.unitPrice;
+ plan.basePrice = offer.basePrice;
+ plan.country = offer.country;
+ plan.vatInfo = offer.vatInfo;
+ plan.language = offer.lang;
+ plan.offerId = offer.customOfferId;
+ plan.ooAvailable = offer.ooAvailable;
+ plan.rawPrice = offer.unitPriceCurrencyFormatted?.match(/[\d\s,.+]+/g);
+ plan.prefix = offer.prefix ?? '';
+ plan.suffix = offer.suffix ?? '';
+ plan.sup = offer.priceSuperScript ?? '';
+ plan.savePer = offer.savePer ?? '';
+ plan.showVat = offer.showVat ?? false;
+ plan.formatted = offer.unitPriceCurrencyFormatted?.replace(
+ plan.rawPrice[0],
+ `${plan.prefix}${plan.rawPrice[0]}`,
+ );
+
+ if (offer.basePriceCurrencyFormatted) {
+ plan.rawBasePrice = offer.basePriceCurrencyFormatted.match(/[\d\s,.+]+/g);
+ plan.formattedBP = offer.basePriceCurrencyFormatted.replace(
+ plan.rawBasePrice[0],
+ `${plan.prefix}${plan.rawBasePrice[0]}`,
+ );
+ }
+ plan.y2p = offer.y2p;
+ }
+ window.pricingPlans[planUrl] = plan;
+ }
+ return plan;
+}
+
+function replaceUrlParam(url, paramName, paramValue) {
+ const params = url.searchParams;
+ params.set(paramName, paramValue);
+ url.search = params.toString();
+ return url;
+}
+
+// TODO probably want to replace / merge this with new getEnv method
+export function getHelixEnv() {
+ let envName = sessionStorage.getItem('helix-env');
+ if (!envName) {
+ envName = 'stage';
+ if (window.spark?.hostname === 'www.adobe.com') envName = 'prod';
+ }
+ const envs = {
+ stage: {
+ commerce: 'commerce-stg.adobe.com',
+ adminconsole: 'stage.adminconsole.adobe.com',
+ spark: 'stage.projectx.corp.adobe.com',
+ },
+ prod: {
+ commerce: 'commerce.adobe.com',
+ spark: 'express.adobe.com',
+ adminconsole: 'adminconsole.adobe.com',
+ },
+ };
+ const env = envs[envName];
+
+ const overrideItem = sessionStorage.getItem('helix-env-overrides');
+ if (overrideItem) {
+ const overrides = JSON.parse(overrideItem);
+ const keys = Object.keys(overrides);
+ env.overrides = keys;
+
+ for (const a of keys) {
+ env[a] = overrides[a];
+ }
+ }
+
+ if (env) {
+ env.name = envName;
+ }
+ return env;
+}
+
+export function buildUrl(optionUrl, country, language, offerId = '') {
+ const currentUrl = new URL(window.location.href);
+ let planUrl = new URL(optionUrl);
+
+ if (!planUrl.hostname.includes('commerce')) {
+ return planUrl.href;
+ }
+ planUrl = replaceUrlParam(planUrl, 'co', country);
+ planUrl = replaceUrlParam(planUrl, 'lang', language);
+ if (offerId) {
+ planUrl.searchParams.set(decodeURIComponent('items%5B0%5D%5Bid%5D'), offerId);
+ }
+ let rUrl = planUrl.searchParams.get('rUrl');
+ if (currentUrl.searchParams.has('host')) {
+ const hostParam = currentUrl.searchParams.get('host');
+ const { host } = new URL(hostParam);
+ if (host === 'express.adobe.com') {
+ planUrl.hostname = 'commerce.adobe.com';
+ if (rUrl) rUrl = rUrl.replace('express.adobe.com', hostParam);
+ } else if (host === 'qa.adobeprojectm.com') {
+ planUrl.hostname = 'commerce.adobe.com';
+ if (rUrl) rUrl = rUrl.replace('express.adobe.com', hostParam);
+ } else if (host.endsWith('.adobeprojectm.com')) {
+ planUrl.hostname = 'commerce-stg.adobe.com';
+ if (rUrl) rUrl = rUrl.replace('adminconsole.adobe.com', 'stage.adminconsole.adobe.com');
+ if (rUrl) rUrl = rUrl.replace('express.adobe.com', hostParam);
+ }
+ }
+
+ const env = getHelixEnv();
+ if (env && env.commerce && planUrl.hostname.includes('commerce')) planUrl.hostname = env.commerce;
+ if (env && env.spark && rUrl) {
+ const url = new URL(rUrl);
+ url.hostname = env.spark;
+ rUrl = url.toString();
+ }
+
+ if (rUrl) {
+ rUrl = new URL(rUrl);
+
+ if (currentUrl.searchParams.has('touchpointName')) {
+ rUrl = replaceUrlParam(rUrl, 'touchpointName', currentUrl.searchParams.get('touchpointName'));
+ }
+ if (currentUrl.searchParams.has('destinationUrl')) {
+ rUrl = replaceUrlParam(rUrl, 'destinationUrl', currentUrl.searchParams.get('destinationUrl'));
+ }
+ if (currentUrl.searchParams.has('srcUrl')) {
+ rUrl = replaceUrlParam(rUrl, 'srcUrl', currentUrl.searchParams.get('srcUrl'));
+ }
+ }
+
+ if (currentUrl.searchParams.has('code')) {
+ planUrl.searchParams.set('code', currentUrl.searchParams.get('code'));
+ }
+
+ if (currentUrl.searchParams.get('rUrl')) {
+ rUrl = currentUrl.searchParams.get('rUrl');
+ }
+
+ if (rUrl) planUrl.searchParams.set('rUrl', rUrl.toString());
+ return planUrl.href;
+}
+
+export async function formatDynamicCartLink(a, plan) {
+ try {
+ const pattern = /.*commerce.*adobe\.com.*/gm;
+ if (pattern.test(a.href)) {
+ let response;
+ if (!plan) {
+ response = await fetchPlanOnePlans(a.href);
+ } else {
+ response = plan;
+ }
+ const newTrialHref = buildUrl(
+ response.url,
+ response.country,
+ response.language,
+ response.offerId,
+ );
+ a.href = newTrialHref;
+ }
+ } catch (error) {
+ window.lana.log('Failed to fetch prices for page plan');
+ window.lana.log(error);
+ }
+ return a;
+}
diff --git a/express/scripts/widgets/floating-cta.css b/express/scripts/widgets/floating-cta.css
new file mode 100644
index 00000000..405da8f3
--- /dev/null
+++ b/express/scripts/widgets/floating-cta.css
@@ -0,0 +1,244 @@
+.floating-button-wrapper {
+ position: fixed;
+ left: 0;
+ bottom: -1px;
+ top: auto;
+ right: auto;
+ z-index: 2;
+ width: 100%;
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-box-pack: center;
+ -webkit-justify-content: center;
+ -ms-flex-pack: center;
+ justify-content: center;
+ -webkit-box-align: flex-end;
+ -webkit-align-items: flex-end;
+ -ms-flex-align: flex-end;
+ align-items: flex-end;
+ mix-blend-mode: normal;
+ box-sizing: border-box;
+ padding: 0 6px;
+ pointer-events: none;
+ background: -webkit-linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.1) 20%, rgba(255,255,255,0.95) 70%, rgba(255,255,255,1) 78%);
+ background: linear-gradient(180deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.1) 20%, rgba(255,255,255,0.95) 70%, rgba(255,255,255,1) 78%);
+ transition: bottom 0.6s ease-out, opacity 0.6s ease-out, height 0.6s ease-out, background 0.6s, padding-bottom 0.6s;
+ height: 150px;
+}
+.floating-button-wrapper.push-up {
+ padding-bottom: 96px;
+}
+.floating-button-wrapper.push-up .floating-button {
+ margin-bottom: 8px;
+}
+
+body.branch-banner-is-active .floating-button-wrapper {
+ height: calc(150px + 76px);
+}
+
+.floating-button-wrapper.floating-button--hidden {
+ height: 150px;
+ bottom: -151px;
+ opacity: 0.9;
+}
+
+.floating-button {
+ display: block;
+ padding: 10px;
+ box-sizing: border-box;
+ position: relative;
+ border-radius: 100px;
+ background-color: var(--color-gray-200);
+ transition: background-color .3s, padding .3s, margin-bottom 0.6s ease-out, bottom .3s;
+ z-index: 2;
+ max-width: 100vw;
+ pointer-events: auto;
+ margin-bottom: 24px;
+}
+
+body.branch-banner-is-active .floating-button-wrapper .floating-button {
+ margin-bottom: calc(24px + 76px);
+}
+
+.floating-button-wrapper.floating-button--hidden .floating-button {
+ margin-bottom: 24px;
+}
+
+.floating-button .floating-button-inner-wrapper {
+ position: relative;
+}
+
+.floating-button .floating-button-inner-wrapper .floating-button-background {
+ position: absolute;
+ height: 100%;
+ display: block;
+ z-index: 2;
+ width: 100%;
+ pointer-events: auto;
+ border-radius: 27px;
+ border-color: transparent;
+ background: linear-gradient(320deg, #7C84F3, #FF4DD2, #FF993B, #FF4DD2, #7C84F3, #FF4DD2, #FF993B);
+ background-size: 400% 400%;
+ -webkit-animation: buttonGradient 45s ease infinite;
+ -moz-animation: buttonGradient 45s ease infinite;
+ animation: buttonGradient 45s ease infinite;
+ transition: width .3s, margin .3s, min-width .3s, background-color .3s, color .3s, border .3s, background-position 2s ease-out, padding-left .3s;
+}
+
+.floating-button a.button:any-link {
+ position: relative;
+ display: block;
+ box-sizing: border-box;
+ max-width: 332px;
+ white-space: normal;
+ margin: 0;
+ z-index: 2;
+ width: calc(100vw - 54px);
+ pointer-events: auto;
+}
+
+.floating-button a.button:any-link,
+.floating-button a.button:any-link:hover,
+.floating-button a.button:any-link:active,
+.floating-button a.button:any-link:focus {
+ background: transparent;
+}
+
+
+.floating-button-wrapper.floating-button--above-the-fold:not(.floating-button--scrolled) a.button:any-link {
+ padding: 13px 80px 14px 48px;
+}
+
+.floating-button .floating-button-lottie {
+ display: block;
+ position: absolute;
+ right: 10px;
+ top: 10px;
+ margin: 0;
+ padding: 4px 0 0;
+ background: rgba(0,0,0,0);
+ -webkit-tap-highlight-color: rgba(0,0,0,0);
+ border: none;
+ border-radius: 60px;
+ cursor: pointer;
+ pointer-events: auto;
+ transition: opacity 0.3s;
+ z-index: 2;
+ opacity: 1;
+}
+
+.floating-button-wrapper[data-audience='desktop'].floating-button--below-the-fold .floating-button .floating-button-lottie {
+ z-index: 0;
+}
+
+.floating-button .floating-button-lottie .lottie-purple-arrows {
+ width: 50px;
+ height: 50px;
+ pointer-events: none;
+}
+
+.floating-button--scrolled .floating-button {
+ background-color: rgba(0,0,0,0);
+}
+
+.floating-button--scrolled .floating-button .floating-button-background {
+ margin-right: 0;
+ min-width: 250px;
+ width: 100%;
+}
+
+.floating-button-wrapper.floating-button--above-the-fold:not(.floating-button--scrolled) .floating-button-background {
+ width: calc(100% - 50px);
+}
+
+.floating-button--scrolled .floating-button-lottie {
+ pointer-events: none;
+ user-select: none;
+ opacity: 0;
+ z-index: 0;
+}
+
+[data-audience].floating-button-wrapper {
+ display: none;
+}
+
+body[data-device="mobile"] .floating-button-wrapper[data-audience="mobile"][data-section-status="loaded"],
+body[data-device="desktop"] .floating-button-wrapper[data-audience="desktop"][data-section-status="loaded"] {
+ display: flex;
+}
+body[data-suppressfloatingcta="true"] .floating-button-wrapper[data-audience="desktop"][data-section-status="loaded"] {
+ display: none;
+}
+
+.floating-button-wrapper:first-of-type + .section {
+ padding-top: 0;
+}
+
+@media screen and (min-width: 900px) {
+ /* reset above-banner styles */
+ body.branch-banner-is-active #branch-banner-iframe {
+ display: none;
+ }
+ body.branch-banner-is-active .floating-button-wrapper .floating-button {
+ margin-bottom: 24px;
+ }
+ body.branch-banner-is-active .floating-button-wrapper {
+ height: 150px;
+ }
+
+ main.branch-banner-is-active .floating-button-wrapper {
+ height: 150px;
+ }
+
+ .floating-button-wrapper.floating-button--intersecting:not(.floating-button--clicked),
+ .floating-button-wrapper {
+ bottom: -151px;
+ opacity: 0.9;
+ }
+
+ .floating-button-wrapper {
+ transition: none;
+ }
+
+ .floating-button-wrapper.floating-button--above-the-fold,
+ .floating-button-wrapper.floating-button--below-the-fold {
+ bottom: -1px;
+ opacity: 1;
+ transition: bottom 0.6s ease-out, opacity 0.6s ease-out;
+ }
+
+ .floating-button--below-the-fold .floating-button {
+ background-color: rgba(0,0,0,0);
+ }
+
+ .floating-button--below-the-fold .floating-button a.button:any-link {
+ margin-right: 0;
+ min-width: 250px;
+ }
+
+ .floating-button--below-the-fold .floating-button-lottie {
+ pointer-events: none;
+ user-select: none;
+ }
+
+ .floating-button-wrapper.floating-button--hidden {
+ bottom: -151px;
+ opacity: 0.9;
+ }
+
+ .floating-button a.button:any-link,
+ .floating-button a.button:any-link:hover,
+ .floating-button a.button:any-link:active,
+ .floating-button a.button:any-link:focus {
+ transition: width .3s, margin .3s, min-width .3s, background-color .3s, color .3s, border .3s, background-position 2s ease-out, padding .3s;
+ }
+}
+
+@media screen and (max-width: 350px) {
+ .floating-button a.button:any-link,
+ .floating-button--scrolled .floating-button a.button:any-link {
+ min-width: unset
+ }
+}
diff --git a/express/scripts/widgets/floating-cta.js b/express/scripts/widgets/floating-cta.js
new file mode 100644
index 00000000..315027d0
--- /dev/null
+++ b/express/scripts/widgets/floating-cta.js
@@ -0,0 +1,426 @@
+// import { decorateLinks} from '../';
+
+import { getLibs, getLottie, lazyLoadLottiePlayer, getMobileOperatingSystem } from '../utils.js';
+import { getIconElementDeprecated } from '../utils/icons.js';
+import BlockMediator from '../block-mediator.min.js';
+
+const { createTag, loadStyle, getConfig } = await import(`${getLibs()}/utils/utils.js`);
+
+export const hideScrollArrow = (floatButtonWrapper, lottieScrollButton) => {
+ floatButtonWrapper.classList.add('floating-button--scrolled');
+ if (lottieScrollButton) {
+ if (document.activeElement === lottieScrollButton) lottieScrollButton.blur();
+ lottieScrollButton.tabIndex = -1;
+ }
+};
+
+export const showScrollArrow = (floatButtonWrapper, lottieScrollButton) => {
+ floatButtonWrapper.classList.remove('floating-button--scrolled');
+ if (lottieScrollButton) lottieScrollButton.removeAttribute('tabIndex');
+};
+
+export function openToolBox(wrapper, lottie, data) {
+ const toolbox = wrapper.querySelector('.toolbox');
+ const button = wrapper.querySelector('.floating-button');
+
+ const scrollAnchor = document.querySelector('.section:not(:nth-child(1)):not(:nth-child(2)) .template-list, .section:not(:nth-child(1)):not(:nth-child(2)) .layouts, .section:not(:nth-child(1)):not(:nth-child(2)) .steps-highlight-container') ?? document.querySelector('.section:nth-child(3)');
+ if (data.scrollState === 'withLottie' && scrollAnchor) {
+ showScrollArrow(wrapper, lottie);
+ }
+ wrapper.classList.remove('toolbox-opened');
+ setTimeout(() => {
+ if (!wrapper.classList.contains('toolbox-opened')) {
+ toolbox.classList.add('hidden');
+ wrapper.classList.remove('clamped');
+ button.classList.remove('toolbox-opened');
+ }
+ }, 500);
+}
+
+export function closeToolBox(wrapper, lottie) {
+ const toolbox = wrapper.querySelector('.toolbox');
+ const button = wrapper.querySelector('.floating-button');
+
+ toolbox.classList.remove('hidden');
+ wrapper.classList.add('clamped');
+ button.classList.add('toolbox-opened');
+ hideScrollArrow(wrapper, lottie);
+
+ setTimeout(() => {
+ wrapper.classList.add('toolbox-opened');
+ }, 10);
+}
+
+export function initLottieArrow(lottieScrollButton, floatButtonWrapper, scrollAnchor, data) {
+ let clicked = false;
+ lottieScrollButton.addEventListener('click', () => {
+ clicked = true;
+ floatButtonWrapper.classList.add('floating-button--clicked');
+ window.scrollTo({
+ top: scrollAnchor.offsetTop,
+ behavior: 'smooth',
+ });
+ const checkIfScrollToIsFinished = setInterval(() => {
+ if (scrollAnchor.offsetTop <= window.scrollY) {
+ clicked = false;
+ floatButtonWrapper.classList.remove('floating-button--clicked');
+ clearInterval(checkIfScrollToIsFinished);
+ }
+ }, 200);
+ hideScrollArrow(floatButtonWrapper, lottieScrollButton);
+ });
+
+ window.addEventListener('scroll', () => {
+ data.scrollState = floatButtonWrapper.classList.contains('floating-button--scrolled') ? 'withoutLottie' : 'withLottie';
+ const multiFunctionButtonOpened = floatButtonWrapper.classList.contains('toolbox-opened');
+ if (clicked) return;
+ if (scrollAnchor.getBoundingClientRect().top < 100) {
+ hideScrollArrow(floatButtonWrapper, lottieScrollButton);
+ } else if (!multiFunctionButtonOpened) {
+ showScrollArrow(floatButtonWrapper, lottieScrollButton);
+ }
+ }, { passive: true });
+}
+
+function makeCTAFromSheet(block, data) {
+ const audience = block.querySelector(':scope > div').textContent.trim();
+ const audienceSpecificUrl = audience && ['desktop', 'mobile'].includes(audience) ? data.mainCta[`${audience}Href`] : null;
+ const audienceSpecificText = audience && ['desktop', 'mobile'].includes(audience) ? data.mainCta[`${audience}Text`] : null;
+ const buttonContainer = createTag('div', { class: 'button-container' });
+ const ctaFromSheet = createTag('a', { href: audienceSpecificUrl || data.mainCta.href, title: audienceSpecificText || data.mainCta.text });
+ ctaFromSheet.textContent = audienceSpecificText || data.mainCta.text;
+ buttonContainer.append(ctaFromSheet);
+ block.append(buttonContainer);
+
+ return ctaFromSheet;
+}
+
+async function buildLottieArrow(wrapper, floatingBtn, data) {
+ const lottieScrollButton = createTag(
+ 'button',
+ { class: 'floating-button-lottie' },
+ getLottie('purple-arrows', '/express/icons/purple-arrows.json'),
+ );
+
+ await import(`${getLibs()}/features/placeholders.js`).then(async (mod) => {
+ const placeholder = await mod.replaceKey('see-more', getConfig());
+ lottieScrollButton.setAttribute('aria-label', placeholder);
+ return mod.replaceKey();
+ });
+
+ floatingBtn.append(lottieScrollButton);
+
+ // Floating button scroll/click events
+ lazyLoadLottiePlayer();
+ const scrollAnchor = document.querySelector('.section:not(:nth-child(1)):not(:nth-child(2)) .template-list, .section:not(:nth-child(1)):not(:nth-child(2)) .layouts, .section:not(:nth-child(1)):not(:nth-child(2)) .steps-highlight-container') ?? document.querySelector('.section:nth-child(3)');
+ if (!scrollAnchor) {
+ hideScrollArrow(wrapper, lottieScrollButton);
+ } else {
+ initLottieArrow(lottieScrollButton, wrapper, scrollAnchor, data);
+ }
+
+ return lottieScrollButton;
+}
+
+export function createFloatingButton(block, audience, data) {
+ const aTag = makeCTAFromSheet(block, data);
+ const main = document.querySelector('main');
+ loadStyle('/express/scripts/widgets/floating-cta.css');
+
+ // Floating button html
+ const floatButtonLink = aTag.cloneNode(true);
+ floatButtonLink.className = '';
+ floatButtonLink.classList.add('button', 'gradient', 'xlarge');
+
+ // Change font size when text is too long
+ function outputsize() {
+ const floatButtonLinkStyle = window.getComputedStyle(floatButtonLink);
+ const lineHeight = floatButtonLinkStyle.getPropertyValue('line-height');
+ const lineHeightInt = +lineHeight.replace('px', '');
+
+ // To figure out the available vertical space for text
+ const paddingTop = floatButtonLinkStyle.getPropertyValue('padding-top');
+ const paddingTopInt = +paddingTop.replace('px', '');
+ const paddingBottom = floatButtonLinkStyle.getPropertyValue('padding-bottom');
+ const paddingBottomInt = +paddingBottom.replace('px', '');
+ const availableHeight = floatButtonLink.offsetHeight - paddingTopInt - paddingBottomInt;
+
+ const numberOfLines = availableHeight / lineHeightInt;
+ if (numberOfLines >= 2) {
+ floatButtonLink.style.fontSize = '0.8rem';
+ floatButtonLink.style.paddingLeft = '0.8rem';
+ floatButtonLink.style.paddingRight = '0.8rem';
+ }
+ }
+
+ new ResizeObserver(outputsize).observe(floatButtonLink);
+
+ // Hide CTAs with same url & text as the Floating CTA && is NOT a Floating CTA (in mobile/tablet)
+ const aTagURL = new URL(aTag.href);
+ const sameUrlCTAs = Array.from(main.querySelectorAll('a.button:any-link'))
+ .filter((a) => (
+ a.textContent.trim() === aTag.textContent.trim()
+ || (new URL(a.href).pathname === aTagURL.pathname && new URL(a.href).hash === aTagURL.hash))
+ && !a.parentElement.parentElement.classList.contains('floating-button'));
+ sameUrlCTAs.forEach((cta) => {
+ cta.classList.add('same-as-floating-button-CTA');
+ });
+
+ const floatButtonWrapperOld = aTag.closest('.floating-button-wrapper');
+ const floatButtonWrapper = createTag('div', { class: 'floating-button-wrapper' });
+ const floatButton = createTag('div', {
+ class: 'floating-button block',
+ 'data-block-name': 'floating-button',
+ 'data-block-status': 'loaded',
+ });
+ [...block.classList].filter((c) => c === 'closed').forEach((c) => floatButtonWrapper.classList.add(c));
+ const floatButtonInnerWrapper = createTag('div', { class: 'floating-button-inner-wrapper' });
+ const floatButtonBackground = createTag('div', { class: 'floating-button-background' });
+
+ if (audience) {
+ floatButtonWrapper.dataset.audience = audience;
+ floatButtonWrapper.dataset.sectionStatus = 'loaded';
+ }
+
+ floatButtonInnerWrapper.append(floatButtonBackground, floatButtonLink);
+ floatButton.append(floatButtonInnerWrapper);
+ floatButtonWrapper.append(floatButton);
+ main.append(floatButtonWrapper);
+ if (floatButtonWrapperOld) {
+ const parent = floatButtonWrapperOld.parentElement;
+ if (parent && parent.children.length === 1) {
+ parent.remove();
+ } else {
+ floatButtonWrapperOld.remove();
+ }
+ }
+
+ const promoBar = BlockMediator.get('promobar');
+ const currentBottom = parseInt(floatButtonWrapper.style.bottom, 10);
+ let promoBarHeight;
+ if (promoBar) {
+ const promoBarMargin = parseInt(window.getComputedStyle(promoBar.block).marginBottom, 10);
+ promoBarHeight = promoBarMargin + promoBar.block.offsetHeight;
+ }
+
+ if (promoBar && promoBar.rendered && floatButtonWrapper.dataset.audience !== 'desktop') {
+ floatButton.style.bottom = currentBottom ? `${currentBottom + promoBarHeight}px` : `${promoBarHeight}px`;
+ } else {
+ floatButton.style.removeProperty('bottom');
+ }
+
+ BlockMediator.subscribe('promobar', (e) => {
+ if (!e.newValue.rendered && floatButtonWrapper.dataset.audience !== 'desktop') {
+ floatButton.style.bottom = currentBottom ? `${currentBottom - promoBarHeight}px` : '';
+ } else {
+ floatButton.style.removeProperty('bottom');
+ }
+ });
+
+ // Intersection observer - hide button when scrolled to footer
+ const footer = document.querySelector('footer');
+ if (footer) {
+ const hideButtonWhenFooter = new IntersectionObserver((entries) => {
+ const entry = entries[0];
+ if (entry.intersectionRatio > 0 || entry.isIntersecting) {
+ floatButtonWrapper.classList.add('floating-button--hidden');
+ floatButton.style.bottom = '0px';
+ } else {
+ floatButtonWrapper.classList.remove('floating-button--hidden');
+ if (promoBar && promoBar.block) {
+ floatButton.style.bottom = currentBottom ? `${currentBottom + promoBarHeight}px` : `${promoBarHeight}px`;
+ } else if (currentBottom) {
+ floatButton.style.bottom = currentBottom;
+ }
+ }
+ }, {
+ root: null,
+ rootMargin: '32px',
+ threshold: 0,
+ });
+
+ if (document.readyState === 'complete') {
+ hideButtonWhenFooter.observe(footer);
+ } else {
+ window.addEventListener('load', () => {
+ hideButtonWhenFooter.observe(footer);
+ });
+ }
+ }
+
+ document.dispatchEvent(new CustomEvent('floatingbuttonloaded', { detail: { block: floatButtonWrapper } }));
+
+ const heroCTA = document.querySelector('a.button.same-as-floating-button-CTA');
+ if (heroCTA) {
+ const hideButtonWhenIntersecting = new IntersectionObserver(([e]) => {
+ if (e.boundingClientRect.top > window.innerHeight - 40 || e.boundingClientRect.top === 0) {
+ floatButtonWrapper.classList.remove('floating-button--below-the-fold');
+ floatButtonWrapper.classList.add('floating-button--above-the-fold');
+ } else {
+ floatButtonWrapper.classList.add('floating-button--below-the-fold');
+ floatButtonWrapper.classList.remove('floating-button--above-the-fold');
+ }
+ if (e.intersectionRatio > 0 || e.isIntersecting) {
+ floatButtonWrapper.classList.add('floating-button--intersecting');
+ floatButton.style.bottom = '0px';
+ } else {
+ floatButtonWrapper.classList.remove('floating-button--intersecting');
+ if (promoBar && promoBar.block) {
+ floatButton.style.bottom = currentBottom ? `${currentBottom + promoBarHeight}px` : `${promoBarHeight}px`;
+ } else if (currentBottom) {
+ floatButton.style.bottom = currentBottom;
+ }
+ }
+ }, {
+ root: null,
+ rootMargin: '-40px 0px',
+ threshold: 0,
+ });
+ if (document.readyState === 'complete') {
+ hideButtonWhenIntersecting.observe(heroCTA);
+ } else {
+ window.addEventListener('load', () => {
+ hideButtonWhenIntersecting.observe(heroCTA);
+ });
+ }
+ } else {
+ floatButtonWrapper.classList.add('floating-button--above-the-fold');
+ }
+
+ if (data.useLottieArrow) {
+ const lottieScrollButton = buildLottieArrow(floatButtonWrapper, floatButton, data);
+ document.dispatchEvent(new CustomEvent('linkspopulated', { detail: [floatButtonLink, lottieScrollButton] }));
+ } else {
+ data.scrollState = 'withoutLottie';
+ floatButtonWrapper.classList.add('floating-button--scrolled');
+ document.dispatchEvent(new CustomEvent('linkspopulated', { detail: [floatButtonLink] }));
+ }
+
+ // decorateLinks(floatButtonWrapper);
+ return floatButtonWrapper;
+}
+
+const CTA_ICON_COUNT = 7;
+export function collectFloatingButtonData() {
+ const metadataMap = Array.from(document.head.querySelectorAll('meta')).reduce((acc, meta) => {
+ if (meta?.name && !meta.property) acc[meta.name] = meta.content || '';
+ return acc;
+ }, {});
+ const getMetadata = (key) => metadataMap[key]; // customized getMetadata to reduce dom queries
+ const data = {
+ scrollState: 'withLottie',
+ showAppStoreBadge: ['yes', 'y', 'true', 'on'].includes(getMetadata('show-floating-cta-app-store-badge')?.toLowerCase()),
+ toolsToStash: getMetadata('ctas-above-divider'),
+ useLottieArrow: ['yes', 'y', 'true', 'on'].includes(getMetadata('use-floating-cta-lottie-arrow')?.toLowerCase()),
+ delay: getMetadata('floating-cta-drawer-delay') || 0,
+ tools: [],
+ mainCta: {
+ desktopHref: getMetadata('desktop-floating-cta-link'),
+ desktopText: getMetadata('desktop-floating-cta-text'),
+ mobileHref: getMetadata('mobile-floating-cta-link'),
+ mobileText: getMetadata('mobile-floating-cta-text'),
+ href: getMetadata('main-cta-link'),
+ text: getMetadata('main-cta-text'),
+ },
+ bubbleSheet: getMetadata('floating-cta-bubble-sheet'),
+ live: getMetadata('floating-cta-live'),
+ };
+
+ for (let i = 1; i < CTA_ICON_COUNT; i += 1) {
+ const iconMetadata = getMetadata(`cta-${i}-icon`);
+ if (!iconMetadata) break;
+ const completeSet = {
+ href: getMetadata(`cta-${i}-link`),
+ text: getMetadata(`cta-${i}-text`),
+ icon: getIconElementDeprecated(iconMetadata),
+ };
+
+ if (Object.values(completeSet).every((val) => !!val)) {
+ const { href, text, icon } = completeSet;
+ const aTag = createTag('a', { title: text, href });
+ aTag.textContent = text;
+ data.tools.push({
+ icon,
+ anchor: aTag,
+ });
+ }
+ }
+
+ return data;
+}
+
+export function decorateBadge() {
+ const anchor = createTag('a');
+ const OS = getMobileOperatingSystem();
+
+ if (anchor) {
+ anchor.textContent = '';
+ anchor.classList.add('badge');
+
+ if (OS === 'iOS') {
+ anchor.append(getIconElementDeprecated('apple-store'));
+ } else {
+ anchor.append(getIconElementDeprecated('google-store'));
+ }
+ }
+
+ return anchor;
+}
+
+export function buildToolBoxStructure(wrapper, data) {
+ lazyLoadLottiePlayer();
+ const toolBox = createTag('div', { class: 'toolbox' });
+ const toolBoxWrapper = createTag('div', { class: 'toolbox-inner-wrapper' });
+ const notch = createTag('a', { class: 'notch' });
+ const notchPill = createTag('div', { class: 'notch-pill' });
+
+ const background = createTag('div', { class: 'toolbox-background' });
+ const toggleButton = createTag('a', { class: 'toggle-button' });
+ const toggleIcon = getIconElementDeprecated('plus-icon-22');
+ const boxTop = createTag('div', { class: 'toolbox-top' });
+ const boxBottom = createTag('div', { class: 'toolbox-bottom' });
+
+ const floatingButton = wrapper.querySelector('.floating-button');
+
+ toggleButton.innerHTML = getLottie('plus-animation', '/express/icons/plus-animation.json');
+ toolBoxWrapper.append(boxTop, boxBottom);
+ toolBox.append(toolBoxWrapper);
+ toggleButton.append(toggleIcon);
+ floatingButton.append(toggleButton);
+ notch.append(notchPill);
+ toolBox.append(notch);
+ wrapper.append(toolBox, background);
+
+ if (data.showAppStoreBadge) {
+ const appStoreBadge = decorateBadge();
+ toolBox.append(appStoreBadge);
+ appStoreBadge.href = data.tools[0].anchor.href;
+ }
+}
+
+export function initToolBox(wrapper, data, toggleFunction) {
+ const floatingButton = wrapper.querySelector('.floating-button');
+ const cta = floatingButton.querySelector('a');
+ const toggleButton = wrapper.querySelector('.toggle-button');
+ const lottie = wrapper.querySelector('.floating-button-lottie');
+ const notch = wrapper.querySelector('.notch');
+ const background = wrapper.querySelector('.toolbox-background');
+
+ cta.addEventListener('click', (e) => {
+ if (!wrapper.classList.contains('toolbox-opened')) {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleFunction(wrapper, lottie, data);
+ }
+ });
+
+ [toggleButton, notch, background].forEach((element) => {
+ if (element) {
+ element.addEventListener('click', (e) => {
+ e.preventDefault();
+ e.stopPropagation();
+ toggleFunction(wrapper, lottie, data);
+ });
+ }
+ });
+}
diff --git a/test/blocks/floating-button/floating-button.test.js b/test/blocks/floating-button/floating-button.test.js
new file mode 100644
index 00000000..800918cc
--- /dev/null
+++ b/test/blocks/floating-button/floating-button.test.js
@@ -0,0 +1,77 @@
+/* eslint-env mocha */
+/* eslint-disable no-unused-vars */
+
+import { readFile } from '@web/test-runner-commands';
+import { expect } from '@esm-bundle/chai';
+
+const imports = await Promise.all([
+ import('../../../express/scripts/scripts.js'),
+ import('../../../express/blocks/floating-button/floating-button.js'),
+]);
+const { default: decorate } = imports[1];
+
+describe('Floating Button', () => {
+ before(() => {
+ window.isTestEnv = true;
+ window.hlx = {};
+ window.floatingCta = [
+ {
+ path: 'default',
+ live: 'Y',
+ },
+ ];
+ window.placeholders = { 'see-more': 'See More' };
+ document.head.innerHTML = `${document.head.innerHTML}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ });
+
+ it('Floating Button exists', async () => {
+ document.body.innerHTML = await readFile({ path: './mocks/body.html' });
+ const floatingButton = document.querySelector('.floating-button');
+ decorate(floatingButton);
+ expect(floatingButton).to.exist;
+ });
+
+ it('Floating Button has the right elements and if mobile, .section should be removed', async () => {
+ document.body.innerHTML = await readFile({ path: './mocks/body.html' });
+ const floatingButton = document.querySelector('.floating-button');
+ decorate(floatingButton);
+
+ const closestSection = floatingButton.closest('.section');
+ const blockLinks = floatingButton.querySelectorAll('a');
+ expect(closestSection).to.exist;
+ expect(document.contains(closestSection)).to.be.false;
+ expect(blockLinks).to.exist;
+ });
+
+ it('Parent element should be removed if there is no link', async () => {
+ document.body.innerHTML = await readFile({ path: './mocks/no-link.html' });
+ const floatingButton = document.querySelector('.floating-button');
+ decorate(floatingButton);
+
+ const { parentElement } = floatingButton;
+ const blockLinks = floatingButton.querySelectorAll('a');
+ expect(document.contains(parentElement)).to.be.false;
+ expect(blockLinks).to.be.empty;
+ });
+});
diff --git a/test/blocks/floating-button/mocks/body.html b/test/blocks/floating-button/mocks/body.html
new file mode 100644
index 00000000..0509c03e
--- /dev/null
+++ b/test/blocks/floating-button/mocks/body.html
@@ -0,0 +1,14 @@
+