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 @@ +
+
+ +
+
diff --git a/test/blocks/floating-button/mocks/no-link.html b/test/blocks/floating-button/mocks/no-link.html new file mode 100644 index 00000000..0f13b19f --- /dev/null +++ b/test/blocks/floating-button/mocks/no-link.html @@ -0,0 +1,5 @@ +
+
+
Sample Text
+
+