From dfa79e26d20bb4ea220e994fa5acfb0160186e86 Mon Sep 17 00:00:00 2001 From: Justin Rings Date: Wed, 25 Sep 2024 10:27:21 -0700 Subject: [PATCH] add tabs-ax --- .../blocks/billing-radio/billing-radio.css | 41 ++ express/blocks/billing-radio/billing-radio.js | 86 +++ express/blocks/ckg-link-list/ckg-link-list.js | 4 +- express/blocks/faq/faq.css | 16 +- .../blocks/pricing-cards/pricing-cards.css | 516 ++++++++++++++++ express/blocks/pricing-cards/pricing-cards.js | 566 ++++++++++++++++++ .../blocks/pricing-cards/pricing-toggle.js | 128 ++++ express/blocks/tabs-ax/tabs-ax.css | 343 +++++++++++ express/blocks/tabs-ax/tabs-ax.js | 225 +++++++ express/scripts/utils.js | 13 + express/scripts/utils/pricing.js | 30 + 11 files changed, 1958 insertions(+), 10 deletions(-) create mode 100644 express/blocks/billing-radio/billing-radio.css create mode 100644 express/blocks/billing-radio/billing-radio.js create mode 100644 express/blocks/pricing-cards/pricing-cards.css create mode 100644 express/blocks/pricing-cards/pricing-cards.js create mode 100644 express/blocks/pricing-cards/pricing-toggle.js create mode 100644 express/blocks/tabs-ax/tabs-ax.css create mode 100644 express/blocks/tabs-ax/tabs-ax.js diff --git a/express/blocks/billing-radio/billing-radio.css b/express/blocks/billing-radio/billing-radio.css new file mode 100644 index 0000000..a127ff8 --- /dev/null +++ b/express/blocks/billing-radio/billing-radio.css @@ -0,0 +1,41 @@ +.billing-radio { + display: flex; + justify-content: center; + gap: 12px; + align-items: center; + padding-top: 36px; + padding-bottom: 16px; + font-size: var(--body-font-size-s); + height: 32px; +} +.billing-radio button { + border: none; + padding: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + outline: inherit; + + font-size: var(--body-font-size-xs); + display: flex; + align-items: center; + gap: 4px; +} + +.billing-radio :focus-within { + border: 2px var(--color-info-accent) solid; +} + +.billing-radio span { + width: 16px; + height: 16px; + border-radius: 50%; + transition: 0.2s all linear; + border: 2px solid #292929; + box-sizing: border-box; +} + +.billing-radio .checked span { + border-width: 6px; +} \ No newline at end of file diff --git a/express/blocks/billing-radio/billing-radio.js b/express/blocks/billing-radio/billing-radio.js new file mode 100644 index 0000000..3a29368 --- /dev/null +++ b/express/blocks/billing-radio/billing-radio.js @@ -0,0 +1,86 @@ +// fires 'billing-plan' BM and has global sync values when multiple on same page +// import { createTag } from '../../scripts/utils.js'; +// import BlockMediator from '../../scripts/block-mediator.min.js'; +// import { addTempWrapper } from '../../scripts/decorate.js'; + +// const BILLING_PLAN = 'billing-plan'; + +export default function init(el) { + el.remove(); + // const title = el.querySelector('strong'); + // const plans = Array.from(el.querySelectorAll('ol > li')).map((li) => li.textContent.trim()); + // el.innerHTML = ''; + // el.setAttribute('role', 'radiogroup'); + // el.setAttribute('aria-labelledby', 'radio-group-label'); + // el.append(title); + // const label = el.children[0]; + // label.setAttribute('id', 'radio-group-label'); + // const buttons = []; + // if (BlockMediator.get(BILLING_PLAN) === undefined) BlockMediator.set(BILLING_PLAN, 0); + // plans.forEach((plan, planIndex) => { + // const checked = planIndex === (BlockMediator.get(BILLING_PLAN) || 0); + // const button = createTag('button', { + // id: plan, + // class: checked ? 'checked' : '', + // }); + // button.setAttribute('aria-checked', !!checked); + // button.append(createTag('label', { for: plan }, plan)); + // button.setAttribute('role', 'radio'); + // button.prepend(createTag('span')); + // button.addEventListener('click', () => { + // if (planIndex !== BlockMediator.get(BILLING_PLAN)) { + // BlockMediator.set(BILLING_PLAN, planIndex); + // } + // }); + // if (planIndex > 0) { + // button.setAttribute('tabindex', -1); + // } + // el.append(button); + // buttons.push(button); + // }); + + // function focusNextButton(currentIndex) { + // const nextIndex = (currentIndex + 1) % buttons.length; + // buttons[nextIndex].focus(); + // } + + // function focusPreviousButton(currentIndex) { + // const prevIndex = (currentIndex - 1 + buttons.length) % buttons.length; + // buttons[prevIndex].focus(); + // } + + // el.addEventListener('keydown', (e) => { + // if (!e.target.isEqualNode(document.activeElement)) return; + // const currentIndex = buttons.indexOf(e.target); + // switch (e.code) { + // case 'ArrowLeft': + // case 'ArrowUp': + // e.preventDefault(); + // focusPreviousButton(currentIndex); + // break; + // case 'ArrowRight': + // case 'ArrowDown': + // e.preventDefault(); + // focusNextButton(currentIndex); + // break; + // case 'Enter': + // case 'Space': + // e.preventDefault(); + // BlockMediator.set(BILLING_PLAN, currentIndex); + // break; + // case 'Tab': + // el.nextElementSibling.focus(); + // break; + // default: + // break; + // } + // }); + + // BlockMediator.subscribe(BILLING_PLAN, ({ newValue, oldValue }) => { + // buttons[oldValue || 0].classList.remove('checked'); + // buttons[oldValue || 0].setAttribute('aria-checked', 'false'); + // buttons[newValue].classList.add('checked'); + // buttons[newValue].setAttribute('aria-checked', 'true'); + // buttons[newValue].focus(); + // }); +} diff --git a/express/blocks/ckg-link-list/ckg-link-list.js b/express/blocks/ckg-link-list/ckg-link-list.js index 1fd18a9..fbae8d4 100644 --- a/express/blocks/ckg-link-list/ckg-link-list.js +++ b/express/blocks/ckg-link-list/ckg-link-list.js @@ -20,8 +20,8 @@ function addColorSampler(pill, colorHex, btn) { export default async function decorate(block) { const headerButton = document.querySelector('.hero-color-wrapper .text-container p:last-child'); - headerButton.classList.add('button-container'); - headerButton.querySelector('a').classList.add('button', 'accent', 'primaryCta', 'same-fcta'); + headerButton?.classList?.add('button-container'); + headerButton?.querySelector('a').classList.add('button', 'accent', 'primaryCta', 'same-fcta'); block.style.visibility = 'hidden'; diff --git a/express/blocks/faq/faq.css b/express/blocks/faq/faq.css index 557aa2d..7ae1a65 100644 --- a/express/blocks/faq/faq.css +++ b/express/blocks/faq/faq.css @@ -1,12 +1,12 @@ -.section:has(.faq) { +.section:has(> .faq) { background-color: var(--color-gray-200); } -.section:has(.faq) > div:first-child { +.section:has(> .faq) > div:first-child { padding-top: 80px; } -.section:has(.faq) > div { +.section:has(> .faq) > div { margin: auto; padding-top: 0; padding-right: 24px; @@ -14,12 +14,12 @@ max-width: 375px; } -.section:has(.faq) > div > h2, .section:has(.faq) > div > h3, .section:has(.faq) > div > h4, .section:has(.faq) > div > h5, .section:has(.faq) > div > h6 { +.section:has(> .faq) > div > h2, .section:has(> .faq) > div > h3, .section:has(> .faq) > div > h4, .section:has(> .faq) > div > h5, .section:has(> .faq) > div > h6 { margin-top: 0; margin-bottom: 40px; } -.section:has(.faq) h3 { +.section:has(> .faq) h3 { margin: 0; } @@ -73,14 +73,14 @@ } @media (min-width: 900px) { - .section:has(.faq) > div { + .section:has(> .faq) > div { padding-top: 0; padding-right: 56px; padding-left: 56px; max-width: 830px; } - .section:has(.faq) > div > h2, .section:has(.faq) > div > h3, .section:has(.faq) > div > h4, .section:has(.faq) > div > h5, .section:has(.faq) > div > h6 { + .section:has(> .faq) > div > h2, .section:has(> .faq) > div > h3, .section:has(> .faq) > div > h4, .section:has(> .faq) > div > h5, .section:has(> .faq) > div > h6 { margin-bottom: 80px; } @@ -91,7 +91,7 @@ } @media (min-width: 1200px) { - .section:has(.faq) > div { + .section:has(> .faq) > div { max-width: 1024px; } } diff --git a/express/blocks/pricing-cards/pricing-cards.css b/express/blocks/pricing-cards/pricing-cards.css new file mode 100644 index 0000000..8811841 --- /dev/null +++ b/express/blocks/pricing-cards/pricing-cards.css @@ -0,0 +1,516 @@ +:root { + --card-width: 353px; + --card-padding: 12px; +} + +.pricing-cards.no-visible { + visibility: hidden; +} + +/* +Legacy CSS for cards and special promo +*/ + +.pricing-cards .card.special-promo { + border: 1px solid #FFE600; +} + +.pricing-cards .card.special-promo>div:first-of-type { + position: absolute; + right: 8px; + top: -10px; + border-radius: 4px; + background: #FFE600; + padding: 0px 6px 1px 6px; + height: 20px; + font-size: var(--body-font-size-xs); + font-weight: 700; + display: flex; + height: 20px; + justify-content: center; + align-items: center; +} + +.pricing-cards .card .card-header h2 { + font-weight: var(--heading-font-weight); + line-height: var(--heading-line-height); + font-size: 1.75rem; + margin-top: 0; + display: flex; + align-items: center; + gap: 8px; +} + +/* +End legacy CSS +*/ +.pricing-cards-container>div.pricing-cards-wrapper { + max-width: none; +} + +.pricing-cards { + padding: 12px 15px 40px 15px; +} + +.pricing-cards .cards-container { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(var(--card-width), calc((1200px - 32px) / 3))); + grid-template-rows: repeat(auto-fit, minmax(311px, 1fr)); + justify-content: center; + gap: 16px; +} + +.pricing-cards .card { + background-color: var(--color-white); + position: relative; + display: flex; + flex-direction: column; + align-items: center; + border-radius: 8px; + width: 100%; + padding: var(--card-padding); + gap: 16px; + text-align: left; + box-sizing: border-box; + height: 100%; + border: 1px solid #C6C6C6; +} + +/* +Border style variants +*/ + +.pricing-cards .gradient-promo>div:first-of-type { + margin-left: 4px; + color: var(--color-white); + font-weight: 400; + padding-bottom: 12px; + line-height: 1.5rem; +} + +.pricing-cards .gradient-promo { + background: linear-gradient(90deg, #ff477b 0%, #5c5ce0 52%, #318fff 100%) !important; + padding: 12px 2px 4px 2px; + border-radius: 10px; + background: var(--color-white); + margin-top: -48px; + margin-left: -2px; + margin-right: -2px; + display: flex; + flex-direction: column; + margin-bottom: 48px; + +} + +.pricing-cards .cards-container:has(.card-border.gradient-promo) { + margin-top: 48px; +} + +.pricing-cards .cards-container { + margin-top: 24px; +} + +.pricing-cards .gradient-promo:has(div:empty) { + margin-top: -48px; +} + +.pricing-cards .gradient-promo div:empty { + padding: 18px; +} + +.pricing-cards .card-border { + margin-bottom: 48px; +} + +.pricing-cards .special-promo { + border: 2px solid #FFE600; + padding-bottom: 1px; +} + +.pricing-cards .card-border.special-promo { + border-radius: 4px; +} + +.pricing-cards .card-border.special-promo .card { + border-style: none; +} + +.pricing-cards .special-promo>div:first-of-type { + position: relative; + width: fit-content; + float: right; + top: -10px; + border-radius: 4px; + background: #FFE600; + padding: 0px 6px 1px 6px; + height: 20px; + font-size: var(--body-font-size-xs); + font-weight: 700; + display: flex; + height: 20px; + justify-content: center; + align-items: center; + z-index: 3; +} + +.pricing-cards .special-promo .card { + position: relative; + top: -20px; +} + +/* +End border style variants +*/ + +.pricing-cards .tooltip-icon { + position: relative; + top: 2px; +} +.pricing-cards .tooltip-text.overflow-left { + right: inherit; + left: -20px; + +} +.pricing-cards .tooltip-text.overflow-right { + left: inherit; + right: -20px; + +} +.pricing-cards .tooltip-text { + pointer-events: none; + opacity: 0; + padding: 8px; + z-index: 10; + background-color: #292929; + color: white; + border-radius: 8px; + position: absolute; + bottom: 100%; + min-width: 200px; + left: -105px; + width: fit-content; + margin-bottom: 19px; + transition: bottom 0.5s; + width: 100%; +} + +.pricing-cards .tooltip-text::after { + content: ''; + position: absolute; + bottom: -5px; + left: 50%; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid black; + +} + +.pricing-cards .tooltip-text.overflow-left::after { + right: inherit; + left: 20px; +} + +.pricing-cards .tooltip-text.overflow-right::after { + left: inherit; + right: 20px; +} + +.pricing-cards .tooltip { + position: relative; + display: inline; +} + +.pricing-cards .tooltip::after { + display: inline; +} + +.pricing-cards .tooltip span { + position: relative; +} + +.pricing-cards .tooltip .icon:hover~.tooltip-text { + opacity: 1; + pointer-events: visible; +} + +.pricing-cards .card-border .hide { + display: none; +} + +.pricing-cards .card-header { + align-self: stretch; + display: flex; + align-items: center; + justify-content: space-between; +} + + +.pricing-cards .card-border .card .card-header h2 { + display: flex; + flex-direction: row-reverse; + gap: 8px; + text-align: left; +} + +.pricing-cards .card-header img { + width: 22px; + height: 22px; +} + +.pricing-cards .card-header .head-cnt { + border-bottom: 1px solid #000; + padding-bottom: 2px; + font-size: var(--body-font-size-m); + font-weight: 400; +} + +.pricing-cards .card-header .head-cnt>img { + width: 14px; + height: 14px; + padding-right: 6px; +} + +.pricing-cards .card-offer { + position: absolute; + right: 8px; + top: -10px; + border-radius: 4px; + background: #D3D5FF; + padding: 0px 6px 1px; + height: 20px; + font-size: var(--body-font-size-xs); + font-weight: 700; + color: #5258E4; + height: 20px; + font-style: normal; + height: auto; + max-width: 240px; +} + +.pricing-cards .card-offer a { + text-decoration: underline; +} + +/** pricing styles start **/ +.pricing-cards .pricing-row { + max-width: 330px; + margin-top: 20px; +} + +.pricing-cards .pricing-base-price, +.pricing-cards .pricing-price { + font-size: var(--heading-font-size-s); + font-weight: 700; +} + +.pricing-cards .pricing-price.price-active { + color: #5258E4; +} + +.pricing-cards .pricing-base-price { + color: var(--color-gray-600); +} + +.pricing-cards .pricing-base-price>strong { + text-decoration: line-through; + padding-right: 4px; +} + +.pricing-cards .pricing-price>strong, +.pricing-cards .pricing-base-price>strong, +.pricing-cards .pricing-price>sup { + padding-left: 4px; +} + +.pricing-cards .pricing-section { + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; +} + +.pricing-cards .pricing-section.hide { + display: none; +} + +.pricing-cards .pricing-area { + position: relative; + border-radius: 8px; + background: var(--color-gray-150); + border: 1px solid #E0E2FF; + padding: var(--card-padding); + box-sizing: border-box; + displaY: flex; + flex-direction: column; + gap: 16px; +} + +.pricing-cards .pricing-area p { + margin: 0; + font-size: var(--body-font-size-s); +} + +.pricing-cards .pricing-area .pricing-row-suf { + font-size: var(--body-font-size-xs); +} + +.pricing-cards .pricing-area:not(:has(p)) { + font-size: 22px; + justify-content: center; + align-items: center; + text-align: center; +} + +.pricing-cards-wrapper+.default-content-wrapper .button-container { + margin-top: -40px; + +} + +.pricing-cards-wrapper+.default-content-wrapper .button-container .button { + padding: 13px 1.5em 14px 1.5em; + border-radius: 27px; + font-size: var(--body-font-size-m); +} + + +/** pricing styles end **/ + +.pricing-cards .billing-toggle.suppressed-billing-toggle { + visibility: hidden; + +} + +.pricing-cards .billing-toggle { + display: flex; + justify-content: center; + gap: 12px; + align-items: center; + font-size: var(--body-font-size-s); + margin-bottom: 12px; +} + +.pricing-cards .billing-toggle button { + border: 2px transparent solid; + padding: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; + outline: inherit; + + font-size: var(--body-font-size-xs); + display: flex; + align-items: center; + gap: 4px; +} + +.pricing-cards .billing-toggle :focus-within { + border-color: var(--color-info-accent); +} + +.pricing-cards .billing-toggle span { + width: 16px; + height: 16px; + border-radius: 50%; + transition: 0.2s all linear; + border: 2px solid #292929; + box-sizing: border-box; +} + +.pricing-cards .billing-toggle .checked span { + border-width: 6px; +} + +.pricing-cards .card-cta-group { + display: flex; + flex-direction: column; + width: 100%; + gap: 16px; +} + +.pricing-cards .card-cta-group a, +.pricing-cards .card-cta-group a.con-button, +.pricing-cards .card-cta-group a.button { + width: 100%; + box-sizing: border-box; + margin: 0; +} + + + +.pricing-cards .card-feature-list { + display: none; + align-self: flex-start; +} + +.pricing-cards .card-feature-list p { + padding: 16px 0px; + border-bottom: 1px solid var(--Palette-gray-100, #E9E9E9); + margin: 0 0 24px 0; + box-sizing: border-box; +} + +.pricing-cards .card-feature-list p strong { + font-size: var(--body-font-size-m); + padding: 8px; +} + +.pricing-cards .card-feature-list ul { + list-style-type: none; + margin: 0; + padding: 0; +} + +.pricing-cards .card-feature-list li { + padding: 8px; +} + +.pricing-cards .card-feature-list li:first-of-type { + padding-top: 0; +} + +.pricing-cards .card-feature-list li:last-of-type { + padding-bottom: 0; +} + +.pricing-cards .card-feature-list li a { + text-decoration: underline; + font-weight: 400; +} + +.pricing-cards .card-compare { + margin-top: auto; + display: flex; + padding-top: 16px; + border-top: 1px solid #E9E9E9; + width: 100%; + justify-content: center; +} + +.pricing-cards .card-compare a { + text-decoration: underline; + color: #5258E4; + font-weight: 400; +} + +.pricing-cards>div:last-of-type { + padding: 12px 8px; + margin: 16px auto; + border-radius: 4px; + background: #E0E2FF; + font-size: var(--body-font-size-xs); + max-width: 1200px; + box-sizing: border-box; +} + +@media screen and (min-width: 900px) { + :root { + --card-width: 400px; + } + + .pricing-cards .card-feature-list { + display: block; + } +} \ No newline at end of file diff --git a/express/blocks/pricing-cards/pricing-cards.js b/express/blocks/pricing-cards/pricing-cards.js new file mode 100644 index 0000000..bf2a717 --- /dev/null +++ b/express/blocks/pricing-cards/pricing-cards.js @@ -0,0 +1,566 @@ +import { + getLibs, + yieldToMain, +} from '../../scripts/utils.js'; +import { addTempWrapperDeprecated, decorateButtonsDeprecated } from '../../scripts/utils/decorate.js'; +import { getIconElementDeprecated, fixIcons } from '../../scripts/utils/icons.js'; +import { debounce } from '../../scripts/utils/hofs.js'; +import { formatSalesPhoneNumber } from '../../scripts/utils/location-utils.js'; +import { + formatDynamicCartLink, + shallSuppressOfferEyebrowText, + fetchPlanOnePlans, +} from '../../scripts/utils/pricing.js'; +import createToggle, { tagFreePlan } from './pricing-toggle.js'; + +const [{ createTag, getConfig }, placeholderMod] = await Promise.all([import(`${getLibs()}/utils/utils.js`), import(`${getLibs()}/features/placeholders.js`)]); + +const blockKeys = [ + 'header', + 'borderParams', + 'explain', + 'mPricingRow', + 'mCtaGroup', + 'yPricingRow', + 'yCtaGroup', + 'featureList', + 'compare', +]; +const SAVE_PERCENTAGE = '((savePercentage))'; +const SALES_NUMBERS = '((business-sales-numbers))'; +const PRICE_TOKEN = '((pricing))'; +const YEAR_2_PRICING_TOKEN = '[[year-2-pricing-token]]'; + +function suppressOfferEyebrow(specialPromo, legacyVersion) { + if (specialPromo.parentElement) { + if (legacyVersion) { + specialPromo.parentElement.classList.remove('special-promo'); + specialPromo.remove(); + } else { + specialPromo.className = 'hide'; + specialPromo.parentElement.className = ''; + specialPromo.parentElement.classList.add('card-border'); + specialPromo.remove(); + } + } +} + +async function getPriceElementSuffix(placeholderArr, response) { + const placeholders = await placeholderMod.replaceKeyArray(placeholderArr, getConfig()); + return placeholderArr + .map((phText) => { + const key = phText.replace('((', '').replace('))', ''); + return key.includes('vat') && !response.showVat + ? '' + : placeholders?.[key] || ''; + }) + .join(' '); +} + +function handleYear2PricingToken(pricingArea, y2p, priceSuffix) { + try { + const elements = pricingArea.querySelectorAll('p'); + const year2PricingToken = Array.from(elements).find( + (p) => p.textContent.includes(YEAR_2_PRICING_TOKEN), + ); + if (!year2PricingToken) return; + if (y2p) { + year2PricingToken.innerHTML = year2PricingToken.innerHTML.replace( + YEAR_2_PRICING_TOKEN, + `${y2p} ${priceSuffix}`, + ); + } else { + year2PricingToken.textContent = ''; + } + } catch (e) { + window.lana.log(e); + } +} + +function handleSpecialPromo( + specialPromo, + isPremiumCard, + response, + legacyVersion, +) { + if (specialPromo?.textContent.includes(SAVE_PERCENTAGE)) { + const offerTextContent = specialPromo.textContent; + const shouldSuppress = shallSuppressOfferEyebrowText( + response.savePer, + offerTextContent, + isPremiumCard, + true, + response.offerId, + ); + + if (shouldSuppress) { + suppressOfferEyebrow(specialPromo, legacyVersion); + } else { + specialPromo.innerHTML = specialPromo.innerHTML.replace( + SAVE_PERCENTAGE, + response.savePer, + ); + } + } + if ( + !isPremiumCard + && specialPromo?.parentElement?.classList?.contains('special-promo') + ) { + specialPromo.parentElement.classList.remove('special-promo'); + if (specialPromo.parentElement.firstChild.innerHTML !== '') { + specialPromo.parentElement.firstChild.remove(); + } + } +} + +function handleSavePercentage(savePercentElem, isPremiumCard, response) { + if (savePercentElem) { + const offerTextContent = savePercentElem.textContent; + if ( + shallSuppressOfferEyebrowText( + response.savePer, + offerTextContent, + isPremiumCard, + true, + response.offerId, + ) + ) { + savePercentElem.remove(); + } else { + savePercentElem.innerHTML = savePercentElem.innerHTML.replace( + SAVE_PERCENTAGE, + response.savePer, + ); + } + } +} + +function handlePriceSuffix(priceEl, priceSuffix, priceSuffixTextContent) { + const parentP = priceEl.parentElement; + if (parentP.children.length > 1) { + Array.from(parentP.childNodes).forEach((node) => { + if (node === priceEl) return; + if (node.nodeName === '#text') { + priceSuffix.append(node); + } else { + priceSuffix.before(node); + } + }); + } else { + priceSuffix.textContent = priceSuffixTextContent; + } +} + +function handleRawPrice(price, basePrice, response) { + price.innerHTML = response.formatted; + basePrice.innerHTML = response.formattedBP || ''; + if (basePrice.innerHTML !== '') { + price.classList.add('price-active'); + } else { + price.classList.remove('price-active'); + } +} + +function adjustElementPosition() { + const elements = document.querySelectorAll('.tooltip-text'); + + if (elements.length === 0) return; + for (const element of elements) { + const rect = element.getBoundingClientRect(); + if (rect.right > window.innerWidth) { + element.classList.remove('overflow-left'); + element.classList.add('overflow-right'); + } else if (rect.left < 0) { + element.classList.remove('overflow-right'); + element.classList.add('overflow-left'); + } else { + element.classList.remove('overflow-right'); + element.classList.remove('overflow-left'); + } + } +} + +function handleTooltip(pricingArea) { + const elements = pricingArea.querySelectorAll('p'); + const pattern = /\[\[([^]+)\]\]([^]+)\[\[\/([^]+)\]\]/g; + let tooltip; + let tooltipDiv; + + Array.from(elements).forEach((p) => { + const res = pattern.exec(p.textContent); + if (res) { + tooltip = res; + tooltipDiv = p; + } + }); + if (!tooltip) return; + + tooltipDiv.innerHTML = tooltipDiv.innerHTML.replace(pattern, ''); + const tooltipText = tooltip[2]; + tooltipDiv.classList.add('tooltip'); + const span = createTag('div', { class: 'tooltip-text' }); + span.innerText = tooltipText; + const icon = getIconElementDeprecated('info', 44, 'Info', 'tooltip-icon'); + icon.append(span); + const iconWrapper = createTag('span'); + iconWrapper.append(icon); + iconWrapper.append(span); + tooltipDiv.append(iconWrapper); +} + +async function handlePrice(pricingArea, specialPromo, legacyVersion) { + const priceEl = Array.from(pricingArea.querySelectorAll('a')).find((anchor) => anchor.textContent === PRICE_TOKEN); + const pricingBtnContainer = pricingArea.querySelector('.action-area'); + + if (!pricingBtnContainer) return; + if (!priceEl) return; + + const pricingSuffixTextElem = pricingBtnContainer.nextElementSibling; + const placeholderArr = pricingSuffixTextElem.textContent?.split(' '); + + const priceRow = createTag('div', { class: 'pricing-row' }); + const price = createTag('span', { class: 'pricing-price' }); + const basePrice = createTag('span', { class: 'pricing-base-price' }); + const priceSuffix = createTag('div', { class: 'pricing-row-suf' }); + + priceRow.append(basePrice, price, priceSuffix); + + const response = await fetchPlanOnePlans(priceEl?.href); + const priceSuffixTextContent = await getPriceElementSuffix( + placeholderArr, + response, + ); + const isPremiumCard = response.ooAvailable || false; + const savePercentElem = pricingArea.querySelector('.card-offer'); + handleRawPrice(price, basePrice, response); + handlePriceSuffix(priceEl, priceSuffix, priceSuffixTextContent); + handleTooltip(pricingArea); + handleSavePercentage(savePercentElem, isPremiumCard, response); + handleSpecialPromo(specialPromo, isPremiumCard, response, legacyVersion); + handleYear2PricingToken(pricingArea, response.y2p, priceSuffixTextContent); + + priceEl?.parentNode?.remove(); + if (!priceRow) return; + pricingArea.prepend(priceRow); + pricingBtnContainer?.remove(); + pricingSuffixTextElem?.remove(); +} + +async function createPricingSection( + pricingArea, + ctaGroup, + specialPromo, + legacyVersion, +) { + const pricingSection = createTag('div', { class: 'pricing-section' }); + pricingArea.classList.add('pricing-area'); + const offer = pricingArea.querySelector(':scope > p > i'); + if (offer) { + offer.classList.add('card-offer'); + offer.parentElement.outerHTML = offer.outerHTML; + } + await handlePrice(pricingArea, specialPromo, legacyVersion); + ctaGroup.classList.add('card-cta-group'); + ctaGroup.querySelectorAll('a').forEach((a, i) => { + a.classList.add('large'); + if (i === 1) a.classList.add('secondary'); + if (a.parentNode.tagName.toLowerCase() === 'strong' || a.getAttribute('href').includes('adobesparkpost.app.link')) { + a.classList.add('button', 'primary'); + a.parentNode.remove(); + } + if (a.parentNode.tagName.toLowerCase() === 'p') { + a.parentNode.remove(); + } + formatDynamicCartLink(a); + ctaGroup.append(a); + }); + pricingSection.append(pricingArea); + pricingSection.append(ctaGroup); + return pricingSection; +} + +function readBraces(inputString, card) { + if (!inputString) { + return null; + } + + // Pattern to find {{...}} + const pattern = /\(\((.*?)\)\)/g; + const matches = Array.from(inputString.trim().matchAll(pattern)); + + if (matches.length > 0) { + const [token, promoType] = matches[matches.length - 1]; + const specialPromo = createTag('div'); + specialPromo.textContent = inputString.split(token)[0].trim(); + card.classList.add(promoType.replaceAll(' ', '')); + card.append(specialPromo); + return specialPromo; + } + return null; +} +// Function for decorating a legacy header / promo. +function decorateLegacyHeader(header, card) { + header.classList.add('card-header'); + const h2 = header.querySelector('h2'); + const h2Text = h2.textContent.trim(); + h2.innerHTML = ''; + const headerConfig = /\((.+)\)/.exec(h2Text); + const premiumIcon = header.querySelector('img'); + let specialPromo; + if (premiumIcon) h2.append(premiumIcon); + if (headerConfig) { + const cfg = headerConfig[1]; + h2.append(h2Text.replace(`(${cfg})`, '').trim()); + if (/^\d/.test(cfg)) { + const headCntDiv = createTag('div', { class: 'head-cnt', alt: '' }); + headCntDiv.textContent = cfg; + headCntDiv.prepend( + createTag('img', { + src: '/express/icons/head-count.svg', + alt: 'icon-head-count', + }), + ); + header.append(headCntDiv); + } else { + specialPromo = createTag('div'); + specialPromo.textContent = cfg; + card.classList.add('special-promo'); + card.append(specialPromo); + } + } else { + h2.append(h2Text); + } + header.querySelectorAll('p').forEach((p) => { + if (p.innerHTML.trim() === '') p.remove(); + }); + card.append(header); + return { specialPromo, cardWrapper: card }; +} + +function decorateHeader(header, borderParams, card, cardBorder) { + const h2 = header.querySelector('h2'); + // The raw text extracted from the word doc + header.classList.add('card-header'); + const specialPromo = readBraces(borderParams?.innerText, cardBorder); + const premiumIcon = header.querySelector('img'); + // Finds the headcount, removes it from the original string and creates an icon with the hc + const extractHeadCountExp = /(>?)\(\d+(.*?)\)/; + if (extractHeadCountExp.test(h2.innerText)) { + const headCntDiv = createTag('div', { class: 'head-cnt', alt: '' }); + const headCount = h2.innerText + .match(extractHeadCountExp)[0] + .replace(')', '') + .replace('(', ''); + [h2.innerText] = h2.innerText.split(extractHeadCountExp); + headCntDiv.textContent = headCount; + headCntDiv.prepend( + createTag('img', { + src: '/express/icons/head-count.svg', + alt: 'icon-head-count', + }), + ); + header.append(headCntDiv); + } + if (premiumIcon) h2.append(premiumIcon); + header.querySelectorAll('p').forEach((p) => { + if (p.innerHTML.trim() === '') p.remove(); + }); + card.append(header); + cardBorder.append(card); + return { cardWrapper: cardBorder, specialPromo }; +} + +function decorateBasicTextSection(textElement, className, card) { + if (textElement.innerHTML.trim()) { + textElement.classList.add(className); + card.append(textElement); + } +} + +// Links user to page where plans can be compared +function decorateCompareSection(compare, el, card) { + if (compare?.innerHTML.trim()) { + compare.classList.add('card-compare'); + compare.querySelector('a')?.classList.remove('button', 'accent'); + // in a tab, update url + const closestTab = el.closest('div.tabpanel'); + if (closestTab) { + try { + const tabId = parseInt(closestTab.id.split('-').pop(), 10); + const compareLink = compare.querySelector('a'); + const url = new URL(compareLink.href); + url.searchParams.set('tab', tabId); + compareLink.href = url.href; + } catch (e) { + // ignore + } + } + card.append(compare); + } +} + +// In legacy versions, the card element encapsulates all content +// In new versions, the cardBorder element encapsulates all content instead +async function decorateCard({ + header, + borderParams, + explain, + mPricingRow, + mCtaGroup, + yPricingRow, + yCtaGroup, + featureList, + compare, +}, el, legacyVersion) { + const card = createTag('div', { class: 'card' }); + const cardBorder = createTag('div', { class: 'card-border' }); + const { specialPromo, cardWrapper } = legacyVersion + ? decorateLegacyHeader(header, card) + : decorateHeader(header, borderParams, card, cardBorder); + + decorateBasicTextSection(explain, 'card-explain', card); + const [mPricingSection, yPricingSection] = await Promise.all([ + createPricingSection(mPricingRow, mCtaGroup, specialPromo, legacyVersion), + createPricingSection(yPricingRow, yCtaGroup, null), + ]); + mPricingSection.classList.add('monthly'); + yPricingSection.classList.add('annually', 'hide'); + const groupID = `${Date.now()}:${header.textContent.replace(/\s/g, '').trim()}`; + const toggle = createToggle( + [mPricingSection, yPricingSection], + groupID, + adjustElementPosition, + ); + + card.append(toggle, mPricingSection, yPricingSection); + decorateBasicTextSection(featureList, 'card-feature-list', card); + decorateCompareSection(compare, el, card); + return cardWrapper; +} + +// less thrashing by separating get and set +async function syncMinHeights(groups) { + const maxHeights = groups.map((els) => els + .filter((e) => !!e) + .reduce((max, e) => Math.max(max, e.offsetHeight), 0)); + await yieldToMain(); + maxHeights.forEach((maxHeight, i) => groups[i].forEach((e) => { + if (e) e.style.minHeight = `${maxHeight}px`; + })); +} + +export default async function init(el) { + await fixIcons(el); + const offers = Array.from(el.querySelectorAll('p > em')); + + // replace with + offers.forEach((offer) => { + const { parentElement } = offer; // + const i = document.createElement('i'); + i.innerHTML = offer.innerHTML; + parentElement.replaceChild(i, offer); + }); + + decorateButtonsDeprecated(el); + addTempWrapperDeprecated(el, 'pricing-cards'); + + // For backwards compatability with old versions of the pricing card + const legacyVersion = el.querySelectorAll(':scope > div').length < 10; + const currentKeys = [...blockKeys]; + if (legacyVersion) { + currentKeys.splice(1, 1); + } + const divs = currentKeys.map((_, index) => el.querySelectorAll(`:scope > div:nth-child(${index + 1}) > div`)); + + const cards = Array.from(divs[0]).map((_, index) => currentKeys.reduce((obj, key, keyIndex) => { + obj[key] = divs[keyIndex][index]; + return obj; + }, {})); + el.querySelectorAll(':scope > div:not(:last-of-type)').forEach((d) => d.remove()); + const cardsContainer = createTag('div', { class: 'cards-container' }); + + const decoratedCards = await Promise.all( + cards.map((card) => decorateCard(card, el, legacyVersion)), + ); + decoratedCards.forEach((card) => cardsContainer.append(card)); + + const phoneNumberTags = [...cardsContainer.querySelectorAll('a')].filter( + (a) => a.title.includes(SALES_NUMBERS), + ); + + if (phoneNumberTags.length > 0) { + await formatSalesPhoneNumber(phoneNumberTags, SALES_NUMBERS); + } + el.classList.add('no-visible'); + el.prepend(cardsContainer); + + const groups = [ + cards.map(({ header }) => header), + cards.map(({ explain }) => explain), + cards.reduce((acc, card) => [...acc, card.mCtaGroup, card.yCtaGroup], []), + [...el.querySelectorAll('.pricing-area')], + cards.map(({ featureList }) => featureList.querySelector('p')), + cards.map(({ featureList }) => featureList), + cards.map(({ compare }) => compare), + ]; + + const decoratedCardEls = [...cardsContainer.querySelectorAll('.card')]; + const synchedItems = groups.flat(); + synchedItems.forEach((item) => { + // elements with js-controlled heights need border-box + if (item) item.style.boxSizing = 'border-box'; + }); + const undoSyncHeights = () => { + synchedItems.forEach((item) => { + item.style?.removeProperty('min-height'); + }); + }; + const doSyncHeights = () => { + // possible 2 card in row 1 and 3rd card in row 2 + const yPositions = decoratedCardEls.map((c) => c.getBoundingClientRect().top); + const positionGroups = []; + // positionGroups -> [2,1] + yPositions.forEach((yPosition, i) => { + // accounting for pixel lineup issues + if (i === 0 || Math.abs(yPosition - yPositions[i - 1]) > 6) { + positionGroups.push(1); + } else { + positionGroups[positionGroups.length - 1] += 1; + } + }); + if (positionGroups.length === cards.length) { + // no sync when 1 card per row + undoSyncHeights(); + return; + } + const groupsByTop = []; + // [[h1, h2, h3], [e1, e2, e3], [m1,y1,m2,y2,m3,y3]] + [2,1] + // -> [[h1, h2], [h3], [e1, e2], [e3], [m1, m2, y1, y2], [m3, y3]] + groups.forEach((group) => { + for (let prev = 0, i = 0; i < positionGroups.length; i += 1) { + const span = positionGroups[i] * (group.length / cards.length); + groupsByTop.push(group.slice(prev, prev + span)); + prev += span; + } + }); + syncMinHeights(groupsByTop); + }; + window.addEventListener('resize', debounce(() => { + doSyncHeights(); + }, 100)); + + const observer = new IntersectionObserver((entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + doSyncHeights(); + el.classList.remove('no-visible'); + } + adjustElementPosition(); + }); + }); + + observer.observe(el); + tagFreePlan(cardsContainer); + + window.addEventListener('resize', adjustElementPosition); +} diff --git a/express/blocks/pricing-cards/pricing-toggle.js b/express/blocks/pricing-cards/pricing-toggle.js new file mode 100644 index 0000000..e8a8f7f --- /dev/null +++ b/express/blocks/pricing-cards/pricing-toggle.js @@ -0,0 +1,128 @@ +import { getLibs } from '../../scripts/utils.js'; + +const [{ createTag, getConfig }, placeholderMod] = await Promise.all([import(`${getLibs()}/utils/utils.js`), import(`${getLibs()}/features/placeholders.js`)]); +const PLANS = ['monthly', 'annually']; +const placeholders = await placeholderMod.replaceKeyArray([...PLANS, 'subscription-type'], getConfig()); + +function toggleOther(pricingSections, buttons, planIndex) { + const button = buttons[planIndex]; + if (button.classList.contains('checked')) return; + buttons.filter((b) => b !== button).forEach((b) => { + b.classList.remove('checked'); + b.setAttribute('aria-checked', 'false'); + }); + const plan = button.getAttribute('plan'); + button.classList.add('checked'); + button.setAttribute('aria-checked', 'true'); + pricingSections.forEach((section) => { + if (section.classList.contains(plan)) { + section.classList.remove('hide'); + } else { + section.classList.add('hide'); + } + }); +} + +function focusNextButton(buttons, currentIndex) { + const nextIndex = (currentIndex + 1) % buttons.length; + buttons[nextIndex].focus(); +} + +function focusPreviousButton(buttons, currentIndex) { + const prevIndex = (currentIndex - 1 + buttons.length) % buttons.length; + buttons[prevIndex].focus(); +} + +function onKeyDown(e, pricingSections, buttons, toggleWrapper) { + if (!e.target.isEqualNode(document.activeElement)) return; + const currentIndex = buttons.indexOf(e.target); + switch (e.code) { + case 'ArrowLeft': + case 'ArrowUp': + e.preventDefault(); + focusPreviousButton(buttons, currentIndex); + break; + case 'ArrowRight': + case 'ArrowDown': + e.preventDefault(); + focusNextButton(buttons, currentIndex); + break; + case 'Enter': + case 'Space': + e.preventDefault(); + toggleOther(pricingSections, buttons, currentIndex); + break; + case 'Tab': + toggleWrapper.nextElementSibling.focus(); + break; + default: + break; + } +} +export function tagFreePlan(cardContainer) { + const cards = Array.from(cardContainer.querySelectorAll('.card')); + let disableAllToggles = true; + const freePlanStatus = []; + + for (const card of cards) { + let isFreePlan = true; + const pricingSections = card.querySelectorAll('.pricing-section'); + for (const section of pricingSections) { + const price = section.querySelector('.pricing-price > strong')?.textContent; + if (price && parseInt(price, 10) > 0) { + isFreePlan = false; + disableAllToggles = false; + break; + } + } + freePlanStatus.push(isFreePlan ? card.querySelector('.billing-toggle') : undefined); + } + + freePlanStatus.forEach((billingToggle) => { + if (disableAllToggles && billingToggle) { + billingToggle.remove(); + } else if (billingToggle) { + billingToggle.classList.add('suppressed-billing-toggle'); + } + }); +} + +export default function createToggle(pricingSections, groupID, adjElemPos) { + const subDesc = placeholders?.['subscription-type'] || 'Subscription Type:'; + const toggleWrapper = createTag('div', { class: 'billing-toggle' }); + toggleWrapper.innerHTML = `${subDesc}`; + toggleWrapper.setAttribute('role', 'radiogroup'); + toggleWrapper.setAttribute('aria-labelledby', groupID); + const groupLabel = toggleWrapper.children[0]; + groupLabel.setAttribute('id', groupID); + const buttons = PLANS.map((plan, i) => { + const buttonID = `${groupID}:${plan}`; + const defaultChecked = i === 0; + const button = createTag('button', { + class: defaultChecked ? 'checked' : '', + id: buttonID, + plan, + tabIndex: defaultChecked ? '' : -1, + }); + button.appendChild(createTag('span')); + button.setAttribute('aria-checked', defaultChecked); + button.setAttribute('aria-labeledby', buttonID); + + const label = placeholders?.[plan] || plan[0].toUpperCase() + plan.slice(1).toLowerCase(); + + button.append(createTag('div', { id: `${buttonID}:radio` }, label)); + button.setAttribute('role', 'radio'); + button.addEventListener('click', () => { + toggleOther(pricingSections, buttons, i); + adjElemPos(); + }); + return button; + }); + + toggleWrapper.addEventListener('keydown', (e) => { + onKeyDown(e, pricingSections, buttons, toggleWrapper); + }); + + toggleWrapper.append(...buttons); + return toggleWrapper; +} diff --git a/express/blocks/tabs-ax/tabs-ax.css b/express/blocks/tabs-ax/tabs-ax.css new file mode 100644 index 0000000..a34c0d2 --- /dev/null +++ b/express/blocks/tabs-ax/tabs-ax.css @@ -0,0 +1,343 @@ +:root { + /* Tab Colors */ + --tabs-active-color: #1473e6; + --tabs-border-color: #d8d8d8; + --tabs-border-hover-color: #acacac; + --tabs-text-color: #7e7e7e; + --tabs-active-text-color: #2C2C2C; + --tabs-bg-color: #f1f1f1; + --tabs-active-bg-color: #fff; + --tabs-list-bg-gradient: linear-gradient(rgb(255 255 255 / 0%) 60%, rgb(245 245 245 / 80%)); + --tabs-pill-bg-color: #e1e1e1; + --tabs-pill-bg-color-hover: #dadada; + --tabs-pill-bg-color-active: #c6c6c6; + --tabs-pill-text-color: #131313; + + /* @TODO REMOVE IMPORTS DIRECTLY FROM MILO STYLE.CSS */ + + /* Spacing */ + --spacing-xxs: 8px; + --spacing-xs: 16px; + --spacing-s: 24px; + --spacing-m: 32px; + --spacing-l: 40px; + --spacing-xl: 48px; + --spacing-xxl: 56px; + --spacing-xxxl: 80px; + + /* grid sizes */ + --grid-container-width: 83.4%; +} + +:root .dark { + --tabs-border-color: rgb(56 56 56); + --tabs-text-color: #cdcdcd; + --tabs-active-text-color: #fff; + --tabs-bg-color: #1a1a1a; + --tabs-active-bg-color: #111; + --tabs-list-bg-gradient: linear-gradient(rgb(0 0 0 / 0%) 60%, rgb(0 0 0 / 80%)); + --tabs-pill-bg-color: #555; + --tabs-pill-bg-color-hover: #666; + --tabs-pill-bg-color-active: #444; + --tabs-pill-text-color: #fff; +} + +:root .ax { + --tabs-pill-ax-bg-color: #e1e1e1; + --tabs-pill-ax-text-color: #000; + --tabs-pill-bg-color: #5258E4; + --tabs-pill-bg-color-hover: #5258E4; + --tabs-pill-bg-color-active: #5258E4; + --tabs-pill-text-color: #fff; +} + +.section div.tabs-ax-wrapper { + /* undo the max width set by section */ + max-width: none; +} + +.tabs-ax { + position: relative; + margin: 0; + color: var(--tabs-active-text-color); + background-color: var(--tabs-active-bg-color); +} + +.tabs-ax.xxl-spacing { + padding: var(--spacing-xxl) 0; +} + +.tabs-ax.xl-spacing { + padding: var(--spacing-xl) 0; +} + +.tabs-ax.l-spacing { + padding: var(--spacing-l) 0; +} + +.tabs-ax.s-spacing { + padding: var(--spacing-s) 0; +} + +.tabs-ax.xs-spacing { + padding: var(--spacing-xs) 0; +} + +.tabs-ax div[role="tablist"] { + position: relative; + box-shadow: 0 -1px 0 inset var(--tabs-border-color); + display: flex; + z-index: 2; + + /* ScrollProps - If content exceeds height of container, overflow! */ + overflow: auto; + overflow-y: hidden; + scroll-snap-type: x mandatory; + -ms-overflow-style: none; /* Internet Explorer 10+ */ + scrollbar-width: none; /* Firefox 64 */ + + /* default bg */ + background: var(--tabs-list-bg-gradient); +} + +.tabs-ax div[role="tablist"]::-webkit-scrollbar { + display: none; /* Safari and Chrome */ +} + +.tabs-ax .tab-headline { + margin-top: var(--spacing-xxl); + margin-bottom: var(--spacing-xl); +} + +.tabs-ax.center .tab-headline { + text-align: center; +} + +.tabs-ax div[role="tablist"] .tab-list-container { + display: flex; + justify-content: center; + padding: 0 8.3%; + width: var(--grid-container-width); + margin: 0 auto; + box-sizing: content-box; +} + +/* center tabs */ +.tabs-ax.center div[role="tablist"]::after, +.tabs-ax.center div[role="tablist"]::before { + content: ""; + margin: auto; +} + +.tabs-ax.center div[role="tablist"] .tab-list-container { + width: auto; + margin: 0; +} + +.tabs-ax .tab-content { + border-bottom: 1px solid var(--tabs-border-color); +} + +/* contained tabs content */ +[role='tabpanel'] > .section > .content, +.tabs-ax.contained .tab-content .tab-content-container { + width: var(--grid-container-width); + margin: 0 auto; +} + +.tab-content [role='tabpanel'] .section { + position: relative; +} + +.tab-content [role='tabpanel'] .section picture.section-background { + z-index: 0; +} + +.tab-content [role='tabpanel'] .section > .content { + z-index: 1; + position: relative; +} + +.tab-content [role='tabpanel'] .section[class*='-up'] > .content { + width: 100%; +} + +.tabs-ax div[role="tablist"] button { + background: transparent; + border-radius: 4px 4px 0 0; + border: 1px solid transparent; + color: var(--tabs-text-color); + cursor: pointer; + float: left; + font-family: var(--body-font-family); + font-weight: 600; + margin-left: -1px; + margin-top: 0; + padding: 14px 16px 12px; + text-decoration: none; + transition: color 0.1s ease-in-out, background-color 0.1s ease-in-out; + white-space: nowrap; + width: auto; + z-index: 1; +} + +.tabs-ax div[role="tablist"] button:first-of-type { + margin-left: 0; + margin-top: 0; +} + +.tabs-ax div[role="tablist"] button:hover { + color: var(--tabs-active-text-color); +} + +.tabs-ax div[role="tablist"] button:active { + color: var(--tabs-active-color); +} + +.tabs-ax div[role="tablist"] button[aria-selected="true"] { + background: var(--tabs-active-bg-color); + border-color: var(--tabs-border-color) var(--tabs-border-color) transparent; + color: var(--tabs-active-text-color); +} + +/* Tabs: .quiet, .pill */ +.tabs-ax.quiet div[role="tablist"] button { + border-width: 0 0 2px; + border-color: transparent; + background: transparent; + padding-right: 0; + padding-left: 0; + margin-inline-start: 16px; +} + +.tabs-ax[class*='pill'] { + background: unset; +} + +.tabs-ax[class*='pill'] .tab-content { + border-bottom: none; +} + +.tabs-ax[class*='pill'] div[role="tablist"] { + box-shadow: unset; + background: unset; +} + +.tabs-ax[class*='pill'] div[role="tablist"] .tab-list-container { + margin-top: var(--spacing-xxs); + margin-bottom: var(--spacing-xxs); +} + +.tabs-ax.ax[class*='pill'] div[role="tablist"] .tab-list-container { + flex-wrap: wrap; + row-gap: 10px; + padding: 0; + margin-top: var(--spacing-m); + margin-bottom: var(--spacing-xxs); + width: 100%; +} + +.tabs-ax[class*='pill'] div[role="tablist"] button { + color: var(--tabs-pill-text-color); + margin: 0; + margin-inline-start: 16px; + border-radius: 75px; + font-weight: 400; + padding: 0.2em 1em; +} + +.tabs-ax.ax[class*='pill'] div[role="tablist"] button { + border-radius: 6px; + margin-inline-start: 10px; + background-color: var(--tabs-pill-ax-bg-color); + color: var(--tabs-pill-ax-text-color); +} + +.tabs-ax.quiet div[role="tablist"] button:first-of-type, +.tabs-ax[class*='pill'] div[role="tablist"] button:first-of-type { + margin-inline-start: 0; +} + +.tabs-ax.quiet div[role="tablist"] button:hover { + border-bottom-color: var(--tabs-border-hover-color); +} + +.tabs-ax.quiet div[role="tablist"] button[aria-selected="true"] { + border-bottom-color: var(--tabs-active-color); +} + +.tabs-ax[class*='pill'] div[role="tablist"] button[aria-selected="true"] { + color: var(--tabs-pill-text-color); + background: var(--tabs-pill-bg-color); + text-shadow: 0.4px 0 0 var(--tabs-pill-text-color); +} + +.tabs-ax[class*='pill'] div[role="tablist"] button:hover { + color: var(--tabs-pill-text-color); + background: var(--tabs-pill-bg-color-hover); +} + +.tabs-ax[class*='pill'] div[role="tablist"] button:focus { + color: var(--tabs-pill-text-color); + background: var(--tabs-pill-bg-color-hover); +} + +.tabs-ax[class*='pill'] div[role="tablist"] button:focus-visible { + outline: 2px solid #4145ca; + outline-offset: 2px; +} + +.tabs-ax[class*='pill'] div[role="tablist"] button:active { + color: var(--tabs-pill-text-color); + background: var(--tabs-pill-bg-color-active); +} + +.tabs-ax[class*='pill'] div[role="tablist"] button.l-pill { + font-size: 16px; + line-height: 20.8px; + height: 40px; +} + +.tabs-ax[class*='pill'] div[role="tablist"] button.m-pill { + font-size: 14px; + line-height: 18.2px; + height: 32px; +} + +.tabs-ax[class*='pill'] div[role="tablist"] button.s-pill { + font-size: 12px; + line-height: 15.6px; + height: 24px; +} + +/* Section Metadata */ +.tabs-ax-background-transparent .tabs-ax, +.tabs-ax-background-transparent .tabs-ax div[role="tablist"], +.tabs-ax-background-transparent .tabs-ax div[role="tablist"] button[aria-selected="true"] { + background: transparent; +} + +.tabs-ax-background-transparent .tabs-ax div[role="tablist"] button[aria-selected="true"] { + border-bottom-color: var(--tabs-active-bg-color); +} + +@media screen and (min-width: 1200px) { + [role='tabpanel'] > .section[class*='-up'] > .content, + [role='tabpanel'] > .section[class*='grid-width-'] > .content { + width: auto; + } + + .tabs-ax div[role="tabpanel"] h2 { + margin-top: 40px; + } + + .tabs-ax div[role="tablist"] button { + padding: 24px 32px 18px; + line-height: 18px; + } + + .tabs-ax.quiet div[role="tablist"] button { + padding-top: 18px; + padding-bottom: 18px; + } +} diff --git a/express/blocks/tabs-ax/tabs-ax.js b/express/blocks/tabs-ax/tabs-ax.js new file mode 100644 index 0000000..71fa7d0 --- /dev/null +++ b/express/blocks/tabs-ax/tabs-ax.js @@ -0,0 +1,225 @@ +/* + * tabs - consonant v6 + * https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/Tab_Role + */ +import { getLibs, readBlockConfig } from '../../scripts/utils.js'; +import { addTempWrapperDeprecated } from '../../scripts/utils/decorate.js'; +import { trackButtonClick } from '../../scripts/instrument.js'; + +const [{ createTag, MILO_EVENTS }] = await Promise.all([import(`${getLibs()}/utils/utils.js`), import(`${getLibs()}/features/placeholders.js`)]); + +const isElementInContainerView = (targetEl) => { + const rect = targetEl.getBoundingClientRect(); + const docEl = document.documentElement; + return ( + rect.top >= 0 + && rect.left >= 0 + && rect.bottom <= (window.innerHeight || /* c8 ignore next */ docEl.clientHeight) + && rect.right <= (window.innerWidth || /* c8 ignore next */ docEl.clientWidth) + ); +}; + +const scrollTabIntoView = (e) => { + const isElInView = isElementInContainerView(e); + /* c8 ignore next */ + if (!isElInView) e.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); +}; + +function changeTabs(e) { + const { target } = e; + const parent = target.parentNode; + const grandparent = parent.parentNode.nextElementSibling; + parent + .querySelectorAll('[aria-selected="true"]') + .forEach((t) => t.setAttribute('aria-selected', false)); + target.setAttribute('aria-selected', true); + scrollTabIntoView(target); + grandparent + .querySelectorAll('[role="tabpanel"]') + .forEach((p) => p.setAttribute('hidden', true)); + grandparent.parentNode + .querySelector(`#${target.getAttribute('aria-controls')}`) + .removeAttribute('hidden'); +} + +function getStringKeyName(str) { + // The \p{L} and \p{N} Unicode props are used to match any letter or digit character in any lang. + const regex = /[^\p{L}\p{N}_-]/gu; + return str.trim().toLowerCase().replace(/\s+/g, '-').replace(regex, ''); +} + +function getUniqueId(el, rootElem) { + const tabs = rootElem.querySelectorAll('.tabs'); + return [...tabs].indexOf(el) + 1; +} + +function configTabs(config, rootElem) { + const activeTab = new URLSearchParams(window.location.search).get('tab') || config['active-tab']; + if (activeTab) { + const id = `#tab-${CSS.escape(config['tab-id'])}-${CSS.escape(getStringKeyName(activeTab))}`; + const sel = rootElem.querySelector(id); + if (sel) sel.click(); + } +} + +function initTabs(elm, config, rootElem) { + const tabs = elm.querySelectorAll('[role="tab"]'); + const tabLists = elm.querySelectorAll('[role="tablist"]'); + tabLists.forEach((tabList) => { + let tabFocus = 0; + tabList.addEventListener('keydown', (e) => { + if (e.key === 'ArrowRight' || e.key === 'ArrowLeft') { + if (e.key === 'ArrowRight') { + tabFocus += 1; + /* c8 ignore next */ + if (tabFocus >= tabs.length) tabFocus = 0; + } else if (e.key === 'ArrowLeft') { + tabFocus -= 1; + /* c8 ignore next */ + if (tabFocus < 0) tabFocus = tabs.length - 1; + } + tabs[tabFocus].setAttribute('tabindex', 0); + tabs[tabFocus].focus(); + } + }); + }); + tabs.forEach((tab) => { + tab.addEventListener('click', changeTabs); + }); + if (config) configTabs(config, rootElem); + tabs.forEach((tab) => { + tab.addEventListener('click', ({ target: { id: tabId } }) => { + trackButtonClick(tab); + const url = new URL(window.location.href); + url.searchParams.set('tab', tabId.substring(tabId.lastIndexOf('-') + 1)); + url.hash = ''; + window.history.replaceState({}, '', url); + }); + }); +} + +const handleDeferredImages = (block) => { + /* c8 ignore next 6 */ + const loadLazyImages = () => { + const images = block.querySelectorAll('img[loading="lazy"]'); + images.forEach((img) => { + img.removeAttribute('loading'); + }); + }; + document.addEventListener(MILO_EVENTS.DEFERRED, loadLazyImages, { once: true, capture: true }); +}; + +const handlePillSize = (pill) => { + const sizes = ['s', 'm', 'l']; + const variant = pill.substring(0, pill.indexOf('-pill')); + const size = sizes.findIndex((tshirt) => variant.startsWith(tshirt)); + return `${sizes[size]?.[0] ?? sizes[1]}-pill`; +}; + +function decorateSectionMetadata(section) { + const metadataDiv = section.querySelector(':scope > .section-metadata'); + + if (metadataDiv) { + const meta = readBlockConfig(metadataDiv); + const keys = Object.keys(meta); + keys.forEach((key) => { + if (!['style', 'anchor', 'background'].includes(key)) { + section.setAttribute(`data-${key}`, meta[key]); + } + }); + } +} + +function decorteSectionsMetadata() { + const sections = document.querySelectorAll('.section'); + sections.forEach(decorateSectionMetadata); +} + +const init = (block) => { + addTempWrapperDeprecated(block, 'tabs-ax'); + decorteSectionsMetadata(); + // to avoid hero style conflicts + const hero = block.closest('#hero.hero-noimage'); + if (hero) { + hero.classList.remove('hero-noimage'); + hero.removeAttribute('id'); + } + const rootElem = block.closest('.fragment') || document; + const rows = block.querySelectorAll(':scope > div'); + const parentSection = block.closest('.section'); + /* c8 ignore next */ + if (!rows.length) return; + + // Tab Config + const config = {}; + const configRows = [...rows]; + configRows.splice(0, 1); + configRows.forEach((row) => { + const rowKey = getStringKeyName(row.children[0].textContent); + const rowVal = row.children[1].textContent.trim(); + config[rowKey] = rowVal; + row.remove(); + }); + const tabId = config.id || getUniqueId(block, rootElem); + config['tab-id'] = tabId; + block.id = `tabs-${tabId}`; + parentSection?.classList.add(`tablist-${tabId}-section`); + + // Tab Content + const tabContentContainer = createTag('div', { class: 'tab-content-container' }); + const tabContent = createTag('div', { class: 'tab-content' }, tabContentContainer); + block.append(tabContent); + + // Tab List + const tabList = rows[0]; + tabList.classList.add('tabList'); + tabList.setAttribute('role', 'tablist'); + const tabListContainer = tabList.querySelector(':scope > div'); + tabListContainer.classList.add('tab-list-container'); + const tabListItems = rows[0].querySelectorAll(':scope li'); + if (tabListItems) { + const pillVariant = [...block.classList].find((variant) => variant.includes('pill')); + const btnClass = pillVariant ? handlePillSize(pillVariant) : 'heading-xs'; + tabListItems.forEach((item, i) => { + const tabName = config.id ? i + 1 : getStringKeyName(item.textContent); + const tabBtnAttributes = { + role: 'tab', + class: btnClass, + id: `tab-${tabId}-${tabName}`, + tabindex: '0', + 'aria-selected': (i === 0) ? 'true' : 'false', + 'aria-controls': `tab-panel-${tabId}-${tabName}`, + }; + const tabBtn = createTag('button', tabBtnAttributes); + tabBtn.innerText = item.textContent; + tabListContainer.append(tabBtn); + + const tabContentAttributes = { + id: `tab-panel-${tabId}-${tabName}`, + role: 'tabpanel', + class: 'tabpanel', + 'aria-labelledby': `tab-${tabId}-${tabName}`, + }; + const tabListContent = createTag('div', tabContentAttributes); + tabListContent.setAttribute('aria-labelledby', `tab-${tabId}-${tabName}`); + if (i > 0) tabListContent.setAttribute('hidden', ''); + tabContentContainer.append(tabListContent); + }); + tabListItems[0].parentElement.remove(); + } + + // Tab Sections + const allSections = Array.from(rootElem.querySelectorAll('div.section[data-tab]')); + allSections.forEach((e) => { + const val = getStringKeyName(e.dataset.tab); + const assocTabItem = rootElem.querySelector(`#tab-panel-${val}`); + + if (assocTabItem) { + assocTabItem.append(e); + } + }); + handleDeferredImages(block); + initTabs(block, config, rootElem); +}; + +export default init; diff --git a/express/scripts/utils.js b/express/scripts/utils.js index 4cefaaf..ec02ff9 100644 --- a/express/scripts/utils.js +++ b/express/scripts/utils.js @@ -431,6 +431,18 @@ function fragmentBlocksToLinks(area) { }); } +const blocksToClean = [ + { name: 'pricing-cards', selector: '.pricing-cards', placeholders: ['{{gradient-promo}}'] }, +]; + +function cleanupBrackets(area) { + const pattern = /\{\{(.*?)\}\}/g; + for (const block of blocksToClean) { + const el = area.querySelector(block.selector); + if (el?.outerHTML) el.outerHTML = el.outerHTML.replace(pattern, '(($1))'); + } +} + export function decorateArea(area = document) { document.body.dataset.device = navigator.userAgent.includes('Mobile') ? 'mobile' : 'desktop'; removeIrrelevantSections(area); @@ -445,6 +457,7 @@ export function decorateArea(area = document) { import('./branchlinks.js').then((mod) => mod.default(links)); } + cleanupBrackets(area); fragmentBlocksToLinks(area); // transpile conflicting blocks transpileMarquee(area); diff --git a/express/scripts/utils/pricing.js b/express/scripts/utils/pricing.js index bad2051..a71e777 100644 --- a/express/scripts/utils/pricing.js +++ b/express/scripts/utils/pricing.js @@ -269,6 +269,36 @@ export async function fetchPlanOnePlans(planUrl) { return plan; } +const offerIdSuppressMap = new Map(); + +export function shallSuppressOfferEyebrowText( + savePer, + offerTextContent, + isPremiumCard, + isSpecialEyebrowText, + offerId, +) { + if (offerId == null || offerId === undefined) return true; + const key = offerId + isSpecialEyebrowText; + if (offerIdSuppressMap.has(key)) { + return offerIdSuppressMap.get(key); + } + let suppressOfferEyeBrowText = false; + if (isPremiumCard) { + if (isSpecialEyebrowText) { + suppressOfferEyeBrowText = !(savePer !== '' && offerTextContent.includes('{{savePercentage}}')); + } else if (isPremiumCard === '84EA7C85DEB6D5260ACE527CB41FDF0B' || isPremiumCard === '2D84772E931C704E05CAD34D43BE1746') { + suppressOfferEyeBrowText = false; + } else { + suppressOfferEyeBrowText = true; + } + } else if (offerTextContent) { + suppressOfferEyeBrowText = savePer === '' && offerTextContent.includes('{{savePercentage}}'); + } + offerIdSuppressMap.set(key, suppressOfferEyeBrowText); + return suppressOfferEyeBrowText; +} + function replaceUrlParam(url, paramName, paramValue) { const params = url.searchParams; params.set(paramName, paramValue);