From 5cc1f765a144d40d3043101c7e31538767d346b1 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Tue, 2 Dec 2025 13:23:01 -0800 Subject: [PATCH 01/89] add minicart, cart --- blocks/cart/cart.css | 226 ++++++++++++++++++++++++++++++++++++++ blocks/cart/cart.js | 151 +++++++++++++++++++++++++ blocks/header/header.css | 45 ++++++++ blocks/header/header.js | 67 ++++++++++- blocks/pdp/add-to-cart.js | 41 +++++-- scripts/cart.js | 158 ++++++++++++++++++++++++++ scripts/scripts.js | 8 +- 7 files changed, 682 insertions(+), 14 deletions(-) create mode 100644 blocks/cart/cart.css create mode 100644 blocks/cart/cart.js create mode 100644 scripts/cart.js diff --git a/blocks/cart/cart.css b/blocks/cart/cart.css new file mode 100644 index 00000000..968e828c --- /dev/null +++ b/blocks/cart/cart.css @@ -0,0 +1,226 @@ +div.section.cart-section { + max-width: 1440px; + margin: 0 auto; +} + +div.section.cart-section > .default-content-wrapper { + margin: var(--spacing-l) auto; +} + +div.section.cart-section .cart-wrapper { + margin-top: var(--spacing-l); +} + +.cart-items-header { + display: grid; + grid-template-columns: 3fr 2fr 1fr; +} + +.cart-items-header h6.title-quantity { + justify-self: right; + margin-left: calc(100% - 180px); + width: 180px; + opacity: 0; +} + +@media (width >= 600px) { + .cart-items-header h6.title-quantity { + opacity: 1; + } + + .minicart .cart-items-header h6.title-quantity { + opacity: 0; + } +} + +@media (width >= 900px) { + .cart-items-header h6.title-quantity { + margin-left: calc(100% - 200px); + width: 200px; + } + + .minicart .cart-items-header h6.title-quantity { + opacity: 1; + margin-left: calc(100% - 120px); + width: 120px; + } +} + +.cart-items-header h6.title-total { + justify-self: right; +} + +.cart-items-list { + display: flex; + flex-direction: column; + gap: 1em; +} + +.minicart .cart-items-list { + min-height: calc(100vh - var(--header-height) - 14em); + max-height: calc(100vh - var(--header-height) - 14em); + overflow-y: scroll; +} + +.cart-item { + display: grid; + grid-template-columns: 3fr 2fr 1fr; +} + +.cart-items-list > span:not(:last-child) { + border-bottom: 1px solid #0000002b; + padding-bottom: 1em; +} + +.cart-item .cart-item-product { + display: grid; + gap: 0.5em; + grid-template: 'image image' + 'price price' + 'link link' / fit-content(150px) 1fr; +} + +@media (width >= 600px) { + .cart-item .cart-item-product { + grid-template: 'image link' + 'image price' / fit-content(150px) 1fr; + } + + .minicart .cart-item .cart-item-product { + grid-template: 'image image' + 'price price' + 'link link' / fit-content(150px) 1fr; + } +} + +@media (width >= 1440px) { + .minicart .cart-item .cart-item-product { + gap: 1em; + grid-template: 'image link' + 'image price' / fit-content(150px) 1fr; + } +} + +.cart-item .cart-item-product span.image { + grid-area: image; + width: 150px; + min-height: 150px; + display: flex; +} + +.cart-item .cart-item-product span.image img { + max-width: 150px; + max-height: 150px; + width: auto; + margin: 1em auto; +} + +.cart-item .cart-item-product a { + grid-area: link; + align-content: end; + font-weight: 600; + max-width: 350px; +} + +.cart-item .cart-item-product span.price { + grid-area: price; +} + +.cart-item .cart-item-quantity { + display: flex; + flex-direction: column; + align-items: center; + gap: 1em; + align-self: center; +} + +@media (width >= 600px) { + .cart-item .cart-item-quantity { + flex-direction: row; + align-items: center; + place-self: center right; + } +} + +@media (width >= 900px) { + .cart-item .cart-item-quantity { + gap: 2em; + } +} + +.cart-item .cart-item-quantity .quantity-control { + display: flex; + flex-direction: row; + gap: 0.5em; +} + +.quantity-control button { + width: 30px; + height: 30px; + border: 1px solid var(--color-gray-400); + border-radius: 50%; + background-color: var(--color-white); + color: var(--color-text); + font-size: 16px; + font-weight: 600; + cursor: pointer; +} + +.quantity-control input { + width: 40px; + height: 30px; + border: 1px solid var(--color-gray-400); + border-radius: 0; + text-align: center; + font-size: 16px; + font-weight: 600; + cursor: pointer; + padding-inline: 0; +} + +.cart-item .cart-item-quantity .remove-button { + color: var(--color-red); + font-size: 14px; + display: none; +} + +@media (width >= 600px) { + .cart-item .cart-item-quantity .remove-button { + display: block; + } + + .minicart .cart-item .cart-item-quantity .remove-button { + display: none; + } +} + +.cart-item .cart-item-total { + align-content: center; + justify-self: end; +} + +.cart-footer-subtotal { + display: flex; + flex-direction: row; + align-items: center; + gap: 1em; + justify-content: flex-end; + margin-top: 2em; + border-top: 1px solid #0000002b; + padding-top: 1em; +} + +.cart-footer-subtotal h4 { + margin: 0; +} + +.cart-footer-subtotal p { + margin: 0; +} + +.cart-controls { + display: flex; + flex-direction: row; + justify-content: flex-end; + margin: 2em 0; +} \ No newline at end of file diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js new file mode 100644 index 00000000..72879d9a --- /dev/null +++ b/blocks/cart/cart.js @@ -0,0 +1,151 @@ +import { loadCSS } from '../../scripts/aem.js'; +import cart from '../../scripts/cart.js'; + +const itemTemplate = /* html */` +
+
+
+
+
`; + +const template = /* html */` +
+
+
+
Product
+
Quantity
+
Total
+
+
+
+ +
+
+ +
+
`; + +/** + * @param {typeof cart.items[0]} item + * @param {HTMLElement} container + * @param {HTMLElement} totalEl + */ +function renderQuantityPicker(item, container, totalEl) { + // initialize the total + totalEl.textContent = `$${(item.price * item.quantity).toFixed(2)}`; + totalEl.setAttribute('data-total', item.price * item.quantity); + + // remove button, to remove the entire line item + const removeButton = document.createElement('button'); + removeButton.textContent = 'Remove'; + removeButton.classList.add('remove-button'); + removeButton.addEventListener('click', () => { + cart.removeItem(item.sku); + container.closest('span .cart-item').remove(); + }); + + // decrement, input, increment are grouped into a single visual element + // [ - ] [ 1 ] [ + ] + const qtyControlEl = document.createElement('div'); + qtyControlEl.classList.add('quantity-control'); + + // quantity input + const qtyInput = document.createElement('input'); + qtyInput.type = 'number'; + qtyInput.id = `qty-input-${item.sku}`; + qtyInput.value = item.quantity; + qtyInput.classList.add('quantity-input'); + qtyInput.addEventListener('change', (e) => { + const newQty = e instanceof CustomEvent ? e.detail : +e.target.value; + if (newQty < 1) { + removeButton.click(); + return; + } + cart.updateItem(item.sku, newQty); + qtyInput.value = newQty; + totalEl.textContent = `$${(item.price * newQty).toFixed(2)}`; + totalEl.setAttribute('data-total', item.price * newQty); + }); + // decrement + const decrementButton = document.createElement('button'); + decrementButton.textContent = '-'; + decrementButton.classList.add('quantity-button'); + decrementButton.addEventListener('click', () => { + const newQty = +qtyInput.value - 1; + qtyInput.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: true, detail: newQty })); + }); + // increment + const incrementButton = document.createElement('button'); + incrementButton.textContent = '+'; + incrementButton.classList.add('quantity-button'); + incrementButton.addEventListener('click', () => { + const newQty = +qtyInput.value + 1; + qtyInput.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: true, detail: newQty })); + }); + + // add the controls to the group + qtyControlEl.append(decrementButton, qtyInput, incrementButton); + container.append(qtyControlEl, removeButton); +} + +/** + * Cart page or minicart popover + * @param {HTMLElement} block + * @param {HTMLElement} [parent] defined if minicart + */ +export default async function decorate(block, parent) { + if (parent) { + // load styles, using minicart + loadCSS(`${window.hlx.codeBasePath}/blocks/cart/cart.css`); + } else { + block.closest('div.section').classList.add('cart-section'); + } + + block.innerHTML = template; + const itemList = block.querySelector('.cart-items-list'); + + // add each item to the list + cart.items.forEach((item) => { + const itemElement = document.createElement('span'); + itemElement.innerHTML = itemTemplate; + itemList.appendChild(itemElement); + + // product element + const productEl = itemElement.querySelector('.cart-item-product'); + const productImgEl = document.createElement('img'); + productImgEl.src = item.image; + productImgEl.alt = item.name; + const productImgWrapper = document.createElement('span'); + productImgWrapper.appendChild(productImgEl); + productImgWrapper.classList.add('image'); + + const productLinkEl = document.createElement('a'); + productLinkEl.textContent = item.name; + productLinkEl.setAttribute('href', `/products/${item.urlKey}`); + + const productPriceEl = document.createElement('span'); + productPriceEl.classList.add('price'); + productPriceEl.textContent = `$${item.price}`; + productPriceEl.setAttribute('data-price', item.price); + productEl.append(productImgWrapper, productLinkEl, productPriceEl); + + // total (qty*unit price) + const totalElement = itemElement.querySelector('.cart-item-total'); + + // quantity picker + const qtyElement = itemElement.querySelector('.cart-item-quantity'); + renderQuantityPicker(item, qtyElement, totalElement); + }); + + // footer subtotal + const subtotalEl = block.querySelector('.cart-footer-total'); + subtotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; + document.addEventListener('cart:change', () => { + subtotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; + }); +} diff --git a/blocks/header/header.css b/blocks/header/header.css index 85c3c51d..12ed0326 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -566,6 +566,51 @@ header .nav-cart [data-cart-items]::after { } } +/* minicart */ + +header .minicart { + position: fixed; + z-index: 9; + top: 0; + right: 0; + bottom: 0; + width: clamp(400px, 50vw, min(800px, 100vw)); + background: var(--layer-elevated); + padding: 2em; + box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); +} + +header .minicart[aria-hidden="true"] { + right: calc(0px - clamp(400px, 50vw, min(800px, 100vw))); + transition: right 0.3s ease-in-out; + display: block; +} + +header .minicart[aria-hidden="false"] { + right: 0; +} + +header .minicart button.close { + position: absolute; + right: 0; + top: 0; + padding: 0.5em; + cursor: pointer; + font-weight: 100; + transform: scaleX(1.3); + font-size: 1.3em; + margin: 0.5em 1em 0.5em 0; +} + +header .minicart h2 { + margin: 0; + font-size: var(--font-size-600); +} + +header .minicart > div { + margin-top: 1em; +} + /* hamburger */ header .nav-hamburger { grid-area: hamburger; diff --git a/blocks/header/header.js b/blocks/header/header.js index d20c33a8..16930c18 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -442,9 +442,70 @@ export default async function decorate(block) { } const cartItems = cookies.cart_items_count; + const cartLink = block.querySelector('.icon-cart').parentElement; + if (cartItems && +cartItems > 0) { - const cart = block.querySelector('.icon-cart').parentElement; - cart.dataset.cartItems = cartItems; - cart.lastChild.textContent = `Cart (${cartItems})`; + cartLink.dataset.cartItems = cartItems; + cartLink.lastChild.textContent = `Cart (${cartItems})`; } + + // update cart qty bubble on change + document.addEventListener('cart:change', (e) => { + cartLink.dataset.cartItems = e.detail.cart.itemCount; + cartLink.lastChild.textContent = `Cart (${e.detail.cart.itemCount})`; + }); + + // handle cart click + cartLink.addEventListener('click', async (e) => { + // if on mobile or using legacy cart, redirect to cart page + if (window.cartMode === 'legacy' || window.innerWidth < 900) { + // redirect to edge cart page in edge mode + if (window.cartMode === 'edge') { + window.location.href = '/us/en_us/checkout/cart'; + } + return; + } + + // on desktop, open minicart popover + e.preventDefault(); + let minicart = document.querySelector('#minicart'); + + if (minicart) { + minicart.setAttribute('aria-hidden', false); + return; + } + + try { + const { default: decorateCart } = await import('../cart/cart.js'); + minicart = document.createElement('div'); + minicart.id = 'minicart'; + minicart.className = 'minicart'; + minicart.setAttribute('aria-hidden', true); + block.append(minicart); + + const cartTitle = document.createElement('h2'); + cartTitle.textContent = 'Cart'; + minicart.append(cartTitle); + + const cartClose = document.createElement('button'); + cartClose.className = 'close'; + cartClose.textContent = 'X'; + cartClose.addEventListener('click', () => { + minicart.setAttribute('aria-hidden', true); + }); + minicart.append(cartClose); + + const cartBlock = document.createElement('div'); + minicart.append(cartBlock); + + decorateCart(cartBlock, cartLink); + minicart.setAttribute('aria-hidden', false); + } catch (error) { + console.error('Error importing cart:', error); + setTimeout(() => { + // redirect to cart page + window.location.href = '/us/en_us/checkout/cart'; + }, 1000); + } + }); } diff --git a/blocks/pdp/add-to-cart.js b/blocks/pdp/add-to-cart.js index 7fbc2b38..d02094c2 100644 --- a/blocks/pdp/add-to-cart.js +++ b/blocks/pdp/add-to-cart.js @@ -173,16 +173,6 @@ export default function renderAddToCart(block, parent) { addToCartButton.textContent = 'Adding...'; addToCartButton.setAttribute('aria-disabled', 'true'); - // import required modules for cart functionality - const { cartApi } = await import('../../scripts/minicart/api.js'); - const { updateMagentoCacheSections, getMagentoCache } = await import('../../scripts/storage/util.js'); - - // cCheck and update customer cache if needed - const currentCache = getMagentoCache(); - if (!currentCache?.customer) { - await updateMagentoCacheSections(['customer']); - } - // get selected quantity and product SKU const quantity = document.querySelector('.quantity-container select')?.value || 1; const sku = getMetadata('sku'); @@ -206,6 +196,37 @@ export default function renderAddToCart(block, parent) { } try { + if (window.cartMode === 'edge') { + const cartApi = (await import('../../scripts/cart.js')).default; + + const { sku: variantSku, price, name } = selectedVariant; + await cartApi.addItem({ + sku, + variantSku, + quantity: parseInt(quantity, 10), + price, + name, + urlKey: custom.urlKey, + image: selectedVariant.image[0], + selectedOptions, + }); + + // reenable button + addToCartButton.textContent = 'Add to Cart'; + addToCartButton.removeAttribute('aria-disabled'); + return; + } + + // import required modules for cart functionality + const { cartApi } = await import('../../scripts/minicart/api.js'); + const { updateMagentoCacheSections, getMagentoCache } = await import('../../scripts/storage/util.js'); + + // cCheck and update customer cache if needed + const currentCache = getMagentoCache(); + if (!currentCache?.customer) { + await updateMagentoCacheSections(['customer']); + } + // add product to cart with selected options and quantity await cartApi.addToCart(sku, selectedOptions, quantity); diff --git a/scripts/cart.js b/scripts/cart.js new file mode 100644 index 00000000..020f01f9 --- /dev/null +++ b/scripts/cart.js @@ -0,0 +1,158 @@ +const debounce = (func, wait) => { + let timeout; + return (...args) => { + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(this, args), wait); + }; +}; + +export class Cart { + static STORAGE_KEY = 'cart'; + + static STORAGE_VERSION = 1; + + static SHIPPING_THRESHOLD = 150; + + /** @type {Record} */ + #items = {}; + + constructor() { + this.#restore(); + } + + #restore() { + const cart = localStorage.getItem(Cart.STORAGE_KEY); + if (cart) { + const parsed = JSON.parse(cart); + if (parsed.version !== Cart.STORAGE_VERSION) { + localStorage.removeItem(Cart.STORAGE_KEY); + return; + } + this.#items = parsed.items.reduce((acc, item) => { + acc[item.sku] = item; + return acc; + }, {}); + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + action: 'restore', + }, + }), + ); + } + } + + #persist = debounce(() => { + const expires = new Date(Date.now() + 30 * 864e5).toUTCString(); + document.cookie = `cart_items_count=${this.itemCount}; expires=${expires}; path=/`; + localStorage.setItem(Cart.STORAGE_KEY, JSON.stringify(this)); + }, 300); + + get items() { + return Object.values(this.#items); + } + + get itemCount() { + return Object.values(this.#items).reduce( + (acc, item) => acc + item.quantity, + 0, + ); + } + + get subtotal() { + return Object.values(this.#items).reduce( + (acc, item) => acc + item.quantity * (typeof item.price === 'string' + ? parseFloat(item.price) + : item.price / 100), + 0, + ); + } + + get shipping() { + return this.subtotal < Cart.SHIPPING_THRESHOLD ? 10 : 0; + } + + clear() { + this.#items = {}; + this.#persist(); + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + action: 'clear', + }, + }), + ); + } + + /** + * @param {CartItem} item + */ + addItem(item) { + const existing = this.#items[item.sku]; + if (existing) { + existing.quantity += item.quantity; + } else { + this.#items[item.sku] = item; + } + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + item, + action: 'add', + }, + }), + ); + this.#persist(); + } + + /** + * @param {string} sku + * @param {number} quantity + */ + updateItem(sku, quantity) { + if (!this.#items[sku]) { + throw new Error(`Item with sku ${sku} not found`); + } + this.#items[sku].quantity = quantity; + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + item: this.#items[sku], + action: 'update', + }, + }), + ); + this.#persist(); + } + + /** + * @param {string} sku + */ + removeItem(sku) { + delete this.#items[sku]; + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + item: this.#items[sku], + action: 'remove', + }, + }), + ); + this.#persist(); + } + + toJSON() { + return { + version: Cart.STORAGE_VERSION, + items: Object.values(this.#items), + }; + } +} + +window.cart = new Cart(); +export default window.cart; diff --git a/scripts/scripts.js b/scripts/scripts.js index dca10ae2..035ead97 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -18,6 +18,12 @@ import { getMetadata, } from './aem.js'; +const { hostname } = window.location; +window.cartMode = hostname.includes('localhost') || hostname.includes('edge-orders--') ? 'edge' : 'legacy'; +if (['edge', 'legacy'].includes(localStorage.getItem('cartMode'))) { + window.cartMode = localStorage.getItem('cartMode'); +} + /** * Load fonts.css and set a session storage flag. */ @@ -1082,7 +1088,7 @@ async function loadLazy(doc) { } else { // wait for sidekick to be loaded document.addEventListener('sidekick-ready', () => { - // sidekick now loaded + // sidekick now loaded document.querySelector('aem-sidekick') .addEventListener('custom:sync', syncSku); }, { once: true }); From dd120293dea7db55539f496a3609349da0095011 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Tue, 2 Dec 2025 13:34:03 -0800 Subject: [PATCH 02/89] tweak path --- blocks/header/header.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/blocks/header/header.js b/blocks/header/header.js index 16930c18..9f7c3202 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -2,6 +2,9 @@ import { getMetadata, toClassName } from '../../scripts/aem.js'; import { swapIcons, getCookies } from '../../scripts/scripts.js'; import { loadFragment } from '../fragment/fragment.js'; +const EDGE_CART_PATH = '/drafts/maxed/checkout/cart'; +// const EDGE_CART_PATH = '/checkout/cart'; + // media query match that indicates desktop width const isDesktop = window.matchMedia('(width >= 1000px)'); @@ -461,7 +464,7 @@ export default async function decorate(block) { if (window.cartMode === 'legacy' || window.innerWidth < 900) { // redirect to edge cart page in edge mode if (window.cartMode === 'edge') { - window.location.href = '/us/en_us/checkout/cart'; + window.location.href = EDGE_CART_PATH; } return; } @@ -504,7 +507,7 @@ export default async function decorate(block) { console.error('Error importing cart:', error); setTimeout(() => { // redirect to cart page - window.location.href = '/us/en_us/checkout/cart'; + window.location.href = EDGE_CART_PATH; }, 1000); } }); From 9ad6234273f2ba1f79545bc6d6acab6c71b00deb Mon Sep 17 00:00:00 2001 From: Max Edell Date: Tue, 2 Dec 2025 13:43:26 -0800 Subject: [PATCH 03/89] consistent width --- blocks/header/header.js | 9 +++++---- scripts/scripts.js | 5 +++++ styles/styles.css | 4 ++++ 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/blocks/header/header.js b/blocks/header/header.js index 9f7c3202..b39a5009 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -458,14 +458,15 @@ export default async function decorate(block) { cartLink.lastChild.textContent = `Cart (${e.detail.cart.itemCount})`; }); + // change to edge cart link + if (window.cartMode === 'edge') { + cartLink.href = EDGE_CART_PATH; + } + // handle cart click cartLink.addEventListener('click', async (e) => { // if on mobile or using legacy cart, redirect to cart page if (window.cartMode === 'legacy' || window.innerWidth < 900) { - // redirect to edge cart page in edge mode - if (window.cartMode === 'edge') { - window.location.href = EDGE_CART_PATH; - } return; } diff --git a/scripts/scripts.js b/scripts/scripts.js index 035ead97..6ab676f5 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -512,6 +512,11 @@ function buildAutoBlocks(main) { if (metaSku || pdpBlock) { document.body.classList.add('pdp-template'); } + + // setup cart page + if (getMetadata('template') === 'cart') { + document.body.classList.add('cart-template'); + } } catch (error) { // eslint-disable-next-line no-console console.error('Auto Blocking failed', error); diff --git a/styles/styles.css b/styles/styles.css index 5155e7f3..60af03d8 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -70,6 +70,10 @@ body.pdp-template { --site-width: 1440px; } +body.cart-template { + --site-width: 1440px; +} + @media (width >= 900px) { :root { /* widths and heights */ From 41e6777833c18689353443431371a680a1bbbe53 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Tue, 2 Dec 2025 13:51:42 -0800 Subject: [PATCH 04/89] update minicart on change --- blocks/cart/cart.js | 71 +++++++++++++++++++++------------------ blocks/pdp/add-to-cart.js | 6 ++-- 2 files changed, 42 insertions(+), 35 deletions(-) diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index 72879d9a..ebb8c1f1 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -109,38 +109,45 @@ export default async function decorate(block, parent) { block.innerHTML = template; const itemList = block.querySelector('.cart-items-list'); - // add each item to the list - cart.items.forEach((item) => { - const itemElement = document.createElement('span'); - itemElement.innerHTML = itemTemplate; - itemList.appendChild(itemElement); - - // product element - const productEl = itemElement.querySelector('.cart-item-product'); - const productImgEl = document.createElement('img'); - productImgEl.src = item.image; - productImgEl.alt = item.name; - const productImgWrapper = document.createElement('span'); - productImgWrapper.appendChild(productImgEl); - productImgWrapper.classList.add('image'); - - const productLinkEl = document.createElement('a'); - productLinkEl.textContent = item.name; - productLinkEl.setAttribute('href', `/products/${item.urlKey}`); - - const productPriceEl = document.createElement('span'); - productPriceEl.classList.add('price'); - productPriceEl.textContent = `$${item.price}`; - productPriceEl.setAttribute('data-price', item.price); - productEl.append(productImgWrapper, productLinkEl, productPriceEl); - - // total (qty*unit price) - const totalElement = itemElement.querySelector('.cart-item-total'); - - // quantity picker - const qtyElement = itemElement.querySelector('.cart-item-quantity'); - renderQuantityPicker(item, qtyElement, totalElement); - }); + const populatelist = () => { + itemList.innerHTML = ''; + + // add each item to the list + cart.items.forEach((item) => { + const itemElement = document.createElement('span'); + itemElement.innerHTML = itemTemplate; + itemList.appendChild(itemElement); + + // product element + const productEl = itemElement.querySelector('.cart-item-product'); + const productImgEl = document.createElement('img'); + productImgEl.src = item.image; + productImgEl.alt = item.name; + const productImgWrapper = document.createElement('span'); + productImgWrapper.appendChild(productImgEl); + productImgWrapper.classList.add('image'); + + const productLinkEl = document.createElement('a'); + productLinkEl.textContent = item.name; + productLinkEl.setAttribute('href', new URL(item.url).pathname); + + const productPriceEl = document.createElement('span'); + productPriceEl.classList.add('price'); + productPriceEl.textContent = `$${item.price}`; + productPriceEl.setAttribute('data-price', item.price); + productEl.append(productImgWrapper, productLinkEl, productPriceEl); + + // total (qty*unit price) + const totalElement = itemElement.querySelector('.cart-item-total'); + + // quantity picker + const qtyElement = itemElement.querySelector('.cart-item-quantity'); + renderQuantityPicker(item, qtyElement, totalElement); + }); + }; + + populatelist(); + document.addEventListener('cart:change', populatelist); // footer subtotal const subtotalEl = block.querySelector('.cart-footer-total'); diff --git a/blocks/pdp/add-to-cart.js b/blocks/pdp/add-to-cart.js index d02094c2..329c78a0 100644 --- a/blocks/pdp/add-to-cart.js +++ b/blocks/pdp/add-to-cart.js @@ -201,12 +201,12 @@ export default function renderAddToCart(block, parent) { const { sku: variantSku, price, name } = selectedVariant; await cartApi.addItem({ - sku, - variantSku, + sku: variantSku ?? sku, + parentSku: variantSku ? sku : undefined, quantity: parseInt(quantity, 10), price, name, - urlKey: custom.urlKey, + url: selectedVariant.url, image: selectedVariant.image[0], selectedOptions, }); From b8439acd46f42558f62bc3b14f163792c57054a1 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Tue, 2 Dec 2025 13:54:26 -0800 Subject: [PATCH 05/89] handle url error --- blocks/cart/cart.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index ebb8c1f1..afec3f53 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -129,7 +129,13 @@ export default async function decorate(block, parent) { const productLinkEl = document.createElement('a'); productLinkEl.textContent = item.name; - productLinkEl.setAttribute('href', new URL(item.url).pathname); + let path; + try { + path = new URL(item.url).pathname; + } catch (error) { + path = item.url; + } + productLinkEl.setAttribute('href', path); const productPriceEl = document.createElement('span'); productPriceEl.classList.add('price'); From 784f786c2095816216efa88e55e4f4d44c143a98 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Wed, 3 Dec 2025 15:55:10 -0800 Subject: [PATCH 06/89] use dialog, open on atc --- blocks/cart/cart.css | 6 ++-- blocks/header/header.css | 19 ++++++------- blocks/header/header.js | 59 ++++++++++++++++++++++++++++++++------- blocks/pdp/add-to-cart.js | 1 + 4 files changed, 61 insertions(+), 24 deletions(-) diff --git a/blocks/cart/cart.css b/blocks/cart/cart.css index 968e828c..875c3327 100644 --- a/blocks/cart/cart.css +++ b/blocks/cart/cart.css @@ -57,8 +57,8 @@ div.section.cart-section .cart-wrapper { } .minicart .cart-items-list { - min-height: calc(100vh - var(--header-height) - 14em); - max-height: calc(100vh - var(--header-height) - 14em); + min-height: calc(100vh - var(--header-height) - 16em); + max-height: calc(100vh - var(--header-height) - 16em); overflow-y: scroll; } @@ -222,5 +222,5 @@ div.section.cart-section .cart-wrapper { display: flex; flex-direction: row; justify-content: flex-end; - margin: 2em 0; + margin: 2em 0 0; } \ No newline at end of file diff --git a/blocks/header/header.css b/blocks/header/header.css index 12ed0326..a2544aa4 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -569,25 +569,22 @@ header .nav-cart [data-cart-items]::after { /* minicart */ header .minicart { - position: fixed; - z-index: 9; - top: 0; - right: 0; - bottom: 0; width: clamp(400px, 50vw, min(800px, 100vw)); + min-height: 100vh; background: var(--layer-elevated); padding: 2em; box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); + margin: 0 0 0 100%; + border: none; + transition: transform 0.3s ease-in-out; } -header .minicart[aria-hidden="true"] { - right: calc(0px - clamp(400px, 50vw, min(800px, 100vw))); - transition: right 0.3s ease-in-out; - display: block; +header .minicart[aria-expanded="false"] { + transform: translateX(0); } -header .minicart[aria-hidden="false"] { - right: 0; +header .minicart[aria-expanded="true"] { + transform: translateX(-100%); } header .minicart button.close { diff --git a/blocks/header/header.js b/blocks/header/header.js index b39a5009..d432d4c0 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -463,28 +463,35 @@ export default async function decorate(block) { cartLink.href = EDGE_CART_PATH; } - // handle cart click - cartLink.addEventListener('click', async (e) => { + const openOrRedirect = async (e) => { + // if already on cart page, do nothing + if (window.location.pathname.includes(EDGE_CART_PATH)) { + return; + } + // if on mobile or using legacy cart, redirect to cart page if (window.cartMode === 'legacy' || window.innerWidth < 900) { return; } // on desktop, open minicart popover - e.preventDefault(); + if (e) { + e.preventDefault(); + } + /** @type {HTMLDialogElement} */ let minicart = document.querySelector('#minicart'); if (minicart) { - minicart.setAttribute('aria-hidden', false); + minicart.openModal(); return; } try { const { default: decorateCart } = await import('../cart/cart.js'); - minicart = document.createElement('div'); + minicart = document.createElement('dialog'); minicart.id = 'minicart'; minicart.className = 'minicart'; - minicart.setAttribute('aria-hidden', true); + minicart.setAttribute('aria-expanded', false); block.append(minicart); const cartTitle = document.createElement('h2'); @@ -495,15 +502,41 @@ export default async function decorate(block) { cartClose.className = 'close'; cartClose.textContent = 'X'; cartClose.addEventListener('click', () => { - minicart.setAttribute('aria-hidden', true); + minicart.closeModal(); }); minicart.append(cartClose); const cartBlock = document.createElement('div'); minicart.append(cartBlock); - decorateCart(cartBlock, cartLink); - minicart.setAttribute('aria-hidden', false); + + minicart.addEventListener('click', (event) => { + const rect = minicart.getBoundingClientRect(); + const isInDialog = (rect.top <= event.clientY + && event.clientY <= rect.top + rect.height + && rect.left <= event.clientX + && event.clientX <= rect.left + rect.width); + if (!isInDialog) { + minicart.closeModal(); + } + }); + + // open/close methods to account for transitions + const open = () => { + minicart.showModal(); + minicart.setAttribute('aria-expanded', true); + }; + minicart.openModal = open; + + const close = () => { + minicart.setAttribute('aria-expanded', false); + setTimeout(() => { + minicart.close(); + }, 300); + }; + minicart.closeModal = close; + + minicart.openModal(); } catch (error) { console.error('Error importing cart:', error); setTimeout(() => { @@ -511,5 +544,11 @@ export default async function decorate(block) { window.location.href = EDGE_CART_PATH; }, 1000); } - }); + }; + + // handle cart click + cartLink.addEventListener('click', openOrRedirect); + + // and when adding to cart from PDP + document.addEventListener('pdp:add-to-cart', openOrRedirect); } diff --git a/blocks/pdp/add-to-cart.js b/blocks/pdp/add-to-cart.js index 329c78a0..7cff8fca 100644 --- a/blocks/pdp/add-to-cart.js +++ b/blocks/pdp/add-to-cart.js @@ -214,6 +214,7 @@ export default function renderAddToCart(block, parent) { // reenable button addToCartButton.textContent = 'Add to Cart'; addToCartButton.removeAttribute('aria-disabled'); + document.dispatchEvent(new CustomEvent('pdp:add-to-cart')); return; } From b762d9a490b8800fc8f743601a60fb22aba635f9 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Thu, 4 Dec 2025 13:54:38 -0700 Subject: [PATCH 07/89] chore: use optimized picture --- blocks/cart/cart.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index afec3f53..59a62013 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -1,4 +1,4 @@ -import { loadCSS } from '../../scripts/aem.js'; +import { loadCSS, createOptimizedPicture } from '../../scripts/aem.js'; import cart from '../../scripts/cart.js'; const itemTemplate = /* html */` @@ -120,11 +120,9 @@ export default async function decorate(block, parent) { // product element const productEl = itemElement.querySelector('.cart-item-product'); - const productImgEl = document.createElement('img'); - productImgEl.src = item.image; - productImgEl.alt = item.name; + const productPictureEl = createOptimizedPicture(item.image, '', true); const productImgWrapper = document.createElement('span'); - productImgWrapper.appendChild(productImgEl); + productImgWrapper.appendChild(productPictureEl); productImgWrapper.classList.add('image'); const productLinkEl = document.createElement('a'); From 0721faf59cff716d85ea9e8057b4a4316fa24b89 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Thu, 4 Dec 2025 19:49:03 -0800 Subject: [PATCH 08/89] checkout form --- blocks/cart-summary/cart-summary.css | 228 +++++++++++++++++++++++++++ blocks/cart-summary/cart-summary.js | 148 +++++++++++++++++ blocks/cart/cart.js | 2 +- blocks/checkout/checkout.css | 89 +++++++++++ blocks/checkout/checkout.js | 51 ++++++ blocks/form/form.js | 82 +++++++++- 6 files changed, 593 insertions(+), 7 deletions(-) create mode 100644 blocks/cart-summary/cart-summary.css create mode 100644 blocks/cart-summary/cart-summary.js create mode 100644 blocks/checkout/checkout.css create mode 100644 blocks/checkout/checkout.js diff --git a/blocks/cart-summary/cart-summary.css b/blocks/cart-summary/cart-summary.css new file mode 100644 index 00000000..47e08e1a --- /dev/null +++ b/blocks/cart-summary/cart-summary.css @@ -0,0 +1,228 @@ +.cart-summary { + background-color: var(--color-gray-100, #f5f5f5); + border-radius: 8px; + overflow: hidden; +} + +.cart-summary-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + background: transparent; + border: none; + width: 100%; + cursor: pointer; + border-bottom: 1px solid var(--color-gray-300, #ddd); +} + +.cart-summary-header h3 { + margin: 0; + font-size: var(--heading-size-l); +} + +.cart-summary-header-right { + display: flex; + align-items: center; + gap: 12px; +} + +.cart-summary-total { + font-size: 20px; + font-weight: 600; +} + +.cart-summary-header .chevron { + transition: transform 0.3s ease; +} + +.cart-summary-header[aria-expanded="true"] .chevron { + transform: rotate(180deg); +} + +.cart-summary-content { + display: none; + padding: 24px; +} + +.cart-summary-header[aria-expanded="true"] ~ .cart-summary-content { + display: block; +} + +/* Mobile: collapsed by default */ +@media (width < 1000px) { + .cart-summary-header { + cursor: pointer; + } +} + +/* Desktop: always expanded, no chevron */ +@media (width >= 1000px) { + .cart-summary-header { + cursor: default; + border-bottom: none; + padding-bottom: 12px; + } + + .cart-summary-header .chevron, + .cart-summary-header .cart-summary-total { + display: none; + } + + .cart-summary-content { + display: block; + padding-top: 0; + } + + .cart-summary-header[aria-expanded="false"] ~ .cart-summary-content { + display: block; + } +} + +/* Cart Items */ +.cart-summary-items { + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 24px; +} + +.cart-summary-item { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: flex-start; +} + +.cart-summary-item-image-wrapper { + position: relative; + width: 64px; + height: 64px; + border: 1px solid var(--color-gray-300, #ddd); + border-radius: 8px; + overflow: visible; + background: white; +} + +.cart-summary-item-image { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 8px; +} + +.cart-summary-item-quantity { + position: absolute; + top: -8px; + right: -8px; + background: var(--color-black, #333); + color: white; + border-radius: 50%; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 600; +} + +.cart-summary-item-details { + flex: 1; + min-width: 0; +} + +.cart-summary-item-name { + margin: 0 0 4px; + font-size: 14px; + font-weight: 500; + line-height: 1.4; +} + +.cart-summary-item-variant { + margin: 0; + font-size: 13px; + color: var(--color-gray-600, #666); +} + +.cart-summary-item-price { + font-size: 14px; + font-weight: 600; + white-space: nowrap; +} + +/* Discount */ +.cart-summary-discount { + display: flex; + gap: 12px; + margin-bottom: 24px; + padding-bottom: 24px; + border-bottom: 1px solid var(--color-gray-300, #ddd); +} + +.discount-input { + flex: 1; + padding: 12px 16px; + border: 1px solid var(--color-gray-400, #ccc); + border-radius: 4px; + font-size: 14px; +} + +.discount-input:focus { + outline: none; + border-color: var(--color-primary, #000); +} + +.discount-apply { + padding: 12px 24px; + background: var(--color-gray-500, #999); + color: var(--color-gray-200, #eee); + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; +} + +.discount-apply:hover { + background: var(--color-gray-600, #777); +} + +/* Totals */ +.cart-summary-totals { + display: flex; + flex-direction: column; + gap: 12px; +} + +.cart-summary-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 14px; +} + +.cart-summary-row.cart-summary-final { + margin-top: 12px; + padding-top: 16px; + border-top: 1px solid var(--color-gray-300, #ddd); + font-size: 18px; +} + +.cart-summary-final-amount { + display: flex; + align-items: center; + gap: 8px; +} + +.cart-summary-final-amount .currency { + font-size: 13px; + font-weight: 400; + color: var(--color-gray-600, #666); +} + +.cart-summary-final-amount strong { + font-size: 20px; +} + diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js new file mode 100644 index 00000000..7a434932 --- /dev/null +++ b/blocks/cart-summary/cart-summary.js @@ -0,0 +1,148 @@ +import cart from '../../scripts/cart.js'; + +const template = /* html */` +
+ +
+
+
+ + +
+
+
+ Subtotal + +
+
+ Shipping + +
+
+ Estimated taxes + $1.00 +
+
+ Total +
+ USD + +
+
+
+
+
+`; + +const itemTemplate = /* html */` +
+
+ + +
+
+

+

+
+
+
+`; + +/** + * @param {HTMLDivElement} block + */ +export default function decorate(block) { + block.innerHTML = template; + + const header = block.querySelector('.cart-summary-header'); + const itemsList = block.querySelector('.cart-summary-items'); + const subtotalEl = block.querySelector('.cart-summary-subtotal'); + const shippingEl = block.querySelector('.cart-summary-shipping'); + const grandTotalEl = block.querySelector('.cart-summary-grand-total'); + const headerTotalEl = block.querySelector('.cart-summary-total'); + + // Toggle expansion on mobile + header.addEventListener('click', () => { + if (window.innerWidth < 1000) { + const isExpanded = header.getAttribute('aria-expanded') === 'true'; + header.setAttribute('aria-expanded', !isExpanded); + } + }); + + // Disable toggle on desktop + const handleResize = () => { + if (window.innerWidth >= 1000) { + header.setAttribute('aria-expanded', 'true'); + header.style.cursor = 'default'; + } else { + header.style.cursor = 'pointer'; + } + }; + + window.addEventListener('resize', handleResize); + handleResize(); // Initial check + + const renderItems = () => { + itemsList.innerHTML = ''; + + cart.items.forEach((item) => { + const itemEl = document.createElement('div'); + itemEl.innerHTML = itemTemplate; + + const img = itemEl.querySelector('.cart-summary-item-image'); + img.src = item.image; + img.alt = item.name; + + const quantity = itemEl.querySelector('.cart-summary-item-quantity'); + quantity.textContent = item.quantity; + + const name = itemEl.querySelector('.cart-summary-item-name'); + name.textContent = item.name; + + const variant = itemEl.querySelector('.cart-summary-item-variant'); + // Extract variant from item if available + if (item.variant) { + variant.textContent = item.variant; + } else { + variant.remove(); + } + + const price = itemEl.querySelector('.cart-summary-item-price'); + const itemPrice = typeof item.price === 'string' + ? parseFloat(item.price) + : item.price / 100; + price.textContent = `$${(itemPrice * item.quantity).toFixed(2)}`; + + itemsList.appendChild(itemEl.firstElementChild); + }); + }; + + const updateTotals = () => { + const { subtotal, shipping } = cart; + const taxes = 1.00; // Placeholder + const total = subtotal + shipping + taxes; + + subtotalEl.textContent = `$${subtotal.toFixed(2)}`; + shippingEl.textContent = shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`; + grandTotalEl.textContent = `$${total.toFixed(2)}`; + headerTotalEl.textContent = `$${total.toFixed(2)}`; + }; + + // Initial render + renderItems(); + updateTotals(); + + // Listen for cart changes + document.addEventListener('cart:change', () => { + renderItems(); + updateTotals(); + }); +} diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index 59a62013..bec224b4 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -26,7 +26,7 @@ const template = /* html */`
- + Checkout
`; diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css new file mode 100644 index 00000000..932d3fb7 --- /dev/null +++ b/blocks/checkout/checkout.css @@ -0,0 +1,89 @@ +.checkout-container { + display: flex; + flex-direction: column; + gap: 24px; + max-width: 1440px; + margin: 0 auto; + padding: 0; +} + +/* Mobile: cart summary on top, form below */ +@media (width < 1000px) { + .checkout-container { + flex-direction: column; + } + + .checkout-summary-column { + order: -1; /* Cart summary appears first on mobile */ + } + + .checkout-form-column { + order: 1; + } +} + +/* Desktop: form on left, cart summary on right */ +@media (width >= 1000px) { + .checkout-container { + flex-direction: row; + align-items: flex-start; + gap: 40px; + } + + .checkout-form-column { + flex: 1; + max-width: 600px; + } + + .checkout-summary-column { + flex: 0 0 450px; + position: sticky; + top: 100px; /* Keeps it visible while scrolling */ + } +} + +/* Remove default block wrapper margins */ +.checkout .form-wrapper, +.checkout .cart-summary-wrapper { + margin: 0; +} + +/* Form styling adjustments for checkout context */ +.checkout-form-column .form { + background: transparent; +} + +.checkout-form-column form { + display: grid; + gap: var(--spacing-80); +} + +.checkout-form-column form input { + font-weight: normal; + font-family: var(--sans-serif-font-family); + font-size: 16px; +} + +.checkout-form-column .button-wrapper { + margin-top: var(--spacing-80); +} + +.checkout-form-column .button { + width: 100%; +} + +.checkout-form-column form .field-help-text { + font-size: var(--body-size-xs); +} + +.checkout-form-column .form form fieldset[data-name="optIn"] { + margin-top: 0; +} + +.checkout-form-column form fieldset[data-name="optIn"] legend { + display: none; +} + +.checkout-form-column .form form .form-field[data-name="street-1"] { + margin-top: -6px; +} \ No newline at end of file diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js new file mode 100644 index 00000000..8001594f --- /dev/null +++ b/blocks/checkout/checkout.js @@ -0,0 +1,51 @@ +import { buildBlock, decorateBlock, loadBlock } from '../../scripts/aem.js'; + +/** + * Creates and decorates the checkout page with form and cart summary + * @param {HTMLElement} block + */ +export default async function decorate(block) { + // Create the checkout layout + const checkoutContainer = document.createElement('div'); + checkoutContainer.className = 'checkout-container'; + + // Create left column (form) + const formColumn = document.createElement('div'); + formColumn.className = 'checkout-form-column'; + + // Create right column (cart summary) + const summaryColumn = document.createElement('div'); + summaryColumn.className = 'checkout-summary-column'; + + // Build the form block + const formContent = [['']]; + const formBlock = buildBlock('form', formContent); + formColumn.appendChild(formBlock); + decorateBlock(formBlock); + + // Build the cart summary block + const summaryBlock = buildBlock('cart-summary', []); + summaryColumn.appendChild(summaryBlock); + decorateBlock(summaryBlock); + + // Add columns to container + checkoutContainer.appendChild(formColumn); + checkoutContainer.appendChild(summaryColumn); + + // Replace block content + block.replaceChildren(checkoutContainer); + + // Load both blocks + await Promise.all([ + loadBlock(formBlock), + loadBlock(summaryBlock), + ]); + + // insert title into form column + const firstAddrField = formColumn.querySelector('form .form-field[data-name="firstname"]'); + if (firstAddrField) { + const title = document.createElement('h3'); + title.textContent = 'Shipping Address'; + firstAddrField.parentElement.insertBefore(title, firstAddrField); + } +} diff --git a/blocks/form/form.js b/blocks/form/form.js index 81b77ab5..e6235665 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -90,6 +90,69 @@ function buildTextArea(field) { return textarea; } +async function appendSelectOptions(select, url) { + try { + // fetch options as JSON sheet + const { pathname } = new URL(url); + const resp = await fetch(pathname); + if (!resp.ok) { + console.error('Failed to fetch select options', resp.status); + return select; + } + const { data } = await resp.json(); + if (!data || !Array.isArray(data)) { + console.error('Invalid select options JSON', data); + return select; + } + + data.forEach((option) => { + const optionEl = createElement('option'); + optionEl.value = option.value; + optionEl.textContent = option.label; + select.append(optionEl); + }); + return select; + } catch (error) { + console.error('Failed to parse select options', error); + return select; + } +} + +function buildSelect(field) { + const { + field: fieldName, required, default: defaultValue, placeholder, options, + } = field; + const select = createElement('select'); + select.id = generateId(fieldName); + select.name = select.id; + select.required = required === 'true'; + if (typeof options !== 'string') { + return select; + } + + if (/^https?:\/\//.test(options)) { + if (placeholder) { + const optionEl = createElement('option'); + if (defaultValue != null) { + optionEl.value = defaultValue; + } + optionEl.textContent = placeholder; + optionEl.setAttribute('disabled', 'true'); + select.append(optionEl); + } + appendSelectOptions(select, options); // async + } else { + options.split(';').forEach((o) => { + const option = o.trim(); + const optionEl = createElement('option'); + optionEl.value = option; + optionEl.textContent = option; + select.append(optionEl); + }); + } + return select; +} + /** * Creates a radio/checkbox input for an option * @param {Object} field - Field configuration object @@ -440,12 +503,19 @@ function buildField(field) { wrapper.append(helpText); } - const input = type === 'textarea' ? buildTextArea(field) : buildInput(field); - - if (type === 'textarea') { - wrapper.append(input); - } else { - wrapper.insertBefore(input, wrapper.firstChild.nextSibling); + let input; + switch (type) { + case 'textarea': + input = buildTextArea(field); + wrapper.append(input); + break; + case 'select': + input = buildSelect(field); + wrapper.append(input); + break; + default: + input = buildInput(field); + wrapper.insertBefore(input, wrapper.firstChild.nextSibling); } if (help) input.setAttribute('aria-describedby', helpText.id); From 66ac4adac2cdb9208f6571d91a58a55a17bb736e Mon Sep 17 00:00:00 2001 From: Max Edell Date: Sat, 6 Dec 2025 18:51:49 -0800 Subject: [PATCH 09/89] creating orders, payment options --- blocks/checkout/checkout.css | 32 +++- blocks/checkout/checkout.js | 211 ++++++++++++++++++++++++-- blocks/form/form.css | 4 + blocks/form/form.js | 35 ++++- blocks/order-summary/order-summary.js | 36 +++++ icons/paypal.svg | 7 + scripts/cart.js | 49 ++++++ scripts/scripts.js | 7 +- 8 files changed, 362 insertions(+), 19 deletions(-) create mode 100644 blocks/order-summary/order-summary.js create mode 100644 icons/paypal.svg diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 932d3fb7..f0fb6e1f 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -58,7 +58,8 @@ gap: var(--spacing-80); } -.checkout-form-column form input { +.checkout-form-column form input, +.checkout-form-column form select { font-weight: normal; font-family: var(--sans-serif-font-family); font-size: 16px; @@ -85,5 +86,30 @@ } .checkout-form-column .form form .form-field[data-name="street-1"] { - margin-top: -6px; -} \ No newline at end of file + margin-top: -2px; +} + +.checkout-form-column .form form .button-wrapper span.payment-button { + width: 100%; +} + +apple-pay-button { + --apple-pay-button-width: 100%; + --apple-pay-button-height: 40px; + --apple-pay-button-border-radius: 5px; + --apple-pay-button-padding: 5px 0px; +} + +.checkout-form-column .form form .button-wrapper span.payment-button.paypal .button { + display: flex; + justify-content: center; + gap: 6px; + padding-top: 13px; + padding-bottom: 13px; + align-items: center; +} + +span.payment-button.paypal .button img { + height: 24px; + width: 70px; +} diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 8001594f..1880be65 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -1,4 +1,12 @@ -import { buildBlock, decorateBlock, loadBlock } from '../../scripts/aem.js'; +import { + buildBlock, + decorateBlock, + loadBlock, + loadScript, +} from '../../scripts/aem.js'; +import { ORDERS_API_ORIGIN } from '../../scripts/scripts.js'; + +const ADDRESS_FORM = 'https://main--vitamix--aemsites.aem.page/drafts/maxed/checkout/address-form.json'; /** * Creates and decorates the checkout page with form and cart summary @@ -18,7 +26,7 @@ export default async function decorate(block) { summaryColumn.className = 'checkout-summary-column'; // Build the form block - const formContent = [['']]; + const formContent = [[``]]; const formBlock = buildBlock('form', formContent); formColumn.appendChild(formBlock); decorateBlock(formBlock); @@ -41,11 +49,196 @@ export default async function decorate(block) { loadBlock(summaryBlock), ]); - // insert title into form column - const firstAddrField = formColumn.querySelector('form .form-field[data-name="firstname"]'); - if (firstAddrField) { - const title = document.createElement('h3'); - title.textContent = 'Shipping Address'; - firstAddrField.parentElement.insertBefore(title, firstAddrField); - } + const sameShipBillCheckbox = formColumn.querySelector('fieldset[data-name="billingEqualsShipping"] input[type="checkbox"]'); + + // duplicate the shipping address form section to create billing address form + // will be hidden by default, only show when sameShipBill is unchecked + const shippingAddressSection = formColumn.querySelector('fieldset.form-section.section-shipping'); + const billingAddressSection = shippingAddressSection.cloneNode(true); + billingAddressSection.classList.add('form-section', 'section-billing'); + billingAddressSection.querySelector('h3').textContent = 'Billing Address'; + billingAddressSection.dataset.name = 'billingAddress'; + + // add shipping- and billing- prefix to all input id, names, and labels + shippingAddressSection.querySelectorAll('input, select').forEach((input) => { + input.id = `shipping-${input.id}`; + input.name = `shipping-${input.name}`; + if (input.previousElementSibling.tagName === 'LABEL') { + input.previousElementSibling.setAttribute('for', input.id); + } + }); + billingAddressSection.querySelectorAll('input, select').forEach((input) => { + input.id = `billing-${input.id}`; + input.name = `billing-${input.name}`; + if (input.previousElementSibling.tagName === 'LABEL') { + input.previousElementSibling.setAttribute('for', input.id); + } + }); + + // insert below sameShipBill checkbox's section + sameShipBillCheckbox.closest('fieldset.form-section').after(billingAddressSection); + + // hide billing address section by default + billingAddressSection.setAttribute('aria-hidden', true); + billingAddressSection.setAttribute('disabled', true); + + // show billing address section when sameShipBill is unchecked + sameShipBillCheckbox.addEventListener('change', () => { + billingAddressSection.setAttribute('aria-hidden', sameShipBillCheckbox.checked); + billingAddressSection.setAttribute('disabled', sameShipBillCheckbox.checked); + }); + + // initialize pay buttons + const submitButtons = [...formColumn.querySelectorAll('form .button-wrapper button[type="submit"]')]; + submitButtons.forEach((button) => { + let paymentMethod = 'Credit Card'; + if (button.textContent.toLowerCase().includes('paypal')) { + paymentMethod = 'PayPal'; + // replace with icon svg + button.textContent = button.textContent.replace('PayPal', ''); + const icon = document.createElement('img'); + icon.src = `${window.hlx.codeBasePath}/icons/paypal.svg`; + icon.classList.add('icon'); + icon.classList.add('icon-paypal'); + button.appendChild(icon); + } else if (button.textContent.toLowerCase().includes('apple')) { + paymentMethod = 'Apple Pay'; + } + + // update button styling, wrap each in a span + const span = document.createElement('span'); + span.classList.add('payment-button'); + span.dataset.paymentMethod = paymentMethod; + button.replaceWith(span); + + if (paymentMethod === 'PayPal') { + // TODO: add paypal's script & styles + span.classList.add('paypal'); + span.appendChild(button); + } else if (paymentMethod === 'Apple Pay') { + // TODO: conditionally disable this payment type if apple pay is not supported + span.classList.add('apple-pay'); + loadScript('https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'); + span.innerHTML = ''; + } else { + span.classList.add('credit-card'); + span.appendChild(button); + button.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + // validate form(s) + /** @type {HTMLFormElement} */ + const form = button.closest('form'); + const isValid = form.checkValidity(); + if (!isValid) { + form.reportValidity(); + return; + } + + // set loading state, create order, simulate payment processing, redirect to complete page + button.classList.add('loading'); + button.textContent = 'Processing...'; + button.disabled = true; + + const reenableButton = () => { + button.classList.remove('loading'); + button.textContent = 'Pay Now'; + button.disabled = false; + }; + + // get form data + const formData = Object.fromEntries(new FormData(form).entries()); + const { + email, + 'shipping-firstname': firstName, + 'shipping-lastname': lastName, + 'shipping-telephone': phone, + } = formData; + // use state text instead of index value + const state = form.querySelector(`select#shipping-state option[value="${formData['shipping-state']}"]`).textContent; + const shipping = { + name: `${firstName} ${lastName}`, + company: formData['shipping-company'], + address1: formData['shipping-street-0'], + address2: formData['shipping-street-1'], + city: formData['shipping-city'], + state, + zip: formData['shipping-zip'], + country: 'US', + phone, + email, + }; + + const { default: cart } = await import('../../scripts/cart.js'); + const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping); + console.debug('order', order); + + // create the order + const resp = await fetch(`${ORDERS_API_ORIGIN}/orders`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(order), + }); + if (!resp.ok) { + console.error('Failed to create order', resp); + // TODO: show validation errors etc. + reenableButton(); + return; + } + const data = await resp.json(); + console.debug('completed order', data); + + // simulate payment processing for a bit.. + await new Promise((resolve) => { + setTimeout(resolve, 2000); + }); + + // clear cart on successful payment + cart.clear(); + + // redirect to complete page + window.location.href = `/drafts/maxed/checkout/complete?id=${data.order.id}&email=${email}`; + + // just in case + reenableButton(); + }); + } + + // default to credit card + if (paymentMethod !== 'Credit Card') { + span.setAttribute('aria-hidden', true); + } + }); + + // handle payment method change + const paymentMethodGroup = formColumn.querySelector('fieldset[data-name="paymentMethod"]'); + const payButtons = [...formColumn.querySelectorAll('form .button-wrapper span.payment-button')]; + paymentMethodGroup.addEventListener('change', () => { + const paymentMethod = paymentMethodGroup.querySelector('input:checked').value; + + // only show billing address section if payment method is credit card + if (sameShipBillCheckbox.checked || paymentMethod !== 'Credit Card') { + billingAddressSection.setAttribute('aria-hidden', true); + } else { + billingAddressSection.removeAttribute('aria-hidden'); + } + + // toggle which submit button is visible + payButtons.forEach((button) => { + if (paymentMethod !== button.dataset.paymentMethod) { + button.setAttribute('aria-hidden', true); + } else { + button.removeAttribute('aria-hidden'); + // NOTE: temp force apply pay button to show up, even on insecure hosts + if (paymentMethod === 'Apple Pay') { + const applePayButton = button.querySelector('apple-pay-button'); + applePayButton.removeAttribute('hidden'); + applePayButton.removeAttribute('aria-hidden'); + } + } + }); + }); } diff --git a/blocks/form/form.css b/blocks/form/form.css index 437092f4..e6f98d45 100644 --- a/blocks/form/form.css +++ b/blocks/form/form.css @@ -162,3 +162,7 @@ padding: 0 var(--spacing-300); } } + +fieldset.form-section h3 { + margin: 1em 0 0.5em; +} \ No newline at end of file diff --git a/blocks/form/form.js b/blocks/form/form.js index e6235665..98089c13 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -50,6 +50,25 @@ function buildLabel(text, type = 'label', id = null) { return label; } +/** + * @param {Object} field + * @returns {HTMLDivElement} Section element + */ +function buildSection(field) { + const { + label, field: fieldName, autocomplete, + } = field; + const section = createElement('fieldset', `form-section section-${fieldName}`); + // section.append(buildLabel(label, 'legend')); + if (label) { + const h3 = createElement('h3'); + h3.textContent = label; + section.append(h3); + } + if (autocomplete) section.autocomplete = `section-${autocomplete}`; + return section; +} + /** * Creates an input element with specified attributes * @param {Object} field - Field configuration object @@ -57,7 +76,7 @@ function buildLabel(text, type = 'label', id = null) { */ function buildInput(field) { const { - type, field: fieldName, required, default: defaultValue, placeholder, + type, field: fieldName, required, default: defaultValue, placeholder, autocomplete, } = field; const input = createElement('input'); @@ -67,6 +86,7 @@ function buildInput(field) { input.required = required === 'true'; if (defaultValue) input.value = defaultValue; if (placeholder) input.placeholder = placeholder; + if (autocomplete) input.autocomplete = autocomplete; return input; } @@ -77,7 +97,7 @@ function buildInput(field) { */ function buildTextArea(field) { const { - field: fieldName, required, default: defaultValue, placeholder, + field: fieldName, required, default: defaultValue, placeholder, autocomplete, } = field; const textarea = createElement('textarea'); @@ -87,6 +107,7 @@ function buildTextArea(field) { textarea.rows = 5; if (defaultValue) textarea.value = defaultValue; if (placeholder) textarea.placeholder = placeholder; + if (autocomplete) textarea.autocomplete = autocomplete; return textarea; } @@ -107,8 +128,8 @@ async function appendSelectOptions(select, url) { data.forEach((option) => { const optionEl = createElement('option'); - optionEl.value = option.value; - optionEl.textContent = option.label; + optionEl.value = option.Value || option.value; + optionEl.textContent = option.Label || option.label; select.append(optionEl); }); return select; @@ -534,11 +555,15 @@ function buildForm(fields, path) { // group buttons at the end const buttons = []; + let section = form; fields.forEach((field) => { if (field.type === 'submit' || field.type === 'reset') { buttons.push(field); + } else if (field.type === 'section') { + section = buildSection(field, form); + form.append(section); } else { - form.append(buildField(field)); + section.append(buildField(field)); } }); diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js new file mode 100644 index 00000000..9ab1e3bd --- /dev/null +++ b/blocks/order-summary/order-summary.js @@ -0,0 +1,36 @@ +import { ORDERS_API_ORIGIN } from '../../scripts/scripts.js'; + +/** + * @param {HTMLDivElement} block + * @returns {Promise} + */ +export default async function decorate(block) { + // get orderId, email from url params + const params = Object.fromEntries(new URLSearchParams(window.location.search).entries()); + + if (!params.id || !params.email) { + // redirect to home page + window.location.href = '/us/en_us/'; + } + + // fetch order info + const resp = await fetch(`${ORDERS_API_ORIGIN}/customers/${params.email}/orders/${params.id}`); + if (!resp.ok) { + console.error(`Failed to fetch order info: ${resp.status} ${resp.statusText}`); + if (resp.status !== 404) { + // redirect after 10 seconds + setTimeout(() => { + window.location.href = '/us/en_us/'; + }, 10000); + } else { + // redirect to home page + window.location.href = '/us/en_us/'; + } + return; + } + + const data = await resp.json(); + // just dump it into a code block for now + block.innerHTML = '
'; + block.querySelector('code').innerText = JSON.stringify(data, null, 2); +} diff --git a/icons/paypal.svg b/icons/paypal.svg new file mode 100644 index 00000000..eb4d8cdc --- /dev/null +++ b/icons/paypal.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/scripts/cart.js b/scripts/cart.js index 020f01f9..7f9fc9eb 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -146,6 +146,55 @@ export class Cart { this.#persist(); } + /** + * @param {string} firstName + * @param {string} lastName + * @param {string} email + * @param {string} phone + * @param {{ + * name: string; + * company: string; + * address1: string; + * address2: string; + * city: string; + * state: string; + * zip: string; + * country: string; + * phone: string; + * email: string; + * }} shipping + * @returns {Object} + */ + getOrderJSON(email, firstName, lastName, phone, shippingAddr) { + // remove empty string values + const shipping = Object.fromEntries( + // eslint-disable-next-line no-unused-vars + Object.entries(shippingAddr).filter(([_, value]) => value !== ''), + ); + const order = { + storeCode: 'main', + storeViewCode: 'default', + customer: { + firstName, + lastName, + email, + phone, + }, + shipping, + items: this.items.map((item) => ({ + sku: item.sku, + urlKey: (item.url || '').split('/').pop() || '', + name: item.name, + quantity: item.quantity, + price: { + currency: 'USD', + final: item.price, + }, + })), + }; + return order; + } + toJSON() { return { version: Cart.STORAGE_VERSION, diff --git a/scripts/scripts.js b/scripts/scripts.js index 6ab676f5..df68e7b6 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -19,6 +19,9 @@ import { } from './aem.js'; const { hostname } = window.location; + +export const ORDERS_API_ORIGIN = 'https://vitamix-api.adobeaem.workers.dev'; + window.cartMode = hostname.includes('localhost') || hostname.includes('edge-orders--') ? 'edge' : 'legacy'; if (['edge', 'legacy'].includes(localStorage.getItem('cartMode'))) { window.cartMode = localStorage.getItem('cartMode'); @@ -514,8 +517,8 @@ function buildAutoBlocks(main) { } // setup cart page - if (getMetadata('template') === 'cart') { - document.body.classList.add('cart-template'); + if (getMetadata('template')) { + document.body.classList.add(`${getMetadata('template').toLowerCase()}-template`); } } catch (error) { // eslint-disable-next-line no-console From bec5dd5210c9bdc9b454b57de46759812f44db94 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Mon, 8 Dec 2025 08:41:57 -0800 Subject: [PATCH 10/89] persist cart.clear instantly --- scripts/cart.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/scripts/cart.js b/scripts/cart.js index 7f9fc9eb..c85b11ff 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -43,10 +43,14 @@ export class Cart { } } - #persist = debounce(() => { + #persistNow() { const expires = new Date(Date.now() + 30 * 864e5).toUTCString(); document.cookie = `cart_items_count=${this.itemCount}; expires=${expires}; path=/`; localStorage.setItem(Cart.STORAGE_KEY, JSON.stringify(this)); + } + + #persist = debounce(() => { + this.#persistNow(); }, 300); get items() { @@ -75,7 +79,7 @@ export class Cart { clear() { this.#items = {}; - this.#persist(); + this.#persistNow(); document.dispatchEvent( new CustomEvent('cart:change', { detail: { From c1e66462fd54e42a69a5c70da3998b2c48bf436c Mon Sep 17 00:00:00 2001 From: Max Edell Date: Mon, 8 Dec 2025 09:52:59 -0800 Subject: [PATCH 11/89] dont close cart on qty decrement, scroll new cart item into view --- blocks/cart/cart.js | 6 +++++- blocks/header/header.js | 22 +++++++++++++++++++++- blocks/pdp/add-to-cart.js | 7 ++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index bec224b4..e28ff6c1 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -44,7 +44,9 @@ function renderQuantityPicker(item, container, totalEl) { const removeButton = document.createElement('button'); removeButton.textContent = 'Remove'; removeButton.classList.add('remove-button'); - removeButton.addEventListener('click', () => { + removeButton.addEventListener('click', (ev) => { + ev.preventDefault(); + ev.stopPropagation(); cart.removeItem(item.sku); container.closest('span .cart-item').remove(); }); @@ -117,6 +119,8 @@ export default async function decorate(block, parent) { const itemElement = document.createElement('span'); itemElement.innerHTML = itemTemplate; itemList.appendChild(itemElement); + const cartItem = itemElement.querySelector('.cart-item'); + cartItem.classList.add(`cart-item-${item.sku}`); // product element const productEl = itemElement.querySelector('.cart-item-product'); diff --git a/blocks/header/header.js b/blocks/header/header.js index d432d4c0..6725e236 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -481,8 +481,16 @@ export default async function decorate(block) { /** @type {HTMLDialogElement} */ let minicart = document.querySelector('#minicart'); + const scrollToItem = () => { + setTimeout(() => { + const addedItem = minicart.querySelector(`.cart-item-${e.detail.item.sku}`); + addedItem.scrollIntoView({ behavior: 'smooth' }); + }, 300); + }; + if (minicart) { minicart.openModal(); + scrollToItem(); return; } @@ -512,11 +520,18 @@ export default async function decorate(block) { minicart.addEventListener('click', (event) => { const rect = minicart.getBoundingClientRect(); + console.log('rect: ', rect); + console.log('rect.top <= event.clientY: ', rect.top <= event.clientY); + console.log('event.clientY <= rect.top + rect.height: ', event.clientY <= rect.top + rect.height); + console.log('rect.left <= event.clientX: ', rect.left <= event.clientX); + console.log('event.clientX <= rect.left + rect.width: ', event.clientX <= rect.left + rect.width); + const isInDialog = (rect.top <= event.clientY && event.clientY <= rect.top + rect.height && rect.left <= event.clientX && event.clientX <= rect.left + rect.width); if (!isInDialog) { + console.log('not in dialog, closing'); minicart.closeModal(); } }); @@ -537,6 +552,9 @@ export default async function decorate(block) { minicart.closeModal = close; minicart.openModal(); + + // scroll item into view + scrollToItem(); } catch (error) { console.error('Error importing cart:', error); setTimeout(() => { @@ -550,5 +568,7 @@ export default async function decorate(block) { cartLink.addEventListener('click', openOrRedirect); // and when adding to cart from PDP - document.addEventListener('pdp:add-to-cart', openOrRedirect); + document.addEventListener('pdp:add-to-cart', (ev) => { + openOrRedirect(ev); + }); } diff --git a/blocks/pdp/add-to-cart.js b/blocks/pdp/add-to-cart.js index 7cff8fca..4aaa1603 100644 --- a/blocks/pdp/add-to-cart.js +++ b/blocks/pdp/add-to-cart.js @@ -200,7 +200,7 @@ export default function renderAddToCart(block, parent) { const cartApi = (await import('../../scripts/cart.js')).default; const { sku: variantSku, price, name } = selectedVariant; - await cartApi.addItem({ + const item = { sku: variantSku ?? sku, parentSku: variantSku ? sku : undefined, quantity: parseInt(quantity, 10), @@ -209,12 +209,13 @@ export default function renderAddToCart(block, parent) { url: selectedVariant.url, image: selectedVariant.image[0], selectedOptions, - }); + }; + await cartApi.addItem(item); // reenable button addToCartButton.textContent = 'Add to Cart'; addToCartButton.removeAttribute('aria-disabled'); - document.dispatchEvent(new CustomEvent('pdp:add-to-cart')); + document.dispatchEvent(new CustomEvent('pdp:add-to-cart', { detail: { item } })); return; } From e18173a7190aa86a863a0ded1d129e98f6edd57c Mon Sep 17 00:00:00 2001 From: Max Edell Date: Mon, 8 Dec 2025 10:00:57 -0800 Subject: [PATCH 12/89] close cart on empty --- blocks/header/header.js | 15 ++++++++------- scripts/cart.js | 16 ++++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/blocks/header/header.js b/blocks/header/header.js index 6725e236..8341e294 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -520,31 +520,32 @@ export default async function decorate(block) { minicart.addEventListener('click', (event) => { const rect = minicart.getBoundingClientRect(); - console.log('rect: ', rect); - console.log('rect.top <= event.clientY: ', rect.top <= event.clientY); - console.log('event.clientY <= rect.top + rect.height: ', event.clientY <= rect.top + rect.height); - console.log('rect.left <= event.clientX: ', rect.left <= event.clientX); - console.log('event.clientX <= rect.left + rect.width: ', event.clientX <= rect.left + rect.width); - const isInDialog = (rect.top <= event.clientY && event.clientY <= rect.top + rect.height && rect.left <= event.clientX && event.clientX <= rect.left + rect.width); if (!isInDialog) { - console.log('not in dialog, closing'); minicart.closeModal(); } }); + const closeOnEmpty = (ev) => { + if (ev.detail.action === 'empty') { + minicart.closeModal(); + } + }; + // open/close methods to account for transitions const open = () => { minicart.showModal(); minicart.setAttribute('aria-expanded', true); + document.addEventListener('cart:change', closeOnEmpty); }; minicart.openModal = open; const close = () => { minicart.setAttribute('aria-expanded', false); + document.removeEventListener('cart:change', closeOnEmpty); setTimeout(() => { minicart.close(); }, 300); diff --git a/scripts/cart.js b/scripts/cart.js index c85b11ff..2a827dce 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -53,6 +53,19 @@ export class Cart { this.#persistNow(); }, 300); + #maybeSendEmptyEvent() { + if (this.itemCount === 0) { + document.dispatchEvent( + new CustomEvent('cart:change', { + detail: { + cart: this, + action: 'empty', + }, + }), + ); + } + } + get items() { return Object.values(this.#items); } @@ -88,6 +101,7 @@ export class Cart { }, }), ); + this.#maybeSendEmptyEvent(); } /** @@ -130,6 +144,7 @@ export class Cart { }, }), ); + this.#maybeSendEmptyEvent(); this.#persist(); } @@ -147,6 +162,7 @@ export class Cart { }, }), ); + this.#maybeSendEmptyEvent(); this.#persist(); } From 321ddb3befb70df168c4524e6e6837d1282b2296 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Sat, 7 Mar 2026 12:12:42 -0700 Subject: [PATCH 13/89] chore: edge orders demo --- .env | 1 - .gitignore | 2 + blocks/accordion/accordion.css | 43 +- blocks/accordion/accordion.js | 4 +- blocks/alert-banners/alert-banners.css | 227 +++ blocks/alert-banners/alert-banners.js | 503 +++++- blocks/article-info/article-info.css | 78 + blocks/article-info/article-info.js | 141 ++ blocks/banner/banner.css | 4 + blocks/cards/cards.css | 384 +++-- blocks/cards/cards.js | 155 +- blocks/carousel/carousel.css | 9 +- blocks/columns/columns.css | 16 +- blocks/featured-recipe/featured-recipe.css | 84 + blocks/featured-recipe/featured-recipe.js | 98 ++ blocks/footer/footer.css | 80 +- blocks/footer/footer.js | 21 +- blocks/form/form.css | 26 +- blocks/form/form.js | 83 +- blocks/fragment/fragment.js | 68 +- blocks/header/header.css | 5 + blocks/hotspot/hotspot.js | 2 +- blocks/navigation/navigation.css | 35 +- blocks/navigation/navigation.js | 79 +- blocks/pdp/add-to-cart.js | 51 +- blocks/pdp/full-warranty.svg | 47 + blocks/pdp/limited-warranty.svg | 53 + blocks/pdp/options.js | 174 +- blocks/pdp/pdp.css | 22 +- blocks/pdp/pdp.js | 256 ++- blocks/pdp/pricing.js | 59 +- blocks/pdp/specification-tabs.js | 54 +- blocks/pixlee/pixlee.css | 4 + blocks/pixlee/pixlee.js | 18 + blocks/plp/plp.css | 11 +- blocks/plp/plp.js | 67 +- blocks/recipe/difficulty.svg | 12 + blocks/recipe/edit.svg | 3 + blocks/recipe/print.svg | 7 + blocks/recipe/recipe.css | 723 +++++++++ blocks/recipe/recipe.js | 417 +++++ blocks/recipe/save.svg | 3 + blocks/recipe/share.svg | 3 + blocks/recipe/time.svg | 8 + blocks/recipe/toggle.svg | 3 + blocks/recipe/yield.svg | 8 + blocks/related-articles/related-articles.css | 102 ++ blocks/related-articles/related-articles.js | 87 + blocks/related-recipes/related-recipes.css | 44 + blocks/related-recipes/related-recipes.js | 251 +++ blocks/table/table.css | 156 ++ blocks/table/table.js | 196 +++ blocks/toc/toc.css | 45 +- blocks/toc/toc.js | 49 +- blocks/widget/widget.css | 1 + blocks/widget/widget.js | 61 + helix-query.yaml | 29 + icons/social-linkedin.svg | 4 + package.json | 2 +- scripts/consented.js | 96 +- scripts/consented/adobe-target.js | 74 + scripts/consented/at.js | 19 + scripts/consented/newsletter.css | 22 + scripts/consented/newsletter.js | 58 + scripts/minicart/cart.js | 25 +- scripts/scripts.js | 574 ++++--- scripts/storage/README.md | 37 +- scripts/storage/util.js | 83 +- styles/containers.css | 1 + styles/styles.css | 106 +- tests/cart/storage.spec.js | 428 +++++ tests/pdp/integration.spec.js | 34 +- tests/scripts/pricing.spec.js | 110 ++ tools/dealer-geocode.html | 71 + tools/dealer-geocode/dealer-geocode.css | 237 +++ tools/dealer-geocode/dealer-geocode.js | 360 +++++ tools/dealer-geocode/index.html | 14 + tools/linkchecker/linkchecker.css | 17 + tools/linkchecker/linkchecker.js | 22 + tools/quick-edit/quick-edit.js | 33 + tools/sidekick/sync/sync.js | 6 +- tools/translate/app.css | 207 +++ tools/translate/app.html | 37 + tools/translate/app.js | 185 +++ tools/translate/plugin.css | 122 ++ tools/translate/plugin.html | 30 + tools/translate/plugin.js | 82 + tools/translate/shared.css | 34 + tools/translate/shared.js | 446 ++++++ widgets/WIDGETS-ENGLISH-LITERALS.md | 245 +++ widgets/article-center/article-center.css | 335 ++++ widgets/article-center/article-center.html | 28 + widgets/article-center/article-center.js | 646 ++++++++ widgets/article-center/article-center.json | 44 + .../blender-recommender.css | 4 + .../blender-recommender.html | 825 ++++++++++ .../blender-recommender.js | 695 ++++++++ widgets/compare-products/compare-products.css | 320 ++++ .../compare-products/compare-products.html | 8 + widgets/compare-products/compare-products.js | 1395 +++++++++++++++++ .../compare-products/compare-products.json | 80 + widgets/compare-products/icon-check.svg | 4 + .../cookie-declaration/cookie-declaration.css | 5 + .../cookie-declaration.html | 1 + .../cookie-declaration/cookie-declaration.js | 5 + widgets/forms/contact-us.css | 69 + widgets/forms/contact-us.html | 44 + widgets/forms/contact-us.js | 114 ++ widgets/forms/contact-us.json | 116 ++ widgets/forms/create-account.css | 96 ++ widgets/forms/create-account.html | 68 + widgets/forms/create-account.js | 122 ++ widgets/forms/create-account.json | 86 + widgets/forms/edit-account.css | 105 ++ widgets/forms/edit-account.html | 63 + widgets/forms/edit-account.js | 116 ++ widgets/forms/edit-account.json | 80 + widgets/forms/login.css | 121 ++ widgets/forms/login.html | 23 + widgets/forms/login.js | 139 ++ widgets/forms/login.json | 44 + widgets/forms/manage-address.css | 121 ++ widgets/forms/manage-address.html | 88 ++ widgets/forms/manage-address.js | 147 ++ widgets/forms/manage-address.json | 143 ++ widgets/forms/media-contact.css | 45 + widgets/forms/media-contact.html | 57 + widgets/forms/media-contact.js | 125 ++ widgets/forms/media-contact.json | 101 ++ widgets/forms/order-status.css | 59 + widgets/forms/order-status.html | 12 + widgets/forms/order-status.js | 87 + widgets/forms/order-status.json | 32 + widgets/forms/product-registration.css | 148 ++ widgets/forms/product-registration.html | 134 ++ widgets/forms/product-registration.js | 172 ++ widgets/forms/product-registration.json | 212 +++ widgets/forms/simple-search.css | 28 + widgets/forms/simple-search.html | 5 + widgets/forms/simple-search.js | 45 + widgets/forms/simple-search.json | 20 + widgets/forms/wellness-program.css | 34 + widgets/forms/wellness-program.html | 47 + widgets/forms/wellness-program.js | 104 ++ widgets/forms/wellness-program.json | 83 + widgets/locator/locator.css | 209 +++ widgets/locator/locator.html | 73 + widgets/locator/locator.jpeg | Bin 0 -> 27322 bytes widgets/locator/locator.js | 644 ++++++++ widgets/locator/locator.json | 83 + widgets/recipe-center/recipe-center.css | 520 ++++++ widgets/recipe-center/recipe-center.html | 47 + widgets/recipe-center/recipe-center.js | 1122 +++++++++++++ widgets/recipe-center/recipe-center.json | 74 + widgets/search-results/search-results.css | 275 ++++ widgets/search-results/search-results.html | 19 + widgets/search-results/search-results.js | 701 +++++++++ widgets/search-results/search-results.json | 50 + 158 files changed, 19715 insertions(+), 945 deletions(-) delete mode 100644 .env create mode 100644 blocks/article-info/article-info.css create mode 100644 blocks/article-info/article-info.js create mode 100644 blocks/featured-recipe/featured-recipe.css create mode 100644 blocks/featured-recipe/featured-recipe.js create mode 100644 blocks/pdp/full-warranty.svg create mode 100644 blocks/pdp/limited-warranty.svg create mode 100644 blocks/pixlee/pixlee.css create mode 100644 blocks/pixlee/pixlee.js create mode 100644 blocks/recipe/difficulty.svg create mode 100644 blocks/recipe/edit.svg create mode 100644 blocks/recipe/print.svg create mode 100644 blocks/recipe/recipe.css create mode 100644 blocks/recipe/recipe.js create mode 100644 blocks/recipe/save.svg create mode 100644 blocks/recipe/share.svg create mode 100644 blocks/recipe/time.svg create mode 100644 blocks/recipe/toggle.svg create mode 100644 blocks/recipe/yield.svg create mode 100644 blocks/related-articles/related-articles.css create mode 100644 blocks/related-articles/related-articles.js create mode 100644 blocks/related-recipes/related-recipes.css create mode 100644 blocks/related-recipes/related-recipes.js create mode 100644 blocks/table/table.css create mode 100644 blocks/table/table.js create mode 100644 blocks/widget/widget.css create mode 100644 blocks/widget/widget.js create mode 100644 icons/social-linkedin.svg create mode 100644 scripts/consented/adobe-target.js create mode 100644 scripts/consented/at.js create mode 100644 scripts/consented/newsletter.css create mode 100644 scripts/consented/newsletter.js create mode 100644 tests/cart/storage.spec.js create mode 100644 tests/scripts/pricing.spec.js create mode 100644 tools/dealer-geocode.html create mode 100644 tools/dealer-geocode/dealer-geocode.css create mode 100644 tools/dealer-geocode/dealer-geocode.js create mode 100644 tools/dealer-geocode/index.html create mode 100644 tools/linkchecker/linkchecker.css create mode 100644 tools/linkchecker/linkchecker.js create mode 100644 tools/quick-edit/quick-edit.js create mode 100644 tools/translate/app.css create mode 100644 tools/translate/app.html create mode 100644 tools/translate/app.js create mode 100644 tools/translate/plugin.css create mode 100644 tools/translate/plugin.html create mode 100644 tools/translate/plugin.js create mode 100644 tools/translate/shared.css create mode 100644 tools/translate/shared.js create mode 100644 widgets/WIDGETS-ENGLISH-LITERALS.md create mode 100644 widgets/article-center/article-center.css create mode 100644 widgets/article-center/article-center.html create mode 100644 widgets/article-center/article-center.js create mode 100644 widgets/article-center/article-center.json create mode 100644 widgets/blender-recommender/blender-recommender.css create mode 100644 widgets/blender-recommender/blender-recommender.html create mode 100644 widgets/blender-recommender/blender-recommender.js create mode 100644 widgets/compare-products/compare-products.css create mode 100644 widgets/compare-products/compare-products.html create mode 100644 widgets/compare-products/compare-products.js create mode 100644 widgets/compare-products/compare-products.json create mode 100644 widgets/compare-products/icon-check.svg create mode 100644 widgets/cookie-declaration/cookie-declaration.css create mode 100644 widgets/cookie-declaration/cookie-declaration.html create mode 100644 widgets/cookie-declaration/cookie-declaration.js create mode 100644 widgets/forms/contact-us.css create mode 100644 widgets/forms/contact-us.html create mode 100644 widgets/forms/contact-us.js create mode 100644 widgets/forms/contact-us.json create mode 100644 widgets/forms/create-account.css create mode 100644 widgets/forms/create-account.html create mode 100644 widgets/forms/create-account.js create mode 100644 widgets/forms/create-account.json create mode 100644 widgets/forms/edit-account.css create mode 100644 widgets/forms/edit-account.html create mode 100644 widgets/forms/edit-account.js create mode 100644 widgets/forms/edit-account.json create mode 100644 widgets/forms/login.css create mode 100644 widgets/forms/login.html create mode 100644 widgets/forms/login.js create mode 100644 widgets/forms/login.json create mode 100644 widgets/forms/manage-address.css create mode 100644 widgets/forms/manage-address.html create mode 100644 widgets/forms/manage-address.js create mode 100644 widgets/forms/manage-address.json create mode 100644 widgets/forms/media-contact.css create mode 100644 widgets/forms/media-contact.html create mode 100644 widgets/forms/media-contact.js create mode 100644 widgets/forms/media-contact.json create mode 100644 widgets/forms/order-status.css create mode 100644 widgets/forms/order-status.html create mode 100644 widgets/forms/order-status.js create mode 100644 widgets/forms/order-status.json create mode 100644 widgets/forms/product-registration.css create mode 100644 widgets/forms/product-registration.html create mode 100644 widgets/forms/product-registration.js create mode 100644 widgets/forms/product-registration.json create mode 100644 widgets/forms/simple-search.css create mode 100644 widgets/forms/simple-search.html create mode 100644 widgets/forms/simple-search.js create mode 100644 widgets/forms/simple-search.json create mode 100644 widgets/forms/wellness-program.css create mode 100644 widgets/forms/wellness-program.html create mode 100644 widgets/forms/wellness-program.js create mode 100644 widgets/forms/wellness-program.json create mode 100644 widgets/locator/locator.css create mode 100644 widgets/locator/locator.html create mode 100644 widgets/locator/locator.jpeg create mode 100644 widgets/locator/locator.js create mode 100644 widgets/locator/locator.json create mode 100644 widgets/recipe-center/recipe-center.css create mode 100644 widgets/recipe-center/recipe-center.html create mode 100644 widgets/recipe-center/recipe-center.js create mode 100644 widgets/recipe-center/recipe-center.json create mode 100644 widgets/search-results/search-results.css create mode 100644 widgets/search-results/search-results.html create mode 100644 widgets/search-results/search-results.js create mode 100644 widgets/search-results/search-results.json diff --git a/.env b/.env deleted file mode 100644 index 2d9fcbdf..00000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -AEM_PAGES_URL="https://main--vitamix--aemsites.aem.network" \ No newline at end of file diff --git a/.gitignore b/.gitignore index f3087019..aaa47646 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ helix-importer-ui .DS_Store *.bak .idea +.env +.claude diff --git a/blocks/accordion/accordion.css b/blocks/accordion/accordion.css index b71a695c..b6923b00 100644 --- a/blocks/accordion/accordion.css +++ b/blocks/accordion/accordion.css @@ -1,13 +1,16 @@ .accordion details { - border-bottom: 1px solid var(--color-gray-300); + background-color: var(--color-gray-300); + margin-bottom: var(--spacing-100); } .accordion details summary { + list-style: none; position: relative; - padding-left: var(--spacing-200); - cursor: pointer; + padding: var(--spacing-100) var(--spacing-300); + padding-right: calc(2 * var(--spacing-300)); + font-family: var(--heading-font-family); font-size: var(--body-size-l); - list-style: none; + cursor: pointer; } .accordion details summary:focus, @@ -19,27 +22,33 @@ display: none; } +.accordion details summary::before, .accordion details summary::after { content: ''; position: absolute; - top: 0.66em; - left: 0; - transform: translateY(-50%) rotate(45deg); - width: 8px; - height: 8px; - border: var(--border-m) solid var(--color-gray-800); - border-width: var(--border-m) var(--border-m) 0 0; - transition: transform 0.2s; + top: 50%; + right: var(--spacing-300); + width: 14px; + height: 2px; + background-color: currentcolor; + transition: transform 0.2s ease; } -.accordion details[open] summary::after { - transform: translateY(-50%) rotate(135deg); +.accordion details summary::after { + transform: rotate(90deg); +} + +.accordion details[open]:not([data-closing]) summary::after { + transform: rotate(0deg); } .accordion details .accordion-item-body { height: 0; - padding-left: var(--spacing-200); - color: var(--color-gray-900); + padding: 0 var(--spacing-300); overflow: hidden; - transition: height 0.6s; + transition: height 0.6s ease; +} + +.accordion details .accordion-item-body > *:last-child { + margin-bottom: var(--spacing-300); } diff --git a/blocks/accordion/accordion.js b/blocks/accordion/accordion.js index 0697a4dc..7669b825 100644 --- a/blocks/accordion/accordion.js +++ b/blocks/accordion/accordion.js @@ -30,15 +30,17 @@ function openAccordion(body, details) { * @param {HTMLDetailsElement} details - Details wrapper element */ function closeAccordion(body, details) { + details.dataset.closing = true; body.style.height = `${body.scrollHeight}px`; // eslint-disable-next-line no-unused-expressions body.offsetHeight; // force reflow - body.style.height = '0px'; + body.style.height = '0'; // cleanup after animation const onEnd = (e) => { if (e.propertyName !== 'height') return; details.open = false; + details.removeAttribute('data-closing'); body.removeEventListener('transitionend', onEnd); }; body.addEventListener('transitionend', onEnd); diff --git a/blocks/alert-banners/alert-banners.css b/blocks/alert-banners/alert-banners.css index e55c6d61..af817a8c 100644 --- a/blocks/alert-banners/alert-banners.css +++ b/blocks/alert-banners/alert-banners.css @@ -77,3 +77,230 @@ main > .section > div.alert-banners-wrapper { .alert-banners-datetime input { max-width: 300px; } + +/* Timeline styles */ + +/* Preview banner */ +.alert-banners-preview { + border-radius: 8px; + margin-bottom: 16px; +} + +.alert-banners-preview-empty { + background: #f5f5f5; + color: #999; + font-style: italic; + text-align: center; + padding: 12px 16px; +} + +.alert-banners-timeline-container { + margin-bottom: 16px; +} + +.alert-banners-timeline { + position: relative; + background: #fafafa; + border: 1px solid #e0e0e0; + border-radius: 8px; + padding: 12px; + overflow: hidden; +} + +.timeline-header { + margin-bottom: 8px; +} + +.timeline-year { + font-size: 1.25rem; + font-weight: 600; + color: #333; + margin-bottom: 8px; +} + +.timeline-months { + display: flex; +} + +.timeline-month { + flex: 1; + text-align: center; + font-size: 0.75rem; + color: #666; + border-left: 1px solid #e0e0e0; + padding: 4px 0; +} + +.timeline-month:last-child { + border-right: 1px solid #e0e0e0; +} + +.timeline-rows { + display: flex; + flex-direction: column; + gap: 4px; +} + +.timeline-row { + display: flex; + align-items: center; + height: 20px; +} + +/* 7-month gridlines (100/7 ≈ 14.28%) */ +.timeline-track { + flex: 1; + position: relative; + height: 100%; + background: linear-gradient(to right, + #e0e0e0 0, #e0e0e0 1px, transparent 1px, transparent 14.28%, + #e0e0e0 14.28%, #e0e0e0 calc(14.28% + 1px), transparent calc(14.28% + 1px), transparent 28.57%, + #e0e0e0 28.57%, #e0e0e0 calc(28.57% + 1px), transparent calc(28.57% + 1px), transparent 42.85%, + #e0e0e0 42.85%, #e0e0e0 calc(42.85% + 1px), transparent calc(42.85% + 1px), transparent 57.14%, + #e0e0e0 57.14%, #e0e0e0 calc(57.14% + 1px), transparent calc(57.14% + 1px), transparent 71.42%, + #e0e0e0 71.42%, #e0e0e0 calc(71.42% + 1px), transparent calc(71.42% + 1px), transparent 85.71%, + #e0e0e0 85.71%, #e0e0e0 calc(85.71% + 1px), transparent calc(85.71% + 1px), transparent 100% + ); +} + +.timeline-bar { + position: absolute; + height: 16px; + top: 2px; + border-radius: 4px; + min-width: 4px; + cursor: pointer; + transition: opacity 0.2s; +} + +.timeline-bar:hover { + opacity: 0.8; +} + +/* Bar states matching list states */ +.timeline-bar-past { + background-color: #bdbdbd; +} + +.timeline-bar-future { + background-color: #64b5f6; +} + +.timeline-bar-current { + background-color: #81c784; +} + +.timeline-bar-selected { + box-shadow: 0 0 0 2px #333; +} + +/* Open-ended indicators */ +.timeline-bar-open-start { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.timeline-bar-open-start::before { + content: '◀'; + position: absolute; + left: -12px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + color: inherit; + opacity: 0.7; +} + +.timeline-bar-open-end { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.timeline-bar-open-end::after { + content: '▶'; + position: absolute; + right: -12px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + color: inherit; + opacity: 0.7; +} + +/* Now marker */ +.timeline-now { + position: absolute; + top: 48px; + bottom: 12px; + width: 2px; + background: #d32f2f; + z-index: 10; + cursor: ew-resize; + touch-action: none; +} + +/* Date label (M/D format) */ +.timeline-now-label { + position: absolute; + top: -20px; + left: 50%; + transform: translateX(-50%); + font-size: 11px; + font-weight: 600; + color: #d32f2f; + white-space: nowrap; + pointer-events: none; + background: #fff; + padding: 1px 4px; + border-radius: 3px; + box-shadow: 0 1px 2px rgb(0 0 0 / 15%); +} + +/* Drag handle */ +.timeline-now-handle { + position: absolute; + top: -8px; + left: 50%; + transform: translateX(-50%); + width: 16px; + height: 16px; + background: #d32f2f; + border: 2px solid #fff; + border-radius: 50%; + box-shadow: 0 2px 4px rgb(0 0 0 / 30%); + cursor: grab; +} + +.timeline-now-handle::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 4px; + height: 4px; + background: #fff; + border-radius: 50%; +} + +.timeline-now-dragging { + background: #b71c1c; +} + +.timeline-now-dragging .timeline-now-handle { + transform: translateX(-50%) scale(1.2); + background: #b71c1c; + cursor: grabbing; +} + +.timeline-now:hover .timeline-now-handle { + transform: translateX(-50%) scale(1.2); + background: #b71c1c; +} + +/* Expand hit area for easier grabbing */ +.timeline-now::after { + content: ''; + position: absolute; + inset: 0 -8px; +} diff --git a/blocks/alert-banners/alert-banners.js b/blocks/alert-banners/alert-banners.js index 543bbb29..76821557 100644 --- a/blocks/alert-banners/alert-banners.js +++ b/blocks/alert-banners/alert-banners.js @@ -1,11 +1,20 @@ -import { parseAlertBanners, findBestAlertBanner, currentPastFuture } from '../../scripts/scripts.js'; +import { + parseAlertBanners, + findBestAlertBanner, + currentPastFuture, +} from '../../scripts/scripts.js'; + +const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; /** * Formats a Date object into a short date-time string format (M/D HHam/pm). * @param {Date} date - The date to format - * @returns {string} Formatted date string, or "Invalid Date" + * @returns {string} Formatted date string, or "Invalid Date" or "Open" */ function formatShortDateTime(date) { + if (date === null) { + return 'Open'; + } if (!date || !(date instanceof Date) || Number.isNaN(date.getTime())) { return 'Invalid Date'; } @@ -30,6 +39,386 @@ function formatShortDateTime(date) { return `${month}/${day} ${timeStr}`; } +/** + * Formats a duration in milliseconds into a human-readable string. + * Shows only the largest rounded unit (weeks for 21+ days, days for 1+ days, etc.) + * @param {number} ms - Duration in milliseconds + * @returns {string} Formatted duration string (e.g., "3d", "2w", "5h") + */ +function formatDuration(ms) { + if (ms === null || Number.isNaN(ms)) return ''; + + const negative = ms < 0; + const absMs = Math.abs(ms); + + const totalMinutes = Math.round(absMs / (1000 * 60)); + const totalHours = totalMinutes / 60; + const totalDays = totalHours / 24; + const totalWeeks = totalDays / 7; + + let value; + let unit; + if (totalDays >= 21) { + // 3+ weeks: show weeks + value = Math.round(totalWeeks); + unit = value === 1 ? 'week' : 'weeks'; + } else if (totalDays >= 1) { + // 1+ days: show days + value = Math.round(totalDays); + unit = value === 1 ? 'day' : 'days'; + } else if (totalHours >= 1) { + // 1+ hours: show hours + value = Math.round(totalHours); + unit = value === 1 ? 'hour' : 'hours'; + } else { + // Less than 1 hour: show minutes + value = Math.round(totalMinutes); + unit = value === 1 ? 'minute' : 'minutes'; + } + + const result = `${value} ${unit}`; + return negative ? `-${result}` : result; +} + +/** + * Creates a timeline visualization showing banners across 6 months. + * Shows 3 months before and 3 months after the current date. + * @param {Array} banners - Array of banner objects + * @param {Object|null} [bestBanner=null] - The optimal banner to highlight + * @param {Date} [date=new Date()] - Reference date for the "now" marker + * @param {Function} [onDateChange=null] - Callback when date is changed via drag (receives newDate) + * @param {Function} [onPreviewChange=null] - Callback when selected banner changes + * @returns {HTMLElement} Timeline container element + */ +function createTimeline( + banners, + bestBanner = null, + date = new Date(), + onDateChange = null, + onPreviewChange = null, +) { + const container = document.createElement('div'); + container.classList.add('alert-banners-timeline'); + + // Calculate 6-month window: 3 months before and 3 months after current date + const currentMonth = date.getMonth(); + const currentYear = date.getFullYear(); + + // Start 3 months before (beginning of that month) + let startMonth = currentMonth - 3; + let startYear = currentYear; + if (startMonth < 0) { + startMonth += 12; + startYear -= 1; + } + const rangeStart = new Date(startYear, startMonth, 1); + + // End 3 months after (end of that month) + let endMonth = currentMonth + 3; + let endYear = currentYear; + if (endMonth > 11) { + endMonth -= 12; + endYear += 1; + } + // Get last day of the end month + const rangeEnd = new Date(endYear, endMonth + 1, 0, 23, 59, 59); + + const rangeDuration = rangeEnd.getTime() - rangeStart.getTime(); + + // Build array of months to display + const displayMonths = []; + let m = startMonth; + let y = startYear; + for (let i = 0; i < 7; i += 1) { + displayMonths.push({ month: m, year: y, name: MONTH_NAMES[m] }); + m += 1; + if (m > 11) { + m = 0; + y += 1; + } + } + + // Create header with months + const header = document.createElement('div'); + header.classList.add('timeline-header'); + + // Show year range or single year + const yearLabel = document.createElement('div'); + yearLabel.classList.add('timeline-year'); + if (startYear === endYear) { + yearLabel.textContent = startYear; + } else { + yearLabel.textContent = `${startYear}–${endYear}`; + } + header.appendChild(yearLabel); + + const monthsRow = document.createElement('div'); + monthsRow.classList.add('timeline-months'); + displayMonths.forEach(({ name }) => { + const monthDiv = document.createElement('div'); + monthDiv.classList.add('timeline-month'); + monthDiv.textContent = name; + monthsRow.appendChild(monthDiv); + }); + header.appendChild(monthsRow); + container.appendChild(header); + + // Create banner rows + const rowsContainer = document.createElement('div'); + rowsContainer.classList.add('timeline-rows'); + + const visibleBanners = []; // Track which banners have visible bars + + banners.forEach((banner) => { + if (!banner.valid) return; + + // Skip banners with negative duration (end before start) + if (banner.start && banner.end && banner.end < banner.start) return; + + // Calculate bar position + const startTime = banner.start ? banner.start.getTime() : rangeStart.getTime(); + const endTime = banner.end ? banner.end.getTime() : rangeEnd.getTime(); + + // Clamp to visible range + const visibleStart = Math.max(startTime, rangeStart.getTime()); + const visibleEnd = Math.min(endTime, rangeEnd.getTime()); + + // Only show row if banner overlaps with the visible range + if (visibleEnd < rangeStart.getTime() || visibleStart > rangeEnd.getTime()) return; + + const row = document.createElement('div'); + row.classList.add('timeline-row'); + + // Track for the bar + const track = document.createElement('div'); + track.classList.add('timeline-track'); + track.title = banner.content?.textContent?.trim() || ''; + + const leftPercent = ((visibleStart - rangeStart.getTime()) / rangeDuration) * 100; + const widthPercent = ((visibleEnd - visibleStart) / rangeDuration) * 100; + + const bar = document.createElement('div'); + bar.classList.add('timeline-bar'); + bar.classList.add(`timeline-bar-${currentPastFuture(banner.start, banner.end, date)}`); + + if (bestBanner === banner) { + bar.classList.add('timeline-bar-selected'); + } + + // Show arrows for open-ended dates + if (!banner.start || banner.start.getTime() < rangeStart.getTime()) { + bar.classList.add('timeline-bar-open-start'); + } + if (!banner.end || banner.end.getTime() > rangeEnd.getTime()) { + bar.classList.add('timeline-bar-open-end'); + } + + bar.style.left = `${Math.max(0, leftPercent)}%`; + bar.style.width = `${Math.min(100, widthPercent)}%`; + + // Tooltip + const startStr = banner.start ? formatShortDateTime(banner.start) : '∞'; + const endStr = banner.end ? formatShortDateTime(banner.end) : '∞'; + bar.title = `${startStr} → ${endStr}`; + + track.appendChild(bar); + visibleBanners.push(banner); // Track this banner has a visible bar + + row.appendChild(track); + rowsContainer.appendChild(row); + }); + + container.appendChild(rowsContainer); + + // Add draggable "now" marker + const nowTime = date.getTime(); + if (nowTime >= rangeStart.getTime() && nowTime <= rangeEnd.getTime()) { + const nowPercent = ((nowTime - rangeStart.getTime()) / rangeDuration) * 100; + const nowMarker = document.createElement('div'); + nowMarker.classList.add('timeline-now'); + nowMarker.title = 'Drag to change date'; + + // Add date label (M/D format) + const dateLabel = document.createElement('div'); + dateLabel.classList.add('timeline-now-label'); + dateLabel.textContent = `${date.getMonth() + 1}/${date.getDate()}`; + nowMarker.appendChild(dateLabel); + + // Add drag handle + const handle = document.createElement('div'); + handle.classList.add('timeline-now-handle'); + nowMarker.appendChild(handle); + + // Position marker after container is in DOM + nowMarker.dataset.percent = nowPercent; + + // Drag functionality + if (onDateChange) { + let isDragging = false; + let trackBoundsCache = null; + + // Cache bars and track for performance + let barsCache = null; + let lastBestIdx = -1; + + const cacheTrackBounds = () => { + const track = container.querySelector('.timeline-track'); + if (track) { + trackBoundsCache = track.getBoundingClientRect(); + } else { + const months = container.querySelector('.timeline-months'); + trackBoundsCache = months ? months.getBoundingClientRect() : null; + } + // Also cache bars + barsCache = [...container.querySelectorAll('.timeline-bar')]; + }; + + // Update banner bar colors and selection based on the new date + // Returns the current best banner (for passing to onDateChange) + const updateBarColors = (newDate) => { + if (!barsCache) return null; + + let newBestIdx = -1; + + // Update bar classes + for (let i = 0; i < barsCache.length; i += 1) { + const bar = barsCache[i]; + const banner = visibleBanners[i]; + if (!banner) continue; // eslint-disable-line no-continue + + const state = currentPastFuture(banner.start, banner.end, newDate); + + // Update state class (toggle instead of remove all + add) + bar.classList.toggle('timeline-bar-past', state === 'past'); + bar.classList.toggle('timeline-bar-current', state === 'current'); + bar.classList.toggle('timeline-bar-future', state === 'future'); + + if (state === 'current') { + newBestIdx = i; + } + } + + // Update selected class only if changed + if (newBestIdx !== lastBestIdx) { + if (lastBestIdx >= 0 && barsCache[lastBestIdx]) { + barsCache[lastBestIdx].classList.remove('timeline-bar-selected'); + } + if (newBestIdx >= 0 && barsCache[newBestIdx]) { + barsCache[newBestIdx].classList.add('timeline-bar-selected'); + } + lastBestIdx = newBestIdx; + + // Only notify preview change when selection changes + if (onPreviewChange) { + onPreviewChange(newBestIdx >= 0 ? visibleBanners[newBestIdx] : null); + } + } + + return newBestIdx >= 0 ? visibleBanners[newBestIdx] : null; + }; + + const updateMarkerFromX = (clientX) => { + if (!trackBoundsCache || trackBoundsCache.width === 0) return null; + + const relativeX = clientX - trackBoundsCache.left; + const percent = Math.max(0, Math.min(relativeX / trackBoundsCache.width, 1)); + + // Update marker position directly (don't re-render timeline) + const containerRect = container.getBoundingClientRect(); + const leftOffset = trackBoundsCache.left - containerRect.left; + const newLeft = leftOffset + (percent * trackBoundsCache.width); + nowMarker.style.left = `${newLeft}px`; + + // Calculate the new date + const newTime = rangeStart.getTime() + (percent * rangeDuration); + const newDate = new Date(newTime); + + // Update the date label + dateLabel.textContent = `${newDate.getMonth() + 1}/${newDate.getDate()}`; + + // Update bar colors and get current best banner + const currentBest = updateBarColors(newDate); + + return { newDate, bestBanner: currentBest }; + }; + + const onMouseMove = (e) => { + if (!isDragging) return; + e.preventDefault(); + const result = updateMarkerFromX(e.clientX); + if (result) { + onDateChange(result.newDate, result.bestBanner); + } + }; + + const onMouseUp = () => { + isDragging = false; + document.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseup', onMouseUp); + nowMarker.classList.remove('timeline-now-dragging'); + trackBoundsCache = null; + }; + + nowMarker.addEventListener('mousedown', (e) => { + e.preventDefault(); + e.stopPropagation(); + isDragging = true; + cacheTrackBounds(); // Cache bounds at start of drag + nowMarker.classList.add('timeline-now-dragging'); + document.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseup', onMouseUp); + }); + + // Touch support + const onTouchMove = (e) => { + if (!isDragging) return; + e.preventDefault(); + const touch = e.touches[0]; + const result = updateMarkerFromX(touch.clientX); + if (result) { + onDateChange(result.newDate, result.bestBanner); + } + }; + + const onTouchEnd = () => { + isDragging = false; + document.removeEventListener('touchmove', onTouchMove); + document.removeEventListener('touchend', onTouchEnd); + nowMarker.classList.remove('timeline-now-dragging'); + trackBoundsCache = null; + }; + + nowMarker.addEventListener('touchstart', (e) => { + e.preventDefault(); + isDragging = true; + cacheTrackBounds(); + nowMarker.classList.add('timeline-now-dragging'); + document.addEventListener('touchmove', onTouchMove, { passive: false }); + document.addEventListener('touchend', onTouchEnd); + }); + + // Position marker after a frame so DOM is ready + requestAnimationFrame(() => { + const track = container.querySelector('.timeline-track'); + if (track) { + const trackRect = track.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + const leftOffset = trackRect.left - containerRect.left; + const newLeft = leftOffset + (nowPercent / 100) * trackRect.width; + nowMarker.style.left = `${newLeft}px`; + } + }); + } else { + // Non-interactive: use CSS calc + nowMarker.style.left = `${nowPercent}%`; + } + + container.appendChild(nowMarker); + } + + return container; +} + /** * Creates a structured list of parsed banner elements with appropriate CSS classes and content. * @param {Array} banners - Array of banner objects @@ -44,7 +433,12 @@ function createParsedBanners(banners, bestBanner = null, date = new Date()) { if (bestBanner === banner) { row.classList.add('alert-banners-selected'); } - if (banner.valid) { + + // Calculate duration and check if it's negative + const duration = banner.end - banner.start; + const isNegativeDuration = duration < 0; + + if (banner.valid && !isNegativeDuration) { row.classList.add('alert-banners-valid'); } else { row.classList.add('alert-banners-invalid'); @@ -53,7 +447,7 @@ function createParsedBanners(banners, bestBanner = null, date = new Date()) { list.appendChild(row); row.innerHTML = ` -
${formatShortDateTime(banner.start)} - ${formatShortDateTime(banner.end)}
+
${formatShortDateTime(banner.start)} - ${formatShortDateTime(banner.end)} [${formatDuration(duration)}]
${banner.content.innerHTML}
${banner.color}
`; @@ -69,24 +463,111 @@ function createParsedBanners(banners, bestBanner = null, date = new Date()) { export default async function decorateAlertBanners(block) { const banners = parseAlertBanners(block); block.innerHTML = ''; - const bestBanner = findBestAlertBanner(banners); + + // Preview banner element (reused, updated by timeline during drag) + const preview = document.createElement('aside'); + preview.classList.add('nav-banner', 'alert-banners-preview'); + + // Timeline view + const timelineContainer = document.createElement('div'); + timelineContainer.classList.add('alert-banners-timeline-container'); + + // List view const bannersContainer = document.createElement('div'); - bannersContainer.append(createParsedBanners(banners, bestBanner)); - block.append(bannersContainer); + // Date/time simulator const div = document.createElement('div'); div.classList.add('alert-banners-datetime'); div.textContent = 'Simulate Date/Time (local)'; const dtl = document.createElement('input'); dtl.type = 'datetime-local'; - dtl.value = new Date().toISOString(); + dtl.value = new Date().toISOString().slice(0, 16); dtl.id = 'alert-banners-party-time'; div.append(dtl); - dtl.addEventListener('input', (e) => { - const simDate = new Date(e.target.value); - bannersContainer.textContent = ''; + + // Track current preview banner to avoid unnecessary DOM updates + let currentPreviewBanner = null; + + // Helper to update preview (skips if banner hasn't changed) + const setPreview = (selectedBanner) => { + if (selectedBanner === currentPreviewBanner) return; + currentPreviewBanner = selectedBanner; + + preview.className = 'nav-banner alert-banners-preview'; + preview.style.backgroundColor = ''; + preview.textContent = ''; + + if (selectedBanner && selectedBanner.content) { + const p = document.createElement('p'); + const contentClone = selectedBanner.content.cloneNode(true); + p.append(...contentClone.childNodes); + preview.append(p); + + if (selectedBanner.color) { + preview.style.backgroundColor = `var(--color-${selectedBanner.color})`; + const darkColors = ['charcoal', 'black', 'dark', 'red', 'blue', 'green', 'xanadu', 'moss']; + const textClass = darkColors.some((c) => selectedBanner.color.includes(c)) ? 'light' : 'dark'; + preview.classList.add(`nav-banner-${textClass}`); + } + } else { + preview.classList.add('alert-banners-preview-empty'); + preview.textContent = 'No banner selected for this date'; + } + }; + + // Cache list items for fast updates during drag + let listItems = []; + + // Update list item classes during drag + const updateListColors = (newDate, newBestBanner) => { + listItems.forEach((item, index) => { + const banner = banners[index]; + if (!banner) return; + + const state = currentPastFuture(banner.start, banner.end, newDate); + item.classList.toggle('alert-banners-past', state === 'past'); + item.classList.toggle('alert-banners-current', state === 'current'); + item.classList.toggle('alert-banners-future', state === 'future'); + item.classList.toggle('alert-banners-selected', banner === newBestBanner); + }); + }; + + // Callback for drag - updates input and list + const onDrag = (newDate, newBestBanner) => { + dtl.value = newDate.toISOString().slice(0, 16); + updateListColors(newDate, newBestBanner); + }; + + // Full update function (used on initial load and when input changes) + const updateViews = (simDate) => { const simBestBanner = findBestAlertBanner(banners, simDate); + + // Update datetime input + dtl.value = simDate.toISOString().slice(0, 16); + + // Update preview + setPreview(simBestBanner); + + // Update timeline + timelineContainer.textContent = ''; + timelineContainer.append(createTimeline(banners, simBestBanner, simDate, onDrag, setPreview)); + + // Update list and cache items + bannersContainer.textContent = ''; bannersContainer.append(createParsedBanners(banners, simBestBanner, simDate)); + listItems = [...bannersContainer.querySelectorAll('li')]; + }; + + // Initial render + updateViews(new Date()); + + // Listen for manual input changes - just call updateViews + dtl.addEventListener('input', (e) => { + updateViews(new Date(e.target.value)); }); + + block.append(preview); + block.append(timelineContainer); + block.append(bannersContainer); block.append(div); } diff --git a/blocks/article-info/article-info.css b/blocks/article-info/article-info.css new file mode 100644 index 00000000..b793f7f7 --- /dev/null +++ b/blocks/article-info/article-info.css @@ -0,0 +1,78 @@ +.article-info { + max-width: calc(800px - (2 * var(--horizontal-spacing))); + margin: 0 auto; + text-align: center; +} + +.article-info p, +.article-info ul { + margin-top: 1em; +} + +.article-info a { + text-decoration: none; + transition: color 0.2s; +} + +.article-info a:hover, +.article-info a:focus-visible { + text-decoration: underline; +} + +.article-info .tags { + font-size: var(--body-size-s); + text-transform: uppercase; +} + +.article-info .tags a { + color: var(--color-blue); +} + +.article-info .tags a:hover, +.article-info .tags a:focus-visible { + color: var(--color-link); +} + +.article-info .byline { + font-size: var(--body-size-m); +} + +.article-info .byline span + span::before { + content: '|'; + margin: 0 0.5ch; +} + +.article-info ul.share { + list-style: none; + padding: 0; + display: flex; + justify-content: center; + gap: var(--spacing-xs); +} + +.article-info ul.share button { + display: flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + border: 0; + border-radius: 50%; + padding: 0; + background-color: var(--color-charcoal); + color: var(--color-white); + line-height: 0; + transition: background-color 0.2s; + cursor: pointer; +} + +.article-info ul.share button:hover, +.article-info ul.share button:focus-visible { + background-color: var(--color-link); +} + +.article-info ul.share button .icon img, +.article-info ul.share button .icon svg { + width: 28px; + height: 28px; +} diff --git a/blocks/article-info/article-info.js b/blocks/article-info/article-info.js new file mode 100644 index 00000000..94e043af --- /dev/null +++ b/blocks/article-info/article-info.js @@ -0,0 +1,141 @@ +import { getMetadata, fetchPlaceholders } from '../../scripts/aem.js'; +import { getLocaleAndLanguage, buildIcon, swapIcons } from '../../scripts/scripts.js'; + +/** + * Creates link to articles search page with search parame. + * @param {string} locale - Locale (e.g., 'us') + * @param {string} language - Language (e.g., 'en_us') + * @param {string} param - Search term + * @returns {HTMLAnchorElement} Anchor element + */ +function buildSearchLink(locale, language, param) { + const a = document.createElement('a'); + a.href = `/${locale}/${language}/articles?search=${encodeURIComponent(param)}`; + a.textContent = param; + return a; +} + +/** + * Builds social share buttons. + * @param {Object} ph - Placeholders object containing localized labels + * @returns {HTMLUListElement} List of share buttons + */ +function buildShare(ph) { + const ul = document.createElement('ul'); + ul.className = 'share'; + + const platforms = [ + { name: 'facebook', icon: 'social-facebook', label: ph.shareOnFacebook || 'Share on Facebook' }, + { name: 'linkedin', icon: 'social-linkedin', label: ph.shareOnLinkedin || 'Share on LinkedIn' }, + { name: 'twitter', icon: 'x', label: ph.shareOnTwitter || 'Share on X' }, + { name: 'pinterest', icon: 'social-pinterest', label: ph.shareOnPinterest || 'Share on Pinterest' }, + { name: 'email', icon: 'email', label: ph.shareViaEmail || 'Share via Email' }, + ]; + + platforms.forEach(({ name, icon, label }) => { + const li = document.createElement('li'); + const button = document.createElement('button'); + button.type = 'button'; + button.className = `share-${name}`; + button.setAttribute('aria-label', label); + button.title = label; + button.append(buildIcon(icon)); + li.append(button); + ul.append(li); + }); + + const getShareData = () => { + const url = window.location.href; + const { title } = document; + const articleTitle = document.querySelector('h1').textContent.trim() || title; + const image = document.querySelector('img').src || ''; + return { + url, title, articleTitle, image, + }; + }; + + const shareHandlers = { + facebook: () => { + const { url } = getShareData(); + window.open( + `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, + '_blank', + 'width=600,height=400', + ); + }, + linkedin: () => { + const { url, title } = getShareData(); + window.open( + `https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(url)}&title=${encodeURIComponent(title)}`, + '_blank', + 'width=600,height=400', + ); + }, + twitter: () => { + const { url, title } = getShareData(); + window.open( + `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`, + '_blank', + 'width=600,height=400', + ); + }, + pinterest: () => { + const { url, title, image } = getShareData(); + window.open( + `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(image)}&description=${encodeURIComponent(title)}`, + '_blank', + 'width=600,height=400', + ); + }, + email: () => { + const { url, articleTitle } = getShareData(); + const subject = encodeURIComponent(articleTitle); + const body = encodeURIComponent(url); + window.location.href = `mailto:?subject=${subject}&body=${body}`; + }, + }; + + Object.entries(shareHandlers).forEach(([platform, handler]) => { + ul.querySelector(`.share-${platform}`).addEventListener('click', handler); + }); + + return ul; +} + +export default async function decorate(block) { + const { locale, language } = getLocaleAndLanguage(); + const ph = await fetchPlaceholders(`/${locale}/${language}`); + const tags = getMetadata('article:tag'); + const author = getMetadata('author'); + const publicationDate = getMetadata('publication-date'); + + if (tags) { + const tagsWrapper = document.createElement('p'); + tagsWrapper.className = 'tags'; + block.append(tagsWrapper); + tags.split(',').map((tag) => tag.trim()).forEach((tag, i) => { + if (i) tagsWrapper.append(', '); + tagsWrapper.append(buildSearchLink(locale, language, tag)); + }); + } + + if (author || publicationDate) { + const byline = document.createElement('p'); + byline.className = 'byline'; + block.append(byline); + if (author) { + const authorSpan = document.createElement('span'); + authorSpan.append(`${(ph.by || 'Par').toUpperCase()}: `); + authorSpan.append(buildSearchLink(locale, language, author.trim())); + byline.append(authorSpan); + } + if (publicationDate) { + const date = document.createElement('span'); + date.textContent = publicationDate.trim(); + byline.append(date); + } + } + + block.append(buildShare(ph)); + swapIcons(); +} diff --git a/blocks/banner/banner.css b/blocks/banner/banner.css index 605ee3c0..ebce0a45 100644 --- a/blocks/banner/banner.css +++ b/blocks/banner/banner.css @@ -433,6 +433,10 @@ background-color: var(--color-charcoal); } +.banner.split.charcoal .eyebrow { + color: var(--color-gray-100) +} + .banner.split.transparent > div { background-color: transparent; color: var(--color-charcoal); diff --git a/blocks/cards/cards.css b/blocks/cards/cards.css index 91da7f4f..9f719e3f 100644 --- a/blocks/cards/cards.css +++ b/blocks/cards/cards.css @@ -3,10 +3,51 @@ padding: 0; display: grid; gap: var(--spacing-400); + grid-template-columns: repeat(var(--grid-cols, 1), minmax(0, 1fr)); + + --grid-cols: 1; } -.cards .card-click { - cursor: pointer; +@media (width >= 1000px) { + .cards ul.rows-2 { + --grid-cols: 2; + } + + .cards ul.rows-3 { + --grid-cols: 3; + } + + .cards ul.rows-4 { + --grid-cols: 4; + } + + .cards ul.rows-5 { + --grid-cols: 5; + } +} + +.cards .card-image { + position: relative; + line-height: 0; + overflow: hidden; +} + +.cards .card-image .img-wrapper { + position: relative; +} + +.cards .card-image > video { + width: 100%; + height: auto; +} + +.cards .card-image .img-wrapper + video { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: cover; } .cards .card-captioned { @@ -16,13 +57,75 @@ text-transform: uppercase; } -.cards .card-captioned .img-wrapper { - margin-bottom: var(--spacing-100); +.cards .card-click { + cursor: pointer; } -.cards .card-image { - line-height: 0; - overflow: hidden; +.cards.text-center { + text-align: center; +} + +/* default filled */ +.cards .filled { + padding: var(--horizontal-spacing); + background-color: var(--color-charcoal); + color: var(--color-white); + border-radius: var(--rounding-m); +} + +.cards .filled h2 { + font-size: var(--body-size-l); +} + +.cards .filled a:any-link { + color: inherit; +} + +.cards .filled .eyebrow { + color: var(--color-steel); +} + +.cards .filled .button-wrapper { + margin-bottom: 0; +} + +/* default captioned */ +.cards .captioned { + background-color: var(--color-charcoal); + color: var(--color-white); + text-align: center; +} + +.cards .captioned a:any-link { + color: inherit; + text-decoration: none; +} + +.cards .captioned p:not(.img-wrapper) { + margin: 1ch; +} + +.cards .card-filled { + padding: var(--horizontal-spacing); + background-color: var(--color-charcoal); + color: var(--color-white); + border-radius: var(--rounding-m); +} + +.cards .card-filled h2 { + font-size: var(--body-size-l); +} + +.cards .card-filled a:any-link { + color: inherit; +} + +.cards .card-filled .eyebrow { + color: var(--color-steel); +} + +.cards .card-filled .button-wrapper { + margin-bottom: 0; } /* stylelint-disable no-descending-specificity */ @@ -37,29 +140,12 @@ text-decoration: none; } -.cards.articles > ul li { - margin: 0 auto; - width: 100%; - max-width: 400px; -} - -.cards.articles > ul li:hover .card-image img { - transform: scale(1.04); -} - .cards.articles .card-image { border-radius: var(--rounding-m); } -.cards.articles .card-image img { - width: 100%; - height: 208px; - object-fit: cover; - transition: transform 0.4s; -} - .cards.articles .card-body { - padding: var(--spacing-400) 0 0 var(--spacing-300); + padding: var(--spacing-400) var(--spacing-300) 0 var(--spacing-300); } .cards.articles .card-body p:not(.eyebrow) { @@ -70,6 +156,25 @@ text-overflow: ellipsis; } +.cards.articles > ul li { + margin: 0 auto; + width: 100%; + max-width: 400px; +} + +.cards.articles .card-image img, +.cards.articles .card-image video { + width: 100%; + height: 208px; + object-fit: cover; + transition: transform 0.4s; +} + +.cards.articles > ul li:hover .card-image img, +.cards.articles > ul li:hover .card-image video { + transform: scale(1.04); +} + @media (width >= 800px) { .cards.articles h2, .cards.articles h3 { @@ -97,7 +202,8 @@ max-width: unset; } - .cards.articles .card-image img { + .cards.articles .card-image img, + .cards.articles .card-image video { height: 324px; } } @@ -114,6 +220,11 @@ margin: 0 auto; } +.cards.grid > ul li:hover, +.cards.knockout > ul li:hover { + border-color: var(--color-charcoal) !important; +} + @media (width >= 800px) { .cards.grid ul, .cards.knockout ul { @@ -149,7 +260,11 @@ .cards.grid ul.rows-2, .cards.grid ul.rows-4 { - grid-template-columns: repeat(2, minmax(0, 1fr)); + --grid-cols: 2; + } + + .cards.grid ul.rows-3 { + --grid-cols: 3; } .cards.grid ul.rows-2 > li, @@ -175,10 +290,17 @@ @media (width >= 1000px) { .cards.grid ul { max-width: unset; + gap: 0; + } + + .cards.grid ul.rows-4 { + --grid-cols: 4; } .cards.grid ul > li { position: relative; + width: 100%; + max-width: unset; border: 1px solid var(--color-gray-300); padding: var(--spacing-600) var(--spacing-400); overflow: hidden; @@ -186,25 +308,8 @@ cursor: context-menu; } - .cards.grid ul.rows-2, - .cards.grid ul.rows-3, - .cards.grid ul.rows-4 { - gap: 0; - } - - .cards.grid ul.rows-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .cards.grid ul.rows-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - .cards.grid ul.rows-1 > li, .cards.grid ul.rows-2 > li, - .cards.grid ul.rows-3 > li, .cards.grid ul.rows-4 > li { - width: 100%; max-width: unset; } @@ -222,10 +327,6 @@ border-top-color: transparent; } - .cards.grid ul > li:hover { - border-color: var(--color-charcoal) !important; - } - .cards.grid ul > li .card-body { position: absolute; inset: 0; @@ -254,6 +355,11 @@ font-size: var(--detail-size-s); } +.cards.knockout a { + color: var(--color-charcoal); + text-decoration: none; +} + .cards.knockout picture { display: block; border-radius: var(--rounding-m); @@ -268,33 +374,13 @@ transition: transform 0.4s; } -.cards.knockout a { - color: var(--color-charcoal); - text-decoration: none; -} - .cards.knockout > ul { gap: 0; } .cards.knockout > ul.rows-2, .cards.knockout > ul.rows-4 { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.cards.knockout > ul.rows-2 > li:nth-child(2n), -.cards.knockout > ul.rows-4 > li:nth-child(2n) { - border-left-color: transparent; -} - -.cards.knockout > ul.rows-2 > li:nth-child(n + 3), -.cards.knockout > ul.rows-4 > li:nth-child(n + 3) { - border-top-color: transparent; -} - -.cards.knockout > ul.rows-1 > li + li, -.cards.knockout > ul.rows-3 > li + li { - border-top-color: transparent; + --grid-cols: 2; } .cards.knockout > ul li { @@ -308,8 +394,16 @@ z-index: 1; } -.cards.knockout > ul li:hover { - border-color: var(--color-charcoal) !important; +.cards.knockout > ul.rows-2 > li:nth-child(2n), +.cards.knockout > ul.rows-4 > li:nth-child(2n) { + border-left-color: transparent; +} + +.cards.knockout > ul.rows-2 > li:nth-child(n + 3), +.cards.knockout > ul.rows-4 > li:nth-child(n + 3), +.cards.knockout > ul.rows-1 > li + li, +.cards.knockout > ul.rows-3 > li + li { + border-top-color: transparent; } .cards.knockout > ul li::after { @@ -339,7 +433,7 @@ @media (width >= 600px) { .cards.knockout > ul.rows-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); + --grid-cols: 3; } .cards.knockout > ul.rows-3 > li:nth-child(n + 4) { @@ -381,12 +475,12 @@ width: unset; } - .cards.knockout > ul > li { - max-width: unset; + .cards.knockout > ul.rows-4 { + --grid-cols: 4; } - .cards.knockout > ul.rows-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); + .cards.knockout > ul > li { + max-width: unset; } .cards.knockout ul.rows-4 > li, @@ -426,7 +520,8 @@ justify-self: center; } -.cards.linked ul > li .card-image img { +.cards.linked ul > li .card-image img, +.cards.linked ul > li .card-image video { width: auto; max-height: 85px; } @@ -436,18 +531,6 @@ gap: var(--spacing-100); } - .cards.linked ul.rows-2 { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .cards.linked ul.rows-3 { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .cards.linked ul.rows-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - .cards.linked ul > li { display: block; border-top: 0; @@ -471,7 +554,8 @@ background-color: var(--color-gray-200); } - .cards.linked ul > li .card-image img { + .cards.linked ul > li .card-image img, + .cards.linked ul > li .card-image video { width: 100%; max-height: unset; } @@ -491,14 +575,14 @@ text-decoration: none; } -.cards.overlay .eyebrow { - color: var(--color-gray-600); -} - .cards.overlay h2 { font-family: var(--body-font-family); } +.cards.overlay .eyebrow { + color: var(--color-gray-600); +} + .cards.overlay > ul { gap: var(--spacing-60); } @@ -523,22 +607,13 @@ transition: background 0.3s; } -.cards.overlay > ul > li.card-click:hover::after { - background-color: var(--shadow-300); -} - -.cards.overlay > ul > li.card-click a::after { - content: '▸'; - margin-left: var(--spacing-40); - font-size: 0.75em; -} - .cards.overlay > ul > li .card-image { position: absolute; inset: 0; } -.cards.overlay > ul > li .card-image img { +.cards.overlay > ul > li .card-image img, +.cards.overlay > ul > li .card-image video { width: 100%; height: 100%; object-fit: cover; @@ -553,22 +628,28 @@ z-index: 1; } +.cards.overlay > ul > li.card-click a::after { + content: '▸'; + margin-left: var(--spacing-40); + font-size: 0.75em; +} + +.cards.overlay > ul > li.card-click:hover::after { + background-color: var(--shadow-300); +} + @media (width >= 800px) { .cards.overlay h2 { font-size: var(--heading-size-xxl); } - .cards.overlay > ul { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .cards.overlay > ul.rows-1 { - grid-template-columns: 1fr; + .cards.overlay > ul.rows-3 { + --grid-cols: 3; } .cards.overlay > ul.rows-2, .cards.overlay > ul.rows-4 { - grid-template-columns: repeat(2, minmax(0, 1fr)); + --grid-cols: 2; } .cards.overlay > ul > li .card-body { @@ -582,8 +663,85 @@ background-color: transparent; } -.cards.icon-list ul > li .card-image img { +.cards.icon-list ul > li .card-image img, +.cards.icon-list ul > li .card-image video { width: 50px; height: 50px; object-fit: contain; } + +@media (width >= 800px) { + .cards.icon-list ul.rows-3 { + --grid-cols: 3; + } + + .cards.icon-list ul.rows-4 { + --grid-cols: 4; + } +} + +/* horizontal variant */ +.cards.horizontal > ul > li { + display: grid; + gap: var(--spacing-200); + border-radius: var(--rounding-m); + padding: var(--spacing-200) var(--horizontal-spacing); + background-color: var(--color-gray-300); +} + +.cards.horizontal.dark > ul > li { + background-color: var(--color-charcoal); + color: var(--color-white); +} + +.cards.horizontal.dark .eyebrow { + color: var(--color-steel); +} + +.cards.horizontal .card-body blockquote { + margin: 0; + border-left: 4px solid var(--color-red); + background-color: var(--color-gray-100); + padding: var(--spacing-60) var(--spacing-200); +} + +.cards.horizontal.dark .card-body blockquote { + border-left-color: var(--color-steel); + background-color: var(--color-dark-charcoal); + color: var(--color-white); +} + +.cards.horizontal .card-image { + margin: var(--spacing-200) auto; + aspect-ratio: 1 / 1; + width: 100%; + max-width: 250px; +} + +.cards.horizontal .card-image img, +.cards.horizontal .card-image video { + border-radius: var(--rounding-m); +} + +@media (width >= 800px) { + .cards.horizontal > ul { + --grid-cols: 1; + } + + .cards.horizontal > ul > li { + grid-template-columns: 250px 1fr; + gap: var(--horizontal-spacing); + padding: var(--horizontal-spacing); + } + + .cards.horizontal .card-image { + margin: 0; + } +} + +@media (width >= 900px) { + .cards.horizontal > ul > li { + gap: var(--spacing-400); + padding: var(--spacing-400); + } +} diff --git a/blocks/cards/cards.js b/blocks/cards/cards.js index 180e6da3..a65ba3aa 100644 --- a/blocks/cards/cards.js +++ b/blocks/cards/cards.js @@ -1,4 +1,5 @@ import { createOptimizedPicture } from '../../scripts/aem.js'; +import { buildVideo } from '../../scripts/scripts.js'; /** * Returns the largest factor of given n among between 1 and 4. @@ -9,83 +10,129 @@ function getLargestFactor(n) { // try to find a factor of 4, 3, or 2 const factor = [4, 3, 2].find((f) => n % f === 0); if (factor) return factor; - // otherwise, set default factor if (n > 4) return n % 2 === 0 ? 4 : 3; return 1; } -export default function decorate(block) { - // replace default div structure with ordered list - const ul = document.createElement('ul'); - const cardsPerRow = getLargestFactor(block.children.length); - ul.classList.add(`rows-${cardsPerRow}`); +function stripButtonClasses(container) { + container.querySelectorAll('.button').forEach((button) => { + button.classList.remove('button'); + button.parentElement.classList.remove('button-wrapper'); + }); +} - [...block.children].forEach((row) => { - // move all children from row into list item - const li = document.createElement('li'); - while (row.firstElementChild) li.append(row.firstElementChild); +function enableClick(container) { + container.querySelectorAll('li').forEach((card) => { + const links = card.querySelectorAll('a[href]'); + if (!links.length) return; - // assign classes based on content - [...li.children].forEach((div, i) => { - if (div.children.length === 1 && div.querySelector('picture')) { // single picture element - div.className = 'card-image'; - } else if (!i && div.querySelector('picture')) { // first div with picture - div.className = 'card-captioned'; - div.querySelectorAll('.button').forEach((button) => { - button.classList.remove('button'); - button.parentElement.classList.remove('button-wrapper'); - }); - } else { // default, all other divs - div.className = 'card-body'; - } - }); - ul.append(li); + const sameLink = links.length === 1 || [...links].every((a) => a.href === links[0].href); + if (sameLink) { + card.classList.add('card-click'); + card.addEventListener('click', () => links[0].click()); + } }); +} - // replace images with optimized versions - ul.querySelectorAll('picture > img').forEach((img) => img.closest('picture').replaceWith( - createOptimizedPicture(img.src, img.alt, false, [{ width: '900' }]), - )); +function setCardDefaults(block, ul, variants) { + // default card styling + "linked" + ul.querySelectorAll('li').forEach((li) => { + const image = li.querySelector('.card-image'); + const body = li.querySelector('.card-body'); + const captioned = li.querySelector('.card-captioned'); - // decorate variant specifics - const clickable = ['knockout', 'articles', 'linked', 'overlay']; - const variants = [...block.classList].filter((c) => c !== 'block' && c !== 'cards'); - if (!variants.length) { - // default card styling - ul.querySelectorAll('li .card-body').forEach((body) => { + if (body && !captioned && !image) { + li.classList.add('filled'); + } else if (captioned && !body && !image) { + li.classList.add('captioned'); + } + + if (body) { const link = body.querySelector('a[href]'); if (link) { const content = body.textContent.trim(); + // link is the only content - if (link.textContent.trim() === content) { - link.removeAttribute('class'); - link.parentElement.classList.remove('button-wrapper'); + if (content === link.textContent.trim()) { + stripButtonClasses(body); + if (!variants.includes('linked')) variants.push('linked'); - if (!block.classList.contains('linked')) block.classList.add('linked'); + block.classList.add('linked'); } } - }); - // check for icon list - const cards = ul.querySelectorAll('li').length; - const icons = ul.querySelectorAll('li img[src*=".svg"]').length; - if (cards === icons) { - variants.push('icon-list'); - block.classList.add('icon-list'); } + }); + + // icon-list detection + const cards = ul.querySelectorAll('li').length; + const icons = ul.querySelectorAll('li img[src*=".svg"]').length; + if (cards && cards === icons) { + if (!variants.includes('icon-list')) variants.push('icon-list'); + block.classList.add('icon-list'); + } + + return variants; +} + +export default function decorate(block) { + // replace default div structure with ordered list + const ul = document.createElement('ul'); + const definedRows = [...block.classList].find((c) => c.startsWith('rows-')); + if (!definedRows) { + const cardsPerRow = getLargestFactor(block.children.length); + ul.classList.add(`rows-${cardsPerRow}`); + } else { + const rows = definedRows.split('-')[1]; + ul.classList.add(`rows-${rows}`); + block.classList.remove(definedRows); } - if (clickable.some((v) => variants.includes(v))) { - ul.querySelectorAll('li').forEach((li) => { - const as = li.querySelectorAll('a'); - // setup full card click if there's one link or all links have same href - if (as.length === 1 || (as.length > 1 && [...as].every((a) => a.href === as[0].href))) { - li.classList.add('card-click'); - li.addEventListener('click', () => as[0].click()); + // build list structure + [...block.children].forEach((row) => { + // move all children from row into list item + const li = document.createElement('li'); + while (row.firstElementChild) li.append(row.firstElementChild); + + // replace images with optimized versions + li.querySelectorAll('picture > img').forEach((img) => img.closest('picture').replaceWith( + createOptimizedPicture(img.src, img.alt, false, [{ width: '900' }]), + )); + ul.append(li); + buildVideo(li); + + // assign classes based on content + [...li.children].forEach((child) => { + const picture = child.querySelector('picture'); + const video = child.querySelector('video'); + const hasMedia = picture || video; + + if (hasMedia) { + const textContent = child.textContent.trim(); + if (textContent) { + child.className = 'card-captioned'; + stripButtonClasses(child); + } else { + child.className = 'card-image'; + } + if (video) child.classList.add('vid-wrapper'); + } else { + child.className = 'card-body'; } }); + }); + + // decorate variant specifics + let variants = [...block.classList].filter((c) => c !== 'block' && c !== 'cards'); + if (variants.length === 0) { + variants = setCardDefaults(block, ul, variants); + } + + const clickable = ['knockout', 'articles', 'linked', 'overlay']; + if (variants.some((v) => clickable.includes(v))) { + enableClick(ul); } - // replace contentwith new list structure + // replace content with new list structure block.replaceChildren(ul); } diff --git a/blocks/carousel/carousel.css b/blocks/carousel/carousel.css index c61b9a92..eff16793 100644 --- a/blocks/carousel/carousel.css +++ b/blocks/carousel/carousel.css @@ -158,7 +158,7 @@ .carousel.items ul { gap: var(--carousel-gap); - align-items: flex-start; + align-items: stretch; } .carousel.items img { @@ -203,6 +203,7 @@ .carousel.items.slides-4 ul > li, .carousel.items.slides-5 ul > li { flex: 0 0 calc((100% - (3 * var(--carousel-gap))) / 4); + height: auto; } } @@ -552,6 +553,12 @@ justify-content: flex-start; } +.carousel.expansion ul > li .img-wrapper { + display: flex; + justify-content: center; + align-items: center; +} + .carousel.expansion ul > li video, .carousel.expansion ul > li img { width: 180px; diff --git a/blocks/columns/columns.css b/blocks/columns/columns.css index 9be2351c..96949843 100644 --- a/blocks/columns/columns.css +++ b/blocks/columns/columns.css @@ -71,7 +71,8 @@ p + .columns-container { margin-top: var(--spacing-400);; } -.columns.icons .column-img img { +/* stylelint-disable-next-line no-descending-specificity */ +.columns.icons img { width: 6em; height: 6em; border-radius: 50%; @@ -121,4 +122,15 @@ p + .columns-container { align-items: unset; justify-content: center; } -} \ No newline at end of file +} + +/* text center variant */ +.columns.text-center { + text-align: center; +} + + +.columns .icon-circle-1 svg, .columns .icon-circle-2 svg, .columns .icon-circle-3 svg { + height: 4em; + width: 4em; +} diff --git a/blocks/featured-recipe/featured-recipe.css b/blocks/featured-recipe/featured-recipe.css new file mode 100644 index 00000000..6f4101d2 --- /dev/null +++ b/blocks/featured-recipe/featured-recipe.css @@ -0,0 +1,84 @@ +main > .featured-recipe-container.section { + margin-top: 0; + margin-bottom: 0; +} + +main > .featured-recipe-container.section > .featured-recipe-wrapper { + margin: 0; + padding: 0; +} + +.featured-recipe > img { + width: 100%; + aspect-ratio: 4 / 3; + object-fit: cover; +} + +.featured-recipe .featured-recipe-content { + padding: var(--spacing-700) var(--horizontal-spacing); + background: var(--color-white); +} + +@media (width >= 800px) { + .featured-recipe { + position: relative; + min-height: 647px; + } + + .featured-recipe > img { + height: 100%; + aspect-ratio: unset; + } + + .featured-recipe .featured-recipe-content { + position: absolute; + top: 50%; + right: 0; + transform: translateY(-50%); + width: 100%; + max-width: 473px; + border-radius: var(--rounding-m) 0 0 var(--rounding-m); + padding: var(--spacing-700); + } +} + +@media (width >= 1000px) { + .featured-recipe { + display: grid; + grid-template-columns: 1fr 1fr; + min-height: 720px; + background: var(--color-white); + } + + .featured-recipe .featured-recipe-content { + align-self: center; + position: static; + transform: none; + max-width: none; + border-radius: 0; + } +} + +.featured-recipe .eyebrow + h2 { + margin-top: 0; +} + +.featured-recipe dl { + display: flex; + align-items: center; + gap: 1ch; +} + +.featured-recipe dt img { + width: 24px; + height: 24px; +} + +.featured-recipe dd { + margin: 0; + color: var(--color-gray-900); +} + +.featured-recipe .button-wrapper { + margin-top: var(--spacing-400); +} diff --git a/blocks/featured-recipe/featured-recipe.js b/blocks/featured-recipe/featured-recipe.js new file mode 100644 index 00000000..ff718543 --- /dev/null +++ b/blocks/featured-recipe/featured-recipe.js @@ -0,0 +1,98 @@ +import { fetchPlaceholders } from '../../scripts/aem.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +function formatTime(timeString, placeholders = {}) { + if (!timeString) return ''; + const parts = timeString.split(':'); + if (parts.length !== 3) return timeString; + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10) + (parseInt(parts[2], 10) > 0 ? 1 : 0); + const h = Math.floor((hours * 60 + minutes) / 60); + const m = (hours * 60 + minutes) % 60; + const hourLabel = h !== 1 ? (placeholders.hours || 'hours') : (placeholders.hour || 'hour'); + const minuteLabel = m !== 1 ? (placeholders.minutes || 'minutes') : (placeholders.minute || 'minute'); + if (h > 0 && m > 0) return `${h} ${hourLabel} ${m} ${minuteLabel}`; + if (h > 0) return `${h} ${hourLabel}`; + return `${m} ${minuteLabel}`; +} + +function formatYield(yieldString, placeholders = {}) { + if (!yieldString) return ''; + const match = yieldString.match(/^([\d.]+)/); + if (!match) return yieldString; + const num = parseFloat(match[1]); + const servingsLabel = placeholders.servings || 'servings'; + return num % 1 === 0 ? `${Math.floor(num)} ${servingsLabel}` : `${num} ${servingsLabel}`; +} + +export default async function decorate(block) { + const { locale, language } = getLocaleAndLanguage(); + const placeholders = await fetchPlaceholders(`/${locale}/${language}`); + + const resp = await fetch(`/${locale}/${language}/recipes/query-index.json`); + if (!resp.ok) { + block.remove(); + return; + } + + const { data } = await resp.json(); + if (!data || data.length === 0) { + block.remove(); + return; + } + + const recipes = data.filter((r) => r.status !== 'Deleted' && r.image && !r.image.includes('default-meta-image')); + + let recipe; + const a = block.querySelector('a[href]'); + if (a) { + const url = new URL(a.href, window.location); + recipe = recipes.find((r) => r.path === url.pathname); + } + + // fall back to most recent recipe + if (!recipe) { + const sorted = recipes + .filter((r) => r['date-created']) + .sort((x, y) => new Date(y['date-created']) - new Date(x['date-created'])); + [recipe] = sorted; + } + + // If no recipe found after filtering and fallback, remove the block + if (!recipe) { + block.remove(); + return; + } + + // Convert image URL to relative path (pathname + query params only) + let imagePath = recipe.image; + try { + const imageUrl = new URL(imagePath, window.location.origin); + imagePath = imageUrl.pathname + imageUrl.search; + } catch (e) { + // If URL parsing fails, use the path as-is + } + + const timeLabel = placeholders.time || 'Time'; + const servingsLabel = placeholders.servings || 'Servings'; + const newFeaturedRecipeLabel = placeholders.newFeaturedRecipe || 'New Featured Recipe'; + const getTheRecipeLabel = placeholders.getTheRecipe || 'Get the Recipe'; + + block.innerHTML = ` + + + `; +} diff --git a/blocks/footer/footer.css b/blocks/footer/footer.css index a82fc12b..e108b7a7 100644 --- a/blocks/footer/footer.css +++ b/blocks/footer/footer.css @@ -1,9 +1,8 @@ footer { - padding: var(--spacing-m) var(--horizontal-spacing); background-color: var(--color-gray-300); color: var(--color-gray-900); - max-width: var(--site-width); margin: 0 auto; + overflow-x: clip; } footer a:any-link { @@ -31,7 +30,11 @@ footer section { flex-direction: column; gap: var(--spacing-600); margin: auto; - max-width: 1000px; + padding-top: var(--spacing-m); +} + +footer section.ribbon { + padding-top: 0; } @media (width >= 800px) { @@ -41,6 +44,76 @@ footer section { "links form" auto "links social" auto "copyright copyright" auto / 2fr 1fr; + max-width: 1000px; + margin: 0 auto; + } + + footer section.ribbon { + grid-template: + "ribbon ribbon" auto + "links form" auto + "links social" auto + "copyright copyright" auto / 2fr 1fr; + } +} + +footer section > div { + padding: var(--spacing-xxs) var(--horizontal-spacing); +} + +footer section > div:last-child { + margin-bottom: var(--spacing-m); +} + +/* ribbon */ +footer .footer-ribbon { + grid-area: ribbon; + margin-left: calc(-50vw + 50%); + margin-right: calc(-50vw + 50%); + padding: var(--spacing-m) 0; + background-color: var(--color-charcoal); + color: var(--color-white); +} + +footer .footer-ribbon strong { + display: block; + margin-bottom: 0.2em; + font-size: var(--body-size-l); + font-weight: bold; +} + +footer .footer-ribbon br { + display: none; +} + +footer .footer-ribbon img, +footer .footer-ribbon svg { + display: block; + width: 64px; + height: auto; +} + +footer .footer-ribbon ul { + margin: 0 auto; + max-width: var(--site-width); + padding: 0 var(--horizontal-spacing); + display: grid; + gap: var(--spacing-m) var(--horizontal-spacing); + justify-content: center; +} + +footer .footer-ribbon ul > li { + max-width: max-content; + display: grid; + grid-template-columns: 64px 1fr; + gap: var(--spacing-m); + align-items: center; +} + +@media (width >= 700px) { + footer .footer-ribbon ul { + display: flex; + flex-wrap: wrap; } } @@ -111,6 +184,7 @@ footer .footer-links > div > ul { gap: var(--spacing-200); } +/* stylelint-disable-next-line no-descending-specificity */ footer .footer-links li { flex-basis: 100%; } diff --git a/blocks/footer/footer.js b/blocks/footer/footer.js index 4eaf0888..1bbefcd3 100644 --- a/blocks/footer/footer.js +++ b/blocks/footer/footer.js @@ -18,7 +18,11 @@ export default async function decorate(block) { footer.id = 'footer'; while (fragment.firstElementChild) footer.append(fragment.firstElementChild); - const classes = ['form', 'social', 'links', 'copyright']; + const classes = ['ribbon', 'form', 'social', 'links', 'copyright']; + const children = footer.children.length; + // legacy footer + if (children < 5) classes.splice(0, 1); // remove ribbon + else footer.classList.add('ribbon'); classes.forEach((c, i) => { const section = footer.children[i]; if (section) { @@ -27,6 +31,21 @@ export default async function decorate(block) { } }); + // decorate ribbon + const ribbon = footer.querySelector('.footer-ribbon'); + if (ribbon) { + ribbon.querySelectorAll('ul > li').forEach((li) => { + const icon = li.querySelector('.icon'); + if (icon) { + const content = document.createElement('div'); + [...li.childNodes].forEach((node) => { + if (node !== icon) content.append(node); + }); + li.append(content); + } + }); + } + // decorate social const social = footer.querySelector('.footer-social'); if (social) { diff --git a/blocks/form/form.css b/blocks/form/form.css index e6f98d45..1abd0e2c 100644 --- a/blocks/form/form.css +++ b/blocks/form/form.css @@ -165,4 +165,28 @@ fieldset.form-section h3 { margin: 1em 0 0.5em; -} \ No newline at end of file +} + +.form i.symbol.symbol-loading { + transform: scale(1.2); + border: var(--border-m) solid; + border-radius: 50%; +} + +.form i.symbol.symbol-loading::after { + inset: calc(-1 * var(--border-m)); + border: inherit; + border-right-color: var(--color-link); + border-radius: inherit; + animation: loading 1.2s linear infinite; +} + +@keyframes loading { + from { + transform: rotate(0deg) + } + + to { + transform: rotate(360deg) + } +} diff --git a/blocks/form/form.js b/blocks/form/form.js index 98089c13..babbabce 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -1,4 +1,5 @@ import { toCamelCase, toClassName } from '../../scripts/aem.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; /** * Creates an HTML element with an optional class name @@ -395,7 +396,7 @@ function resetLoadingButton(button) { button.removeAttribute('style'); } -function toggleForm(form, disabled = true) { +export function toggleForm(form, disabled = true) { [...form.elements].forEach((el) => { el.disabled = disabled; if (el.type === 'submit') { @@ -405,6 +406,24 @@ function toggleForm(form, disabled = true) { }); } +/** + * Configures nav search form with submission handling. + * @param {HTMLFormElement} form - Nav search form + */ +function enableNavSearch(form) { + form.classList.add('nav-search'); + const button = form.querySelector('button'); + button.classList.add('emphasis'); + form.addEventListener('submit', (e) => { + e.preventDefault(); + const data = new FormData(form); + const { search } = Object.fromEntries(data.entries()) || ''; + const { locale, language } = getLocaleAndLanguage(); + const basePath = `/${locale}/${language}`; + window.location.href = `https://www.vitamix.com${basePath}/search-result?search=${search}`; + }); +} + /** * Configures footer sign-up form with submission handling. * @param {HTMLFormElement} form - Footer sign-up form @@ -419,10 +438,14 @@ function enableFooterSignUp(form) { const entries = Object.fromEntries(data.entries()); const { email, mobile, optIn } = entries; const country = window.location.pathname.split('/')[1]; - let leadSource = optIn ? `sub-emsms-footer-${country}` : `sub-em-footer-${country}`; + let leadSource = `sub-em-footer-${country}`; if (form.closest('dialog')) { - leadSource = optIn ? `sub-emsms-modal-${country}` : `sub-em-modal-${country}`; + leadSource = `sub-em-modal-${country}`; } + if (window.leadSourceOverride) { + leadSource = `sub-em-${window.leadSourceOverride}-${country}`; + } + const payload = { email, mobile, @@ -452,19 +475,12 @@ function enableFooterSignUp(form) { }); } -/** - * Configures nav search form with submission handling. - * @param {HTMLFormElement} form - Nav search form - */ -function enableNavSearch(form) { - form.classList.add('nav-search'); - const button = form.querySelector('button'); - button.classList.add('emphasis'); - form.addEventListener('submit', (e) => { - e.preventDefault(); - const data = new FormData(form); - const { search } = Object.fromEntries(data.entries()) || ''; - window.location.href = `https://www.vitamix.com/us/en_us/search-result?search=${search}`; +function enableLocator(form) { + // set initial values from query params + const queryParams = Object.fromEntries(new URLSearchParams(window.location.search)); + Object.entries(queryParams).forEach(([key, value]) => { + const input = form.querySelector(`[name="${toClassName(key)}"]`); + if (input) input.value = value; }); } @@ -474,11 +490,9 @@ function enableNavSearch(form) { * @param {string} path -Path associated with the form */ function enableSubmission(form, path) { - if (path.includes('/footer-sign-up.json')) { - enableFooterSignUp(form); - } else if (path.includes('/nav-search.json')) { - enableNavSearch(form); - } + if (path.includes('/nav-search.json')) enableNavSearch(form); + if (path.includes('/footer-sign-up.json')) enableFooterSignUp(form); + if (path.includes('/locator.json')) enableLocator(form); } /** @@ -507,7 +521,7 @@ function buildField(field) { return fieldset; } - // inputs and textareas get a wrapper div + // inputs, textareas, and selects get a wrapper div const wrapper = createElement('div', `form-field ${type}-field`); if (controlled) { const controller = controlled.split('-')[0]; @@ -524,19 +538,20 @@ function buildField(field) { wrapper.append(helpText); } + // create the appropriate input element let input; - switch (type) { - case 'textarea': - input = buildTextArea(field); - wrapper.append(input); - break; - case 'select': - input = buildSelect(field); - wrapper.append(input); - break; - default: - input = buildInput(field); - wrapper.insertBefore(input, wrapper.firstChild.nextSibling); + if (type === 'textarea') { + input = buildTextArea(field); + } else if (type === 'select') { + input = buildSelect(field); + } else { + input = buildInput(field); + } + + if (type === 'textarea') { + wrapper.append(input); + } else { + wrapper.insertBefore(input, wrapper.firstChild.nextSibling); } if (help) input.setAttribute('aria-describedby', helpText.id); diff --git a/blocks/fragment/fragment.js b/blocks/fragment/fragment.js index 60c4300a..38d31a86 100644 --- a/blocks/fragment/fragment.js +++ b/blocks/fragment/fragment.js @@ -5,7 +5,7 @@ */ // eslint-disable-next-line import/no-cycle -import { decorateMain } from '../../scripts/scripts.js'; +import { decorateMain, parseEasternDateTime } from '../../scripts/scripts.js'; import { loadSections } from '../../scripts/aem.js'; /** @@ -14,58 +14,26 @@ import { loadSections } from '../../scripts/aem.js'; * @returns {string} The resolved path */ async function pickFromSchedule(path) { - const parseDateWithTime = (dateStr) => { + /** + * Parses a datetime string safely, returning null for empty/invalid values. + * @param {string} dateStr - The datetime string to parse + * @returns {Date|null} The parsed Date object or null if empty/malformed + */ + const parseDateSafe = (dateStr) => { if (!dateStr) { return null; } - - // Handle formats like "9/12/2025 9am EDT", "9/19/2025 3pm EDT", or "9/12/2025 9:30am EDT" - const timeMatch = dateStr.match(/^(\d{1,2}\/\d{1,2}\/\d{4})\s+(\d{1,2})(?::(\d{2}))?(am|pm)\s+([A-Z]{3,4})$/); - - if (timeMatch) { - const [, datePart, hour, minutes, ampm, timezone] = timeMatch; - - // Parse the date part (M/D/YYYY) - const [month, day, year] = datePart.split('/').map((num) => parseInt(num, 10)); - - // Convert hour to 24-hour format - let hour24 = parseInt(hour, 10); - if (ampm.toLowerCase() === 'pm' && hour24 !== 12) { - hour24 += 12; - } else if (ampm.toLowerCase() === 'am' && hour24 === 12) { - hour24 = 0; + try { + return parseEasternDateTime(dateStr); + } catch { + // Fallback to simple date parsing for formats without time + const fallbackDate = new Date(dateStr); + // Return null if the fallback also fails (Invalid Date) + if (Number.isNaN(fallbackDate.getTime())) { + return null; } - - // Parse minutes (default to 0 if not provided) - const minutesValue = minutes ? parseInt(minutes, 10) : 0; - - // Handle timezone offset (simplified - you might want to use a proper timezone library) - // For now, we'll assume EDT is UTC-4 (Eastern Daylight Time) - const timezoneOffsets = { - EDT: -4, // UTC-4 hours - EST: -5, // UTC-5 hours - CDT: -5, // UTC-5 hours - CST: -6, // UTC-6 hours - MDT: -6, // UTC-6 hours - MST: -7, // UTC-7 hours - PDT: -7, // UTC-7 hours - PST: -8, // UTC-8 hours - }; - - const offsetHours = timezoneOffsets[timezone] || 0; - - // Convert the local time to UTC by adding the offset - // If EDT is UTC-4, then 9am EDT = 1pm UTC (9 + 4 = 13) - const utcHour = hour24 - offsetHours; - - // Create UTC date object directly with minutes - const utcDate = new Date(Date.UTC(year, month - 1, day, utcHour, minutesValue, 0)); - - return utcDate; + return fallbackDate; } - - // Fallback to simple date parsing for formats without time/timezone - return new Date(dateStr); }; const resp = await fetch(path); @@ -73,8 +41,8 @@ async function pickFromSchedule(path) { const now = window.simulateDate ? new Date(window.simulateDate) : new Date(); let pickedItem = null; schedule.data.forEach((item) => { - const startDate = parseDateWithTime(item.Start); - const endDate = parseDateWithTime(item.End); + const startDate = parseDateSafe(item.Start); + const endDate = parseDateSafe(item.End); if ((now >= startDate || !startDate) && (now <= endDate || !endDate)) { pickedItem = item; } diff --git a/blocks/header/header.css b/blocks/header/header.css index a2544aa4..67a3f14f 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -3,6 +3,9 @@ header { max-width: var(--site-width); margin: 0 auto; background-color: var(--layer-base); + position: sticky; + top: 0; + z-index: 10; } header a:any-link { @@ -59,6 +62,8 @@ header section { height: var(--header-height); padding: 0 var(--spacing-200); transition: height 0.6s ease-in; + border-bottom: 1px solid var(--color-gray-400); + box-shadow: 0 -2px 8px rgb(0 0 0 / 10%); } header section[data-expanded='true'] { diff --git a/blocks/hotspot/hotspot.js b/blocks/hotspot/hotspot.js index 9f5f35e0..b68d2c0f 100644 --- a/blocks/hotspot/hotspot.js +++ b/blocks/hotspot/hotspot.js @@ -385,7 +385,7 @@ function toggleExplore(block, button, ph) { * @param {HTMLElement} block - Block element */ async function buildExpand(block) { - const { locale, language } = await getLocaleAndLanguage(); + const { locale, language } = getLocaleAndLanguage(); const ph = await fetchPlaceholders(`/${locale}/${language}`); const svgWrapper = block.querySelector('.svg-wrapper'); diff --git a/blocks/navigation/navigation.css b/blocks/navigation/navigation.css index 6f5f068e..b4bf376a 100644 --- a/blocks/navigation/navigation.css +++ b/blocks/navigation/navigation.css @@ -1,7 +1,27 @@ main > .navigation-container.section { position: sticky; - top: 0; - z-index: 2; + top: var(--header-height); + z-index: 9; +} + +main > .navigation-container.section ~ .section h1, +main > .navigation-container.section ~ .section h2, +main > .navigation-container.section ~ .section h3, +main > .navigation-container.section ~ .section h4, +main > .navigation-container.section ~ .section h5, +main > .navigation-container.section ~ .section h6 { + scroll-margin-top: calc(56px + 1em); +} + +@media (width >= 800px) { + main > .navigation-container.section ~ .section h1, + main > .navigation-container.section ~ .section h2, + main > .navigation-container.section ~ .section h3, + main > .navigation-container.section ~ .section h4, + main > .navigation-container.section ~ .section h5, + main > .navigation-container.section ~ .section h6 { + scroll-margin-top: calc(83px + 1em); + } } .hero-container + .navigation-container { @@ -44,13 +64,13 @@ main > .navigation-container.section { list-style: none; margin: 0; padding: 0; - cursor: pointer; } .navigation ul a:any-link { color: var(--color-white); text-decoration: none; font-weight: normal; + cursor: pointer; } .navigation nav ul a:any-link { @@ -82,7 +102,6 @@ main > .navigation-container.section { overflow-x: auto; scroll-snap-type: x mandatory; max-width: 100%; - cursor: unset; } .navigation nav .navigation-list-wrapper { @@ -199,8 +218,14 @@ main > .navigation-container.section { padding: var(--spacing-100) var(--spacing-400); } +.navigation .navigation-popover a:focus-visible { + outline: 2px solid var(--color-gray); + outline-offset: -2px; + background-color: rgb(255 255 255 / 10%); +} + .navigation .navigation-popover a:hover { - color: var(--color-gray); + color: var(--color-white); text-decoration: underline; } diff --git a/blocks/navigation/navigation.js b/blocks/navigation/navigation.js index a0a561ff..9968c871 100644 --- a/blocks/navigation/navigation.js +++ b/blocks/navigation/navigation.js @@ -10,8 +10,9 @@ function buildPopover(block, ul, popover) { popover.append(clone); block.append(popover); popover.hidden = window.matchMedia('(width >= 800px)').matches; - clone.addEventListener('click', () => { - popover.hidden = true; + clone.addEventListener('click', (e) => { + const link = e.target.closest('a[href]'); + if (link) popover.hidden = true; }); } @@ -30,6 +31,8 @@ export default function decorate(block) { const popover = document.createElement('div'); popover.className = 'navigation-popover'; popover.hidden = true; + popover.setAttribute('role', 'region'); + popover.setAttribute('aria-label', 'Navigation menu'); block.addEventListener('click', () => buildPopover(block, ul, popover), { once: true }); @@ -68,33 +71,63 @@ export default function decorate(block) { // set scroll state ul.dispatchEvent(new Event('scroll')); - const links = ul.querySelectorAll('a[href]'); + // handle switch from mobile to desktop + const mediaQuery = window.matchMedia('(width >= 800px)'); + const handleResize = (e) => { + if (e.matches) { + popover.hidden = true; + block.querySelectorAll('[aria-expanded]').forEach((el) => el.removeAttribute('aria-expanded')); + ul.dispatchEvent(new Event('scroll')); + } + }; + mediaQuery.addEventListener('change', handleResize); + + const sectionsToObserve = []; + + const links = ul.querySelectorAll('a[href*="#"]'); links.forEach((link) => { - // enable scroll tracking const hash = link.getAttribute('href').split('#')[1]; const section = document.getElementById(hash); - if (section) { - const observer = new IntersectionObserver((entries) => { - entries.forEach((entry) => { - if (entry.isIntersecting) { - links.forEach((l) => { - l.closest('li').removeAttribute('aria-current'); - }); - // highlight the current link - link.closest('li').setAttribute('aria-current', true); - // scroll nav into view if it's currently sticky on desktop - const mobile = !window.matchMedia('(width >= 800px)').matches; - const sticky = block.getBoundingClientRect().top === 0; - if (!mobile && sticky) link.scrollIntoView({ behavior: 'smooth', inline: 'center' }); - } - }); - }, { threshold: 0.75 }); - observer.observe(section); - } + if (section) sectionsToObserve.push(section); }); + // observe all sections + if (sectionsToObserve.length > 0) { + const sectionObserver = new IntersectionObserver(() => { + // check ALL sections every time (not just entries that changed) + const visibleSections = sectionsToObserve + .map((section) => { + const rect = section.getBoundingClientRect(); + const viewportHeight = window.innerHeight; + const visibleHeight = Math.min(rect.bottom, viewportHeight) - Math.max(rect.top, 0); + const visiblePixels = visibleHeight > 0 ? visibleHeight * rect.width : 0; + return { section, visiblePixels }; + }) + .filter((item) => item.visiblePixels > 0) + .sort((a, b) => b.visiblePixels - a.visiblePixels); + + if (visibleSections.length > 0) { + const mostVisible = visibleSections[0]; + const link = [...links].find((l) => l.getAttribute('href').endsWith(`#${mostVisible.section.id}`)); + + if (link) { + links.forEach((l) => l.closest('li').removeAttribute('aria-current')); + link.closest('li').setAttribute('aria-current', true); + + // scroll nav into view if sticky on desktop + const mobile = !window.matchMedia('(width >= 800px)').matches; + const sticky = Math.abs(block.getBoundingClientRect().top) < 1; + if (!mobile && sticky) link.scrollIntoView({ behavior: 'smooth', inline: 'center' }); + } + } + }, { threshold: [0, 0.25, 0.5, 0.75, 1.0] }); + + sectionsToObserve.forEach((section) => sectionObserver.observe(section)); + } + wrapper.appendChild(ul); - ul.querySelector('li').setAttribute('aria-current', true); + const firstLi = ul.querySelector('li'); + if (firstLi) firstLi.setAttribute('aria-current', true); nav.appendChild(wrapper); row.prepend(nav); } diff --git a/blocks/pdp/add-to-cart.js b/blocks/pdp/add-to-cart.js index 4aaa1603..91ce125e 100644 --- a/blocks/pdp/add-to-cart.js +++ b/blocks/pdp/add-to-cart.js @@ -1,35 +1,39 @@ import { getMetadata } from '../../scripts/aem.js'; -import { checkOutOfStock } from '../../scripts/scripts.js'; +import { checkVariantOutOfStock, getLocaleAndLanguage } from '../../scripts/scripts.js'; /** * Renders "Find Locally" button container. + * @param {Object} ph - Placeholders object * @param {HTMLElement} block - PDP block element * @returns {HTMLElement} Container div with the "Find Locally" button */ -function renderFindLocally(block) { +function renderFindLocally(ph, block) { + const { locale, language } = getLocaleAndLanguage(); const findLocallyContainer = document.createElement('div'); findLocallyContainer.classList.add('add-to-cart'); findLocallyContainer.innerHTML = `Find Locally`; + href="https://www.vitamix.com/${locale}/${language}/where-to-buy?productFamily=&productType=HH">${ph.findLocally || 'Find Locally'}`; block.classList.add('pdp-find-locally'); return findLocallyContainer; } /** * Renders a "Find Dealer" button container. + * @param {Object} ph - Placeholders object * @param {HTMLElement} block - PDP block element * @returns {HTMLElement} Container div with "Find Dealer" button and expert consultation link */ -function renderFindDealer(block) { +function renderFindDealer(ph, block) { + const { locale, language } = getLocaleAndLanguage(); const findDealerContainer = document.createElement('div'); findDealerContainer.classList.add('add-to-cart'); findDealerContainer.innerHTML = `Find Dealer + href="https://www.vitamix.com/${locale}/${language}/where-to-buy?productFamily=2205202&productType=COMM">${ph.findDealer || 'Find Dealer'}

Have a question? Consult an expert. + href="https://www.vitamix.com/${locale}/${language}/commercial/resources/consult-an-expert">${ph.consultAnExpert || 'Have a question? Consult an expert.'}

`; block.classList.add('pdp-find-dealer'); return findDealerContainer; @@ -57,7 +61,12 @@ function toggleFixedAddToCart(container) { // apply or remove "fixed" class and dynamic top offset if (scrollY > 0) { container.classList.add('fixed'); - container.style.top = `${offset}px`; + + if (offset > 0) { + container.style.top = `${offset}px`; + } else { + container.style.removeProperty('top'); + } } else { container.classList.remove('fixed'); container.removeAttribute('style'); @@ -80,7 +89,7 @@ export function isVariantAvailableForSale(variant) { return true; } - return !checkOutOfStock(variant.sku); + return !checkVariantOutOfStock(variant.sku); } /** @@ -91,7 +100,7 @@ export function isVariantAvailableForSale(variant) { * @param {Object} parent - Parent product object * @returns {HTMLElement} Container div with either add to cart functionality or alternative buttons */ -export default function renderAddToCart(block, parent) { +export default function renderAddToCart(ph, block, parent) { // Default selectedVariant to parent product, if simple product, selectedVariant will be undefined // TODO: this should be fixed with https://github.com/aemsites/vitamix/issues/185 let selectedVariant = parent.offers?.[0]?.custom ? parent.offers[0] : parent; @@ -102,13 +111,18 @@ export default function renderAddToCart(block, parent) { } // Only look at findLocally and findDealer from parent product - const { findLocally, findDealer } = parent; + const { findLocally, findDealer } = parent.custom; block.classList.remove('pdp-find-locally'); block.classList.remove('pdp-find-dealer'); // Figure out if the selected variant is available for sale const isAvailableForSale = isVariantAvailableForSale(selectedVariant); + // If the parent product is a bundle and is out of stock, return an empty string + if (parent.custom.type === 'bundle' && parent.custom.parentAvailability === 'OutOfStock') { + return ''; + } + // If we have a selected variant, use it's custom object, // otherwise use the parent product's custom object const { custom } = selectedVariant || parent; @@ -118,18 +132,18 @@ export default function renderAddToCart(block, parent) { // we always show the "Find Locally" button, // regardless of whether findLocally or findDealer is set to true or false. if (managedStock === '1' && !isAvailableForSale) { - return renderFindLocally(block); + return renderFindLocally(ph, block); } // check if product should show "Find Locally" instead of add to cart if: // findLocally is enabled, findDealer is enabled but not commercial, OR product is out of stock if (findLocally === 'Yes' && !isAvailableForSale) { - return renderFindLocally(block); + return renderFindLocally(ph, block); } // check if product should show "Find Dealer" instead of add to cart if (findDealer === 'Yes' && !isAvailableForSale) { - return renderFindDealer(block); + return renderFindDealer(ph, block); } // create main add to cart container @@ -140,7 +154,7 @@ export default function renderAddToCart(block, parent) { // create and configure quantity label const quantityLabel = document.createElement('label'); - quantityLabel.textContent = 'Quantity:'; + quantityLabel.textContent = `${ph.quantity || 'Quantity'}:`; quantityLabel.classList.add('pdp-quantity-label'); quantityLabel.htmlFor = 'pdp-quantity-select'; addToCartContainer.appendChild(quantityLabel); @@ -165,12 +179,12 @@ export default function renderAddToCart(block, parent) { // create and configure add to cart button const addToCartButton = document.createElement('button'); - addToCartButton.textContent = 'Add to Cart'; + addToCartButton.textContent = ph.addToCart || 'Add to Cart'; // add click event handler for add to cart functionality addToCartButton.addEventListener('click', async () => { // update button state to show loading - addToCartButton.textContent = 'Adding...'; + addToCartButton.textContent = ph.adding || 'Adding...'; addToCartButton.setAttribute('aria-disabled', 'true'); // get selected quantity and product SKU @@ -233,13 +247,14 @@ export default function renderAddToCart(block, parent) { await cartApi.addToCart(sku, selectedOptions, quantity); // redirect to cart page after successful addition - window.location.href = '/us/en_us/checkout/cart/'; + const { locale, language } = getLocaleAndLanguage(); + window.location.href = `/${locale}/${language}/checkout/cart/`; } catch (error) { // eslint-disable-next-line no-console console.error('Failed to add item to cart', error); } finally { // update button state to show ATC - addToCartButton.textContent = 'Add to Cart'; + addToCartButton.textContent = ph.addToCart || 'Add to Cart'; addToCartButton.removeAttribute('aria-disabled'); } }); diff --git a/blocks/pdp/full-warranty.svg b/blocks/pdp/full-warranty.svg new file mode 100644 index 00000000..7484c13f --- /dev/null +++ b/blocks/pdp/full-warranty.svg @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/blocks/pdp/limited-warranty.svg b/blocks/pdp/limited-warranty.svg new file mode 100644 index 00000000..3c4df96a --- /dev/null +++ b/blocks/pdp/limited-warranty.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/blocks/pdp/options.js b/blocks/pdp/options.js index f35f1473..4ec5e05b 100644 --- a/blocks/pdp/options.js +++ b/blocks/pdp/options.js @@ -1,16 +1,52 @@ import { buildSlide, buildThumbnails } from './gallery.js'; -import { rebuildIndices, checkOutOfStock } from '../../scripts/scripts.js'; +import { rebuildIndices, checkVariantOutOfStock, formatPrice } from '../../scripts/scripts.js'; import { toClassName } from '../../scripts/aem.js'; import renderPricing from './pricing.js'; import renderAddToCart from './add-to-cart.js'; +/** + * Updates the OOS message text based on whether parent or variant is out of stock. + * @param {Element} oosMessage - The OOS message element + * @param {boolean} isParentOutOfStock - Whether the parent product is out of stock + */ +function updateOOSMessage(ph, oosMessage, isParentOutOfStock) { + if (!oosMessage) return; + + if (isParentOutOfStock) { + oosMessage.textContent = ph.itemOutOfStock || 'This item is temporarily out of stock.'; + } else { + oosMessage.textContent = ph.colorOutOfStock || 'This color is temporarily out of stock.'; + } +} + +/** + * Updates the visibility of the free gift container based on stock availability. + * @param {Element} freeGiftContainer - The free gift container element + * @param {boolean} isParentOutOfStock - Whether the parent product is out of stock + * @param {boolean} isVariantOutOfStock - Whether the variant is out of stock + */ +export function updateFreeGiftVisibility( + freeGiftContainer, + isParentOutOfStock, + isVariantOutOfStock, +) { + if (!freeGiftContainer) return; + + if (isParentOutOfStock || isVariantOutOfStock) { + freeGiftContainer.classList.add('hidden'); + } else { + freeGiftContainer.classList.remove('hidden'); + } +} + /** * Handles the change of an option. * @param {Element} block - The PDP block element * @param {Array} variants - The variants of the product * @param {string} color - The color of the selected option + * @param {boolean} isParentOutOfStock - Whether the parent product is out of stock */ -export function onOptionChange(block, variants, color) { +export function onOptionChange(ph, block, variants, color, isParentOutOfStock = false) { if (variants[0].options.color.replace(/\s+/g, '-').toLowerCase() !== color) { // eslint-disable-next-line no-restricted-globals history.replaceState(null, '', `?color=${color}`); @@ -23,19 +59,28 @@ export function onOptionChange(block, variants, color) { const variant = variants.find((colorVariant) => colorVariant.options.color.replace(/\s+/g, '-').toLowerCase() === color); const { sku } = variant; - const oos = checkOutOfStock(sku); + const oos = checkVariantOutOfStock(sku); const buyBox = block.querySelector('.pdp-buy-box'); - buyBox.dataset.oos = oos; + // Set OOS to true if either parent or variant is out of stock + buyBox.dataset.oos = isParentOutOfStock || oos; buyBox.dataset.sku = sku; + // Update the OOS message text based on parent vs variant + const oosMessage = block.querySelector('.pdp-oos-message'); + updateOOSMessage(ph, oosMessage, isParentOutOfStock); + + // Hide/show free gift container based on availability + const freeGiftContainer = block.querySelector('.pdp-free-gift-container'); + updateFreeGiftVisibility(freeGiftContainer, isParentOutOfStock, oos); + // update pricing - const pricingContainer = renderPricing(block, variant); + const pricingContainer = renderPricing(ph, block, variant); if (pricingContainer) { block.querySelector('.pricing').replaceWith(pricingContainer); } const variantColor = variant.options.color; - selectedOptionLabel.textContent = `Color: ${variantColor}`; + selectedOptionLabel.textContent = `${ph.color || 'Color'}: ${variantColor}`; // check if bundle (should skip variant images) let variantImages = variant.images || []; @@ -90,12 +135,19 @@ export function onOptionChange(block, variants, color) { window.selectedVariant = variant; // update add to cart - const addToCartContainer = renderAddToCart(block, window.jsonLdData); + const addToCartContainer = renderAddToCart(ph, block, window.jsonLdData); if (addToCartContainer) { block.querySelector('.add-to-cart').replaceWith(addToCartContainer); } } +function renderOOSMessage(ph, element, isParentOutOfStock) { + const oosMessage = document.createElement('div'); + oosMessage.classList.add('pdp-oos-message'); + updateOOSMessage(ph, oosMessage, isParentOutOfStock); + element.append(oosMessage); +} + /** * Renders the options section of the PDP block. * @param {Element} block - The PDP block element @@ -103,75 +155,36 @@ export function onOptionChange(block, variants, color) { * @param {Record} custom - The custom data for the product * @returns {Element} The options container element */ -export function renderOptions(block, variants, custom) { - const { options } = custom; - // if there are no variants, don't render anything - if (!variants || variants.length === 0) { - return; - } - +export function renderOptions(ph, block, variants, custom, isParentOutOfStock) { const optionsContainer = document.createElement('div'); optionsContainer.classList.add('options'); + const { options } = custom; - const selectionContainer = document.createElement('div'); - selectionContainer.classList.add('selection'); - - const selectedOptionLabel = document.createElement('div'); - selectedOptionLabel.classList.add('selected-option-label'); - selectedOptionLabel.textContent = `Color: ${variants[0].options.color}`; - selectionContainer.append(selectedOptionLabel); - - const colors = variants.map((variant) => toClassName(variant.options.color)); - - const colorOptions = colors.map((color, index) => { - const { sku } = variants[index]; - const colorOption = document.createElement('div'); - colorOption.classList.add('pdp-color-swatch'); - - const colorSwatch = document.createElement('div'); - colorSwatch.classList.add('pdp-color-inner'); - colorSwatch.style.backgroundColor = `var(--color-${color})`; - if (checkOutOfStock(sku)) { - colorSwatch.classList.add('pdp-color-swatch-oos'); - } - colorOption.append(colorSwatch); - - colorOption.addEventListener('click', () => { - onOptionChange(block, variants, color); - }); - - return colorOption; - }); - - const colorOptionsContainer = document.createElement('div'); - colorOptionsContainer.classList.add('pdp-color-options'); - colorOptionsContainer.append(...colorOptions); - selectionContainer.append(colorOptionsContainer); - - optionsContainer.append(selectionContainer); - const oosMessage = document.createElement('div'); - oosMessage.classList.add('pdp-oos-message'); - oosMessage.textContent = 'This color is temporarily out of stock.'; - optionsContainer.append(oosMessage); + // If we are dealing with an out of stock simple product, + // render the OOS message and return + if (isParentOutOfStock && custom.type === 'simple') { + renderOOSMessage(ph, optionsContainer, isParentOutOfStock); + return optionsContainer; + } if (options && options.length > 0) { const warrantyContainer = document.createElement('div'); warrantyContainer.classList.add('warranty'); const warrantyHeading = document.createElement('div'); - warrantyHeading.textContent = 'Warranty:'; + warrantyHeading.textContent = `${ph.warranty || 'Warranty'}:`; warrantyContainer.append(warrantyHeading); options.forEach((option, i) => { - const formatPrice = (price) => { + const formatOptionPrice = (price) => { if (price) { - return `$${price.toFixed(2)}`; + return formatPrice(price, ph); } - return 'Free'; + return ph.free || 'Free'; }; const warrantyValue = document.createElement('div'); warrantyValue.classList.add('pdp-warranty-option'); - warrantyValue.textContent = `${option.name} (${formatPrice(+option.finalPrice)})`; + warrantyValue.textContent = `${option.name} (${formatOptionPrice(+option.finalPrice)})`; if (options.length > 1) { const radio = document.createElement('input'); radio.type = 'radio'; @@ -194,6 +207,49 @@ export function renderOptions(block, variants, custom) { optionsContainer.append(warrantyContainer); } + // if there are no variants, don't render anything + if (!variants?.length) { + return optionsContainer; + } + + const selectionContainer = document.createElement('div'); + selectionContainer.classList.add('selection'); + + const selectedOptionLabel = document.createElement('div'); + selectedOptionLabel.classList.add('selected-option-label'); + selectedOptionLabel.textContent = `${ph.color || 'Color'}: ${variants[0].options.color}`; + selectionContainer.append(selectedOptionLabel); + + const colors = variants.map((variant) => toClassName(variant.options.color)); + + const colorOptions = colors.map((color, index) => { + const { sku } = variants[index]; + const colorOption = document.createElement('div'); + colorOption.classList.add('pdp-color-swatch'); + + const colorSwatch = document.createElement('div'); + colorSwatch.classList.add('pdp-color-inner'); + colorSwatch.style.backgroundColor = `var(--color-${color})`; + if (checkVariantOutOfStock(sku)) { + colorSwatch.classList.add('pdp-color-swatch-oos'); + } + colorOption.append(colorSwatch); + + colorOption.addEventListener('click', () => { + onOptionChange(ph, block, variants, color, isParentOutOfStock); + }); + + return colorOption; + }); + + const colorOptionsContainer = document.createElement('div'); + colorOptionsContainer.classList.add('pdp-color-options'); + colorOptionsContainer.append(...colorOptions); + selectionContainer.append(colorOptionsContainer); + + optionsContainer.append(selectionContainer); + renderOOSMessage(ph, optionsContainer, isParentOutOfStock); + // eslint-disable-next-line consistent-return return optionsContainer; } diff --git a/blocks/pdp/pdp.css b/blocks/pdp/pdp.css index 25739496..7ac51b9f 100644 --- a/blocks/pdp/pdp.css +++ b/blocks/pdp/pdp.css @@ -137,20 +137,22 @@ .add-to-cart.fixed { position: fixed; - top: 0; - left: 0; - right: 0; - border-bottom: 1px solid var(--color-gray-400); + inset: auto 0 0; + border-top: 1px solid var(--color-gray-400); + border-bottom: none; padding: var(--spacing-80) var(--horizontal-spacing); background-color: var(--color-gray-100); + z-index: 8; + box-shadow: 0 -2px 8px rgb(0 0 0 / 10%); } @media (width >= 900px) { .add-to-cart.fixed { position: static; - border-bottom: 0; + border-top: 0; padding: 0; background-color: transparent; + box-shadow: none; } } @@ -647,8 +649,8 @@ a.button.pdp-find-locally-button { margin-top: 0; } -main .section > div.pdp-wrapper { - margin-top: 0; +main > .section.pdp-container > div.pdp-wrapper:first-child:not(.default-content-wrapper) { + margin-top: var(--horizontal-spacing); } .pdp-questions-container img.icon { @@ -687,6 +689,10 @@ main .section > div.pdp-wrapper { max-width: 32em; } +.pdp-free-gift-container.hidden { + display: none; +} + .pdp-free-gift-heading { font-size: var(--body-size-s); font-weight: var(--weight-medium); @@ -723,4 +729,4 @@ main .section > div.pdp-wrapper { background-repeat: no-repeat; background-size: contain; background-position: center; -} \ No newline at end of file +} diff --git a/blocks/pdp/pdp.js b/blocks/pdp/pdp.js index bdd36876..9ab6c71a 100644 --- a/blocks/pdp/pdp.js +++ b/blocks/pdp/pdp.js @@ -1,12 +1,20 @@ -import { loadScript, toClassName, getMetadata } from '../../scripts/aem.js'; +import { + loadScript, toClassName, getMetadata, fetchPlaceholders, +} from '../../scripts/aem.js'; import renderAddToCart from './add-to-cart.js'; import renderGallery from './gallery.js'; import renderSpecs from './specification-tabs.js'; -import renderPricing, { extractPricing } from './pricing.js'; +import renderPricing from './pricing.js'; // eslint-disable-next-line import/no-cycle -import { renderOptions, onOptionChange } from './options.js'; -import { loadFragment } from '../fragment/fragment.js'; -import { checkOutOfStock, isNextPipeline } from '../../scripts/scripts.js'; +import { renderOptions, onOptionChange, updateFreeGiftVisibility } from './options.js'; +import { + getOfferPricing, + checkVariantOutOfStock, + isProductOutOfStock, + parseEasternDateTime, + getLocaleAndLanguage, + formatPrice, +} from '../../scripts/scripts.js'; import { openModal } from '../modal/modal.js'; /** @@ -41,12 +49,12 @@ function renderTitle(block, custom, reviewsId) { * @param {Element} features - The features element from the fragment * @returns {Element} The details container element */ -function renderDetails(features) { +function renderDetails(ph, features) { const detailsContainer = document.createElement('div'); detailsContainer.classList.add('details'); detailsContainer.append(...features.children); const h2 = document.createElement('h2'); - h2.textContent = 'About'; + h2.textContent = ph.about || 'About'; detailsContainer.prepend(h2); return detailsContainer; } @@ -56,14 +64,14 @@ function renderDetails(features) { * @param {Element} block - The PDP block element */ // eslint-disable-next-line no-unused-vars -async function renderReviews(block, reviewsId) { +async function renderReviews(ph, block, reviewsId) { // TODO: Add Bazaarvoice reviews const bazaarvoiceContainer = document.createElement('div'); bazaarvoiceContainer.classList.add('pdp-reviews-container'); bazaarvoiceContainer.innerHTML = `
`; setTimeout(async () => { - await loadScript('https://apps.bazaarvoice.com/deployments/vitamix/main_site/production/en_US/bv.js'); + await loadScript(`https://apps.bazaarvoice.com/deployments/vitamix/main_site/production/${ph.languageCode || 'en_US'}/bv.js`); }, 500); window.bvCallback = () => { }; @@ -71,31 +79,33 @@ async function renderReviews(block, reviewsId) { block.parentElement.append(bazaarvoiceContainer); } -function renderFAQ() { +function renderFAQ(ph) { + const { locale, language } = getLocaleAndLanguage(); const faqContainer = document.createElement('div'); faqContainer.classList.add('faq-container'); faqContainer.innerHTML = ` -

Have a question?

+

${ph.haveAQuestion || 'Have a question?'}

`; return faqContainer; } -function renderCompare(custom) { +function renderCompare(ph, custom) { + const { locale, language } = getLocaleAndLanguage(); const { entityId } = custom; const compareContainer = document.createElement('div'); compareContainer.classList.add('pdp-compare-container'); compareContainer.innerHTML = ` `; const compareButton = compareContainer.querySelector('.pdp-compare-button'); compareButton.addEventListener('click', () => { - fetch('/us/en_us/catalog/product_compare/add/', { + fetch(`/${locale}/${language}/catalog/product_compare/add/`, { headers: { 'content-type': 'application/x-www-form-urlencoded; charset=UTF-8', 'x-requested-with': 'XMLHttpRequest', @@ -105,7 +115,7 @@ function renderCompare(custom) { credentials: 'include', }).then((resp) => { if (resp.ok) { - openModal('/us/en_us/products/modals/compare').then((modal) => { + openModal(`/${locale}/${language}/products/modals/compare`).then((modal) => { if (modal) { const content = modal.querySelector('.default-content-wrapper'); const product = document.createElement('p'); @@ -121,7 +131,7 @@ function renderCompare(custom) { return compareContainer; } -function renderContent(block) { +function renderContent(ph, block) { const { jsonLdData } = window; const { custom } = jsonLdData; @@ -135,59 +145,35 @@ function renderContent(block) { const { features } = window; if (features) { - const detailsContainer = renderDetails(features); + const detailsContainer = renderDetails(ph, features); block.append(detailsContainer); } const { specifications } = window; if (specifications) { - const specsContainer = renderSpecs(specifications, custom, jsonLdData.name); + const specsContainer = renderSpecs(ph, specifications, custom, jsonLdData.name); block.append(specsContainer); } } -async function fetchFragment(block) { - const fragmentPath = window.location.pathname.replace('/products/', '/products/fragments/'); - const fragment = await loadFragment(fragmentPath); - if (fragment) { - const sections = [...fragment.querySelectorAll('main > div.section')]; - while (sections.length > 0) { - const section = sections.shift(); - const h3 = section.querySelector('h3')?.textContent.toLowerCase(); - if (h3) { - // Only include features for now, ignore all other sections with an h3 - if (h3.includes('features')) { - window.features = section; - } else if (h3.includes('specifications')) { - window.specifications = section; - } else if (h3.includes('warranty')) { - window.warranty = section; - } - } - } - } - - renderContent(block); -} - -function renderFreeShipping(offers) { +function renderFreeShipping(ph, offers) { if (!offers[0] || offers[0].price < 150) return null; const freeShippingContainer = document.createElement('div'); freeShippingContainer.classList.add('pdp-free-shipping-container'); freeShippingContainer.innerHTML = ` Free Shipping - Eligible for FREE shipping + ${ph.freeShipping || 'Eligible for FREE shipping'} `; return freeShippingContainer; } -function renderAlert(block, custom) { +function renderAlert(ph, block, custom) { const alertContainer = document.createElement('div'); alertContainer.classList.add('pdp-alert'); /* retired and coming soon */ if (custom && custom.retired === 'Yes') { - alertContainer.innerText = 'Retired Product'; + alertContainer.innerText = ph.retiredProduct || 'Retired Product'; block.classList.add('pdp-retired'); return alertContainer; } @@ -200,11 +186,10 @@ function renderAlert(block, custom) { } /* save now */ - const pricingElement = block.querySelector('p:nth-of-type(1)'); - const pricing = extractPricing(pricingElement); - if (pricing.regular && pricing.regular > pricing.final) { + const pricing = getOfferPricing(window.jsonLdData?.offers?.[0]); + if (pricing && pricing.regular && pricing.regular > pricing.final) { alertContainer.classList.add('pdp-promo-alert'); - alertContainer.innerText = 'Save Now!'; + alertContainer.innerText = ph.saveNow || 'Save Now!'; return alertContainer; } @@ -212,7 +197,7 @@ function renderAlert(block, custom) { return null; } -function renderRelatedProducts(custom) { +function renderRelatedProducts(ph, custom) { const { relatedSkus } = custom; const relatedProducts = relatedSkus || []; if (relatedProducts.length > 0) { @@ -229,7 +214,7 @@ function renderRelatedProducts(custom) { const currentRelatedProducts = products.filter((product) => product && product.custom.retired === 'No'); if (currentRelatedProducts.length > 0) { relatedProductsContainer.innerHTML = ` -

Related Products

+

${ph.relatedProducts || 'Related Products'}

`; const ul = document.createElement('ul'); currentRelatedProducts.forEach((product) => { @@ -237,7 +222,7 @@ function renderRelatedProducts(custom) { const title = product.name; const image = new URL(product.images[0].url, window.location.href); const price = +product.price.final; - li.innerHTML = `${title}

${title}

$${price.toFixed(2)}
`; + li.innerHTML = `${title}

${title}

${formatPrice(price, ph)}
`; ul.appendChild(li); }); relatedProductsContainer.appendChild(ul); @@ -257,22 +242,35 @@ function renderRelatedProducts(custom) { return null; } -function renderShare() { +function renderShare(ph) { const shareContainer = document.createElement('div'); shareContainer.classList.add('pdp-share-container'); const url = decodeURIComponent(window.location.href); shareContainer.innerHTML = ` - Share: + ${ph.share || 'Share'}: Facebook X Pinterest - Email + Email `; return shareContainer; } async function renderFreeGift() { try { + /** + * Parses a datetime string, returning null for empty values (open-ended). + * @param {string} dateStr - The datetime string to parse + * @returns {Date|null} The parsed Date or null if empty + */ + const parseDateOrNull = (dateStr) => { + const trimmed = dateStr?.trim(); + if (!trimmed) { + return null; // Empty = open-ended + } + return parseEasternDateTime(trimmed); + }; + const fetchGifts = async () => { const resp = await fetch('/us/en_us/products/config/free-gifts.plain.html'); if (!resp.ok) return null; @@ -280,82 +278,36 @@ async function renderFreeGift() { const doc = new DOMParser().parseFromString(text, 'text/html'); const gifts = doc.querySelector('.free-gifts'); return [...gifts.children].map((gift) => { - const [dates, minPrice, label, body] = gift.children; - const datesText = dates.textContent; - const minPriceText = minPrice.textContent.startsWith('$') ? minPrice.textContent.slice(1) : minPrice.textContent; - const labelText = label.textContent; - const bodyText = body.innerHTML.replaceAll('./media_', './config/media_'); - return { - dates: datesText, - minPrice: minPriceText, - label: labelText, - body: bodyText, - }; - }); - }; - - const gifts = await fetchGifts(); - const parseDateRange = (dates) => { - const [startDateStr, endDateStr] = dates.split(' - '); - - // Helper function to parse individual date strings with time and timezone - const parseDateWithTime = (dateStr) => { - // Handle formats like "9/12/2025 9am EDT", "9/19/2025 3pm EDT", or "9/12/2025 9:30am EDT" - const timeMatch = dateStr.match(/^(\d{1,2}\/\d{1,2}\/\d{4})\s+(\d{1,2})(?::(\d{2}))?(am|pm)\s+([A-Z]{3,4})$/); - - if (timeMatch) { - const [, datePart, hour, minutes, ampm, timezone] = timeMatch; - - // Parse the date part (M/D/YYYY) - const [month, day, year] = datePart.split('/').map((num) => parseInt(num, 10)); - - // Convert hour to 24-hour format - let hour24 = parseInt(hour, 10); - if (ampm.toLowerCase() === 'pm' && hour24 !== 12) { - hour24 += 12; - } else if (ampm.toLowerCase() === 'am' && hour24 === 12) { - hour24 = 0; - } - - // Parse minutes (default to 0 if not provided) - const minute = minutes ? parseInt(minutes, 10) : 0; - - // Handle timezone offset (simplified - you might want to use a proper timezone library) - // For now, we'll assume EDT is UTC-4 (Eastern Daylight Time) - const timezoneOffsets = { - EDT: -4, // UTC-4 hours - EST: -5, // UTC-5 hours - CDT: -5, // UTC-5 hours - CST: -6, // UTC-6 hours - MDT: -6, // UTC-6 hours - MST: -7, // UTC-7 hours - PDT: -7, // UTC-7 hours - PST: -8, // UTC-8 hours + const [startDateEl, endDateEl, minPrice, label, body] = gift.children; + try { + const minPriceText = minPrice.textContent.startsWith('$') + ? minPrice.textContent.slice(1) + : minPrice.textContent; + const labelText = label.textContent; + const bodyText = body.innerHTML.replaceAll('./media_', './config/media_'); + return { + valid: true, + startDate: parseDateOrNull(startDateEl.textContent), + endDate: parseDateOrNull(endDateEl.textContent), + minPrice: minPriceText, + label: labelText, + body: bodyText, }; - - const offsetHours = timezoneOffsets[timezone] || 0; - - // Convert the local time to UTC by adding the offset - // If EDT is UTC-4, then 9am EDT = 1pm UTC (9 + 4 = 13) - const utcHour = hour24 - offsetHours; - - // Create UTC date object directly - const utcDate = new Date(Date.UTC(year, month - 1, day, utcHour, minute, 0)); - - return utcDate; + } catch { + // Skip rows with malformed dates + return { valid: false }; } - - // Fallback to simple date parsing for formats without time/timezone - return new Date(dateStr); - }; - - return [parseDateWithTime(startDateStr), parseDateWithTime(endDateStr)]; + }).filter((gift) => gift.valid); }; + const gifts = await fetchGifts(); + const findGift = (giftList) => giftList.find((gift) => { - const [startDate, endDate] = parseDateRange(gift.dates); const today = new Date(); - return today >= startDate && today <= endDate; + // Null start = open-ended past, null end = open-ended future + const afterStart = !gift.startDate || today >= gift.startDate; + const beforeEnd = !gift.endDate || today <= gift.endDate; + return afterStart && beforeEnd; }); const gift = findGift(gifts); if (gift) { @@ -384,23 +336,32 @@ async function renderFreeGift() { export default async function decorate(block) { const { jsonLdData, variants } = window; const { custom, offers } = jsonLdData; + const { locale, language } = getLocaleAndLanguage(); + const ph = await fetchPlaceholders(`/${locale}/${language}/products/config`); const reviewsId = custom.reviewsId || toClassName(getMetadata('sku')).replace(/-/g, ''); const galleryContainer = renderGallery(block, variants); const titleContainer = renderTitle(block, custom, reviewsId); - const alertContainer = renderAlert(block, custom); - const relatedProductsContainer = renderRelatedProducts(custom); + const alertContainer = renderAlert(ph, block, custom); + const relatedProductsContainer = renderRelatedProducts(ph, custom); const buyBox = document.createElement('div'); buyBox.classList.add('pdp-buy-box'); - const pricingContainer = renderPricing(block); - const optionsContainer = renderOptions(block, variants, custom); - const addToCartContainer = renderAddToCart(block, jsonLdData); - const compareContainer = renderCompare(custom); + // Check if parent product is out of stock (all variants are out of stock) + const isParentOutOfStock = isProductOutOfStock(); + + const pricingContainer = renderPricing(ph, block); + const optionsContainer = renderOptions(ph, block, variants, custom, isParentOutOfStock); + const addToCartContainer = renderAddToCart(ph, block, jsonLdData); + const compareContainer = renderCompare(ph, custom); const freeGiftContainer = await renderFreeGift(); - const freeShippingContainer = renderFreeShipping(offers); - const shareContainer = renderShare(); + const freeShippingContainer = renderFreeShipping(ph, offers); + const shareContainer = renderShare(ph); + + // Hide free gift container if parent is out of stock + updateFreeGiftVisibility(freeGiftContainer, isParentOutOfStock, false); + buyBox.append( pricingContainer, optionsContainer || '', @@ -411,17 +372,11 @@ export default async function decorate(block) { shareContainer, ); - const faqContainer = renderFAQ(block); + const faqContainer = renderFAQ(ph); - if (isNextPipeline()) { - // Content is already in the initial HTML - renderContent(block); - } else { - // Fetch and render the fragment for legacy pipeline - fetchFragment(block); - } + renderContent(ph, block); - renderReviews(block, reviewsId); + renderReviews(ph, block, reviewsId); block.append( alertContainer || '', @@ -436,15 +391,20 @@ export default async function decorate(block) { const color = queryParams.get('color'); if (color) { - onOptionChange(block, variants, color); + onOptionChange(ph, block, variants, color, isParentOutOfStock); } else if (variants.length > 0) { [window.selectedVariant] = variants; } buyBox.dataset.sku = window.selectedVariant?.sku || offers[0].sku; - buyBox.dataset.oos = checkOutOfStock( + const variantOos = checkVariantOutOfStock( window.selectedVariant ? offers.find((offer) => offer.sku === window.selectedVariant.sku).sku : offers[0].sku, ); + // Set OOS to true if either parent or variant is out of stock + buyBox.dataset.oos = isParentOutOfStock || variantOos; + + // Hide free gift container if variant is also out of stock + updateFreeGiftVisibility(freeGiftContainer, isParentOutOfStock, variantOos); } diff --git a/blocks/pdp/pricing.js b/blocks/pdp/pricing.js index e3c333b7..54fd5c36 100644 --- a/blocks/pdp/pricing.js +++ b/blocks/pdp/pricing.js @@ -1,46 +1,31 @@ -/** - * Extracts pricing information from a given element. - * @param {Element} element - The element containing the pricing information. - * @returns {Object} An object containing the final price and regular price. - */ -export function extractPricing(element) { - if (!element) return null; - - const pricingText = element.textContent.trim(); - - // Matches price values in the format $XXX.XX (e.g. $399.95, $1,299.99) - // \$ - matches literal dollar sign - // ([\d,]+) - matches one or more digits or commas (for thousands) - // \.\d{2} - matches decimal point followed by exactly 2 digits - const priceMatch = pricingText.match(/\$([\d,]+\.\d{2})/g); - - if (!priceMatch) return null; - - const finalPrice = parseFloat(priceMatch[0].replace(/[$,]/g, '')); - const regularPrice = priceMatch[1] ? parseFloat(priceMatch[1].replace(/[$,]/g, '')) : null; - - return { - final: finalPrice, - regular: regularPrice, - }; -} +import { formatPrice, getOfferPricing } from '../../scripts/scripts.js'; /** * Renders the pricing section of the PDP block. * @param {Element} block - The PDP block element * @returns {Element} The pricing container element */ -export default function renderPricing(block, variant) { +export default function renderPricing(ph, block, variant) { const pricingContainer = document.createElement('div'); pricingContainer.classList.add('pricing'); - const pricingElement = block.querySelector('p:nth-of-type(1)'); - const pricing = variant ? variant.price : extractPricing(pricingElement); + // Don't render pricing if addToCart is set to No + if (window.jsonLdData?.custom?.addToCart === 'No') { + return pricingContainer; + } + + const pricing = variant + ? variant.price + : getOfferPricing(window.jsonLdData?.offers?.[0]); if (!pricing) { return null; } - if (!variant) pricingElement.remove(); + // remove the pipeline-rendered pricing text from the DOM + if (!variant) { + const pricingElement = block.querySelector('p:nth-of-type(1)'); + if (pricingElement) pricingElement.remove(); + } // Check if the product is reconditioned // If the variant is not null, check if the item condition is refurbished @@ -51,13 +36,13 @@ export default function renderPricing(block, variant) { if (pricing.regular && pricing.regular > pricing.final) { const nowLabel = document.createElement('div'); nowLabel.className = 'pricing-now'; - nowLabel.textContent = isReconditioned ? 'Recon Price' : 'Now'; + nowLabel.textContent = isReconditioned ? (ph.reconPrice || 'Recon Price') : (ph.now || 'Now'); pricingContainer.appendChild(nowLabel); } const finalPrice = document.createElement('div'); finalPrice.className = 'pricing-final'; - finalPrice.textContent = `$${pricing.final.toFixed(2)}`; + finalPrice.textContent = formatPrice(pricing.final, ph); pricingContainer.appendChild(finalPrice); if (pricing.regular && pricing.regular > pricing.final) { @@ -67,11 +52,13 @@ export default function renderPricing(block, variant) { const savingsAmount = pricing.regular - pricing.final; const saveText = document.createElement('span'); saveText.className = 'pricing-save'; - saveText.textContent = isReconditioned ? `Save $${savingsAmount.toFixed(2)} | New ` : `Save $${savingsAmount.toFixed(2)} | Was `; + saveText.textContent = isReconditioned + ? `${ph.save || 'Save'} ${formatPrice(savingsAmount, ph)} | ${ph.new || 'New'} ` + : `${ph.save || 'Save'} ${formatPrice(savingsAmount, ph)} | ${ph.was || 'Was'} `; const regularPrice = document.createElement('del'); regularPrice.className = 'pricing-regular'; - regularPrice.textContent = `$${pricing.regular.toFixed(2)}`; + regularPrice.textContent = formatPrice(pricing.regular, ph); savingsContainer.appendChild(saveText); savingsContainer.appendChild(regularPrice); @@ -97,8 +84,8 @@ export default function renderPricing(block, variant) { window._affirm_config = { public_api_key: '6PJNMXGC9XLXNFHX', script: 'https://cdn1.affirm.com/js/v2/affirm.js', - locale: 'en_US', - country_code: 'USA', + locale: ph.languageCode || 'en_US', + country_code: ph.countryCode || 'USA', logo: 'blue', min_order_total: '50.00', max_order_total: '50000', diff --git a/blocks/pdp/specification-tabs.js b/blocks/pdp/specification-tabs.js index 868a56ca..50fc7c15 100644 --- a/blocks/pdp/specification-tabs.js +++ b/blocks/pdp/specification-tabs.js @@ -1,5 +1,3 @@ -import { createOptimizedPicture } from '../../scripts/aem.js'; - /* * Creates the tab buttons for the specifications section. * @param {Array<{id: string, label: string}>} tabs - Array of tab objects with id and label. @@ -59,10 +57,23 @@ function createWarrantyContent(warranty, customWarranty) { const container = document.createElement('div'); container.classList.add('warranty-container'); - if (warranty && warranty.sku) { - const warrantyImage = createOptimizedPicture(`/blocks/pdp/${warranty.sku}.png`, warranty.name, false); - warrantyImage.classList.add('warranty-icon'); - container.append(warrantyImage); + if (customWarranty) { + const h3 = customWarranty.querySelector('h3'); + + let warrantyImageName = ''; + if (h3 && h3.textContent.toLowerCase().includes('limited')) { + warrantyImageName = 'limited'; + } else if (h3 && h3.textContent.toLowerCase().includes('full')) { + warrantyImageName = 'full'; + } + + if (warrantyImageName !== '') { + const warrantyImage = document.createElement('img'); + warrantyImage.src = `/blocks/pdp/${warrantyImageName}-warranty.svg`; + warrantyImage.alt = ''; + warrantyImage.classList.add('warranty-icon'); + container.append(warrantyImage); + } } const details = document.createElement('div'); @@ -90,13 +101,13 @@ function createWarrantyContent(warranty, customWarranty) { * @param {Array} resources - The resources array containing name, content-type, and URL. * @returns {HTMLDivElement} The resources content container. */ -function createResourcesContent(resources, productName) { +function createResourcesContent(ph, resources, productName) { const container = document.createElement('div'); container.classList.add('resources-container'); if (resources && resources.length > 0) { const resourceTitle = document.createElement('h3'); - resourceTitle.textContent = `${productName} Resources`; + resourceTitle.textContent = `${productName} ${ph.resources || 'Resources'}`; container.append(resourceTitle); resources.forEach((resource) => { @@ -110,6 +121,8 @@ function createResourcesContent(resources, productName) { const resourceLink = document.createElement('a'); resourceLink.href = resource.url; + resourceLink.target = '_blank'; + resourceLink.rel = 'noopener noreferrer'; resourceLink.textContent = resource.name; const resourceDetails = document.createElement('p'); @@ -123,8 +136,8 @@ function createResourcesContent(resources, productName) { const questions = document.createElement('div'); questions.classList.add('pdp-questions-container'); questions.innerHTML = ` -

Have a question?

-

Contact customer service!

+

${ph.haveAQuestion || 'Have a question?'}

+

${ph.contactCustomerService || 'Contact customer service!'}

Emailservice@vitamix.com Phone1.800.848.2649 `; @@ -141,7 +154,7 @@ function createResourcesContent(resources, productName) { * @param {Object} data - The JSON-LD object containing custom data. * @returns {HTMLDivElement} The content container for the tab. */ -function createTabContent(tab, specifications, standardWarranty, custom, productName) { +function createTabContent(ph, tab, specifications, standardWarranty, custom, productName) { const { warranty } = window; const content = document.createElement('div'); content.classList.add('tab-content'); @@ -159,7 +172,7 @@ function createTabContent(tab, specifications, standardWarranty, custom, product } break; case 'resources': - content.appendChild(createResourcesContent(custom.resources, productName)); + content.appendChild(createResourcesContent(ph, custom.resources, productName)); break; default: break; @@ -212,14 +225,14 @@ function initializeTabs(container) { * @param {Object} data - The JSON-LD object containing custom data. * @returns {Element} The specifications container element */ -export default function renderSpecs(specifications, custom, productName) { +export default function renderSpecs(ph, specifications, custom, productName) { const { options } = custom; const { warranty } = window; const standardWarranty = options?.find((option) => option.name.includes('Standard Warranty')); const tabs = [ - { id: 'specifications', label: 'Specifications', show: !!specifications }, - { id: 'warranty', label: 'Warranty', show: warranty }, - { id: 'resources', label: 'Resources', show: true }, + { id: 'specifications', label: ph.specifications || 'Specifications', show: !!specifications }, + { id: 'warranty', label: ph.warranty || 'Warranty', show: warranty }, + { id: 'resources', label: ph.resources || 'Resources', show: true }, ].filter((tab) => tab.show); // if there are no tabs, don't render anything @@ -237,7 +250,14 @@ export default function renderSpecs(specifications, custom, productName) { contents.classList.add('tab-contents'); tabs.forEach((tab) => { - const content = createTabContent(tab, specifications, standardWarranty, custom, productName); + const content = createTabContent( + ph, + tab, + specifications, + standardWarranty, + custom, + productName, + ); contents.appendChild(content); }); diff --git a/blocks/pixlee/pixlee.css b/blocks/pixlee/pixlee.css new file mode 100644 index 00000000..3d39384c --- /dev/null +++ b/blocks/pixlee/pixlee.css @@ -0,0 +1,4 @@ +.pixlee-wrapper { + width: 100%; + max-width: 100%; +} diff --git a/blocks/pixlee/pixlee.js b/blocks/pixlee/pixlee.js new file mode 100644 index 00000000..6e4f5027 --- /dev/null +++ b/blocks/pixlee/pixlee.js @@ -0,0 +1,18 @@ +/* global Pixlee */ + +export default function decorate(block) { + const container = document.createElement('div'); + container.id = 'pixlee_container'; + block.textContent = ''; + block.appendChild(container); + + window.PixleeAsyncInit = function initPixlee() { + Pixlee.init({ apiKey: '21Zd2RSf2TKMzGbhr0rr' }); + Pixlee.addSimpleWidget({ widgetId: 6665206 }); + }; + + const script = document.createElement('script'); + script.src = 'https://assets.pxlecdn.com/assets/pixlee_widget_1_0_0.js'; + script.async = true; + document.head.appendChild(script); +} diff --git a/blocks/plp/plp.css b/blocks/plp/plp.css index 59563b75..98a184f4 100644 --- a/blocks/plp/plp.css +++ b/blocks/plp/plp.css @@ -252,6 +252,11 @@ main .plp-product-card p { } @media (min-width: 900px) { + main .plp:not(.carousel) { + display: grid; + grid-template-areas: '- controls sortby' 'facets results results'; + } + main .plp-facets > div::after { display: none; } @@ -296,11 +301,6 @@ main .plp-product-card p { width: 360px; } - main .plp { - display: grid; - grid-template-areas: '- controls sortby' 'facets results results'; - } - main .plp-sortby { display: unset; grid-area: sortby; @@ -455,7 +455,6 @@ main .plp-product-card p { } } - .plp.carousel li.carousel-slide { cursor: pointer; height: 100%; diff --git a/blocks/plp/plp.js b/blocks/plp/plp.js index 8ca09af3..5089652b 100644 --- a/blocks/plp/plp.js +++ b/blocks/plp/plp.js @@ -9,7 +9,7 @@ import { loadBlock, } from '../../scripts/aem.js'; -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getLocaleAndLanguage, formatPrice } from '../../scripts/scripts.js'; /** * Constructs a localized product URL path. @@ -68,11 +68,19 @@ function parseData(data, locale, language) { * @returns {Promise>} Array of filtered parent product objects (with nested variants) */ export async function lookupProducts(config, facets = {}) { - const { locale, language } = await getLocaleAndLanguage(); + const { locale, language } = getLocaleAndLanguage(); + const corsProxyFetch = async (url) => { + const corsProxy = 'https://fcors.org/?url='; + const corsKey = '&key=Mg23N96GgR8O3NjU'; + const fullUrl = `https://main--vitamix--aemsites.aem.network${url}`; + return fetch(`${corsProxy}${encodeURIComponent(fullUrl)}${corsKey}`); + }; if (!window.productIndex) { // fetch the main product index - const resp = await fetch(`/${locale}/${language}/products/index.json?include=all`); + const isProd = window.location.hostname.includes('vitamix.com') || window.location.hostname.includes('.aem.network'); + const pathname = `/${locale}/${language}/products/index.json?include=all`; + const resp = await (isProd ? fetch(pathname) : corsProxyFetch(pathname)); const { data } = await resp.json(); // separate products into parents (standalone products) and variants (color/style options) @@ -150,14 +158,15 @@ export async function lookupProducts(config, facets = {}) { // map singular filter names to their plural product property names const cleanKeys = { - category: 'categories', + category: 'categoriesUrlKey', collection: 'collections', }; // parse comma-separated filter values into trimmed token arrays for matching const tokens = {}; filterKeys.forEach((key) => { - tokens[key] = config[key].split(',').map((t) => t.trim()); + const raw = config[key].split(',').map((t) => t.trim()); + tokens[key] = key === 'category' ? raw.map((t) => toClassName(t)) : raw; }); // filter products based on all configured criteria (must match ALL filters) const results = window.productIndex.parents.filter((product) => { @@ -267,10 +276,10 @@ function createProductTitle(product, h = 'h4') { * @param {Object} product - Product data object * @returns {HTMLParagraphElement} Product price element */ -function createProductPrice(product) { +function createProductPrice(product, ph) { const price = document.createElement('p'); price.className = 'plp-price'; - price.textContent = product.price ? `$${product.price}` : ''; + price.textContent = product.price ? formatPrice(product.price, ph) : ''; return price; } @@ -374,7 +383,7 @@ function createProductCard(product, ph) { const image = createProductImage(product); const title = createProductTitle(product); - const price = createProductPrice(product); + const price = createProductPrice(product, ph); const colors = createProductColors(product); const viewDetails = createProductButton(product, ph, 'View Details', 'emphasis'); const compare = createProductButton(product, ph, 'Compare'); @@ -405,9 +414,13 @@ function createProductCard(product, ph) { async function styleRowAsSlide(content, ph) { const [image, body] = content.children; const link = body.querySelector('a[href]'); - link.parentElement.remove(); const { pathname } = new URL(link.href); const [product] = await lookupProducts([pathname]); + if (!product) { + link.classList.add('linkchecker-invalid-link'); + return; + } + link.parentElement.remove(); // replace or add product image with lazy loading let img = image.querySelector('picture'); @@ -443,13 +456,13 @@ async function styleRowAsSlide(content, ph) { startingAt.className = 'eyebrow'; startingAt.textContent = ph.startingAt || 'Starting at'; - const price = createProductPrice(product); + const price = createProductPrice(product, ph); if (product.regularPrice && product.regularPrice > product.price) { const savings = (product.regularPrice - product.price).toFixed(2); const saleInfo = document.createElement('span'); - saleInfo.textContent = `| ${ph.save || 'Save'} $${savings}`; + saleInfo.textContent = `| ${ph.save || 'Save'} ${formatPrice(savings, ph)}`; const regularPrice = document.createElement('del'); - regularPrice.textContent = `$${product.regularPrice}`; + regularPrice.textContent = formatPrice(product.regularPrice, ph); saleInfo.prepend(regularPrice); price.append(saleInfo); } @@ -584,6 +597,29 @@ function buildFiltering(block, ph, config) { highlightResults(resultsElement); }; + // merge URL query params into config so shared links load pre-filtered + const mergeParamsFromUrl = (base) => { + const params = new URLSearchParams(window.location.search); + const urlConfig = { ...base }; + params.forEach((value, key) => { + urlConfig[key] = value.trim(); + }); + return urlConfig; + }; + + // sync current filter state to URL for shareable links + const syncFilterConfigToUrl = (filterConfig) => { + const params = new URLSearchParams(); + Object.entries(filterConfig).forEach(([key, value]) => { + if (key === 'category') return; + const v = value != null ? String(value).trim() : ''; + if (v) params.set(key, v); + }); + const search = params.toString(); + const url = `${window.location.pathname}${search ? `?${search}` : ''}`; + window.history.replaceState(null, '', url); + }; + // gets all currently selected filter checkboxes const getSelectedFilters = () => [...block.querySelectorAll('input[type="checkbox"]:checked')]; @@ -699,6 +735,7 @@ function buildFiltering(block, ph, config) { block.querySelector('#plp-results-count').textContent = results.length; displayResults(results, null); displayFacets(facets, filterConfig); + syncFilterConfigToUrl(filterConfig); }; const fulltextElement = block.querySelector('#fulltext'); @@ -710,11 +747,13 @@ function buildFiltering(block, ph, config) { fulltextElement.style.display = 'none'; } - runSearch(config); + const initialConfig = mergeParamsFromUrl(config); + if (initialConfig.fulltext) fulltextElement.value = initialConfig.fulltext; + runSearch(initialConfig); } export default async function decorate(block) { - const { locale, language } = await getLocaleAndLanguage(); + const { locale, language } = getLocaleAndLanguage(); const ph = await fetchPlaceholders(`/${locale}/${language}/products/config`); const config = readBlockConfig(block); const isCarousel = block.classList.contains('carousel'); diff --git a/blocks/recipe/difficulty.svg b/blocks/recipe/difficulty.svg new file mode 100644 index 00000000..99a7b9d5 --- /dev/null +++ b/blocks/recipe/difficulty.svg @@ -0,0 +1,12 @@ + + icon/difficulty + + + + + + + + + + diff --git a/blocks/recipe/edit.svg b/blocks/recipe/edit.svg new file mode 100644 index 00000000..e12c2c1a --- /dev/null +++ b/blocks/recipe/edit.svg @@ -0,0 +1,3 @@ + diff --git a/blocks/recipe/print.svg b/blocks/recipe/print.svg new file mode 100644 index 00000000..13d1bf57 --- /dev/null +++ b/blocks/recipe/print.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/blocks/recipe/recipe.css b/blocks/recipe/recipe.css new file mode 100644 index 00000000..9788ddea --- /dev/null +++ b/blocks/recipe/recipe.css @@ -0,0 +1,723 @@ +/* stylelint-disable no-descending-specificity */ +main > .recipe-container.section { + margin-top: 0; + margin-bottom: 0; +} + +main > .recipe-container.section > .recipe-wrapper { + margin: 0; + padding: 0; +} + +/* Recipe Header */ +.recipe .recipe-header { + display: grid; + grid-template-areas: 'details' 'image' 'additional-info'; + gap: var(--spacing-300); + padding: var(--spacing-600) var(--spacing-400); + background-color: var(--color-charcoal); + color: var(--color-gray-100); +} + +.recipe .recipe-header.no-image { + grid-template-areas: 'details' 'additional-info'; +} + +@media (width >= 800px) { + .recipe .recipe-header { + padding: var(--spacing-850) var(--spacing-800); + } +} + +@media (width >= 1000px) { + .recipe .recipe-header { + grid-template-areas: 'image details' 'image additional-info'; + grid-template-columns: minmax(0, 1fr) 2fr; + gap: var(--spacing-800); + } + + .recipe .recipe-header.no-image { + display: flex; + flex-direction: column; + } +} + +.recipe .recipe-header .eyebrow { + margin-bottom: 0; + color: var(--color-steel); +} + +.recipe .recipe-header .eyebrow + p { + margin-top: 0.5ch; + font-size: var(--body-size-s); +} + +.recipe .recipe-header a:any-link { + color: var(--color-white); + text-decoration: none; +} + +.recipe .recipe-header a:any-link:hover { + color: var(--color-steel); + text-decoration: underline; +} + +/* Recipe Details */ +.recipe .recipe-header .recipe-details { + grid-area: details; +} + +.recipe .recipe-header .recipe-details h1 { + margin-top: 0; + font-size: var(--heading-size-xl); +} + +.recipe .recipe-header .recipe-details > p { + line-height: var(--line-height-l); +} + +@media (width >= 800px) { + .recipe .recipe-header .recipe-details { + margin: 0 auto; + max-width: 488px; + } +} + +@media (width >= 800px) { + .recipe .recipe-header .recipe-details { + max-width: unset; + width: 100%; + padding: 0 var(--spacing-500); + } +} + +/* Recipe Rating */ +.recipe .recipe-header .recipe-rating { + visibility: hidden; + min-height: 20px; + margin: var(--spacing-100) 0; +} + +/* Recipe Stats */ +.recipe .recipe-stats { + display: flex; + gap: 0 var(--spacing-600); + flex-wrap: wrap; + margin-top: var(--spacing-400); +} + +.recipe .recipe-stat { + flex: 1 1 0; + padding-left: calc(20px + var(--spacing-100)); + background-size: 20px; + background-position: left center; + background-repeat: no-repeat; +} + +@media (width >= 800px) { + .recipe .recipe-stats { + flex-wrap: nowrap; + justify-content: space-between; + } + + .recipe .recipe-stat { + flex: 0 0 max-content; + padding-left: calc(28px + var(--spacing-100)); + background-size: 28px; + } +} + +.recipe .recipe-stat .eyebrow + p { + font-weight: bold; +} + +.recipe .recipe-stat-difficulty { + background-image: url('./difficulty.svg'); +} + +.recipe .recipe-stat-total-time { + background-image: url('./time.svg'); +} + +.recipe .recipe-stat-yields { + background-image: url('./yield.svg'); +} + +/* Recipe Image */ +.recipe .recipe-header .recipe-image { + grid-area: image; + place-self: center; +} + +.recipe .recipe-header .recipe-image img { + max-width: 400px; + border-radius: var(--rounding-m); + aspect-ratio: 4 / 3; + object-fit: cover; +} + +@media (width >= 800px) { + .recipe .recipe-header .recipe-image { + justify-self: unset; + padding: 0 var(--spacing-500); + } + + .recipe .recipe-header .recipe-image img { + max-width: unset; + height: 100%; + } +} + +@media (width >= 1000px) { + .recipe .recipe-header .recipe-image { + justify-self: center; + padding: 0; + } +} + +/* Recipe Additional Info */ +.recipe .recipe-header .recipe-additional-info { + grid-area: additional-info; + display: flex; + flex-wrap: wrap; + gap: 0 var(--spacing-600); + justify-content: space-between; +} + +.recipe .recipe-additional-info-item .eyebrow + p { + margin-top: 1ch; +} + +@media (width >= 800px) { + .recipe .recipe-header .recipe-additional-info { + max-width: 488px; + margin: 0 auto; + } +} + +@media (width >= 800px) { + .recipe .recipe-header .recipe-additional-info { + max-width: unset; + width: 100%; + padding: 0 var(--spacing-500); + } +} + +.recipe .recipe-submitted-by .eyebrow + p { + font-weight: bold; + letter-spacing: var(--letter-spacing-s); + text-transform: uppercase; +} + +.recipe .recipe-manage-preferences { + flex: 1 0 100%; +} + +.recipe .recipe-manage-preferences a { + display: flex; + align-items: center; + gap: 1ch; + font-size: var(--body-size-s); + font-weight: bold; +} + +.recipe .recipe-manage-preferences a::before { + content: ''; + display: inline-block; + width: 16px; + height: 16px; + background-image: url('./edit.svg'); + background-size: contain; + background-repeat: no-repeat; +} + +@media (width >= 1000px) { + .recipe .recipe-header .recipe-additional-info { + position: relative; + } + + .recipe .recipe-header .recipe-additional-info::before { + content: ''; + position: absolute; + top: calc(-1 * var(--spacing-400)); + width: calc(100% - (2 * var(--spacing-500))); + height: 1px; + background-color: var(--color-steel); + } +} + +/* Recipe Body */ +.recipe .recipe-body { + display: grid; + grid-template-columns: 1fr; + grid-template-areas: + "toolbar" + "instructions" + "refine" + "nutrition" + "notes"; + gap: var(--spacing-200); + padding: var(--horizontal-spacing) 0; + background-color: var(--color-white); +} + +.recipe .recipe-body h2 { + margin-bottom: var(--spacing-100); +} + +.recipe .recipe-body > div { + margin: 0 var(--horizontal-spacing); +} + +/* Tablet: refine + nutrition side by side */ +@media (width >= 800px) { + .recipe .recipe-body { + grid-template-columns: 1fr 1fr; + grid-template-areas: + "toolbar toolbar" + "instructions instructions" + "refine nutrition" + "notes notes"; + padding: var(--horizontal-spacing); + } + + .recipe .recipe-body > div { + margin: 0; + } +} + +/* Desktop: sidebar layout */ +@media (width >= 1000px) { + .recipe .recipe-body { + grid-template: + "toolbar instructions" auto + "refine instructions" auto + "nutrition instructions" 1fr + "notes notes" auto + / 1fr minmax(0, 2fr); + } + + .recipe .recipe-body .recipe-toolbar, + .recipe .recipe-body .recipe-refine { + align-self: start; + } +} + +/* Recipe Toolbar */ +.recipe .recipe-toolbar { + grid-area: toolbar; + display: flex; + justify-content: center; + gap: var(--spacing-60); + border-radius: var(--rounding-m); + padding: var(--spacing-100); + background-color: var(--color-charcoal); +} + +.recipe .recipe-toolbar button { + display: flex; + align-items: center; + gap: var(--spacing-40); + padding: var(--spacing-60); + color: var(--color-white); + font: inherit; + cursor: pointer; +} + +.recipe .recipe-toolbar button img { + margin-right: 0.5ch; + width: 30px; + height: 30px; +} + +@media (width >= 800px) { + .recipe .recipe-toolbar { + padding: var(--spacing-400); + } + + .recipe .recipe-toolbar button { + position: relative; + padding: var(--spacing-60) var(--spacing-80); + } + + .recipe .recipe-toolbar > button + button::before, + .recipe .recipe-toolbar > button + .recipe-share-wrapper::before { + content: ''; + position: absolute; + top: 0; + left: calc(-1 * var(--spacing-40)); + width: 1px; + height: 100%; + background-color: var(--color-steel); + } +} + +/* Share Popup */ +.recipe .recipe-share-wrapper { + position: relative; +} + +.recipe .recipe-share-popup { + position: absolute; + left: 50%; + bottom: 100%; + transform: translateX(-50%); + display: flex; + gap: var(--spacing-60); + border-radius: var(--rounding-m); + padding: var(--spacing-100); + background-color: var(--color-gray-400); +} + +.recipe .recipe-share-popup::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 8px solid transparent; + border-top-color: var(--color-gray-400); +} + +.recipe .recipe-share-popup[hidden] { + display: none; +} + +.recipe .recipe-share-popup button { + width: 36px; + height: 36px; + border-radius: 50%; + padding: 0; + background-color: var(--color-charcoal); + color: var(--color-white); +} + +.recipe .recipe-share-popup button img { + width: 20px; + height: 20px; + margin: 0 auto; + filter: brightness(0) invert(1); +} + +/* Recipe Instructions */ +.recipe .recipe-body .recipe-instructions { + grid-area: instructions; + margin: 0; + border-radius: var(--rounding-m); + padding: var(--spacing-300) var(--horizontal-spacing); + background-color: var(--color-gray-100); +} + +@media (width >= 800px) { + .recipe .recipe-body .recipe-instructions { + padding: var(--spacing-300) var(--spacing-500); + } + + .recipe .recipe-instructions h2 { + margin-top: 0.5em; + } +} + +/* Recipe Ingredients */ +.recipe .recipe-ingredients ul { + list-style: none; + margin: 0; + padding: 0; +} + +.recipe .recipe-ingredients li { + position: relative; + margin: 0; + padding-left: var(--spacing-200); + line-height: var(--line-height-l); +} + +.recipe .recipe-ingredients li::before { + content: ''; + position: absolute; + top: 0.5em; + left: 0; + display: block; + width: 6px; + height: 6px; + border-radius: 50%; + background-color: currentcolor; +} + +.recipe .recipe-ingredients li + li { + margin-top: 1ch; +} + +/* Recipe Directions */ +.recipe .recipe-directions ol { + list-style: none; + margin: 0; + padding: 0; + counter-reset: directions-counter; +} + +.recipe .recipe-directions li { + position: relative; + counter-increment: directions-counter; + margin-left: var(--spacing-100); + padding-top: 0.3em; + padding-left: var(--spacing-700); + line-height: var(--line-height-l); +} + +.recipe .recipe-directions li::before { + content: counter(directions-counter); + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + width: 34px; + height: 34px; + background-color: var(--color-red); + color: var(--color-white); + font-size: var(--body-size-s); + font-weight: bold; +} + +.recipe .recipe-directions li + li { + margin-top: var(--spacing-300); +} + +/* Recipe Notes */ +.recipe .recipe-notes { + grid-area: notes; + border-radius: var(--rounding-m); + padding: var(--spacing-800) var(--spacing-200); + background-color: var(--color-charcoal); + color: var(--color-white); +} + +.recipe .recipe-notes h2 { + margin: 0 0 var(--spacing-300) 0; + font-size: var(--heading-size-m); + text-align: center; +} + +.recipe .recipe-notes p { + line-height: var(--line-height-l); +} + +@media (width >= 800px) { + .recipe .recipe-notes { + padding: var(--spacing-850) var(--spacing-500); + text-align: center; + } + + .recipe .recipe-notes h2 { + font-size: var(--heading-size-l); + } +} + +/* Recipe Refine */ +.recipe .recipe-refine { + grid-area: refine; + padding: var(--spacing-200); + background-color: var(--color-gray-100); +} + +.recipe .recipe-refine h2 { + margin-top: 0; + font-family: var(--body-font-family); + font-size: var(--body-size-m); + text-align: center; +} + +.recipe .recipe-refine summary { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-200); + border-top: 1px solid var(--color-gray-300); + font-size: var(--body-size-xs); + font-weight: bold; + letter-spacing: var(--letter-spacing-s); + text-transform: uppercase; + cursor: pointer; +} + +.recipe .recipe-refine summary::-webkit-details-marker { + display: none; +} + +.recipe .recipe-refine summary::after { + content: ''; + display: block; + width: 10px; + height: 8px; + background: url('./toggle.svg') no-repeat center; + background-size: contain; + transition: transform 0.2s; + transform: rotate(0deg); +} + +.recipe .recipe-refine details { + margin-bottom: var(--spacing-80); + transform: margin-bottom 0.2s; +} + +.recipe .recipe-refine details[open] { + margin-bottom: var(--spacing-300); +} + +.recipe .recipe-refine details[open] summary::after { + transform: rotate(180deg); +} + +.recipe .recipe-refine ul { + margin: 0; + padding: 0 var(--spacing-200); + list-style: none; +} + +.recipe .recipe-refine li { + font-size: var(--body-size-s); + line-height: var(--line-height-l); +} + +.recipe .recipe-refine li + li { + margin-top: var(--spacing-40); +} + +.recipe .recipe-refine a { + text-decoration: none; +} + +.recipe .recipe-refine a[aria-current="page"] { + font-weight: bold; +} + +@media (width >= 800px) { + .recipe .recipe-refine { + padding: var(--spacing-200) var(--spacing-500); + } +} + +/* Recipe Nutrition */ +.recipe .recipe-nutrition { + grid-area: nutrition; + padding: var(--spacing-200); + padding-bottom: var(--spacing-500); + background-color: var(--color-gray-100); +} + +.recipe .recipe-nutrition h2 { + margin: 0.4em 0 0.2em; + font-family: var(--body-font-family); + font-size: var(--body-size-xl); + text-align: center; +} + +.recipe .recipe-nutrition h2 + p { + margin-top: 0; + font-size: var(--body-size-s); + text-align: center; +} + +.recipe .recipe-nutrition table { + width: 100%; + border-collapse: collapse; + font-size: var(--body-size-xs); + letter-spacing: var(--letter-spacing-s); + text-transform: uppercase; +} + +.recipe .recipe-nutrition tr { + border-top: 1px solid var(--color-gray-300); + border-bottom: 1px solid var(--color-gray-300); +} + +.recipe .recipe-nutrition td { + padding: var(--spacing-100); + font-weight: bold; +} + +.recipe .recipe-nutrition td:last-child { + text-align: right; +} + +.recipe .recipe-nutrition .nested td { + padding-left: var(--spacing-500); + font-weight: normal; +} + +.recipe .recipe-nutrition .nested td:last-child { + font-weight: bold; +} + +@media (width >= 800px) { + .recipe .recipe-nutrition { + padding: var(--spacing-200) var(--spacing-500); + } +} + +@media print { + .header-wrapper, + .footer-wrapper, + .fragment-wrapper, + .cards-wrapper, + .related-recipes-wrapper, + .recipe .recipe-rating, + .recipe .recipe-toolbar, + .recipe .recipe-refine, + .recipe .recipe-notes, + .recipe .recipe-additional-info { + display: none !important; + } + + .recipe .recipe-header { + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-areas: 'details image'; + gap: var(--horizontal-spacing); + padding-bottom: 0; + background-color: white; + color: var(--color-charcoal); + page-break-after: avoid; + } + + .recipe .recipe-body { + display: block; + } + + .recipe .recipe-body > div { + margin: 0; + } + + .recipe .recipe-header .eyebrow { + color: var(--color-red); + } + + .recipe .recipe-header a:any-link { + color: var(--color-charcoal); + } + + .recipe .recipe-header h1, + .recipe .recipe-body h2 { + font-size: var(--body-size-xxxl) !important; + } + + .recipe .recipe-stats { + flex-wrap: wrap; + } + + .recipe .recipe-details, + .recipe .recipe-image { + padding: 0 !important; + } + + .recipe .recipe-instructions, + .recipe .recipe-nutrition { + padding-left: 0 !important; + padding-right: 0 !important; + background-color: white !important; + page-break-inside: avoid; + } +} diff --git a/blocks/recipe/recipe.js b/blocks/recipe/recipe.js new file mode 100644 index 00000000..b02031e8 --- /dev/null +++ b/blocks/recipe/recipe.js @@ -0,0 +1,417 @@ +import { getMetadata, toClassName, fetchPlaceholders } from '../../scripts/aem.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +function wrapInDiv(element, className) { + if (!element) return; + const { previousSibling, parentElement } = element; + const tag = element.tagName; + const wrapper = document.createElement('div'); + wrapper.classList.add(className); + while (element.nextElementSibling && element.nextElementSibling.tagName !== tag) { + wrapper.append(element.nextElementSibling); + } + wrapper.prepend(element); + parentElement.insertBefore( + wrapper, + previousSibling ? previousSibling.nextSibling : parentElement.firstElementChild, + ); +} + +function formatTime(timeString, placeholders = {}) { + if (!timeString) return ''; + + // Parse HH:MM:SS format + const parts = timeString.split(':'); + if (parts.length !== 3) return timeString; + + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + const seconds = parseInt(parts[2], 10); + + // Round seconds up to next minute if > 0 + let totalMinutes = hours * 60 + minutes; + if (seconds > 0) { + totalMinutes += 1; + } + + // Convert back to hours and minutes + const finalHours = Math.floor(totalMinutes / 60); + const finalMinutes = totalMinutes % 60; + + // Build readable string + const parts2 = []; + if (finalHours > 0) { + const hourLabel = finalHours !== 1 ? (placeholders.hours || 'Hours') : (placeholders.hour || 'Hour'); + parts2.push(`${finalHours} ${hourLabel}`); + } + if (finalMinutes > 0) { + const minuteLabel = finalMinutes !== 1 ? (placeholders.minutes || 'Minutes') : (placeholders.minute || 'Minute'); + parts2.push(`${finalMinutes} ${minuteLabel}`); + } + + return parts2.length > 0 ? parts2.join(' ') : `0 ${placeholders.minutes || 'Minutes'}`; +} + +function formatServings(servingsString) { + if (!servingsString) return ''; + + // Extract number from string like "8.00 servings" + const match = servingsString.match(/^([\d.]+)\s*(.*)$/); + if (!match) return servingsString; + + const number = parseFloat(match[1]); + const unit = match[2]; + + // Remove decimals if not needed (e.g., 8.00 → 8, but 8.5 stays 8.5) + const formattedNumber = number % 1 === 0 ? Math.floor(number) : number; + + return unit ? `${formattedNumber} ${unit}` : `${formattedNumber}`; +} + +function buildToolbar(placeholders = {}) { + const toolbar = document.createElement('div'); + toolbar.classList.add('recipe-toolbar'); + + const saveLabel = placeholders.save || 'Save'; + const printLabel = placeholders.print || 'Print'; + const shareLabel = placeholders.share || 'Share'; + const shareFacebookLabel = placeholders.shareOnFacebook || 'Share on Facebook'; + const shareTwitterLabel = placeholders.shareOnTwitter || 'Share on X'; + const sharePinterestLabel = placeholders.shareOnPinterest || 'Share on Pinterest'; + const shareEmailLabel = placeholders.shareViaEmail || 'Share via Email'; + + toolbar.innerHTML = ` + + +
+ + +
+ `; + + // Save button + const saveButton = toolbar.querySelector('.recipe-save'); + saveButton.addEventListener('click', () => { + const { locale, language } = getLocaleAndLanguage(); + const title = document.querySelector('h1').textContent.trim().toLowerCase().replace(/[^a-z0-9]/g, ''); + const recipeId = `rcp${title}recipe`; + const returnUrl = `https://www.vitamix.com/${locale}/${language}/recipebook?recipe_id=${recipeId}`; + const encodedReturn = btoa(returnUrl); + window.location.href = `https://www.vitamix.com/${locale}/${language}/customer/account/login/referer/${encodeURIComponent(encodedReturn)}/`; + }); + + // Print button + const printButton = toolbar.querySelector('.recipe-print'); + printButton.addEventListener('click', () => { + printButton.setAttribute('aria-pressed', true); + window.print(); + }); + + // Share popup toggle + const shareButton = toolbar.querySelector('.recipe-share'); + const sharePopup = toolbar.querySelector('.recipe-share-popup'); + shareButton.addEventListener('click', () => { + sharePopup.toggleAttribute('hidden'); + }); + + // Close share popup when clicking outside + document.addEventListener('click', (e) => { + if (!e.target.closest('.recipe-share-wrapper')) { + sharePopup.setAttribute('hidden', ''); + } + }); + + // Share button handlers + const getShareData = () => { + const url = window.location.href; + const { title } = document; + const recipeTitle = document.querySelector('h1').textContent.trim(); + const image = document.querySelector('.recipe-image img')?.src || ''; + return { + url, title, recipeTitle, image, + }; + }; + + const shareHandlers = { + facebook: () => { + const { url } = getShareData(); + window.open( + `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(url)}`, + '_blank', + 'width=600,height=400', + ); + }, + twitter: () => { + const { url, title } = getShareData(); + window.open( + `https://twitter.com/intent/tweet?url=${encodeURIComponent(url)}&text=${encodeURIComponent(title)}`, + '_blank', + 'width=600,height=400', + ); + }, + pinterest: () => { + const { url, title, image } = getShareData(); + window.open( + `https://pinterest.com/pin/create/button/?url=${encodeURIComponent(url)}&media=${encodeURIComponent(image)}&description=${encodeURIComponent(title)}`, + '_blank', + 'width=600,height=400', + ); + }, + email: () => { + const { url, recipeTitle } = getShareData(); + const recipeLabel = placeholders.recipe || 'Recipe'; + const subject = encodeURIComponent(`${recipeTitle} ${recipeLabel}`); + const body = encodeURIComponent(`${recipeTitle} ${recipeLabel}: ${url}`); + window.location.href = `mailto:?subject=${subject}&body=${body}`; + }, + }; + + Object.entries(shareHandlers).forEach(([platform, handler]) => { + toolbar.querySelector(`.share-${platform}`).addEventListener('click', handler); + }); + + return toolbar; +} + +function writeDietaryInterests(data, locale, language) { + const dietaryInterests = data.split(',').map((i) => i.trim()); + return dietaryInterests.map((interest) => { + const a = document.createElement('a'); + a.href = `/${locale}/${language}/search?refineby=${toClassName(interest)}`; + a.textContent = interest; + return a; + }); +} + +export default async function decorate(block) { + const { locale, language } = getLocaleAndLanguage(); + const placeholders = await fetchPlaceholders(`/${locale}/${language}`); + + const totalTime = getMetadata('total-time'); + const yields = getMetadata('yield'); + const difficulty = getMetadata('difficulty'); + + const dietaryInterests = writeDietaryInterests(getMetadata('dietary-interests'), locale, language); + const h1 = block.querySelector('h1'); + const description = block.querySelector('h1 + p'); + const picture = block.querySelector('picture'); + + // Format total time to be human readable + const formattedTime = formatTime(totalTime, placeholders); + const formattedYields = formatServings(yields); + + const recipeHeader = document.createElement('div'); + recipeHeader.classList.add('recipe-header'); + if (!picture) { + recipeHeader.classList.add('no-image'); + } + + recipeHeader.innerHTML = `
+

${h1 ? h1.textContent : ''}

+
+

${description ? description.textContent : ''}

+
+
+

${placeholders.totalTime || 'Total Time'}

+

${formattedTime}

+
+
+

${placeholders.yields || 'Yields'}

+

${formattedYields}

+
+
+

${placeholders.difficulty || 'Difficulty'}

+

${difficulty}

+
+
+
+ ${picture ? `
${picture.outerHTML}
` : ''} +
+
+

${placeholders.dietaryInterests || 'Dietary Interests'}

+

${dietaryInterests.map((i) => i.outerHTML).join(', ')}

+
+ + +
`; + + if (h1) h1.remove(); + if (description) description.remove(); + if (picture) { + const wrapper = picture.closest('.img-wrapper'); + if (wrapper) wrapper.remove(); + else picture.remove(); + } + + const recipeContainer = document.createElement('div'); + recipeContainer.classList.add('recipe-body'); + recipeContainer.id = 'recipe'; + const recipeToolbar = buildToolbar(placeholders); + recipeContainer.prepend(recipeToolbar); + recipeContainer.append(...block.children); + block.prepend(recipeHeader); + block.append(recipeContainer); + + // Get section names from placeholders + const sectionNames = { + ingredients: placeholders.ingredients || 'Ingredients', + directions: placeholders.directions || 'Directions', + notes: placeholders.notes || 'Notes', + nutrition: placeholders.nutrition || 'Nutrition', + }; + + // Find H2 elements by text content and wrap them + Object.keys(sectionNames).forEach((key) => { + const sectionText = sectionNames[key]; + // Find H2 with matching text content (case-insensitive, trimmed) + const h2Elements = [...block.querySelectorAll('h2')]; + const h2 = h2Elements.find( + (heading) => heading.textContent.trim().toLowerCase() === sectionText.toLowerCase(), + ); + + if (h2) { + wrapInDiv(h2, `recipe-${key}`); + } + }); + + // Group ingredients + directions in shared container + const ingredients = block.querySelector('.recipe-ingredients'); + const directions = block.querySelector('.recipe-directions'); + if (ingredients && directions) { + const instructions = document.createElement('div'); + instructions.className = 'recipe-instructions'; + ingredients.before(instructions); + instructions.append(ingredients, directions); + } + + // Move notes to end of recipe-body + const notes = block.querySelector('.recipe-notes'); + if (notes) recipeContainer.append(notes); + + // Convert nutrition ul to table + const nutritionSection = block.querySelector('.recipe-nutrition'); + if (nutritionSection) { + const ul = nutritionSection.querySelector('ul'); + if (ul) { + const table = document.createElement('table'); + const tbody = document.createElement('tbody'); + + ul.querySelectorAll(':scope > li').forEach((li) => { + const p = li.querySelector(':scope > p'); + const nestedUl = li.querySelector(':scope > ul'); + const text = p ? p.textContent : ''; + + if (text && text.includes(':')) { + const [label, value] = text.split(':').map((s) => s.trim()); + const tr = document.createElement('tr'); + tr.innerHTML = `${label}${value}`; + tbody.append(tr); + } + + if (nestedUl) { + nestedUl.querySelectorAll('li').forEach((nestedLi) => { + const nestedText = nestedLi.textContent; + if (nestedText && nestedText.includes(':')) { + const [label, value] = nestedText.split(':').map((s) => s.trim()); + const tr = document.createElement('tr'); + tr.classList.add('nested'); + tr.innerHTML = `${label}${value}`; + tbody.append(tr); + } + }); + } + }); + + table.append(tbody); + ul.replaceWith(table); + } + } + + // Add compatible containers section above ingredients + const recipeTitle = h1 ? h1.textContent.trim() : ''; + const ingredientsSection = block.querySelector('.recipe-ingredients'); + if (recipeTitle && ingredientsSection) { + try { + const response = await fetch(`/${locale}/${language}/recipes/query-index.json`); + const data = await response.json(); + + // Find all recipes with the same title + const sameRecipes = data.data.filter((recipe) => recipe.title === recipeTitle); + + // Build container names from index and map to a recipe path + const containerMap = new Map(); + sameRecipes.forEach((recipe) => { + if (recipe['compatible-containers']) { + const names = recipe['compatible-containers'].split(',').map((c) => c.trim()).filter(Boolean); + names.forEach((name) => { + if (!containerMap.has(name)) { + containerMap.set(name, recipe.path); + } + }); + } + }); + + // Only create the section if we have containers + if (containerMap.size > 0) { + const currentPath = window.location.pathname; + const sortedContainers = Array.from(containerMap.entries()) + .sort((a, b) => b[0].localeCompare(a[0])); + + const containerSection = document.createElement('div'); + containerSection.className = 'recipe-refine'; + + const heading = document.createElement('h2'); + heading.textContent = placeholders.refineYourRecipe || 'Refine Your Recipe'; + + const details = document.createElement('details'); + details.open = true; + + const summary = document.createElement('summary'); + summary.textContent = placeholders.containerSize || 'Container Size'; + + const ul = document.createElement('ul'); + sortedContainers.forEach(([container, path]) => { + const li = document.createElement('li'); + const a = document.createElement('a'); + a.href = `${path}#recipe`; + a.textContent = container; + if (path === currentPath) { + a.setAttribute('aria-current', 'page'); + } + li.append(a); + ul.append(li); + }); + + details.append(summary, ul); + containerSection.append(heading, details); + + if (nutritionSection) { + nutritionSection.parentElement.insertBefore(containerSection, nutritionSection); + } + } + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error loading recipe containers:', error); + } + } +} diff --git a/blocks/recipe/save.svg b/blocks/recipe/save.svg new file mode 100644 index 00000000..5ff9c957 --- /dev/null +++ b/blocks/recipe/save.svg @@ -0,0 +1,3 @@ + + + diff --git a/blocks/recipe/share.svg b/blocks/recipe/share.svg new file mode 100644 index 00000000..b51266b0 --- /dev/null +++ b/blocks/recipe/share.svg @@ -0,0 +1,3 @@ + + + diff --git a/blocks/recipe/time.svg b/blocks/recipe/time.svg new file mode 100644 index 00000000..6d627f6d --- /dev/null +++ b/blocks/recipe/time.svg @@ -0,0 +1,8 @@ + + icon/time/large-grey + + + + + + diff --git a/blocks/recipe/toggle.svg b/blocks/recipe/toggle.svg new file mode 100644 index 00000000..fc58f0ad --- /dev/null +++ b/blocks/recipe/toggle.svg @@ -0,0 +1,3 @@ + + + diff --git a/blocks/recipe/yield.svg b/blocks/recipe/yield.svg new file mode 100644 index 00000000..cfa89841 --- /dev/null +++ b/blocks/recipe/yield.svg @@ -0,0 +1,8 @@ + + icon/yield + + + + + + diff --git a/blocks/related-articles/related-articles.css b/blocks/related-articles/related-articles.css new file mode 100644 index 00000000..7a3cfcc5 --- /dev/null +++ b/blocks/related-articles/related-articles.css @@ -0,0 +1,102 @@ +.related-articles ul { + list-style: none; + gap: var(--spacing-400); + grid-template-columns: 1fr; + display: grid; + margin: 0; + padding: 0; +} + +.related-articles h2, +.related-articles h3 { + font-size: var(--font-size-200); +} + +.related-articles a { + text-decoration: none; +} + +.related-articles .article-click { + width: 100%; + max-width: 400px; + margin: 0 auto; + cursor: pointer; +} + +.related-articles .article-image { + position: relative; + border-radius: var(--rounding-m); + line-height: 0; + overflow: hidden; +} + +.related-articles .article-image img { + width: 100%; + height: 208px; + object-fit: cover; + transition: transform 0.4s; +} + +.related-articles .article-click:hover .article-image img { + transform: scale(1.04); +} + +.related-articles .article-body { + padding: var(--spacing-400) 0 0 var(--spacing-300); +} + +.related-articles .article-body p:not(.eyebrow) { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + overflow: hidden; + text-overflow: ellipsis; +} + +@media (width >= 800px) { + .related-articles h2, + .related-articles h3 { + font-size: var(--font-size-700); + } + + .related-articles .article-click { + max-width: 600px; + } + + .related-articles .article-body { + padding: var(--spacing-300) var(--spacing-300) 0; + } +} + +@media (width >= 1000px) { + .related-articles ul { + gap: var(--spacing-300); + max-width: 900px; + margin: 0 auto; + padding: 0 var(--spacing-300); + } + + .related-articles.rows-1 ul, + .related-articles.rows-3 ul { + grid-template-columns: 1fr; + } + + .related-articles.rows-2 ul, + .related-articles.rows-4 ul { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .related-articles .article-click { + max-width: unset; + } + + .related-articles .article-image img { + height: 324px; + } +} + +@media (width >= 1200px) { + .related-articles.rows-3 ul { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} diff --git a/blocks/related-articles/related-articles.js b/blocks/related-articles/related-articles.js new file mode 100644 index 00000000..2983b855 --- /dev/null +++ b/blocks/related-articles/related-articles.js @@ -0,0 +1,87 @@ +import { createOptimizedPicture } from '../../scripts/aem.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** + * Returns the largest factor of given n among between 1 and 4. + * @param {number} n - Number to find largest factor for + * @returns {number} Largest factor + */ +function getLargestFactor(n) { + const factor = [4, 3, 2].find((f) => n % f === 0); + if (factor) return factor; + if (n > 4) return n % 2 === 0 ? 4 : 3; + return 1; +} + +/** + * Builds an article card element. + * @param {Object} article - Article data + * @returns {HTMLLIElement} Card list item + */ +function buildArticleCard(article) { + const { + path, title, description, image, + } = article; + + const li = document.createElement('li'); + li.className = 'article-click'; + + const cardImage = document.createElement('div'); + cardImage.className = 'article-image'; + const imagePath = new URL(image, window.location.origin).pathname; + cardImage.append(createOptimizedPicture(imagePath, '', false, [{ width: '900' }])); + + const cardBody = document.createElement('div'); + cardBody.className = 'article-body'; + + const h3 = document.createElement('h3'); + const titleLink = document.createElement('a'); + titleLink.href = path; + titleLink.textContent = title.split('|')[0].trim(); + h3.append(titleLink); + + const desc = document.createElement('p'); + desc.textContent = description; + + cardBody.append(h3, desc); + li.append(cardImage, cardBody); + + return li; +} + +export default async function decorate(block) { + const { locale, language } = getLocaleAndLanguage(); + const path = `/${locale}/${language}/articles/query-index.json`; + const resp = await fetch(path); + if (!resp.ok) { + block.remove(); + return; + } + + const { data } = await resp.json(); + if (!data || data.length === 0) { + block.remove(); + return; + } + + // Get links from block and find matching articles in data + const links = [...block.querySelectorAll('a[href]')]; + const hrefs = links.map((a) => new URL(a.href).pathname); + const matchingArticles = hrefs + .map((href) => data.find((article) => article.path === href)) + .filter((a) => a); + + if (matchingArticles.length === 0) { + block.remove(); + return; + } + + const ul = document.createElement('ul'); + const articlesPerRow = getLargestFactor(matchingArticles.length); + block.classList.add(`rows-${articlesPerRow}`); + matchingArticles.forEach((article) => { + ul.append(buildArticleCard(article)); + }); + + block.replaceChildren(ul); +} diff --git a/blocks/related-recipes/related-recipes.css b/blocks/related-recipes/related-recipes.css new file mode 100644 index 00000000..3d9f1afd --- /dev/null +++ b/blocks/related-recipes/related-recipes.css @@ -0,0 +1,44 @@ +.related-recipes > ul { + gap: var(--spacing-100); + align-items: flex-start; +} + +.related-recipes.carousel.block ul > li { + flex: 0 0 calc(50% - (var(--spacing-100) / 2)); + width: auto; +} + +@media (width >= 600px) { + .related-recipes.carousel.block ul > li { + flex: 0 0 calc((100% / 3) - (var(--spacing-100) / 1.5)); + } +} + +.related-recipes li a { + display: block; + width: 100%; + color: var(--text-color); + font-family: var(--heading-font-family); + font-size: var(--body-size-l); + text-decoration: none; +} + +.related-recipes li img { + width: 100%; + aspect-ratio: 1; + border-radius: var(--rounding-m); + object-fit: cover; +} + +.related-recipes nav { + position: static; + display: flex; + justify-content: center; + gap: var(--spacing-100); + margin-top: var(--spacing-100); +} + +.related-recipes nav button.nav-arrow { + position: relative; + transform: none; +} diff --git a/blocks/related-recipes/related-recipes.js b/blocks/related-recipes/related-recipes.js new file mode 100644 index 00000000..2437b252 --- /dev/null +++ b/blocks/related-recipes/related-recipes.js @@ -0,0 +1,251 @@ +import { getMetadata } from '../../scripts/aem.js'; +import { buildCarousel, getLocaleAndLanguage } from '../../scripts/scripts.js'; + +const WEIGHTS = { + titleWords: 4, + recipeType: 3, + course: 2, + dietaryInterests: 1, +}; + +/** + * Find matching recipe in data by href path. + * @param {string} href - href path to match + * @param {Object[]} data - Array of all recipe objects + * @returns {Object|undefined} Matching recipe or undefined + */ +function findMatchingRecipe(href, data) { + if (href.match(/-r\d+$/)) { + return data.find((recipe) => recipe.path === href); + } + + // Match base path (without r-ID suffix) + return data.find((recipe) => { + const lastIndex = recipe.path.lastIndexOf('-r'); + const recipePath = recipe.path.substring(0, lastIndex); + return recipePath === href; + }); +} + +/** + * Parse comma-separated string into trimmed array. + * @param {string} str - String + * @returns {string[]} Array of trimmed, non-empty values + */ +function parseList(str) { + if (!str) return []; + return str.split(',').map((s) => s.trim()).filter((s) => s); +} + +/** + * Extract words from a title. + * @param {string} title - Recipe title + * @returns {string[]} Array of lowercase words + */ +function getTitleWords(title) { + if (!title) return []; + return title + .toLowerCase() + .replace(/[^a-z\s]/g, '') + .split(/\s+/) + .filter((word) => word.length > 2); +} + +/** + * Count how many items from array A appear in array B. + * @param {string[]} a - Source array + * @param {string[]} b - Array to check against + * @returns {number} Number of items from A that exist in B + */ +function intersectionCount(a, b) { + return a.filter((item) => b.includes(item)).length; +} + +/** + * Check if all items in A are present in B. + * @param {string[]} a - Source array + * @param {string[]} b - Array to check against + * @returns {boolean} `true` if A is non-empty and all items in A exist in B + */ +function isExactMatch(a, b) { + return a.length > 0 && a.every((item) => b.includes(item)); +} + +/** + * Calculate relatedness score between target recipe and a candidate. + * @param {Object} target - Target recipe with parsed attributes + * @param {Object} candidate - Candidate recipe + * @returns {number} Weighted relatedness score + */ +function getRelatedScore(target, candidate) { + let score = 0; + const exactMatchBonus = 2; + + // Title word overlap (strongest signal - "Agua Fresca" matches "Agua Fresca") + const titleOverlap = intersectionCount(target.titleWords, candidate.titleWords); + score += titleOverlap * WEIGHTS.titleWords; + + // Recipe type overlap + const typeOverlap = intersectionCount(target.recipeType, candidate.recipeType); + score += typeOverlap * WEIGHTS.recipeType; + if (isExactMatch(target.recipeType, candidate.recipeType)) { + score += exactMatchBonus; + } + + // Course overlap + const courseOverlap = intersectionCount(target.course, candidate.course); + score += courseOverlap * WEIGHTS.course; + if (isExactMatch(target.course, candidate.course)) { + score += exactMatchBonus; + } + + // Dietary interests overlap + const dietaryOverlap = intersectionCount(target.dietaryInterests, candidate.dietaryInterests); + score += dietaryOverlap * WEIGHTS.dietaryInterests; + + return score; +} + +/** + * Check if a recipe has ANY overlap with the target (fast pre-filter). + * @param {Object} target - Target recipe with parsed attribute arrays + * @param {Object} recipe - Raw recipe object from data source + * @returns {boolean} True if any attribute overlaps with target + */ +function hasAnyOverlap(target, recipe) { + const titleWords = getTitleWords(recipe.title); + if (titleWords.some((w) => target.titleWords.includes(w))) return true; + + const types = parseList(recipe['recipe-type']); + if (types.some((t) => target.recipeType.includes(t))) return true; + + const courses = parseList(recipe.course); + if (courses.some((c) => target.course.includes(c))) return true; + + const dietary = parseList(recipe['dietary-interests']); + if (dietary.some((d) => target.dietaryInterests.includes(d))) return true; + + return false; +} + +/** + * Find related recipes based on weighted attribute overlap. + * @param {Object} target - Target recipe with parsed attributes + * @param {Object[]} allRecipes - Array of all recipe objects + * @param {number} [max=3] - Maximum number of related recipes + * @returns {Object[]} Array of related recipe objects + */ +function findRelatedRecipes(target, allRecipes, max = 3) { + // Pre-filter: only recipes with at least one overlapping attribute + const candidates = allRecipes.filter((recipe) => ( + recipe.path !== target.path + && recipe.title !== target.title + && recipe.status !== 'Deleted' + && recipe.image + && !recipe.image.includes('default-meta-image') + && hasAnyOverlap(target, recipe) + )); + + const scored = candidates + .map((recipe) => { + const candidate = { + ...recipe, + titleWords: getTitleWords(recipe.title), + recipeType: parseList(recipe['recipe-type']), + course: parseList(recipe.course), + dietaryInterests: parseList(recipe['dietary-interests']), + }; + return { + recipe, + score: getRelatedScore(target, candidate), + }; + }) + .sort((a, b) => b.score - a.score); + + // Deduplicate by title, keeping the highest-scored version + const seenTitles = new Set(); + return scored.reduce((results, { recipe }) => { + if (results.length < max && !seenTitles.has(recipe.title)) { + seenTitles.add(recipe.title); + results.push(recipe); + } + return results; + }, []); +} + +export default async function decorate(block) { + const { locale, language } = getLocaleAndLanguage(); + const path = `/${locale}/${language}/recipes/query-index.json`; + const resp = await fetch(path); + if (!resp.ok) { + block.remove(); + return; + } + + const { data } = await resp.json(); + if (!data || data.length === 0) { + block.remove(); + return; + } + + // Get links from block and find matching recipes in data + const links = [...block.querySelectorAll('a[href]')]; + const hrefs = links.map((a) => new URL(a.href).pathname); + const matchingRecipes = hrefs + .map((href) => findMatchingRecipe(href, data)) + .filter((r) => r); + + // If no manual links found, use algorithmic matching + let relatedRecipes; + if (matchingRecipes.length > 0) { + relatedRecipes = matchingRecipes; + } else { + // Get current recipe metadata + const title = document.querySelector('h1').textContent.trim() || ''; + const titleWords = getTitleWords(title); + const recipeType = parseList(getMetadata('recipe-type')); + const course = parseList(getMetadata('course')); + const dietaryInterests = parseList(getMetadata('dietary-interests')); + + const target = { + path: window.location.pathname, + title, + titleWords, + recipeType, + course, + dietaryInterests, + }; + + relatedRecipes = findRelatedRecipes(target, data); + } + + if (relatedRecipes.length < 1) { + block.remove(); + return; + } + + // Build the related recipes UI + const ul = document.createElement('ul'); + + relatedRecipes.forEach((recipe) => { + const li = document.createElement('li'); + // Convert image URL to relative path (pathname + query params only) + let imagePath = recipe.image; + try { + const imageUrl = new URL(imagePath, window.location.origin); + imagePath = imageUrl.pathname + imageUrl.search; + } catch (e) { + // If URL parsing fails, use the path as-is + } + li.innerHTML = ` + + + ${recipe.title} + + `; + ul.append(li); + }); + + block.replaceChildren(ul); + buildCarousel(block, false); +} diff --git a/blocks/table/table.css b/blocks/table/table.css new file mode 100644 index 00000000..1dcac7cf --- /dev/null +++ b/blocks/table/table.css @@ -0,0 +1,156 @@ +.table { + width: 100%; + overflow-x: auto; +} + +.table table { + width: 100%; + min-width: 900px; + border-collapse: collapse; +} + +.table table th, +.table table td { + padding: 0 var(--spacing-s); + text-align: left; + vertical-align: top; +} + +.table table th { + border-bottom: 1px solid; + font-weight: normal; +} + +.table.fixed table { + table-layout: fixed; +} + +.table.row-headers table th { + border-bottom: none; + border-right: 1px solid; +} + +.table.striped tbody tr:nth-child(odd) { + background-color: var(--color-gray-200); +} + +/* Comparison variant: heading row + heading column, 2x first column width */ +.table.comparison .table-comparison-scroll { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; +} + +.table.comparison .table-comparison-scroll table { + width: 100%; + min-width: 600px; + table-layout: fixed; + border-collapse: collapse; + border: 1px solid #e0e0e0; +} + +.table.comparison .table-comparison-scroll th, +.table.comparison .table-comparison-scroll td { + padding: 16px 12px; + border: 1px solid #e0e0e0; + vertical-align: top; +} + +.table.comparison .table-comparison-scroll thead th { + background-color: #ececec; + font-weight: 700; + text-align: center; + color: #333; +} + +.table.comparison .table-comparison-scroll tbody th { + background-color: #fff; + font-weight: 600; + text-align: left; + color: #333; + vertical-align: middle; +} + +.table.comparison .table-comparison-scroll thead th:first-child { + text-align: left; + color: #6c757d; + font-weight: 600; +} + +/* Heading column: flex on

, image left, text right, vertically centered */ +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content p { + display: flex; + align-items: center; + gap: 12px; + margin: 0; +} + +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content p picture, +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content p img { + flex-shrink: 0; + width: 48px; + height: 48px; + margin: 0; +} + +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content p img { + width: 48px; + height: 48px; + object-fit: contain; +} + +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content p a { + color: #06c; + text-decoration: none; + flex: 1; + min-width: 0; +} + +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content p a:hover { + text-decoration: underline; +} + +.table.comparison .table-comparison-scroll tbody td { + background-color: #fff; + text-align: center; + color: #333; +} + +.table.comparison .table-comparison-scroll thead tr.table-comparison-row-header-empty th, +.table.comparison .table-comparison-scroll tbody tr.table-comparison-row-header-empty th, +.table.comparison .table-comparison-scroll tbody tr.table-comparison-row-header-empty td { + background-color: transparent; + border-color: transparent; +} + +/* Comparison table: colors row – round swatches from pdp/color-swatches.css */ +.table.comparison .table-comparison-scroll .table-comparison-color-swatches { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 6px; +} + +.table.comparison .table-comparison-scroll .table-comparison-color-swatch { + width: 24px; + height: 24px; + border: 1px solid #333f48; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + box-sizing: border-box; +} + +.table.comparison .table-comparison-scroll .table-comparison-color-inner { + width: 16px; + height: 16px; + border: 1px solid #333f48; + border-radius: 50%; +} + +.table.comparison .table-comparison-scroll .table-comparison-color-text { + font-size: 0.9em; + color: #666; +} diff --git a/blocks/table/table.js b/blocks/table/table.js new file mode 100644 index 00000000..74decde2 --- /dev/null +++ b/blocks/table/table.js @@ -0,0 +1,196 @@ +import { toClassName } from '../../scripts/aem.js'; + +const COLOR_SWATCHES_CSS_PATH = '/blocks/pdp/color-swatches.css'; + +/** + * Fetch color-swatches.css, parse --color-* names and the rule body, inject a scoped + * "}function yr(e,t){if(F(t))return;const n=A(e=>!Qn("#at-"+L(e)),t);if(F(n))return;const r=e.defaultContentHiddenStyle;mr(ne("\n",oe(e=>function(e,t){return vr("at-"+L(t),t+" {"+e+"}")}(r,e),n)),"head")}function br(e,t){if(F(t)||Qn("#at-views"))return;mr(function(e,t){return vr("at-views",t+" {"+e+"}")}(e.defaultContentHiddenStyle,ne(", ",t)),"head")}function xr(){!function(e){if(!0!==e.bodyHidingEnabled)return;if(Qn("#at-body-style"))return;mr(vr("at-body-style",e.bodyHiddenStyle),"head")}(it())}function wr(){!function(e){!0===e.bodyHidingEnabled&&Qn("#at-body-style")&&dr("#at-body-style")}(it())}function Sr(e){return!v(e.id)}function Er(e){return!v(e.authState)}function Tr(e){return Sr(e)||Er(e)}function Cr(e,t){return ue((e,n,r)=>{const o={};return o.integrationCode=r,Sr(n)&&(o.id=n.id),Er(n)&&(o.authenticatedState=function(e){switch(e){case 0:return"unknown";case 1:return"authenticated";case 2:return"logged_out";default:return"unknown"}}(n.authState)),o[Le]=t,function(e){return e.primary}(n)&&(o.primary=!0),e.push(o),e},[],A(Tr,e))}function kr(e){if(v(e))return[];if(!E(e.getCustomerIDs))return[];const t=e.getCustomerIDs(!0);return S(t)?function(e){if(!e.nameSpaces&&!e.dataSources)return Cr(e,"DS");const t=[];return e.nameSpaces&&t.push.apply(t,Cr(e.nameSpaces,"NS")),e.dataSources&&t.push.apply(t,Cr(e.dataSources,"DS")),t}(t):[]}function Ir(e){return Kt("Visitor API requests error",e),{}}function Nr(e,t,n){if(v(e))return xn({});return En(function(e,t){if(!E(e.getVisitorValues))return xn({});const n=["MCMID","MCAAMB","MCAAMLH"];return t&&n.push("MCOPTOUT"),bn(t=>{e.getVisitorValues(e=>t(e),n)})}(e,n),t,"Visitor API requests timed out")['catch'](Ir)}function Or(e,t){return v(e)?{}:function(e,t){if(!E(e.getVisitorValues))return{};const n=["MCMID","MCAAMB","MCAAMLH"];t&&n.push("MCOPTOUT");const r={};return e.getVisitorValues(e=>g(r,e),n),r}(e,t)}function _r(){const e=it(),t=e.imsOrgId,n=e.supplementalDataIdParamTimeout;return function(e,t,n){if(G(t))return null;if(v(e.Visitor))return null;if(!E(e.Visitor.getInstance))return null;const r=e.Visitor.getInstance(t,{sdidParamExpiry:n});return S(r)&&E(r.isAllowed)&&r.isAllowed()?r:null}(Xe,t,n)}function Ar(e){return function(e,t){return v(e)?null:E(e.getSupplementalDataID)?e.getSupplementalDataID(t):null}(_r(),e)}function Pr(e){return function(e,t){if(v(e))return null;const n=e[t];return v(n)?null:n}(_r(),e)}const qr={};function Mr(e,t){qr[e]=t}function Rr(e){return qr[e]}function Dr(e){const t=e.name;if(!D(t)||F(t))return!1;const n=e.version;if(!D(n)||F(n))return!1;const r=e.timeout;if(!v(r)&&!W(r))return!1;return!!E(e.provider)}function Lr(e,t,n,r,o,i){const c={};c[e]=t,c[n]=r,c[o]=i;const s={};return s.dataProvider=c,s}function jr(e){const t=e.name,n=e.version,r=e.timeout||2e3;return En(function(e){return bn((t,n)=>{e((e,r)=>{v(e)?t(r):n(e)})})}(e.provider),r,"timed out").then(e=>{const r=Lr("name",t,"version",n,"params",e);return Kt("Data provider",Ge,r),en(r),e})['catch'](e=>{const r=Lr("name",t,"version",n,$e,e);return Kt("Data provider",$e,r),en(r),{}})}function Vr(e){const t=ue((e,t)=>g(e,t),{},e);return Mr("dataProviders",t),t}function Hr(e){if(!function(e){const t=e.targetGlobalSettings;if(v(t))return!1;const n=t.dataProviders;return!(!y(n)||F(n))}(e))return xn({});return Sn(oe(jr,A(Dr,e.targetGlobalSettings.dataProviders))).then(Vr)}function Ur(){return function(){const e=Rr("dataProviders");return v(e)?{}:e}()}function Br(){const e=function(e){const{location:t}=e,{search:n}=t,r=yt(n).authorization;return G(r)?null:r}(Xe),t=function(){const e=Ot("mboxDebugTools");return G(e)?null:e}();return e||t}function Fr(e){return!F(e)&&2===e.length&&Z(e[0])}function zr(e,t,n,r){M((e,o)=>{S(e)?(t.push(o),zr(e,t,n,r),t.pop()):F(t)?n[r(o)]=e:n[r(ne(".",t.concat(o)))]=e},e)}function $r(e){if(!E(e))return{};let t=null;try{t=e()}catch(e){return{}}return v(t)?{}:y(t)?function(e){const t=ue((e,t)=>(e.push(function(e){const t=e.indexOf("=");return-1===t?[]:[e.substr(0,t),e.substr(t+1)]}(t)),e),[],A(Z,e));return ue((e,t)=>(e[xt(J(t[0]))]=xt(J(t[1])),e),{},A(Fr,t))}(t):D(t)&&Z(t)?A((e,t)=>Z(t),yt(t)):S(t)?function(e,t){const n={};return v(t)?zr(e,[],n,T):zr(e,[],n,t),n}(t):{}}function Jr(){const{userAgentData:e}=window.navigator;return e}function Gr(e){return g({},e,$r(Xe.targetPageParamsAll))}function Zr(e){const t=it(),n=t.globalMboxName,r=t.mboxParams,o=t.globalMboxParams;return n!==e?Gr(r||{}):g(Gr(r||{}),function(e){return g({},e,$r(Xe.targetPageParams))}(o||{}))}const Wr=["architecture","bitness","model","platformVersion","fullVersionList"];function Kr(){let{devicePixelRatio:e}=Xe;if(!v(e))return e;e=1;const{screen:t}=Xe,{systemXDPI:n,logicalXDPI:r}=t;return!v(n)&&!v(r)&&n>r&&(e=n/r),e}function Xr(e){if(!y(e)||0===e.length)return"";let t="";return e.forEach((n,r)=>{const{brand:o,version:i}=n,c=r1&&void 0!==arguments[1]?arguments[1]:{};try{return e.getHighEntropyValues(Wr).then(e=>{const{platformVersion:n,architecture:r,bitness:o,model:i,fullVersionList:c}=e;return g({},t,{model:i,platformVersion:n,browserUAWithFullVersion:Xr(c),architecture:r,bitness:o})})}catch(e){return xn(t)}}function eo(e){return Mr("clientHints",e),e}function to(e){return xn(e).then(eo)}function no(e){let t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];const n=Rr("clientHints");if(ye(n))return to(n);if(ve(e))return to({});const r=Yr(e);return to(t?Qr(e,r):r)}function ro(){const{screen:e}=Xe,{orientation:t,width:n,height:r}=e;if(v(t))return n>r?"landscape":"portrait";if(v(t.type))return null;const o=le("-",t.type);if(F(o))return null;const i=o[0];return v(i)?null:i}function oo(){return function(){const e=Ke.createElement("canvas"),t=e.getContext("webgl")||e.getContext("experimental-webgl");if(v(t))return null;const n=t.getExtension("WEBGL_debug_renderer_info");if(v(n))return null;const r=t.getParameter(n.UNMASKED_RENDERER_WEBGL);return v(r)?null:r}()}function io(e){return-1!==e.indexOf("profile.")}function co(e){return io(e)||function(e){return"mbox3rdPartyId"===e}(e)||function(e){return"at_property"===e}(e)||function(e){return"orderId"===e}(e)||function(e){return"orderTotal"===e}(e)||function(e){return"productPurchasedId"===e}(e)||function(e){return"productId"===e}(e)||function(e){return"categoryId"===e}(e)}function so(e){return e.substring("profile.".length)}function uo(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};return ue((e,t,n)=>(co(n)||(e[n]=v(t)?"":t),e),{},e)}function ao(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{},t=!(arguments.length>1&&void 0!==arguments[1])||arguments[1];return ue((e,n,r)=>{const o=t?so(r):r;return t&&!io(r)||G(o)||(e[o]=v(n)?"":n),e},{},e)}function fo(e){let{url:t,headers:n,body:r,timeout:o,async:i}=e;return bn((e,c)=>{let s=new window.XMLHttpRequest;s=function(e,t,n){return e.onload=()=>{const r=1223===e.status?204:e.status;if(r<100||r>599)return void n(new Error("Network request failed"));let o;try{const t=Oe();o=JSON.parse(e.responseText),o.parsingTime=Oe()-t,o.responseSize=new Blob([e.responseText]).size}catch(e){return void n(new Error("Malformed response JSON"))}const i=e.getAllResponseHeaders();t({status:r,headers:i,response:o})},e}(s,e,c),s=function(e,t){return e.onerror=()=>{t(new Error("Network request failed"))},e}(s,c),s.open("POST",t,i),s.withCredentials=!0,s=function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};return M((t,n)=>{y(t)&&M(t=>{e.setRequestHeader(n,t)},t)},t),e}(s,n),i&&(s=function(e,t,n){return e.timeout=t,e.ontimeout=()=>{n(new Error("Request timed out"))},e}(s,o,c)),s.send(JSON.stringify(r))}).then(e=>{const{response:t}=e,{status:n,message:r}=t;if(!v(n)&&!v(r))throw new Error(r);return t})}function lo(e,t){return W(t)?t<0?e.timeout:t:e.timeout}function po(e){const t=e.serverDomain;if(!e.overrideMboxEdgeServer)return t;const n=function(){if(!it().overrideMboxEdgeServer)return"";const e=Ot("mboxEdgeCluster");return G(e)?"":e}();return G(n)?t:"mboxedge"+n+".tt.omtrdc.net"}function ho(e){return e.scheme+"//"+po(e)+e.endpoint+"?"+bt({client:e.clientCode,sessionId:Pn(),version:e.version})}function mo(e,t,n){const r=it(),o=ho(r),i={"Content-Type":["text/plain"]},c=lo(r,t),s={url:o,headers:i,body:e,timeout:c,async:!0};return _e.timeStart(e.requestId),fo(s).then(t=>{const r={execution:_e.timeEnd(e.requestId),parsing:t.parsingTime};delete t.parsingTime;const i=function(e,t){if(!performance)return null;const n=performance.getEntriesByType("resource").find(t=>t.name.endsWith(e));if(!n)return null;const r={};return n.domainLookupEnd&&n.domainLookupStart&&(r.dns=n.domainLookupEnd-n.domainLookupStart),n.secureConnectionStart&&n.connectEnd&&(r.tls=n.connectEnd-n.secureConnectionStart),n.responseStart&&(r.timeToFirstByte=n.responseStart-n.requestStart),n.responseEnd&&n.responseStart&&(r.download=n.responseEnd-n.responseStart),n.encodedBodySize?r.responseSize=n.encodedBodySize:t.responseSize&&(r.responseSize=t.responseSize,delete t.responseSize),r}(o,t);return i&&(r.request=i),t.telemetryServerToken&&(r.telemetryServerToken=t.telemetryServerToken),window.__target_telemetry.addDeliveryRequestEntry(e,r,t,n),g(t,{decisioningMethod:he})})}const go=e=>!F(e);let vo;function yo(e){if(e.MCOPTOUT)throw new Error("Disabled due to optout");return e}function bo(){const e=function(){const e=_r(),t=it();return Nr(e,t.visitorApiTimeout,t.optoutEnabled)}(),t=Hr(Xe);return Sn([e.then(yo),t])}function xo(){return[Or(_r(),it().optoutEnabled),Ur()]}function wo(){const{screen:e}=Xe;return{width:e.width,height:e.height,orientation:ro(),colorDepth:e.colorDepth,pixelRatio:Kr()}}function So(){const{documentElement:e}=Ke;return{width:e.clientWidth,height:e.clientHeight}}function Eo(e){const{location:t}=Xe;return e.withWebGLRenderer?{host:t.hostname,webGLRenderer:oo()}:{host:t.hostname}}function To(){const{location:e}=Xe;return{url:e.href,referringUrl:Ke.referrer}}function Co(e){const{id:t,integrationCode:n,authenticatedState:r,type:o,primary:i}=e,c={};return Z(t)&&(c.id=t),Z(n)&&(c.integrationCode=n),Z(r)&&(c.authenticatedState=r),Z(o)&&(c.type=o),i&&(c.primary=i),c}function ko(e,t,n,r,o){const i={};Z(t)&&(i.tntId=t),Z(n)&&(i.thirdPartyId=n),Z(e.thirdPartyId)&&(i.thirdPartyId=e.thirdPartyId);const c=r.MCMID;return Z(c)&&(i.marketingCloudVisitorId=c),Z(e.marketingCloudVisitorId)&&(i.marketingCloudVisitorId=e.marketingCloudVisitorId),F(e.customerIds)?(F(o)||(i.customerIds=function(e){return oe(Co,e)}(o)),i):(i.customerIds=e.customerIds,i)}function Io(e,t){const n={},r=function(e,t){if(!v(e))return e;const n={};if(F(t))return n;const r=t.MCAAMLH,o=parseInt(r,10);isNaN(o)||(n.locationHint=o);const i=t.MCAAMB;return Z(i)&&(n.blob=i),n}(e.audienceManager,t);return F(r)||(n.audienceManager=r),F(e.analytics)||(n.analytics=e.analytics),F(e.platform)||(n.platform=e.platform),n}function No(e){return v(e)?function(){const e=Ot("at_preview_mode");if(G(e))return{};try{return JSON.parse(e)}catch(e){return{}}}():e}function Oo(e){return v(e)?function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:Ot;const t=e("at_qa_mode");if(G(t))return{};try{return JSON.parse(t)}catch(e){return{}}}():e}function _o(e){const t={},n=function(e){return e.orderId}(e);v(n)||(t.id=n);const r=function(e){return e.orderTotal}(e),o=parseFloat(r);isNaN(o)||(t.total=o);const i=function(e){const t=oe(J,le(",",e.productPurchasedId));return A(Z,t)}(e);return F(i)||(t.purchasedProductIds=i),t}function Ao(e,t){const n={},r=g({},uo(t),uo(e.parameters||{})),o=g({},ao(t),ao(e.profileParameters||{},!1)),i=g({},_o(t),e.order||{}),c=g({},function(e){const t={},n=function(e){return e.productId}(e);v(n)||(t.id=n);const r=function(e){return e.categoryId}(e);return v(r)||(t.categoryId=r),t}(t),e.product||{});return F(r)||(n.parameters=r),F(o)||(n.profileParameters=o),F(i)||(n.order=i),F(c)||(n.product=c),n}function Po(e,t){let n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:{};const r=it(),o=r.globalMboxName,{index:i,name:c,address:s}=e,u=g({},c===o?t:n,Zr(c)),a=Ao(e,u);return v(i)||(a.index=i),Z(c)&&(a.name=c),F(s)||(a.address=s),a}function qo(e,t,n){const{prefetch:r={}}=e,o={};if(F(r))return o;const{mboxes:i}=r;v(i)||!y(i)||F(i)||(o.mboxes=oe(e=>Po(e,t,n),i));const{views:c}=r;return v(c)||!y(c)||F(c)||(o.views=oe(e=>function(e,t){const{name:n,address:r}=e,o=Ao(e,t);return Z(n)&&(o.name=n),F(r)||(o.address=r),o}(e,t),c)),o}function Mo(e,t){if(kn()&&!Cn(Xe,"ANALYTICS"))return null;const n=it(),r=Ar(e),o=Pr("trackingServer"),i=Pr("trackingServerSecure"),{experienceCloud:c={}}=t,{analytics:s={}}=c,{logging:u,supplementalDataId:a,trackingServer:f,trackingServerSecure:l}=s,d={};return v(u)?d.logging=n.analyticsLogging:d.logging=u,v(a)||(d.supplementalDataId=a),Z(r)&&(d.supplementalDataId=r),v(f)||(d.trackingServer=f),Z(o)&&(d.trackingServer=o),v(l)||(d.trackingServerSecure=l),Z(i)&&(d.trackingServerSecure=i),F(d)?null:d}function Ro(e,t,n){const r=function(e){const t=it().globalMboxName;return g({},e,Zr(t))}(n),o=qn(),i=r.mbox3rdPartyId;const c=kr(_r()),s=ko(e.id||{},o,i,t,c),u=function(e,t){if(!v(e)&&Z(e.token))return e;const n={},r=t.at_property;return Z(r)&&(n.token=r),n}(e.property,r),a=Io(e.experienceCloud||{},t),f=function(e){if(!v(e)&&Z(e.authorizationToken))return e;const t={},n=Br();return Z(n)&&(t.authorizationToken=n),t}(e.trace),l=No(e.preview),d=Oo(e.qaMode),p=function(e,t,n){const{execute:r={}}=e,o={};if(F(r))return o;const{pageLoad:i}=r;v(i)||(o.pageLoad=Ao(i,t));const{mboxes:c}=r;if(!v(c)&&y(c)&&!F(c)){const e=A(go,oe(e=>Po(e,t,n),c));F(e)||(o.mboxes=e)}return o}(e,r,n),h=qo(e,r,n),{notifications:m}=e;let b={};return b.requestId=De(),b.context=function(e){if(!v(e)&&"web"===e.channel)return e;const t=it(),n=Rr("clientHints")||{},r=e||{},{beacon:o}=r;return{userAgent:Xe.navigator.userAgent,clientHints:n,timeOffsetInMinutes:-(new Date).getTimezoneOffset(),channel:"web",screen:wo(),window:So(),browser:Eo(t),address:To(),geo:e&&e.geo,crossDomain:t.crossDomain,beacon:o}}(e.context),F(s)||(b.id=s),F(u)||(b.property=u),F(f)||(b.trace=f),F(a)||(b.experienceCloud=a),F(l)||(b.preview=l),F(d)||(b.qaMode=d),F(p)||(b.execute=p),F(h)||(b.prefetch=h),F(m)||(b.notifications=m),b=Xe.__target_telemetry.addTelemetryToDeliveryRequest(b),b}function Do(e,t,n){const r=n[0],o=n[1];return Ro(e,r,g({},o,t))}function Lo(e,t){const n=it();return Sn([bo(),no(Jr(),n.allowHighEntropyClientHints)]).then(n=>{let[r]=n;return Do(e,t,r)})}function jo(e,t){return Kt("request",e),en({request:e}),mo(e,t,he).then(t=>(Kt("response",t),en({response:t}),{request:e,response:t}))}const Vo=e=>t=>t[e],Ho=e=>t=>!e(t),Uo=Ho(v),Bo=Ho(G),Fo=e=>t=>A(e,t),zo=e=>e.status===$e,$o=e=>"actions"===e.type,Jo=e=>"redirect"===e.type,Go=Fo(Uo),Zo=Fo(Bo),Wo=Vo("options"),Ko=Vo(je),Xo=Vo("eventToken"),Yo=Vo("responseTokens"),Qo=e=>Z(e.name),ei=e=>S(e)&&Qo(e),ti=e=>S(e)&&Qo(e)&&(e=>!v(e.index))(e),ni=e=>S(e)&&Qo(e),ri=Vo("data"),oi=q([ri,Uo]);function ii(e,t){return{status:Ge,type:e,data:t}}function ci(e,t){return{status:$e,type:e,data:t}}function si(e){return S(e)}function ui(e){return!!si(e)&&Z(e.eventToken)}function ai(e){return!F(e)&&!G(e.type)&&Z(e.eventToken)}function fi(e){return!!ai(e)&&Z(e.selector)}function li(e){const{id:t}=e;return S(t)&&Z(t.tntId)}function di(e){const{response:t}=e;return li(t)&&function(e){const t=it();Vt({name:"PC",value:e,expires:t.deviceIdLifetime,domain:t.cookieDomain,secure:t.secureOnly})}(t.id.tntId),e}function pi(e){const{response:t}=e;if(li(t)){const{id:e}=t,{tntId:n}=e;Rn(n)}return Rn(null),e}function hi(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const{trace:t}=e;F(t)||Qt(t)}function mi(e){const{response:t}=e,{execute:n={},prefetch:r={},notifications:o={}}=t,{pageLoad:i={},mboxes:c=[]}=n,{mboxes:s=[],views:u=[]}=r;return hi(i),M(hi,c),M(hi,s),M(hi,u),M(hi,o),e}function gi(e){if(window.URL){const t=e.searchParams.get("adobe_mc_sdid");if(!D(t)||G(t))return e.search;const n=Math.round(ie()/1e3);return e.searchParams.set("adobe_mc_sdid",t.replace(/\|TS=\d+/,"|TS="+n)),e.search}const t=e.queryKey,n=t.adobe_mc_sdid;if(!D(n))return t;if(G(n))return t;const r=Math.round(ie()/1e3);return t.adobe_mc_sdid=n.replace(/\|TS=\d+/,"|TS="+r),t}function vi(e){return window.URL?e.search:e.queryKey}function yi(e,t,n){if(window.URL){const r=new URL(e,window.location);return r.search=n(r),Object.entries(t).forEach(e=>{let[t,n]=e;r.searchParams.set(t,n)}),e.href}const r=St(e),{protocol:o}=r,{host:i}=r,{path:c}=r,s=""===r.port?"":":"+r.port,u=G(r.anchor)?"":"#"+r.anchor,a=n(r),f=bt(g({},a,t));return o+"://"+i+s+c+(G(f)?"":"?"+f)+u}function bi(e,t){return yi(e,t,gi)}function xi(e){const t=e.method||"GET",n=e.url||function(e){throw new Error(e)}("URL is required"),r=e.headers||{},o=e.data||null,i=e.credentials||!1,c=e.timeout||3e3,s=!!v(e.async)||!0===e.async,u={};return u.method=t,u.url=n,u.headers=r,u.data=o,u.credentials=i,u.timeout=c,u.async=s,u}function wi(e,t){const n=xi(t),r=n.method,o=n.url,i=n.headers,c=n.data,s=n.credentials,u=n.timeout,a=n.async;return bn((t,n)=>{let f=new e.XMLHttpRequest;f=function(e,t,n){return e.onload=()=>{const r=1223===e.status?204:e.status;if(r<100||r>599)return void n(new Error("Network request failed"));const o=e.responseText,i=e.getAllResponseHeaders();t({status:r,headers:i,response:o})},e}(f,t,n),f=function(e,t){return e.onerror=()=>{t(new Error("Network request failed"))},e}(f,n),f.open(r,o,a),f=function(e,t){return!0===t&&(e.withCredentials=t),e}(f,s),f=function(e,t){return M((t,n)=>{M(t=>e.setRequestHeader(n,t),t)},t),e}(f,i),a&&(f=function(e,t,n){return e.timeout=t,e.ontimeout=()=>{n(new Error("Request timed out"))},e}(f,u,n)),f.send(c)})}function Si(e){return wi(Xe,e)}function Ei(e,t,n){const r={method:"GET"};return r.url=function(e,t){return yi(e,t,vi)}(e,t),r.timeout=n,r}function Ti(e){const{status:t}=e;if(!function(e){return e>=200&&e<300||304===e}(t))return null;const n=e.response;if(G(n))return null;const r={type:"html"};return r.content=n,r}const Ci=/CLKTRK#(\S+)/,ki=/CLKTRK#(\S+)\s/;function Ii(e){const t=e[je],n=function(e){const t=e[Ve];if(G(t))return"";const n=Ci.exec(t);return F(n)||2!==n.length?"":n[1]}(e);if(G(n)||G(t))return e;const r=e[Ve];return e[Ve]=r.replace(ki,""),e[je]=function(e,t){const n=document.createElement("div");n.innerHTML=t;const r=n.firstElementChild;return v(r)?t:(r.id=e,r.outerHTML)}(n,t),e}const Ni=e=>!v(e);function Oi(e){const{selector:t}=e;return!v(t)}function _i(e){const t=e[Le];if(G(t))return null;switch(t){case"setHtml":return function(e){if(!Oi(e))return null;const t=Ii(e);return D(t[je])?t:(Kt(Fe,t),null)}(e);case"setText":return function(e){if(!Oi(e))return null;const t=Ii(e);return D(t[je])?t:(Kt(Fe,t),null)}(e);case"appendHtml":return function(e){if(!Oi(e))return null;const t=Ii(e);return D(t[je])?t:(Kt(Fe,t),null)}(e);case"prependHtml":return function(e){if(!Oi(e))return null;const t=Ii(e);return D(t[je])?t:(Kt(Fe,t),null)}(e);case"replaceHtml":return function(e){if(!Oi(e))return null;const t=Ii(e);return D(t[je])?t:(Kt(Fe,t),null)}(e);case"insertBefore":return function(e){if(!Oi(e))return null;const t=Ii(e);return D(t[je])?t:(Kt(Fe,t),null)}(e);case"insertAfter":return function(e){if(!Oi(e))return null;const t=Ii(e);return D(t[je])?t:(Kt(Fe,t),null)}(e);case"customCode":return function(e){return Oi(e)?D(e[je])?e:(Kt(Fe,e),null):null}(e);case"setAttribute":return function(e){return Oi(e)?S(e[je])?e:(Kt("Action has no attributes",e),null):null}(e);case"setImageSource":return function(e){return Oi(e)?D(e[je])?e:(Kt("Action has no image url",e),null):null}(e);case"setStyle":return function(e){return Oi(e)?S(e[je])?e:(Kt("Action has no CSS properties",e),null):null}(e);case"resize":return function(e){return Oi(e)?S(e[je])?e:(Kt("Action has no height or width",e),null):null}(e);case"move":return function(e){return Oi(e)?S(e[je])?e:(Kt("Action has no left, top or position",e),null):null}(e);case"remove":return function(e){return Oi(e)?e:null}(e);case"rearrange":return function(e){return Oi(e)?S(e[je])?e:(Kt("Action has no from or to",e),null):null}(e);case"redirect":return function(e){const{content:t}=e;return G(t)?(Kt("Action has no url",e),null):(e.content=bi(t,{}),e)}(e);default:return null}}function Ai(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const{options:t}=e;return y(t)?F(t)?[]:Go(oe(Yo,t)):[]}function Pi(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const{execute:t={}}=e,{pageLoad:n={},mboxes:r=[]}=t,o=Wo(n)||[],i=P(Go(oe(Wo,r))),c=P([o,i]),s=P(oe(Ko,A($o,c))),u=A(Jo,c),a=A(Jo,s),f=u.concat(a),l={};if(F(f))return l;const d=f[0],p=d.content;return G(p)||(l.url=p),l}function qi(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const{analytics:t}=e;return F(t)?[]:[t]}function Mi(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const{execute:t={},prefetch:n={}}=e,{pageLoad:r={},mboxes:o=[]}=t,{mboxes:i=[],views:c=[],metrics:s=[]}=n,u=qi(r),a=P(oe(qi,o)),f=P(oe(qi,i)),l=P(oe(qi,c)),d=P(oe(qi,s));return P([u,a,f,l,d])}function Ri(e,t){e.parameters=t.parameters,e.profileParameters=t.profileParameters,e.order=t.order,e.product=t.product}function Di(e,t){const n=t[0],r=t[1],o=!F(n),i=!F(r);return o||i?(o&&(e.options=n),i&&(e.metrics=r),e):e}function Li(e){const{type:t}=e;switch(t){case"redirect":return xn(function(e){const t=e.content;if(G(t))return Kt("Action has no url",e),null;const n=g({},e);return n.content=bi(t,{}),n}(e));case"dynamic":return function(e){const{content:t}=e;return Si(Ei(t,{},it().timeout)).then(Ti)['catch'](()=>null)}(e);case"actions":return xn(function(e){const t=e[je];if(!y(t))return null;if(F(t))return null;const n=A(Ni,oe(_i,t));if(F(n))return null;const r=g({},e);return r.content=n,r}(e));default:return xn(e)}}function ji(e,t){if(!y(e))return xn([]);if(F(e))return xn([]);const n=A(t,e);if(F(n))return xn([]);return Sn(oe(e=>Li(e),n)).then(Go)}function Vi(e,t){return y(e)?F(e)?xn([]):xn(A(t,e)):xn([])}function Hi(e){const{name:t,analytics:n,options:r,metrics:o}=e,i={name:t,analytics:n};return Sn([ji(r,si),Vi(o,ai)]).then(e=>Di(i,e))}function Ui(e,t){const{index:n,name:r,state:o,analytics:i,options:c,metrics:s}=t,u=function(e,t,n){const{prefetch:r={}}=e,{mboxes:o=[]}=r;return F(o)?null:(i=A(e=>function(e,t,n){return e.index===t&&e.name===n}(e,t,n),o))&&i.length?i[0]:void 0;var i}(e,n,r),a={name:r,state:o,analytics:i};return v(u)||Ri(a,u),Sn([ji(c,ui),Vi(s,ai)]).then(e=>Di(a,e))}function Bi(e,t){const{name:n,state:r,analytics:o,options:i,metrics:c}=t,s=function(e){const{prefetch:t={}}=e,{views:n=[]}=t;return F(n)?null:n[0]}(e),u={name:n.toLowerCase(),state:r,analytics:o};return v(s)||Ri(u,s),Sn([ji(i,ui),Vi(c,fi)]).then(e=>Di(u,e))}function Fi(e){if(v(e)||G(e.id))return xn(null);const{id:t}=e;return xn({id:t})}function zi(e){const t=e[0],n=e[1],r=e[2],o=e[3],i=e[4],c=e[5],s=e[6],u={},a={};S(t)&&(a.pageLoad=t),F(n)||(a.mboxes=n);const f={};return F(r)||(f.mboxes=r),F(o)||(f.views=o),F(i)||(f.metrics=i),F(a)||(u.execute=a),F(f)||(u.prefetch=f),F(c)||(u.meta=c),F(s)||(u.notifications=s),u}function $i(e){const t=q([mi,di,pi])(e),n=function(e){const{response:t}=e,{execute:n}=t;if(!S(n))return xn(null);const{pageLoad:r}=n;if(!S(r))return xn(null);const{analytics:o,options:i,metrics:c}=r,s=F(o)?{}:{analytics:o};return Sn([ji(i,si),Vi(c,fi)]).then(e=>Di(s,e))}(t),r=function(e){const{response:t}=e,{execute:n}=t;if(!S(n))return xn([]);const{mboxes:r}=n;return!y(r)||F(r)?xn([]):Sn(oe(Hi,A(ei,r))).then(Go)}(t),o=function(e){const{request:t,response:n}=e,{prefetch:r}=n;if(!S(r))return xn([]);const{mboxes:o}=r;return!y(o)||F(o)?xn([]):Sn(oe(e=>Ui(t,e),A(ti,o))).then(Go)}(t),i=function(e){const{request:t,response:n}=e,{prefetch:r}=n;if(!S(r))return xn([]);const{views:o}=r;return!y(o)||F(o)?xn([]):Sn(oe(e=>Bi(t,e),A(ni,o))).then(Go)}(t),c=function(e){const{response:t}=e,{prefetch:n}=t;if(!S(n))return xn([]);const{metrics:r}=n;return Vi(r,fi)}(t),s=function(e){const{response:t}=e,{remoteMboxes:n,remoteViews:r,decisioningMethod:o}=t,i={};return S(n)&&(i.remoteMboxes=n),S(r)&&(i.remoteViews=r),D(o)&&(i.decisioningMethod=o),xn(i)}(t),u=function(e){const{response:t}=e,{notifications:n}=t;return y(n)?Sn(oe(Fi,n)).then(Go):xn([])}(t);return Sn([n,r,o,i,c,s,u]).then(zi)}function Ji(e){return!F(Pi(e))}function Gi(e){const t=function(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const{execute:t={},prefetch:n={}}=e,{pageLoad:r={},mboxes:o=[]}=t,{mboxes:i=[],views:c=[]}=n,s=Ai(r),u=P(oe(Ai,o)),a=P(oe(Ai,i)),f=P(oe(Ai,c));return P([s,u,a,f])}(e),n={};return F(t)||(n.responseTokens=t),n}function Zi(e){const t=e.aepSandboxId,n=e.aepSandboxName,r={};return F(t)||(r.sandboxId=t),F(n)||(r.sandboxName=n),r}function Wi(e){const t=it(),{mbox:n,timeout:r}=e,o=S(e.params)?e.params:{},i=function(e,t){const n=e.globalMboxName,{mbox:r}=t,o={},i={},c={};r===n?i.pageLoad={}:i.mboxes=[{index:0,name:r}],o.execute=i;const s=Mo(r,o);F(s)||(c.analytics=s);const u=Zi(e);return F(u)||(c.platform=u),F(c)||(o.experienceCloud=c),o}(t,e);return jn({mbox:n}),Lo(i,o).then(e=>jo(e,r)).then($i).then(e=>function(e,t){const n=Gi(t);n.mbox=e;const r=Mi(t);return F(r)||(n.analyticsDetails=r),Kt("request succeeded",t),Vn(n,Ji(t)),xn(t)}(n,e))['catch'](e=>function(e,t){return Wt("request failed",t),Hn({mbox:e,error:t}),wn(t)}(n,e))}function Ki(e,t){const n=e.globalMboxName,{consumerId:r=n,request:o,page:i=!0}=t,c=Mo(r,o);o.impressionId=o.impressionId||function(e){return!e&&vo||(vo=De()),vo}(i);const s=o.experienceCloud||{};F(c)||(s.analytics=c);const u=Zi(e);return F(u)||(s.platform=u),F(s)||(o.experienceCloud=s),o}function Xi(e){const t=it(),{timeout:n}=e,r=Ki(t,e);return jn({}),Lo(r,{}).then(e=>jo(e,n)).then($i).then(e=>function(e){const t=Gi(e),n=Mi(e);return F(n)||(t.analyticsDetails=n),Kt("request succeeded",e),Vn(t,Ji(e)),xn(e)}(e))['catch'](e=>function(e){return Wt("request failed",e),Hn({error:e}),wn(e)}(e))}function Yi(e,t){return Yn(t).addClass(e)}function Qi(e,t){return Yn(t).css(e)}function ec(e,t){return Yn(t).attr(e)}function tc(e,t,n){return Yn(n).attr(e,t)}function nc(e,t){return Yn(t).removeAttr(e)}function rc(e,t,n){const r=ec(e,n);Z(r)&&(nc(e,n),tc(t,r,n))}function oc(e){return new Error("Could not find: "+e)}function ic(e,t,n){return bn((r,o)=>{const i=vn(()=>{const t=n(e);F(t)||(i.disconnect(),r(t))});de(()=>{i.disconnect(),o(oc(e))},t),i.observe(Ke,{childList:!0,subtree:!0})})}function cc(){return"visible"===Ke.visibilityState}function sc(e,t,n){return bn((r,o)=>{!function t(){const o=n(e);F(o)?Xe.requestAnimationFrame(t):r(o)}(),de(()=>{o(oc(e))},t)})}function uc(e,t,n){return bn((r,o)=>{!function t(){const o=n(e);F(o)?de(t,100):r(o)}(),de(()=>{o(oc(e))},t)})}function ac(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:it().selectorsPollingTimeout,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:Yn;const r=n(e);return F(r)?gn()?ic(e,t,n):cc()?sc(e,t,n):uc(e,t,n):xn(r)}function fc(e){return ec("data-at-src",e)}function lc(e){return Z(ec("data-at-src",e))}function dc(e){return M(e=>rc(He,"data-at-src",e),H(nr("img",e))),e}function pc(e){return M(e=>rc("data-at-src",He,e),H(nr("img",e))),e}function hc(e){return Kt("Loading image",e),ec(He,tc(He,e,hn("")))}function mc(e){const t=A(lc,H(nr("img",e)));return F(t)||M(hc,oe(fc,t)),e}function gc(e){const t=ec(He,e);return Z(t)?t:null}function vc(e,t){return Wt("Unexpected error",t),en({action:e,error:t}),e}function yc(e,t){const n=Yn(t[Ve]),r=function(e){return q([dc,mc,pc])(e)}(er(t[je])),o=function(e){return A(Z,oe(gc,H(nr("script",e))))}(r);let i;try{i=xn(e(n,r))}catch(e){return wn(vc(t,e))}return F(o)?i.then(()=>t)['catch'](e=>vc(t,e)):i.then(()=>function(e){return ue((e,t)=>e.then(()=>(Kt("Script load",t),en({remoteScript:t}),Gn(t))),xn(),e)}(o)).then(()=>t)['catch'](e=>vc(t,e))}function bc(e){const t=g({},e),n=t[je];if(G(n))return t;const r=Yn(t[Ve]);return o="head",Yn(r).is(o)?(t[Le]="appendHtml",t[je]=function(e){return ne("",ue((e,t)=>(e.push(gr(er(t))),e),[],H(nr("script,link,style",er(e)))))}(n),t):t;var o}function xc(e){return e.indexOf("px")===e.length-2?e:e+"px"}function wc(e,t){return n=gr(t),Yn(e).html(n);var n}function Sc(e){const t=Yn(e[Ve]),n=e[je];return Kt("Rendering action",e),en({action:e}),function(e,t){Yn(t).text(e)}(n,t),xn(e)}function Ec(e,t){return mr(gr(t),e)}function Tc(e,t){return n=gr(t),Yn(e).prepend(n);var n}function Cc(e,t){const n=tr(e);return dr(hr(gr(t),e)),n}function kc(e,t){return Yn(hr(gr(t),e)).prev()}function Ic(e,t){return Yn(pr(gr(t),e)).next()}function Nc(e,t){return tr(hr(gr(t),e))}function Oc(e){const t=Yn(e[Ve]),n=e[je],r=n.priority;return Kt("Rendering action",e),en({action:e}),G(r)?Qi(n,t):function(e,t,n){M(e=>{M((t,r)=>e.style.setProperty(r,t,n),t)},H(e))}(t,n,r),xn(e)}function _c(e){const t=Yn(e[Ve]),n=e[je],r=Number(n.from),o=Number(n.to);if(isNaN(r)&&isNaN(o))return Kt('Rearrange has incorrect "from" and "to" indexes',e),wn(e);const i=H(Yn(t).children());const c=i[r],s=i[o];return Qn(c)&&Qn(s)?(Kt("Rendering action",e),en({action:e}),rtc(t,e,n),t),xn(e)}(t);case"setImageSource":return function(e){const t=e[je],n=Yn(e[Ve]);return Kt("Rendering action",e),en({action:e}),nc(He,n),tc(He,hc(t),n),xn(e)}(t);case"setStyle":return Oc(t);case"resize":return function(e){const t=Yn(e[Ve]),n=e[je];return n.width=xc(n.width),n.height=xc(n.height),Kt("Rendering action",e),en({action:e}),Qi(n,t),xn(e)}(t);case"move":return function(e){const t=Yn(e[Ve]),n=e[je];return n.left=xc(n.left),n.top=xc(n.top),Kt("Rendering action",e),en({action:e}),Qi(n,t),xn(e)}(t);case"remove":return function(e){const t=Yn(e[Ve]);return Kt("Rendering action",e),en({action:e}),dr(t),xn(e)}(t);case"rearrange":return _c(t);default:return xn(t)}}function Pc(e){const t=e[Ve];return Z(t)||Zn(t)}function qc(e){const t=e.cssSelector;G(t)||dr("#at-"+L(t))}function Mc(e){if(!Pc(e))return void qc(e);const t=e[Ve];!function(e){return"trackClick"===e[Le]||"signalClick"===e[Le]}(e)?(Yi("at-element-marker",t),qc(e)):Yi("at-element-click-tracking",t)}function Rc(e){return function(e){const{key:t}=e;if(G(t))return!0;if("customCode"===e[Le])return e.page;const n=ec("at-action-key",e[Ve]);return n!==t||n===t&&!e.page}(e)?Ac(e).then(()=>(Kt("Action rendered successfully",e),en({action:e}),function(e){const{key:t}=e;if(G(t))return;if(!Pc(e))return;tc("at-action-key",t,e[Ve])}(e),Mc(e),e))['catch'](t=>{Wt("Unexpected error",t),en({action:e,error:t}),Mc(e);const n=g({},e);return n[$e]=!0,n}):(Mc(e),e)}function Dc(e){const t=A(e=>!0===e[$e],e);return F(t)?xn():(function(e){M(Mc,e)}(t),wn(e))}function Lc(e){return function(e){return ac(e[Ve]).then(()=>e)['catch'](()=>{const t=g({},e);return t[$e]=!0,t})}(e).then(Rc)}function jc(e,t,n){return Yn(n).on(e,t)}function Vc(e){const t=e.name,n=Rr("views")||{};n[t]=e,Mr("views",n)}function Hc(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:{};const{page:n=!0}=t,r=Rr("views")||{},o=r[e];if(v(o))return o;const{impressionId:i}=t;return v(i)?o:g({page:n,impressionId:i},o)}function Uc(e){const t=Mo(e,{}),n={context:{beacon:!0}};if(!F(t)){const e={};e.analytics=t,n.experienceCloud=e}return n}function Bc(e,t,n){const r=function(e,t){return Do(e,t,xo())}(Uc(e),t);return r.notifications=n,r}function Fc(e,t,n){const r=De(),o=ie(),{parameters:i,profileParameters:c,order:s,product:u}=e,a={id:r,type:t,timestamp:o,parameters:i,profileParameters:c,order:s,product:u};return F(n)||(a.tokens=n),a}function zc(e){return new Promise((t,n)=>{const r=ho(it());if(function(e,t){return"navigator"in(n=Xe)&&"sendBeacon"in n.navigator?function(e,t,n){return e.navigator.sendBeacon(t,n)}(Xe,e,t):function(e,t,n){const r={"Content-Type":["text/plain"]},o={method:"POST"};o.url=t,o.data=n,o.credentials=!0,o.async=!1,o.headers=r;try{e(o)}catch(e){return!1}return!0}(Si,e,t);var n}(r,JSON.stringify(e)))return Kt("Beacon data sent",r,e),void t();Wt("Beacon data sent failed",r,e),n()})}function $c(e,t,n){const r=Zr(it().globalMboxName),o=Fc(Ao({},r),t,[n]),i=Bc(De(),r,[o]);Kt("Event handler notification",e,o),en({source:e,event:t,request:i}),zc(i)}function Jc(e,t,n){const r=Zr(e),o=Fc(Ao({},r),t,[n]);o.mbox={name:e};const i=Bc(De(),r,[o]);Kt("Mbox event handler notification",e,o),en({mbox:e,event:t,request:i}),zc(i)}function Gc(e){const t=it().globalMboxName,n=[],r=We;if(M(e=>{const{mbox:t,data:o}=e;if(v(o))return;const{eventTokens:i=[]}=o;F(i)||n.push(function(e,t,n){const{name:r,state:o}=e,i=Fc(e,t,n);return i.mbox={name:r,state:o},i}(t,r,i))},e),F(n))return;const o=Bc(t,{},n);Kt("Mboxes rendered notification",n),en({source:"prefetchMboxes",event:"rendered",request:o}),zc(o)}function Zc(e,t,n){const r=Zr(it().globalMboxName),o=Fc(Ao({},r),t,[n]);o.view={name:e};const i=Bc(De(),r,[o]);Kt("View event handler notification",e,o),en({view:e,event:t,request:i}),zc(i)}function Wc(e){const{viewName:t,impressionId:n}=e,r=Zr(it().globalMboxName),o=Fc(Ao({},r),We,[]);o.view={name:t},Kt("View triggered notification",t),function(e,t,n){return Lo(Uc(e),t).then(e=>(e.notifications=n,e))}(t,r,[o]).then(e=>{e.impressionId=n,en({view:t,event:"triggered",request:e}),zc(e)})}function Kc(e){if(v(e))return;const{view:t,data:n={}}=e,{eventTokens:r=[]}=n,{name:o,impressionId:i}=t,c=Hc(o);if(v(c))return;const s=Bc(o,{},[function(e,t,n){const{name:r,state:o}=e,i=Fc(e,t,n);return i.view={name:r,state:o},i}(c,We,r)]);s.impressionId=i,Kt("View rendered notification",o,r),en({view:o,event:"rendered",request:s}),zc(s)}const Xc={},Yc=Vo("metrics"),Qc=()=>ii("metric"),es=e=>ci("metric",e);function ts(e,t,n){if(!v(Xc[e]))return;const r=k(Xc);F(r)||M(e=>{M(r=>{const o=Xc[e][r];!function(e,t,n){Yn(n).off(e,t)}(t,o,n)},k(Xc[e])),delete Xc[e]},r)}function ns(e,t,n,r){const{type:o,selector:i,eventToken:c}=n,s=L(o+":"+i+":"+c),u=()=>r(e,o,c);!function(e,t){"click"===e&&Yi("at-element-click-tracking",t)}(o,i),t?function(e,t){return!v(Xc[e])&&!v(Xc[e][t])}(e,s)||(ts(e,o,i),function(e,t,n){Xc[e]=Xc[e]||{},Xc[e][t]=n}(e,s,u),jc(o,u,i)):jc(o,u,i)}function rs(e,t,n,r){return function(e){return ac(e[Ve]).then(()=>{en({metric:e});return g({found:!0},e)})['catch'](()=>(Wt("metric element not found",e),en({metric:e,message:"metric element not found"}),e))}(n).then(n=>{n.found&&ns(e,t,n,r)})}function os(e,t,n,r){return Sn(oe(n=>rs(e,t,n,r),n)).then(Qc)['catch'](es)}function is(e){const{name:t}=e;return os(t,!1,Yc(e),Jc)}function cs(e){const{name:t}=e;return os(t,!0,Yc(e),Zc)}function ss(e){return os("pageLoadMetrics",!1,Yc(e),$c)}function us(e){return os("prefetchMetrics",!1,Yc(e),$c)}const as=Vo(je),fs=Vo("cssSelector"),ls=e=>Ho(zo)(e)&&oi(e);function ds(e){const t=oe(fs,e);var n;n=Zo(t),yr(it(),n)}function ps(e){const t=oe(fs,e);var n;n=Go(t),br(it(),n)}function hs(e){const t=A($o,Wo(e));return P(oe(as,t))}function ms(e){return S(e)&&"setJson"!==e.type}function gs(e,t,n){const{eventToken:r,responseTokens:o,content:i}=e;return function(e){return Sn(oe(Lc,e)).then(Dc)}(function(e,t,n){return oe(e=>g({key:t,page:n},e),A(ms,e))}(i,t,n)).then(()=>ii("render",{eventToken:r,responseTokens:o}))['catch'](e=>((e,t)=>{const n=y(e)?{errors:e}:{errors:[e]};return ci("render",g(n,t))})(e,{eventToken:r,responseTokens:o}))}function vs(e){return S(e)&&"json"!==e.type}function ys(e,t){return oe(e,A(vs,Wo(t)))}function bs(e,t,n){const r={status:Ge,[e]:t},o=oe(ri,A(zo,n)),i={};return F(o)||(r.status=$e,i.errors=o),F(i)||(r.data=i),r}function xs(e,t,n){return Sn(ys(e=>gs(e,!0),e)).then(t).then(t=>(n(e),t))}function ws(e,t,n,r){const{name:o}=t;return Sn(ys(e=>gs(e,o,n),t)).then(n=>function(e,t,n){const r={status:Ge,[e]:t},o=oe(ri,A(zo,n)),i=oe(ri,A(ls,n)),c=Go(oe(Xo,i)),s=Go(oe(Yo,i)),u={};return F(o)||(r.status=$e,u.errors=o),F(c)||(u.eventTokens=c),F(s)||(u.responseTokens=s),F(u)||(r.data=u),r}(e,t,n)).then(e=>(r(t),e))}function Ss(e){return xs(e,t=>bs("mbox",e,t),is)}function Es(e){return ws("mbox",e,!0,is)}function Ts(e){ds(hs(e))}function Cs(e){let t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];if(t)return;const{execute:n={}}=e,{pageLoad:r={}}=n;F(r)||Ts(r)}function ks(e){ds(hs(e)),Qn("#at-views")&&dr("#at-views")}var Is={exports:{}};function Ns(){}Ns.prototype={on:function(e,t,n){var r=this.e||(this.e={});return(r[e]||(r[e]=[])).push({fn:t,ctx:n}),this},once:function(e,t,n){var r=this;function o(){r.off(e,o),t.apply(n,arguments)}return o._=t,this.on(e,o,n)},emit:function(e){for(var t=[].slice.call(arguments,1),n=((this.e||(this.e={}))[e]||[]).slice(),r=0,o=n.length;r{switch(e.action){case"setContent":Z((t=e).selector)&&Z(t.cssSelector)?o.push(function(e){const t={type:"setHtml"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e)):n.push({type:"html",content:e.content});break;case"setJson":F(e.content)||M(e=>n.push({type:"json",content:e}),e.content);break;case"setText":o.push(function(e){const t={type:"setText"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"appendContent":o.push(function(e){const t={type:"appendHtml"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"prependContent":o.push(function(e){const t={type:"prependHtml"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"replaceContent":o.push(function(e){const t={type:"replaceHtml"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"insertBefore":o.push(function(e){const t={type:"insertBefore"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"insertAfter":o.push(function(e){const t={type:"insertAfter"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"customCode":o.push(function(e){const t={type:"customCode"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"setAttribute":o.push(function(e){const t={};if(t.selector=e.selector,t.cssSelector=e.cssSelector,e.attribute===He)return t.type="setImageSource",t.content=e.value,t;t.type="setAttribute";const n={};return n[e.attribute]=e.value,t.content=n,t}(e));break;case"setStyle":o.push(function(e){const{style:t={}}=e,n={};return n.selector=e.selector,n.cssSelector=e.cssSelector,v(t.left)||v(t.top)?v(t.width)||v(t.height)?(n.type="setStyle",n.content=t,n):(n.type="resize",n.content=t,n):(n.type="move",n.content=t,n)}(e));break;case"remove":o.push(function(e){const t={type:"remove"};return t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"rearrange":o.push(function(e){const t={};t.from=e.from,t.to=e.to;const n={type:"rearrange"};return n.selector=e.selector,n.cssSelector=e.cssSelector,n.content=t,n}(e));break;case"redirect":n.push(Ps(e));break;case"trackClick":r.push({type:"click",selector:e.selector,eventToken:e.clickTrackId})}var t},e);const i={};!F(o)&&n.push({type:"actions",content:o});!F(n)&&(i.options=n);if(!F(r)&&(i.metrics=r),F(i))return t;const c={};return c.pageLoad=i,t.execute=c,t}function Ms(e,t,n){return n?qs(t):function(e,t){const n={};if(F(t))return n;const r=[],o=[];M(e=>{switch(e.action){case"setContent":r.push({type:"html",content:e.content});break;case"setJson":F(e.content)||M(e=>r.push({type:"json",content:e}),e.content);break;case"redirect":r.push(Ps(e));break;case"signalClick":o.push({type:"click",eventToken:e.clickTrackId})}},t);const i={name:e};if(!F(r)&&(i.options=r),!F(o)&&(i.metrics=o),F(i))return n;const c={},s=[i];return c.mboxes=s,n.execute=c,n}(e,t)}const Rs=e=>!F(A(zo,e));function Ds(e){const{status:t,data:n}=e,r={status:t,pageLoad:!0};return v(n)||(r.data=n),r}function Ls(e){const{status:t,mbox:n,data:r}=e,{name:o}=n,i={status:t,mbox:o};return v(r)||(i.data=r),i}function js(e){const{status:t,view:n,data:r}=e,{name:o}=n,i={status:t,view:o};return v(r)||(i.data=r),i}function Vs(e){const{status:t,data:n}=e,r={status:t,prefetchMetrics:!0};return v(n)||(r.data=n),r}function Hs(e){if(v(e))return[null];const t=oe(Ds,[e]);return Rs(t)&&Wt("Page load rendering failed",e),t}function Us(e){if(v(e))return[null];const t=oe(Ls,e);return Rs(t)&&Wt("Mboxes rendering failed",e),t}function Bs(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Gc;if(v(e))return[null];const n=oe(Ls,e);return Rs(n)&&Wt("Mboxes rendering failed",e),t(e),n}function Fs(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Kc;if(v(e))return[null];const n=oe(js,[e]);Rs(n)&&Wt("View rendering failed",e);const{view:r}=e;return r.page?(t(e),n):n}function zs(e){if(v(e))return[null];const t=oe(Vs,[e]);return Rs(t)&&Wt("Prefetch rendering failed",e),t}function $s(e){const t=P([Hs(e[0]),Us(e[1]),Bs(e[2]),zs(e[3])]),n=A(Uo,t),r=A(zo,n);return F(r)?xn(n):wn(r)}function Js(e){return wn(e)}function Gs(e,t){if(F(t))return;const{options:n}=t;F(n)||M(t=>{if("html"!==t.type)return;const{content:n}=t;t.type="actions",t.content=[{type:"setHtml",selector:e,content:n}]},n)}function Zs(e,t){const{metrics:n}=t;if(F(n))return;const{name:r}=t;M(t=>{t.name=r,t.selector=t.selector||e},n)}function Ws(e,t){const n=g({},t),{execute:r={},prefetch:o={}}=n,{pageLoad:i={},mboxes:c=[]}=r,{mboxes:s=[]}=o;return Gs(e,i),M(t=>Gs(e,t),c),M(t=>Zs(e,t),c),M(t=>Gs(e,t),s),M(t=>Zs(e,t),s),n}function Ks(e){const{prefetch:t={}}=e,{views:n=[]}=t;F(n)||function(e){M(Vc,e)}(n)}function Xs(e){const t=[],{execute:n={}}=e,{pageLoad:r={},mboxes:o=[]}=n;F(r)?t.push(xn(null)):t.push(function(e){return xs(e,t=>bs("pageLoad",e,t),ss)}(r)),F(o)?t.push(xn(null)):t.push(function(e){return Sn(oe(Ss,e))}(o));const{prefetch:i={}}=e,{mboxes:c=[],metrics:s=[]}=i;return F(c)?t.push(xn(null)):t.push(function(e){return Sn(oe(Es,e))}(c)),y(s)&&!F(s)?t.push(function(e){return Sn([us(e)]).then(bs)}(i)):t.push(xn(null)),wr(),Sn(t).then($s)['catch'](Js)}function Ys(e,t){de(()=>e.location.replace(t))}function Qs(e){return Z(e)||Zn(e)?e:"head"}function eu(e){Yi("at-element-marker",e)}function tu(){let e=arguments.length>0&&void 0!==arguments[0]?arguments[0]:{};const{prefetch:t={}}=e,{execute:n={}}=e,{pageLoad:r={}}=n,{mboxes:o=[]}=n,{pageLoad:i={}}=t,{views:c=[]}=t,{mboxes:s=[]}=t;return F(r)&&F(o)&&F(i)&&F(c)&&F(s)}function nu(e){let t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];const{selector:n,response:r}=e;if(tu(r))return Kt(ze),eu(n),wr(),zn({}),_s("no-offers-event"),xn();const o=Ws(n,r),i=Pi(o);if(!F(i)){const{url:e}=i;return Kt("Redirect action",i),$n({url:e}),_s("redirect-offer-event"),Ys(Xe,e),xn()}return Un({}),Ks(o),_s("cache-updated-event"),Cs(o,t),Xs(o).then(e=>{F(e)||Bn({execution:e})})['catch'](e=>Fn({error:e}))}const ru="[page-init]";function ou(e){Wt(ru,"View delivery error",e),_s("no-offers-event"),en({source:ru,error:e}),wr()}function iu(e){let t=arguments.length>1&&void 0!==arguments[1]&&arguments[1];const n={selector:"head",response:e};Kt(ru,"response",e),en({source:ru,response:e}),nu(n,t)['catch'](ou)}function cu(e){const t=function(e){return e.serverState}(e),{request:n,response:r}=t;Kt(ru,"Using server state"),en({source:ru,serverState:t});const o=function(e,t){const n=g({},t),{execute:r,prefetch:o}=n,i=e.pageLoadEnabled,c=e.viewsEnabled;return r&&(n.execute.mboxes=void 0),r&&!i&&(n.execute.pageLoad=void 0),o&&(n.prefetch.mboxes=void 0),o&&!c&&(n.prefetch.views=void 0),n}(e,r);Cs(o),function(e){const{prefetch:t={}}=e,{views:n=[]}=t;if(F(n))return;ps(P(oe(hs,n)))}(o),function(e){window.__target_telemetry.addServerStateEntry(e)}(n),$i({request:n,response:o}).then(e=>iu(e,!0))['catch'](ou)}function su(){if(!Bt())return Wt(ru,Ue),void en({source:ru,error:Ue});const e=it();if(function(e){const t=e.serverState;if(F(t))return!1;const{request:n,response:r}=t;return!F(n)&&!F(r)}(e))return void cu(e);const t=e.pageLoadEnabled,n=e.viewsEnabled;if(!t&&!n)return Kt(ru,"Page load disabled"),void en({source:ru,error:"Page load disabled"});xr();const r={};if(t){const e={pageLoad:{}};r.execute=e}if(n){const e={views:[{}]};r.prefetch=e}const o=e.timeout;Kt(ru,"request",r),en({source:ru,request:r});const i={request:r,timeout:o};kn()&&!In()?Nn().then(()=>{Xi(i).then(iu)['catch'](ou)})['catch'](ou):Xi(i).then(iu)['catch'](ou)}function uu(){const e={valid:!0};return e}function au(e){const t={valid:!1};return t[$e]=e,t}function fu(e){return G(e)?au("mbox option is required"):e.length>250?au("mbox option is too long"):uu()}function lu(e){return{action:"redirect",url:e.content}}function du(e){const t=[];return M(e=>{const{type:n}=e;switch(n){case"setHtml":t.push(function(e){const t={action:"setContent"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"setText":t.push(function(e){const t={action:"setText"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"appendHtml":t.push(function(e){const t={action:"appendContent"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"prependHtml":t.push(function(e){const t={action:"prependContent"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"replaceHtml":t.push(function(e){const t={action:"replaceContent"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"insertBefore":t.push(function(e){const t={action:"insertBefore"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"insertAfter":t.push(function(e){const t={action:"insertAfter"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"customCode":t.push(function(e){const t={action:"customCode"};return t.content=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"setAttribute":t.push(function(e){const t=k(e.content)[0],n={action:"setAttribute"};return n.attribute=t,n.value=e.content[t],n.selector=e.selector,n.cssSelector=e.cssSelector,n}(e));break;case"setImageSource":t.push(function(e){const t={action:"setAttribute"};return t.attribute=He,t.value=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"setStyle":t.push(function(e){const t={action:"setStyle"};return t.style=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"resize":t.push(function(e){const t={action:"setStyle"};return t.style=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"move":t.push(function(e){const t={action:"setStyle"};return t.style=e.content,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"remove":t.push(function(e){const t={action:"remove"};return t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"rearrange":t.push(function(e){const t={action:"rearrange"};return t.from=e.content.from,t.to=e.content.to,t.selector=e.selector,t.cssSelector=e.cssSelector,t}(e));break;case"redirect":t.push(lu(e))}},e),t}function pu(e){if(F(e))return[];const t=[];return M(e=>{"click"===e.type&&(Z(e.selector)?t.push({action:"trackClick",selector:e.selector,clickTrackId:e.eventToken}):t.push({action:"signalClick",clickTrackId:e.eventToken}))},e),t}function hu(e){if(F(e))return[];const t=[],n=[],r=[],{options:o=[],metrics:i=[]}=e;M(e=>{const{type:o}=e;switch(o){case"html":t.push(e.content);break;case"json":n.push(e.content);break;case"redirect":r.push(lu(e));break;case"actions":r.push.apply(r,du(e.content))}},o),F(t)||r.push({action:"setContent",content:t.join("")}),F(n)||r.push({action:"setJson",content:n});const c=pu(i);return F(c)||r.push.apply(r,c),r}const mu="[getOffer()]";function gu(e,t){const n=function(e){const{execute:t={}}=e,{pageLoad:n={}}=t,{mboxes:r=[]}=t,o=[];return o.push.apply(o,hu(n)),o.push.apply(o,P(oe(hu,r))),o}(t);e[Ge](n)}function vu(e){const t=function(e){if(!S(e))return au(Be);const t=fu(e.mbox);return t[Je]?E(e[Ge])?E(e[$e])?uu():au("error option is required"):au("success option is required"):t}(e),n=t[$e];if(!t[Je])return Wt(mu,n),void en({source:mu,options:e,error:n});if(!Bt())return de(e[$e]("warning",Ue)),Wt(mu,Ue),void en({source:mu,options:e,error:Ue});const r=t=>gu(e,t),o=t=>function(e,t){const n=t.status||"unknown";e[$e](n,t)}(e,t);Kt(mu,e),en({source:mu,options:e}),kn()&&!In()?Nn().then(()=>{Wi(e).then(r)['catch'](o)}):Wi(e).then(r)['catch'](o)}const yu="[getOffers()]";function bu(e){const t=function(e){if(!S(e))return au(Be);const{request:t}=e;if(!S(t))return au("request option is required");const{execute:n,prefetch:r}=t;return S(n)||S(r)?uu():au("execute or prefetch is required")}(e),n=t[$e];return t[Je]?Bt()?(Kt(yu,e),en({source:yu,options:e}),!kn()||In()?Xi(e):Nn().then(()=>Xi(e))):(Wt(yu,Ue),en({source:yu,options:e,error:Ue}),wn(new Error(Ue))):(Wt(yu,n),en({source:yu,options:e,error:n}),wn(t))}const xu="[applyOffer()]";function wu(e){const t=Qs(e.selector),n=L(t);_e.timeStart(n);const r=function(e){if(!S(e))return au(Be);const t=fu(e.mbox);if(!t[Je])return t;const n=e.offer;return y(n)?uu():au("offer option is required")}(e),o=r[$e];if(!r[Je])return Wt(xu,e,o),en({source:xu,options:e,error:o}),void eu(t);if(!Bt())return Wt(xu,Ue),en({source:xu,options:e,error:Ue}),void eu(t);e.selector=t,Kt(xu,e),en({source:xu,options:e}),function(e){const{mbox:t,selector:n,offer:r}=e,o=it(),i=t===o.globalMboxName;if(F(r))return Kt(ze),eu(n),wr(),void zn({mbox:t});const c=Ws(n,Ms(t,r,i)),s=Pi(c);if(!F(s)){const{url:e}=s;return Kt("Redirect action",s),$n({url:e}),void Ys(Xe,e)}Un({mbox:t}),Cs(c),Xs(c).then(e=>{F(e)||Bn({mbox:t,execution:e})})['catch'](e=>Fn({error:e}))}(e);const i=_e.timeEnd(n);_e.clearTiming(n),window.__target_telemetry.addRenderEntry(n,i)}function Su(e){const t=Qs(e.selector),n=L(t);_e.timeStart(n);const r=function(e){if(!S(e))return au(Be);const{response:t}=e;return S(t)?uu():au("response option is required")}(e),o=r[$e];return r[Je]?Bt()?(e.selector=t,Kt("[applyOffers()]",e),en({source:"[applyOffers()]",options:e}),nu(e).then(()=>{const e=_e.timeEnd(n);_e.clearTiming(n),window.__target_telemetry.addRenderEntry(n,e)})):(Wt("[applyOffers()]",Ue),en({source:"[applyOffers()]",options:e,error:Ue}),eu(t),wn(new Error(Ue))):(Wt("[applyOffers()]",e,o),en({source:"[applyOffers()]",options:e,error:o}),eu(t),wn(r))}function Eu(e){const t=it().globalMboxName,{consumerId:n=t,request:r}=e,o=function(e){if(!S(e))return au(Be);const{request:t}=e;if(!S(t))return au("request option is required");const{execute:n,prefetch:r,notifications:o}=t;return S(n)||S(r)?au("execute or prefetch is not allowed"):y(o)?uu():au("notifications are required")}(e),i=o[$e];if(!o[Je])return Wt("[sendNotifications()]",i),void en({source:"[sendNotifications()]",options:e,error:i});if(!Bt())return Wt("[sendNotifications()]",Ue),void en({source:"[sendNotifications()]",options:e,error:Ue});Kt("[sendNotifications()]",e),en({source:"[sendNotifications()]",options:e});const{notifications:c}=r,s=Bc(n,{},c);!kn()||In()?zc(s):Wt("[sendNotifications()]","Adobe Target is not opted in")}const Tu="[trackEvent()]";function Cu(e){if(kn()&&!In())return Wt("Track event request failed","Adobe Target is not opted in"),void e[$e]($e,"Adobe Target is not opted in");!function(e){const{mbox:t,type:n=We}=e,r=S(e.params)?e.params:{},o=g({},Zr(t),r),i=Fc(Ao({},o),n,[]);i.mbox={name:t},zc(Bc(t,o,[i])).then(()=>{Kt("Track event request succeeded",e),e[Ge]()})['catch'](()=>{Wt("Track event request failed",e),e[$e]("unknown","Track event request failed")})}(e)}function ku(e){const t=e[Ve],n=e[Le],r=H(Yn(t)),o=()=>function(e){return Cu(e),!e.preventDefault}(e);M(e=>jc(n,o,e),r)}function Iu(e){const t=function(e){if(!S(e))return au(Be);const t=fu(e.mbox);return t[Je]?uu():t}(e),n=t[$e];if(!t[Je])return Wt(Tu,n),void en({source:Tu,options:e,error:n});const r=function(e,t){const n=t.mbox,r=g({},t),o=S(t.params)?t.params:{};return r.params=g({},Zr(n),o),r.timeout=lo(e,t.timeout),r[Ge]=E(t[Ge])?t[Ge]:be,r[$e]=E(t[$e])?t[$e]:be,r}(it(),e);if(!Bt())return Wt(Tu,Ue),de(r[$e]("warning",Ue)),void en({source:Tu,options:e,error:Ue});Kt(Tu,r),en({source:Tu,options:r}),function(e){const t=e[Le],n=e[Ve];return Z(t)&&(Z(n)||Zn(n))}(r)?ku(r):Cu(r)}const Nu=[];let Ou=0;function _u(e){return ks(e),function(e){const{page:t}=e;return ws("view",e,t,cs)}(e).then(Fs).then(e=>{F(e)||Bn({execution:e})})['catch'](e=>{Wt("View rendering failed",e),Fn({error:e})})}function Au(){for(;Nu.length>0;){const e=Nu.pop(),{viewName:t,page:n}=e,r=Hc(t,e);v(r)?n&&Wc(e):_u(r)}}function Pu(){Ou=1,Au()}function qu(e,t){if(!it().viewsEnabled)return void Wt("[triggerView()]","Views are not enabled");if(!D(e)||G(e))return Wt("[triggerView()]","View name should be a non-empty string",e),void en({source:"[triggerView()]",view:e,error:"View name should be a non-empty string"});const n=e.toLowerCase(),r=function(e,t){const n={};return n.viewName=e,n.impressionId=De(),n.page=!0,F(t)||(n.page=!!t.page),n}(n,t);Kt("[triggerView()]",n,r),zt()?function(e){const t=e.viewName;Xe._AT.currentView=t}(r):(en({source:"[triggerView()]",view:n,options:r}),function(e){Nu.push(e),0!==Ou&&Au()}(r))}As("cache-updated-event",Pu),As("no-offers-event",Pu),As("redirect-offer-event",Pu);const Mu="function has been deprecated. Please use getOffer() and applyOffer() functions instead.",Ru="adobe.target.registerExtension() function has been deprecated. Please review the documentation for alternatives.",Du="mboxCreate() "+Mu,Lu="mboxDefine() "+Mu,ju="mboxUpdate() "+Mu;function Vu(){Wt(Ru,arguments)}function Hu(){Wt(Du,arguments)}function Uu(){Wt(Lu,arguments)}function Bu(){Wt(ju,arguments)}const Fu=/^tgt:.+/i,zu=e=>Fu.test(e);function $u(e,t){try{localStorage.setItem(e,JSON.stringify(t))}catch(e){Object.keys(localStorage).filter(zu).forEach(e=>localStorage.removeItem(e))}}function Ju(){function e(e){return"tgt:tlm:"+e}function t(e){const t=localStorage.getItem(e);let n=parseInt(t,10);return Number.isNaN(n)&&(n=-1),n}function n(e,t){localStorage.setItem(e,t)}function r(t){const n=e(t),r=localStorage.getItem(n);return localStorage.removeItem(n),r}return{addEntry:function(r){!function(t,n){$u(e(t),n)}(function(){const e=t("tgt:tlm:upper")+1;return n("tgt:tlm:upper",e),e}(),r)},getAndClearEntries:function(){return function(){const e=[],o=t("tgt:tlm:lower")||-1,i=t("tgt:tlm:upper")||-1;for(let t=i;t>o;t-=1){const n=r(t);n&&e.push(JSON.parse(n))}return n("tgt:tlm:lower",i),e}()},hasEntries:function(){const n=e(t("tgt:tlm:upper"));return!!localStorage.getItem(n)}}}return{init:function(e,t,n){if(e.adobe&&e.adobe.target&&void 0!==e.adobe.target.getOffer)return void Wt("Adobe Target has already been initialized.");ot(n);const r=it(),o=r.version;if(e.adobe.target.VERSION=o,e.adobe.target.event={LIBRARY_LOADED:"at-library-loaded",REQUEST_START:"at-request-start",REQUEST_SUCCEEDED:"at-request-succeeded",REQUEST_FAILED:"at-request-failed",CONTENT_RENDERING_START:"at-content-rendering-start",CONTENT_RENDERING_SUCCEEDED:"at-content-rendering-succeeded",CONTENT_RENDERING_FAILED:"at-content-rendering-failed",CONTENT_RENDERING_NO_OFFERS:"at-content-rendering-no-offers",CONTENT_RENDERING_REDIRECT:"at-content-rendering-redirect"},!r.enabled)return function(e){e.adobe=e.adobe||{},e.adobe.target={VERSION:"",event:{},getOffer:be,getOffers:xe,applyOffer:be,applyOffers:xe,sendNotifications:be,trackEvent:be,triggerView:be,registerExtension:be,init:be},e.mboxCreate=be,e.mboxDefine=be,e.mboxUpdate=be}(e),void Wt(Ue);e.__target_telemetry=function(){let e=!(arguments.length>0&&void 0!==arguments[0])||arguments[0],t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:he,n=arguments.length>2&&void 0!==arguments[2]?arguments[2]:Ie();function r(e){return e.edgeHost?me:ge}function o(e){const t={},n=we(e),r=Se(e),o=Ee(e),i=Te(e),c=Ce(e);return n&&(t.executePageLoad=n),r&&(t.executeMboxCount=r),o&&(t.prefetchPageLoad=o),i&&(t.prefetchMboxCount=i),c&&(t.prefetchViewCount=c),t}function i(e){const t={};return e.dns&&(t.dns=ke(e.dns)),e.tls&&(t.tls=ke(e.tls)),e.timeToFirstByte&&(t.timeToFirstByte=ke(e.timeToFirstByte)),e.download&&(t.download=ke(e.download)),e.responseSize&&(t.responseSize=ke(e.responseSize)),t}function c(e){const t={};return e.execution&&(t.execution=ke(e.execution)),e.parsing&&(t.parsing=ke(e.parsing)),e.request&&(t.request=i(e.request)),g(e,t)}function s(e){n.addEntry(c(e))}function u(t){e&&s({requestId:t.requestId,timestamp:ie()})}function a(t,n){e&&s({requestId:t,timestamp:ie(),execution:n})}function f(e,t){s(g(t,{requestId:e,timestamp:ie()}))}function l(t,n){e&&n&&f(t,n)}function d(n,i,c){let s=arguments.length>3&&void 0!==arguments[3]?arguments[3]:t;if(!e||!i)return;const{requestId:u}=n,a=g(o(n),{decisioningMethod:s}),l={mode:r(c),features:a},d=g(i,l);f(u,d)}function p(){return n.getAndClearEntries()}function h(){return n.hasEntries()}function m(e){return h()?g(e,{telemetry:{entries:p()}}):e}return{addDeliveryRequestEntry:d,addArtifactRequestEntry:l,addRenderEntry:a,addServerStateEntry:u,getAndClearEntries:p,hasEntries:h,addTelemetryToDeliveryRequest:m}}(r.telemetryEnabled&&function(){try{const e=window.localStorage,t="__storage_test__";return e.setItem(t,t),e.removeItem(t),!0}catch(e){return!1}}(),r.decisioningMethod,Ju()),Xt(Xe,it(),Ft()),or(),function(e){let t=arguments.length>1&&void 0!==arguments[1]?arguments[1]:Nt;const n=fr(e.location.search);if(v(n))return;const r=new Date(ie()+186e4),o=it(),i=o.secureOnly,c=o.cookieDomain,s=g({expires:r,secure:i,domain:c},i?{sameSite:"None"}:{});t("at_qa_mode",JSON.stringify(n),s)}(e),lr(e),su(),e.adobe.target.getOffer=vu,e.adobe.target.getOffers=bu,e.adobe.target.applyOffer=wu,e.adobe.target.applyOffers=Su,e.adobe.target.sendNotifications=Eu,e.adobe.target.trackEvent=Iu,e.adobe.target.triggerView=qu,e.adobe.target.registerExtension=Vu,e.mboxCreate=Hu,e.mboxDefine=Uu,e.mboxUpdate=Bu,function(){const e=Ln("at-library-loaded",{});Dn(Xe,Ke,"at-library-loaded",e)}()}}}(),window.adobe.target.init(window,document,{clientCode:"${clientCode}",imsOrgId:"${imsOrgId}",serverDomain:"${serverDomain}",crossDomain:"${crossDomain}",timeout:Number("${timeout}"),globalMboxName:"${globalMboxName}",version:"2.11.4",defaultContentHiddenStyle:"visibility: hidden;",defaultContentVisibleStyle:"visibility: visible;",bodyHiddenStyle:"body {opacity: 0 !important}",bodyHidingEnabled:!0,deviceIdLifetime:632448e5,sessionIdLifetime:186e4,selectorsPollingTimeout:5e3,visitorApiTimeout:2e3,overrideMboxEdgeServer:!0,overrideMboxEdgeServerTimeout:186e4,optoutEnabled:!1,optinEnabled:!1,secureOnly:!1,supplementalDataIdParamTimeout:30,authoringScriptUrl:"//cdn.tt.omtrdc.net/cdn/target-vec.js",urlSizeLimit:2048,endpoint:"/rest/v1/delivery",pageLoadEnabled:"true"===String("${globalMboxAutoCreate}"),viewsEnabled:!0,analyticsLogging:"server_side",serverState:{},decisioningMethod:"${decisioningMethod}",legacyBrowserSupport:!1,allowHighEntropyClientHints:!1,aepSandboxId:null,aepSandboxName:null,withWebGLRenderer:!0}); diff --git a/scripts/consented/newsletter.css b/scripts/consented/newsletter.css new file mode 100644 index 00000000..5f8c1f8d --- /dev/null +++ b/scripts/consented/newsletter.css @@ -0,0 +1,22 @@ +.newsletter-minimized-teaser { + position: fixed; + bottom: var(--spacing-100); + left: var(--spacing-100); + z-index: 2; + display: flex; + align-items: center; + gap: 0; + padding: var(--spacing-80) var(--spacing-100); + background: var(--color-dark-charcoal); + color: var(--color-white); + font-family: var(--body-font-family); + font-size: var(--body-size-s); + line-height: var(--line-height-m); + border-radius: var(--rounding-l); + box-shadow: 0 4px 12px var(--shadow-200); + cursor: pointer; +} + +.newsletter-minimized-teaser-text { + padding-right: var(--spacing-80); +} diff --git a/scripts/consented/newsletter.js b/scripts/consented/newsletter.js new file mode 100644 index 00000000..9b5d258a --- /dev/null +++ b/scripts/consented/newsletter.js @@ -0,0 +1,58 @@ +import { loadCSS, fetchPlaceholders } from '../aem.js'; + +await loadCSS(new URL('./newsletter.css', import.meta.url).href); + +function showMinimizedTeaser(text, newsletterLink) { + const teaser = document.createElement('div'); + teaser.className = 'newsletter-minimized-teaser'; + teaser.innerHTML = ``; + document.body.appendChild(teaser); + + const textEl = teaser.querySelector('.newsletter-minimized-teaser-text'); + + const markSignedUp = () => { + localStorage.setItem('newsletter-popped-up', 'true'); + teaser.remove(); + }; + + const attachFormSubmitListener = () => { + const form = document.querySelector('form.footer-sign-up'); + if (form && !form.dataset.newsletterTeaserListener) { + form.dataset.newsletterTeaserListener = 'true'; + form.addEventListener('submit', () => markSignedUp(), { once: true }); + return true; + } + return false; + }; + + textEl.addEventListener('click', (e) => { + e.stopPropagation(); + window.leadSourceOverride = 'minimizedmodal'; + newsletterLink.click(); + attachFormSubmitListener(); + setTimeout(() => attachFormSubmitListener(), 1000); + }); +} + +async function initNewsletterPrompt() { + if (localStorage.getItem('newsletter-signed-up') === 'true') return; + + const pathSegments = window.location.pathname.split('/').filter(Boolean); + const locale = pathSegments[0] || 'us'; + const language = pathSegments[1] || 'en_us'; + const placeholders = await fetchPlaceholders(`/${locale}/${language}`); + const minimizedText = placeholders.newsletterMinimized?.trim?.(); + + const newsletterLink = document.querySelector('a[href*="/modals/sign-up"]'); + + if (minimizedText && newsletterLink) { + showMinimizedTeaser(minimizedText, newsletterLink); + } + + if (newsletterLink && !localStorage.getItem('newsletter-signed-up') && !localStorage.getItem('newsletter-popped-up')) { + localStorage.setItem('newsletter-popped-up', 'true'); + setTimeout(() => newsletterLink.click(), 5000); + } +} + +initNewsletterPrompt(); diff --git a/scripts/minicart/cart.js b/scripts/minicart/cart.js index 9a2b9d1e..816b3228 100644 --- a/scripts/minicart/cart.js +++ b/scripts/minicart/cart.js @@ -121,10 +121,11 @@ export function getSignInToken() { export async function performMonolithGraphQLQuery(query, variables, GET = true, USE_TOKEN = false) { const GRAPHQL_ENDPOINT = `${window.location.origin}/graphql`; + const { language } = getLocaleAndLanguage(true); const headers = { 'Content-Type': 'application/json', - Store: 'en_us', + Store: language, }; if (USE_TOKEN) { @@ -238,7 +239,7 @@ export async function resolveSessionCartDrift(options) { return; } - let done = () => {}; + let done = () => { }; if (options.waitForCart) { done = waitForCart(); } @@ -262,7 +263,7 @@ export async function resolveSessionCartDrift(options) { } export function updateCartFromLocalStorage(options) { - let done = () => {}; + let done = () => { }; if (options.waitForCart) { done = waitForCart(); } @@ -310,7 +311,8 @@ function shouldUseLegacyAddToCart() { let pformKey; async function getFormKey() { if (!pformKey) { - const resp = await fetch('/us/en_us/checkout/cart/'); + const { locale, language } = getLocaleAndLanguage(); + const resp = await fetch(`/${locale}/${language}/checkout/cart/`); const txt = await resp.text(); const input = txt.match(/ 0) { - const { locale, language } = await getLocaleAndLanguage(); + const { locale, language } = getLocaleAndLanguage(); console.error('User errors while adding item to cart', userErrors); const { code } = userErrors[0]; diff --git a/scripts/scripts.js b/scripts/scripts.js index df68e7b6..f791933c 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -1,4 +1,3 @@ -import { extractPricing } from '../blocks/pdp/pricing.js'; import { loadHeader, loadFooter, @@ -39,6 +38,50 @@ async function loadFonts() { } } +/** + * Extracts pricing from a JSON-LD offer object. + * @param {Object} offer - A schema.org Offer from the JSON-LD data + * @returns {Object|null} An object containing the final and regular price. + */ +export function getOfferPricing(offer) { + if (!offer) return null; + return { + final: parseFloat(offer.price), + regular: offer.priceSpecification?.price || null, + }; +} + +/** + * Formats a price using the locale and currency from placeholders. + * Uses Intl.NumberFormat for locale-aware currency formatting. + * @param {number} value - The price value to format + * @param {Object} ph - Placeholders object containing languageCode and currencyCode + * @returns {string} The formatted price string (e.g., "$399.95" or "399,95 $") + */ +export function formatPrice(value, ph) { + const locale = (ph.languageCode || 'en_US').replace('_', '-'); + const currency = ph.currencyCode || 'USD'; + return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value); +} + +/** + * Gets the locale and language from the window.location.pathname. + * @returns {Object} Object with locale and language. + */ +export function getLocaleAndLanguage(forceEnCA = false) { + const pathSegments = window.location.pathname.split('/').filter(Boolean); + const locale = pathSegments[0] || 'us'; // fallback to 'us' if not found + const language = pathSegments[1] || 'en_us'; // fallback to 'en_us' if not found + + // Commerce backend uses the language code en_ca for the Canada english store view. + // On the frontend they are incorrectly using the en_us language code. + if (forceEnCA && locale === 'ca' && language === 'en_us') { + return { locale, language: 'en_ca' }; + } + + return { locale, language }; +} + /** * Parses `document.cookie` into key-value map. * @returns {Object} Object representing all cookies as key-value pairs @@ -59,7 +102,8 @@ function setAffiliateCoupon() { if (!cjdata || !cjevent || !COUPON) return; - const loginUrl = new URL('https://www.vitamix.com/us/en_us/checkout/cart'); + const { locale, language } = getLocaleAndLanguage(); + const loginUrl = new URL(`https://www.vitamix.com/${locale}/${language}/checkout/cart`); Object.entries({ cjdata, cjevent, COUPON }).forEach(([key, value]) => { loginUrl.searchParams.set(key, value); }); @@ -299,46 +343,6 @@ export function buildCarousel(container, pagination = true) { } function parseVariants(sections) { - return sections.map((div) => { - const name = div.querySelector('h2')?.textContent.trim(); - - const metadata = {}; - const options = {}; - const metadataDiv = div.querySelector('.section-metadata'); - - if (metadataDiv) { - metadataDiv.querySelectorAll('div').forEach((meta) => { - const [keyNode, valueNode, uidNode] = meta.children; - const key = keyNode?.textContent.trim(); - const value = valueNode?.textContent.trim(); - const uid = uidNode?.textContent.trim(); - - if (key && value) { - (key === 'sku' ? metadata : options)[key] = value; - } - - if (uid) { - options.uid = uid; - } - }); - } - - const imagesHTML = div.querySelectorAll('picture'); - - const priceHTML = div.querySelector('p:nth-of-type(1)'); - const price = extractPricing(priceHTML); - - return { - ...metadata, - name, - options, - price, - images: imagesHTML, - }; - }); -} - -function parseVariantsNext(sections) { return sections.map((div) => { const name = div.querySelector('h2')?.textContent.trim(); @@ -351,10 +355,8 @@ function parseVariantsNext(sections) { const imagesHTML = div.querySelectorAll('picture'); - const priceHTML = div.querySelector('p:nth-of-type(1)'); - const price = extractPricing(priceHTML); - const ldVariant = window.jsonLdData.offers.find((offer) => offer.sku === metadata.sku); + const price = getOfferPricing(ldVariant); if (ldVariant) { metadata.itemCondition = ldVariant.itemCondition; metadata.availability = ldVariant.availability; @@ -372,18 +374,25 @@ function parseVariantsNext(sections) { } // eslint-disable-next-line no-unused-vars -export function checkOutOfStock(sku) { +export function checkVariantOutOfStock(sku) { const { availability } = window.jsonLdData.offers.find((offer) => offer.sku === sku); return availability === 'https://schema.org/OutOfStock'; } -/** - * Checks if the current pipeline is the Next pipeline. - * @returns {boolean} True if the current pipeline is the Next pipeline, false otherwise. - */ -export function isNextPipeline() { - const pipelineMeta = document.head.querySelector('meta[name="pipeline"]')?.content; - return pipelineMeta === 'next'; +export function isProductOutOfStock() { + // Check if all variants are out of stock, if any are in stock, return false + const { offers, custom } = window.jsonLdData; + + // If the product is a bundle and parent is out of stock, return true + if (custom.type === 'bundle' && custom.parentAvailability === 'OutOfStock') { + return true; + } + + // If the product is not a bundle and no offers are available, return true + if (!offers || offers.length === 0) return true; + + // If the product is not a bundle and any offers are in stock, return false + return !offers.some((offer) => offer.availability === 'https://schema.org/InStock'); } /** @@ -394,11 +403,11 @@ function parsePDPContentSections(sections) { sections.forEach((section) => { const h3 = section.querySelector('h3')?.textContent.toLowerCase(); if (h3) { - if (h3.includes('features')) { + if (h3.includes('features') || h3.includes('caractéristiques')) { window.features = section; - } else if (h3.includes('specifications')) { + } else if (h3.includes('specifications') || h3.includes('spécifications')) { window.specifications = section; - } else if (h3.includes('warranty')) { + } else if (h3.includes('warranty') || h3.includes('garantie')) { window.warranty = section; } } @@ -413,18 +422,13 @@ function buildPDPBlock(main) { const section = document.createElement('div'); const type = document.head.querySelector('meta[name="type"]')?.content; - const nextPipeline = isNextPipeline(); const isValidType = ['simple', 'configurable', 'bundle'].includes(type); if (isValidType) { // Find LCP picture element based on pipeline structure // In both cases we try and pull the first picture from the first image in a variant section // If it's a simple product, we pull the first picture on the page - let lcpPicture; - if (nextPipeline) { - lcpPicture = main.querySelector('div.section picture') || main.querySelector('picture:first-of-type'); - } else { - lcpPicture = main.querySelector('div:nth-child(2) picture') || main.querySelector('picture:first-of-type'); - } + const lcpPicture = main.querySelector('div.section picture') || main.querySelector('picture:first-of-type'); + const lcpImage = lcpPicture?.querySelector('img'); if (lcpImage) { lcpImage.loading = 'eager'; @@ -449,27 +453,20 @@ function buildPDPBlock(main) { const jsonLd = document.head.querySelector('script[type="application/ld+json"]'); window.jsonLdData = jsonLd ? JSON.parse(jsonLd.textContent) : null; - // Select variant sections based on pipeline type - const selector = nextPipeline - ? ':scope > div.section' - : ':scope > div'; - const variantSections = Array.from(main.querySelectorAll(selector)); + const variantSections = Array.from(main.querySelectorAll(':scope > div.section')); // Parse variants using the appropriate parser - window.variants = nextPipeline - ? parseVariantsNext(variantSections) - : parseVariants(variantSections); + window.variants = parseVariants(variantSections); - if (nextPipeline) { - parsePDPContentSections(Array.from(main.querySelectorAll(':scope > div'))); - } + parsePDPContentSections(Array.from(main.querySelectorAll(':scope > div'))); + const { locale, language } = getLocaleAndLanguage(); const navMeta = document.head.querySelector('meta[name="nav"]'); if (!navMeta) { [ - ['nav', '/us/en_us/nav/nav'], - ['footer', '/us/en_us/footer/footer'], - ['nav-banners', '/us/en_us/nav/nav-banners'], + ['nav', `/${locale}/${language}/nav/nav`], + ['footer', `/${locale}/${language}/footer/footer`], + ['nav-banners', `/${locale}/${language}/nav/nav-banners`], ].forEach(([name, content]) => { const meta = document.createElement('meta'); meta.name = name; @@ -516,9 +513,54 @@ function buildAutoBlocks(main) { document.body.classList.add('pdp-template'); } - // setup cart page - if (getMetadata('template')) { - document.body.classList.add(`${getMetadata('template').toLowerCase()}-template`); + // setup articles pages + if (getMetadata('template') === 'article') { + let hero = main.querySelector('.hero'); + if (!hero) { + const picture = main.querySelector('picture'); + const h1 = main.querySelector('h1'); + if (picture && h1) { + const section = document.createElement('div'); + hero = buildBlock('hero', { elems: [picture, h1] }); + section.append(hero); + main.prepend(section); + } + } + // add article-info block after hero + if (hero) { + hero.after(buildBlock('article-info', { elems: [] })); + } + } + + // wrap recipes in block + if (document.querySelector('main') === main) { + const template = getMetadata('template'); + const recipeType = getMetadata('recipe-type'); + const totalTime = getMetadata('total-time'); + if (template === 'recipe' && (recipeType || totalTime)) { + const block = document.createElement('div'); + block.classList.add('recipe'); + block.append(...main.firstElementChild.children); + main.firstElementChild.append(block); + // eslint-disable-next-line no-use-before-define + const { locale, language } = getLocaleAndLanguage(); + const footerPath = new URL(`/${locale}/${language}/recipes/fragments/footer`, window.location.origin); + const footerLink = document.createElement('a'); + footerLink.href = footerPath.pathname; + footerLink.textContent = footerPath.pathname; + const footer = buildBlock('fragment', footerLink); + main.firstElementChild.append(footer); + } + } + + // setup toc + const tocRef = getMetadata('toc'); + if (tocRef && (tocRef !== 'none') && !document.querySelector('.toc')) { + const toc = buildBlock('toc', [[`${tocRef}`]]); + const section = document.createElement('div'); + section.classList.add('section'); + section.append(toc); + main.prepend(section); } } catch (error) { // eslint-disable-next-line no-console @@ -540,8 +582,10 @@ export function buildVideo(el) { const video = document.createElement('video'); video.loop = true; video.muted = true; + video.autoplay = true; + video.playsInline = true; + video.setAttribute('autoplay', ''); video.setAttribute('muted', ''); - video.setAttribute('playsinline', ''); video.setAttribute('preload', 'none'); // create source element const source = document.createElement('source'); @@ -553,9 +597,15 @@ export function buildVideo(el) { entries.forEach((entry) => { if (entry.isIntersecting && !source.dataset.loaded) { source.src = source.dataset.src; - video.autoplay = true; video.load(); - video.addEventListener('canplay', () => video.play()); + // handle play promise to catch autoplay blocks + const playPromise = video.play(); + if (playPromise !== undefined) { + playPromise.catch((error) => { + // eslint-disable-next-line no-console + console.log('video autoplay prevented:', error); + }); + } source.dataset.loaded = true; observer.disconnect(); } @@ -580,6 +630,7 @@ function decorateFullWidthBlocks(main) { */ function decorateButtons(main) { main.querySelectorAll('p a[href]').forEach((a) => { + if (a.closest('[data-button-decoration="disabled"]')) return; a.title = a.title || a.textContent; const p = a.closest('p'); const text = a.textContent.trim(); @@ -644,6 +695,10 @@ function decorateEyebrows(main) { // ignore p tags with images or links const disqualifiers = beforeH.querySelector('img, a[href]'); if (disqualifiers) return; + // ignore really long p tags + const words = beforeH.textContent.trim().split(' '); + if (words.length > 12) return; + beforeH.classList.add('eyebrow'); h.dataset.eyebrow = beforeH.textContent.trim(); } @@ -694,20 +749,14 @@ function decorateSectionBackgrounds(main) { }); main.querySelectorAll('.section.light, .section.dark').forEach((section) => { - /** - * Sets the collapse data attribute on a section element. - * @param {Element} el - The section element to set collapse on. - * @param {string} position - 'top' or 'bottom'. - */ - const setCollapse = (el, position) => { - const existing = el?.dataset?.collapse; - if (existing === (position === 'top' ? 'bottom' : 'top')) { - el.dataset.collapse = 'both'; - } else if (!existing) el.dataset.collapse = position; - }; - - setCollapse(section.previousElementSibling, 'bottom'); - setCollapse(section.nextElementSibling, 'top'); + const prev = section.previousElementSibling; + const next = section.nextElementSibling; + if (prev) { + prev.dataset.collapse = prev.dataset.collapse === 'top' ? 'both' : 'bottom'; + } + if (next) { + next.dataset.collapse = next.dataset.collapse === 'bottom' ? 'both' : 'top'; + } }); } @@ -746,26 +795,17 @@ function autolinkModals(doc) { } async function decorateFragmentPreviews() { - const params = new URLSearchParams(window.location.search); - const fragmentPath = params.get('reloadFragment'); - if (fragmentPath && fragmentPath.length < 200) { - const isValid = /^[a-zA-Z0-9-_/]+$/.test(fragmentPath); - if (!isValid) return; - const url = new URL(fragmentPath, window.location); - const { pathname } = url; - const resp = await fetch(`${pathname}.plain.html`, { - cache: 'reload', - }); - await resp.text(); - } const path = window.location.pathname; - if (path.includes('/nav/') || path.includes('/footer/')) { - if (window.location.search.includes('dapreview=on')) { - document.body.classList.add('fragment-preview'); + if (path.includes('/nav/')) { + if (path.endsWith('/nav/nav') || path.endsWith('/nav/products')) { + document.body.classList.add('fragment-preview-nav'); } else { - window.location.href = `/us/en_us/why-vitamix?reloadFragment=${path}`; + document.body.classList.add('fragment-preview'); } } + if (path.includes('/footer/')) { + document.body.classList.add('fragment-preview-footer'); + } } /** @@ -833,85 +873,146 @@ export function applyImgColor(block) { } /** - * Parses alert banner rows from a block element and returns an array of banner objects. - * @param {HTMLElement} block - The DOM element containing alert banner rows as children. - * @returns {Array} Array of parsed banner objects with properties: + * Determines if a given date falls within US Eastern Daylight Saving Time. + * DST starts: 2nd Sunday of March at 2:00 AM + * DST ends: 1st Sunday of November at 2:00 AM + * @param {number} year - The year + * @param {number} month - The month (1-12) + * @param {number} day - The day of month + * @returns {boolean} True if the date is in DST (EDT), false if in standard time (EST) */ -export function parseAlertBanners(block) { - // Timezone offset lookup table (offsets from UTC in hours) - const convertToISODate = (date, time) => { - const TIMEZONE_OFFSETS = { - // Eastern Time - EST: -5, // Eastern Standard Time - EDT: -4, // Eastern Daylight Time - // Central Time - CST: -6, // Central Standard Time - CDT: -5, // Central Daylight Time - // Mountain Time - MST: -7, // Mountain Standard Time - MDT: -6, // Mountain Daylight Time - // Pacific Time - PST: -8, // Pacific Standard Time - PDT: -7, // Pacific Daylight Time - // Other common timezones - UTC: 0, // Coordinated Universal Time - GMT: 0, // Greenwich Mean Time - }; +function isEasternDST(year, month, day) { + // DST doesn't apply in Jan, Feb, or Dec + if (month < 3 || month > 11) return false; + // DST always applies Apr-Oct + if (month > 3 && month < 11) return true; + + // For March: DST starts 2nd Sunday at 2am + if (month === 3) { + // Find the 2nd Sunday of March + const firstOfMonth = new Date(year, 2, 1); // March 1st (month is 0-indexed) + const firstSunday = 1 + ((7 - firstOfMonth.getDay()) % 7); + const secondSunday = firstSunday + 7; + // On or after the second Sunday means DST + return day >= secondSunday; + } - // Parse date as month/day format - const [month, day] = date.split('/'); - const year = new Date().getFullYear(); + // For November: DST ends 1st Sunday at 2am + if (month === 11) { + // Find the 1st Sunday of November + const firstOfMonth = new Date(year, 10, 1); // November 1st (month is 0-indexed) + const firstSunday = 1 + ((7 - firstOfMonth.getDay()) % 7); + // Before the first Sunday means still in DST + return day < firstSunday; + } - // Extract time and timezone from strings like "12am EDT", "11:59pm EST", "2:30pm", etc. - const timeMatch = time.match(/(\d{1,2})(?::(\d{2}))?\s*(am|pm)(?:\s+([A-Z]{2,4}))?/i); - if (!timeMatch) { - throw new Error(`Invalid time format: ${time}`); - } + return false; +} - let hours = parseInt(timeMatch[1], 10); - const minutes = timeMatch[2] || '00'; - const ampm = timeMatch[3].toLowerCase(); - const timezone = timeMatch[4] || 'UTC'; // Default to UTC if no timezone specified +/** + * Parses a datetime string and returns a Date object. + * Supports formats like "9/12/2025 9am", "9/19/2025 3pm", or "9/12/2025 9:30am" + * Defaults to Eastern time (EST/EDT based on daylight savings) when no timezone specified. + * @param {string} dateStr - The datetime string to parse (e.g., "6/23/2025 9am") + * @returns {Date} The parsed Date object + * @throws {Error} If the datetime format is invalid + */ +export function parseEasternDateTime(dateStr) { + if (!dateStr) { + throw new Error('DateTime string is required'); + } - // Convert 12-hour to 24-hour format - if (ampm === 'am' && hours === 12) { - hours = 0; // 12am = 00:00 - } else if (ampm === 'pm' && hours !== 12) { - hours += 12; // 1pm-11pm = 13:00-23:00, 12pm stays 12:00 - } + // Handle formats like "9/12/2025 9am" or "9/12/2025 9:30am" + // Timezone is optional, defaults to Eastern (EST/EDT) + const regex = /^(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2})(?::(\d{2}))?(am|pm)(?:\s+([A-Z]{3,4}))?$/i; + const match = dateStr.trim().match(regex); + + if (!match) { + throw new Error(`Invalid datetime format: ${dateStr}. Expected format: M/D/YYYY HHam/pm`); + } + + const month = parseInt(match[1], 10); + const day = parseInt(match[2], 10); + const year = parseInt(match[3], 10); + let hours = parseInt(match[4], 10); + const minutesValue = match[5] ? parseInt(match[5], 10) : 0; + const ampm = match[6].toLowerCase(); + const timezone = match[7]; + + // Convert 12-hour to 24-hour format + if (ampm === 'am' && hours === 12) { + hours = 0; // 12am = 00:00 + } else if (ampm === 'pm' && hours !== 12) { + hours += 12; // 1pm-11pm = 13:00-23:00, 12pm stays 12:00 + } - // Get timezone offset - const timezoneOffset = TIMEZONE_OFFSETS[timezone.toUpperCase()]; - if (timezoneOffset === undefined) { + // Timezone offsets from UTC + const timezoneOffsets = { + EDT: -4, + EST: -5, + CDT: -5, + CST: -6, + MDT: -6, + MST: -7, + PDT: -7, + PST: -8, + }; + + let offsetHours; + if (timezone) { + // Use explicitly specified timezone + offsetHours = timezoneOffsets[timezone.toUpperCase()]; + if (offsetHours === undefined) { throw new Error(`Unsupported timezone: ${timezone}`); } + } else { + // Default to Eastern time - auto-detect EST/EDT based on date + const isDST = isEasternDST(year, month, day); + offsetHours = isDST ? -4 : -5; // EDT = -4, EST = -5 + } + + // Convert the local time to UTC by subtracting the offset + // If EDT is UTC-4, then 9am EDT = 1pm UTC (9 - (-4) = 13) + const utcHour = hours - offsetHours; - // Pad with zeros - const paddedMonth = month.padStart(2, '0'); - const paddedDay = day.padStart(2, '0'); - const paddedHours = hours.toString().padStart(2, '0'); - const paddedMinutes = minutes.padStart(2, '0'); + // Create UTC date object + return new Date(Date.UTC(year, month - 1, day, utcHour, minutesValue, 0)); +} - // Return ISO string with timezone offset - if (timezoneOffset === 0) { - return `${year}-${paddedMonth}-${paddedDay}T${paddedHours}:${paddedMinutes}:00Z`; +/** + * Parses alert banner rows from a block element and returns an array of banner objects. + * Expected row format: + * Column 1: Start datetime (e.g., "6/23/2025 9am") + * Column 2: End datetime (e.g., "12/31/2025 11:59pm") + * Column 3: Content + * Column 4: Color + * Defaults to Eastern time (EST/EDT based on whether DST is in effect for that date). + * @param {HTMLElement} block - The DOM element containing alert banner rows as children. + * @returns {Array} Array of parsed banner objects with properties: + */ +export function parseAlertBanners(block) { + /** + * Parses a datetime string, returning null for empty values (open-ended). + * @param {string} dateStr - The datetime string to parse + * @returns {Date|null} The parsed Date or null if empty + */ + const parseDateOrNull = (dateStr) => { + const trimmed = dateStr?.trim(); + if (!trimmed) { + return null; // Empty = open-ended } - const offsetSign = timezoneOffset >= 0 ? '+' : '-'; - const offsetHours = Math.abs(timezoneOffset).toString().padStart(2, '0'); - return `${year}-${paddedMonth}-${paddedDay}T${paddedHours}:${paddedMinutes}:00${offsetSign}${offsetHours}:00`; + return parseEasternDateTime(trimmed); }; const rows = [...block.children]; const banners = rows.map((row) => { - const [dates, times, content, colorEl] = [...row.children]; + const [startEl, endEl, content, colorEl] = [...row.children]; const color = colorEl.textContent.trim(); try { - const [startDate, endDate] = dates.textContent.split('-'); - const [startTime, endTime] = times.textContent.split('-'); return ({ valid: true, - start: new Date(convertToISODate(startDate, startTime)), - end: new Date(convertToISODate(endDate, endTime)), + start: parseDateOrNull(startEl.textContent), + end: parseDateOrNull(endEl.textContent), content, color, }); @@ -931,16 +1032,21 @@ export function parseAlertBanners(block) { /** * Determines whether the current date is before, during, or after the given start/end range. - * @param {Date} start - The start date/time of the range. - * @param {Date} end - The end date/time of the range. + * Null start means open-ended in the past (always started). + * Null end means open-ended in the future (never expires). + * @param {Date|null} start - The start date/time of the range (null = open-ended past). + * @param {Date|null} end - The end date/time of the range (null = open-ended future). * @param {Date} [date=new Date()] - The reference date/time to compare (defaults to now). * @returns {string} String indicating the status relative to the range. */ export function currentPastFuture(start, end, date = new Date()) { - if (start <= date && end >= date) { + const afterStart = !start || start <= date; + const beforeEnd = !end || end >= date; + + if (afterStart && beforeEnd) { return 'current'; } - if (start > date) { + if (start && start > date) { return 'future'; } return 'past'; @@ -964,17 +1070,6 @@ export function findBestAlertBanner(banners, date = new Date()) { return bestBanner; } -/** - * Gets the locale and language from the window.location.pathname. - * @returns {Object} Object with locale and language. - */ -export async function getLocaleAndLanguage() { - const pathSegments = window.location.pathname.split('/').filter(Boolean); - const locale = pathSegments[0] || 'us'; // fallback to 'us' if not found - const language = pathSegments[1] || 'en_us'; // fallback to 'en_us' if not found - return { locale, language }; -} - /** * Loads and prepends nav banner. * @param {HTMLElement} main - Main element @@ -1017,6 +1112,35 @@ async function loadNavBanner(main) { } } +/** + * Simulates a PDP preview on localhost, aem.page and aem.live. + */ + +async function simulatePDPPreview() { + const corsProxyFetch = async (url) => { + const corsProxy = 'https://fcors.org/?url='; + const corsKey = '&key=Mg23N96GgR8O3NjU'; + const fullUrl = `https://main--vitamix--aemsites.aem.network${url}`; + return fetch(`${corsProxy}${encodeURIComponent(fullUrl)}${corsKey}`); + }; + const { pathname } = new URL(window.location.href); + const resp = await corsProxyFetch(pathname); + const html = await resp.text(); + const dom = new DOMParser().parseFromString(html, 'text/html'); + const stashedMain = document.querySelector('main'); + const mainProductInfoDivs = dom.querySelectorAll('main div'); + const lastDiv = mainProductInfoDivs[mainProductInfoDivs.length - 1]; + lastDiv.after(...stashedMain.children); + dom.querySelector('main').querySelectorAll('img[src^="./media_"]').forEach((el) => { + el.setAttribute('src', el.getAttribute('src').replace('./media_', 'https://main--vitamix--aemsites.aem.network/us/en_us/products/media_')); + }); + dom.querySelector('main').querySelectorAll('source[srcset^="./media_"]').forEach((el) => { + el.setAttribute('srcset', el.getAttribute('srcset').replace('./media_', 'https://main--vitamix--aemsites.aem.network/us/en_us/products/media_')); + }); + document.body.innerHTML = dom.body.innerHTML; + document.head.innerHTML = dom.head.innerHTML; +} + /** * Loads everything needed to get to LCP. * @param {Element} doc The container element @@ -1026,11 +1150,27 @@ async function loadEager(doc) { const language = locale ? locale.split('_')[0] : 'en'; document.documentElement.lang = language; + /* simulation date */ const params = new URLSearchParams(window.location.search); if (params.get('simulateDate')) { window.simulateDate = params.get('simulateDate'); } + /* query param based redirects: comma-separated pairs of + * =: (e.g. "product=123:/us/en_us/products/123") + */ + const paramRedirects = getMetadata('param-redirects'); + if (paramRedirects) { + paramRedirects.split(',').forEach((row) => { + const i = row.indexOf(':'); + if (i === -1) return; + const matchParam = row.slice(0, i).trim(); + const pathname = row.slice(i + 1).trim(); + if (window.location.search.includes(matchParam)) window.location.pathname = pathname; + }); + } + + /* adjust shop images to locale root path, util all of shop is mapped */ if (window.location.pathname.includes('/shop/')) { const images = doc.querySelectorAll('img[src^="./media_"]'); images.forEach((img) => { @@ -1042,6 +1182,18 @@ async function loadEager(doc) { }); } + /* pdp simulation on localhost, aem.page and aem.live */ + const isProd = window.location.hostname.includes('vitamix.com') || window.location.hostname.includes('.aem.network'); + if (!isProd && window.location.pathname.includes('/products/')) { + const metaSku = document.querySelector('meta[name="sku"]'); + const pdpBlock = document.querySelector('.pdp'); + if (!metaSku && !pdpBlock) { + /* eslint-disable-next-line no-console */ + console.log('PDP simulation on localhost, aem.page and aem.live'); + await simulatePDPPreview(); + } + } + decorateTemplateAndTheme(); const main = doc.querySelector('main'); @@ -1049,7 +1201,10 @@ async function loadEager(doc) { decorateMain(main); await loadNavBanner(main); document.body.classList.add('appear'); - await loadSection(main.querySelector('.section'), waitForFirstImage); + await loadSection(main.querySelector('.section'), (section) => { + if (document.body.classList.contains('quick-edit')) return Promise.resolve(); + return waitForFirstImage(section); + }); } sampleRUM.enhance(); @@ -1090,19 +1245,39 @@ async function loadLazy(doc) { await openSyncModal(); }; + const loadQuickEdit = async (...args) => { + // eslint-disable-next-line import/no-cycle + const { default: initQuickEdit } = await import('../tools/quick-edit/quick-edit.js'); + initQuickEdit(...args); + }; + + const addSidekickListeners = (sk) => { + sk.addEventListener('custom:sync', syncSku); + sk.addEventListener('custom:quick-edit', loadQuickEdit); + }; + const sk = document.querySelector('aem-sidekick'); if (sk) { - sk.addEventListener('custom:sync', syncSku); + addSidekickListeners(sk); } else { // wait for sidekick to be loaded document.addEventListener('sidekick-ready', () => { - // sidekick now loaded - document.querySelector('aem-sidekick') - .addEventListener('custom:sync', syncSku); + // sidekick now loaded + addSidekickListeners(document.querySelector('aem-sidekick')); }, { once: true }); } } +function decorateExternalLinks() { + const externalLinks = document.querySelectorAll('a[href^="https://"]'); + externalLinks.forEach((link) => { + const linkHostname = new URL(link.href).hostname; + if (!link.href.includes('vitamix') || linkHostname === 'localhost') { + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener'); + } + }); +} /** * Loads everything that happens a lot later, * without impacting the user experience. @@ -1136,12 +1311,23 @@ async function loadDelayed() { }); } } + + try { + if (window.location.origin.endsWith('.aem.page') || window.location.origin === 'http://localhost:3000') { + import('../tools/linkchecker/linkchecker.js'); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error loading link checker', e); + } + + setTimeout(decorateExternalLinks, 1000); } /** * Loads the page in eager, lazy, and delayed phases. */ -async function loadPage() { +export async function loadPage() { await loadEager(document); await loadLazy(document); loadDelayed(); diff --git a/scripts/storage/README.md b/scripts/storage/README.md index 0fce83c7..718d5438 100644 --- a/scripts/storage/README.md +++ b/scripts/storage/README.md @@ -8,4 +8,39 @@ tracking the cache timeout in `mage-cache-timeout`, etc. This is also where the actual contact with Magento is happening to refresh the local storage cache and store the information coming directly from Magento. -**Note: this requires that the endpoint `/customer/section/load` is routed to Magento.** \ No newline at end of file +**Note: this requires that the endpoint `/customer/section/load` is routed to Magento.** + +## Store-Specific Cache Keys + +To support multiple store views (US, Canada, Mexico, etc.) on the same domain, localStorage keys are +store-specific to prevent cart data conflicts between different stores. + +### Key Naming Convention + +- **US Store (`/us/en_us/`)**: Uses original key names without suffix for backward compatibility + - `mage-cache-storage` + - `mage-cache-timeout` + - `mage-cache-storage-section-invalidation` + +- **Other Stores**: Use store-specific suffixes in the format `-{locale}-{language}` + - Example for French Canadian (`/ca/fr_ca/`): + - `mage-cache-storage-ca-fr_ca` + - `mage-cache-timeout-ca-fr_ca` + - `mage-cache-storage-section-invalidation-ca-fr_ca` + +### Benefits + +- **Cart Isolation**: Each store view maintains its own cart, preventing conflicts when users switch between stores +- **Backward Compatibility**: Existing US customer carts are preserved (no migration required) +- **Multi-Store Support**: Users can have active carts in multiple stores simultaneously + +### Helper Functions + +The following helper functions generate the appropriate keys based on the current store: + +- `getStoreSuffix()` - Returns the store-specific suffix or empty string for US +- `getCacheStorageKey()` - Returns the appropriate `mage-cache-storage` key +- `getCacheTimeoutKey()` - Returns the appropriate `mage-cache-timeout` key +- `getCacheInvalidationKey()` - Returns the appropriate invalidation key + +All localStorage operations use these functions to ensure consistent key generation across the application. \ No newline at end of file diff --git a/scripts/storage/util.js b/scripts/storage/util.js index bdab6e9b..9a5fb958 100644 --- a/scripts/storage/util.js +++ b/scripts/storage/util.js @@ -1,8 +1,44 @@ -const COMMERCE_CACHE_TIMEOUT_KEY = 'mage-cache-timeout'; -const COMMERCE_CACHE_STORAGE_KEY = 'mage-cache-storage'; -const COMMERCE_CACHE_INVALIDATION_KEY = 'mage-cache-storage-section-invalidation'; +import { getLocaleAndLanguage } from '../scripts.js'; + const COMMERCE_CACHE_SESSION_COOKIE = 'mage-cache-sessid'; +/** + * Gets the store-specific suffix for localStorage keys. + * Returns empty string for US store to preserve existing carts. + * @returns {string} Store suffix (e.g., '-ca-fr_ca') or empty string for US + */ +function getStoreSuffix() { + const { locale, language } = getLocaleAndLanguage(); + if (locale === 'us' && language === 'en_us') { + return ''; + } + return `-${locale}-${language}`; +} + +/** + * Gets the store-specific cache storage key. + * @returns {string} The localStorage key for mage-cache-storage + */ +function getCacheStorageKey() { + return `mage-cache-storage${getStoreSuffix()}`; +} + +/** + * Gets the store-specific cache timeout key. + * @returns {string} The localStorage key for mage-cache-timeout + */ +function getCacheTimeoutKey() { + return `mage-cache-timeout${getStoreSuffix()}`; +} + +/** + * Gets the store-specific cache invalidation key. + * @returns {string} The localStorage key for mage-cache-storage-section-invalidation + */ +function getCacheInvalidationKey() { + return `mage-cache-storage-section-invalidation${getStoreSuffix()}`; +} + /** * Use the Magento systems cache timeout to determine if it's safe to use the * local storage cache or not. @@ -10,7 +46,7 @@ const COMMERCE_CACHE_SESSION_COOKIE = 'mage-cache-sessid'; * @returns {boolean} true if local storage is expired */ export function isMagentoLocalStorageExpired() { - const localMageCacheTimeout = localStorage.getItem(COMMERCE_CACHE_TIMEOUT_KEY); + const localMageCacheTimeout = localStorage.getItem(getCacheTimeoutKey()); // This cookie will expire around when when the Magento PHP session cookie expires // see: vendor/magento/module-customer/view/frontend/web/js/customer-data.js:48 @@ -44,7 +80,8 @@ export function isMagentoLocalStorageExpired() { * @returns {void} */ export function addMagentoCacheInvalidations(sectionsToAdd) { - let invalidations = localStorage.getItem(COMMERCE_CACHE_INVALIDATION_KEY); + const cacheInvalidationKey = getCacheInvalidationKey(); + let invalidations = localStorage.getItem(cacheInvalidationKey); try { invalidations = JSON.parse(invalidations); @@ -57,7 +94,7 @@ export function addMagentoCacheInvalidations(sectionsToAdd) { return; } - localStorage.setItem(COMMERCE_CACHE_INVALIDATION_KEY, JSON.stringify(invalidations)); + localStorage.setItem(cacheInvalidationKey, JSON.stringify(invalidations)); } /** @@ -68,7 +105,8 @@ export function addMagentoCacheInvalidations(sectionsToAdd) { */ function removeMagentoCacheInvalidations(invalidatedSections) { // update invalidation to remove the cart and customer - let invalidations = localStorage.getItem(COMMERCE_CACHE_INVALIDATION_KEY); + const cacheInvalidationKey = getCacheInvalidationKey(); + let invalidations = localStorage.getItem(cacheInvalidationKey); try { invalidations = JSON.parse(invalidations); @@ -83,7 +121,7 @@ function removeMagentoCacheInvalidations(invalidatedSections) { } return true; })); - localStorage.setItem(COMMERCE_CACHE_INVALIDATION_KEY, JSON.stringify(result)); + localStorage.setItem(cacheInvalidationKey, JSON.stringify(result)); } /** @@ -95,7 +133,7 @@ function removeMagentoCacheInvalidations(invalidatedSections) { * @returns {boolean} true if local storage is expired */ export function isMagentoCacheInvalidated(sections) { - const localMageCacheInvalidations = localStorage.getItem(COMMERCE_CACHE_INVALIDATION_KEY); + const localMageCacheInvalidations = localStorage.getItem(getCacheInvalidationKey()); if (!localMageCacheInvalidations) { return false; @@ -121,7 +159,7 @@ export function isMagentoCacheInvalidated(sections) { * @returns {*} object representing the current Magento cache */ export function getMagentoCache() { - const magentoCache = localStorage.getItem(COMMERCE_CACHE_STORAGE_KEY); + const magentoCache = localStorage.getItem(getCacheStorageKey()); if (!magentoCache || isMagentoLocalStorageExpired()) { return {}; } @@ -152,9 +190,9 @@ export function getLoggedInFromLocalStorage() { * @returns {boolean} true if no commerce state is present */ export function isCommerceStatePristine() { - return !localStorage.getItem(COMMERCE_CACHE_INVALIDATION_KEY) - && !localStorage.getItem(COMMERCE_CACHE_STORAGE_KEY) - && !localStorage.getItem(COMMERCE_CACHE_TIMEOUT_KEY) + return !localStorage.getItem(getCacheInvalidationKey()) + && !localStorage.getItem(getCacheStorageKey()) + && !localStorage.getItem(getCacheTimeoutKey()) && (document.cookie.indexOf(`${COMMERCE_CACHE_SESSION_COOKIE}`) === -1); } @@ -165,15 +203,15 @@ export function isCommerceStatePristine() { * @return {void} */ export async function updateMagentoCacheSections(sections) { + const { locale, language } = getLocaleAndLanguage(); + const cacheStorageKey = getCacheStorageKey(); + const cacheTimeoutKey = getCacheTimeoutKey(); + let result = {}; let updatedSections = null; try { const loginAbortController = new AbortController(); - const pathSegments = window.location.pathname.split('/').filter(Boolean); - const locale = pathSegments[0] || 'us'; // fallback to 'us' if not found - const language = pathSegments[1] || 'en_us'; // fallback to 'en_us' if not found - setTimeout(() => loginAbortController.abort('Section data took too long to respond.'), 10000); result = await fetch(`/${locale}/${language}/customer/section/load/?sections=${encodeURIComponent(sections.join(','))}&force_new_section_timestamp=false`, { signal: loginAbortController.signal, @@ -201,7 +239,7 @@ export async function updateMagentoCacheSections(sections) { // to the lifetime of the PHPSESSID but it's unknown here. This could be improved. const minutesToTimeout = 25; document.cookie = `${COMMERCE_CACHE_SESSION_COOKIE}=true; path=/; expires=${(new Date(new Date().getTime() + minutesToTimeout * 60000)).toUTCString()}; SameSite=Lax; ${window.location.protocol === 'http:' ? '' : 'Secure'}`; - let magentoCache = localStorage.getItem(COMMERCE_CACHE_STORAGE_KEY); + let magentoCache = localStorage.getItem(cacheStorageKey); try { magentoCache = JSON.parse(magentoCache) || {}; @@ -217,12 +255,12 @@ export async function updateMagentoCacheSections(sections) { removeMagentoCacheInvalidations(sections); } const minutesToExpire = 30; - localStorage.setItem(COMMERCE_CACHE_STORAGE_KEY, JSON.stringify(magentoCache)); + localStorage.setItem(cacheStorageKey, JSON.stringify(magentoCache)); localStorage.setItem( - COMMERCE_CACHE_TIMEOUT_KEY, + cacheTimeoutKey, JSON.stringify((new Date(new Date().getTime() + minutesToExpire * 60000)).toISOString()), ); - window.dispatchEvent(new StorageEvent('storage', { key: COMMERCE_CACHE_STORAGE_KEY })); + window.dispatchEvent(new StorageEvent('storage', { key: cacheStorageKey })); } /** @@ -232,8 +270,9 @@ export async function updateMagentoCacheSections(sections) { * @param {function} callback the method to be called with the results of the event */ export function addMagentoCacheListener(callback) { + const cacheStorageKey = getCacheStorageKey(); window.addEventListener('storage', (event) => { - if (event.key === COMMERCE_CACHE_STORAGE_KEY) { + if (event.key === cacheStorageKey) { callback(event); } }); diff --git a/styles/containers.css b/styles/containers.css index 01c7674b..f7ebd99d 100644 --- a/styles/containers.css +++ b/styles/containers.css @@ -23,6 +23,7 @@ --spacing-600: 40px; --spacing-700: 48px; --spacing-800: 64px; + --spacing-850: 112px; --spacing-900: 160px; /* icons */ diff --git a/styles/styles.css b/styles/styles.css index 60af03d8..6b2dfbbc 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -107,9 +107,15 @@ body.cart-template { box-sizing: border-box; } - html { scroll-behavior: smooth; + scroll-padding-top: calc(var(--header-height) + 56px); +} + +@media (width >= 800px) { + html { + scroll-padding-top: calc(var(--header-height) + 83px); + } } body { @@ -160,22 +166,21 @@ main > .section { scroll-margin: 100px 0; } +main > .section:first-of-type[class*='-container'] { + margin-top: 0; +} + main > .section > div { margin: var(--horizontal-spacing) auto; padding: 0 var(--horizontal-spacing); } -main > .section > div.full-width { - padding: 0; -} - -/* page theme */ -.text-center main .default-content-wrapper { - text-align: center; +main > .section:first-of-type[class*='-container'] > div:first-child:not(.default-content-wrapper) { + margin-top: 0; } -.text-center main .default-content-wrapper .button-wrapper { - justify-content: center; +main > .section > div.full-width { + padding: 0; } /* section templates */ @@ -278,6 +283,7 @@ main .section.banner.overlay.dark::before { opacity: 0.6; } +/* stylelint-disable no-descending-specificity */ main > .section.banner > div { padding: 0; } @@ -300,6 +306,8 @@ main .section.banner > div { max-width: 70vw; } +/* stylelint-enable no-descending-specificity */ + @media (width >= 800px) { main .section.banner > div { max-width: 500px; @@ -417,6 +425,11 @@ h6 { margin-top: 0; } +/* Superscript styling for trademark symbols */ +h1 sup { + font-size: 0.4em; +} + strong { font-weight: var(--weight-medium); } @@ -675,6 +688,47 @@ input[type='radio'] { width: unset; } +/* page template */ +.article main { + color: var(--color-gray-900); + font-size: var(--body-size-l); +} + +.article main .default-content-wrapper { + max-width: 800px; +} + +.article main .default-content-wrapper h1, +.article main .default-content-wrapper h2, +.article main .default-content-wrapper h3, +.article main .default-content-wrapper h4, +.article main .default-content-wrapper h5, +.article main .default-content-wrapper h6 { + color: var(--color-charcoal); + font-family: var(--body-font-family); +} + +.article main .default-content-wrapper h2 { + font-size: var(--body-size-xxxl); +} + +.article main .default-content-wrapper h3 { + font-size: var(--body-size-xxl); +} + +.article main .default-content-wrapper p { + line-height: var(--line-height-xl); +} + +/* page theme */ +.text-center main .default-content-wrapper { + text-align: center; +} + +.text-center main .default-content-wrapper .button-wrapper { + justify-content: center; +} + /* shared carousel functionality */ .carousel { position: relative; @@ -785,6 +839,19 @@ input[type='radio'] { padding: 0; } + +body.fragment-preview-footer > header, +body.fragment-preview-footer > main { + display: none; +} + + +body.fragment-preview-nav > footer, +body.fragment-preview-nav > main { + display: none; +} + + body.fragment-preview > header, body.fragment-preview > footer, body.fragment-preview > main > aside.nav-banner @@ -792,14 +859,14 @@ body.fragment-preview > main > aside.nav-banner display: none; } -body.fragment-preview { +body.fragment-preview, body.fragment-preview-nav, body.fragment-preview-footer { margin: 32px; padding: 32px; border: 1px solid var(--color-gray-900); border-radius: var(--rounding-l); } -body.fragment-preview::before { +body.fragment-preview::before, body.fragment-preview-nav::before, body.fragment-preview-footer::before { content: 'Fragment Preview (to see in context, navigate to containing page)'; display: block; margin-bottom: 16px; @@ -817,3 +884,18 @@ body.hide-consent-banner-for-us #CybotCookiebotDialog { #CybotCookiebotDialogNav ul li:has(#CybotCookiebotDialogNavAbout) { display: none; } + +/* corp template */ + +body.corp main .default-content-wrapper { + --heading-font-family: var(--sans-serif-font-family); + --body-font-family: var(--sans-serif-font-family); +} + +body.corp main .default-content-wrapper h1 { + font-size: var(--font-size-700); +} + +body.corp main .default-content-wrapper h2 { + font-size: var(--font-size-500); +} diff --git a/tests/cart/storage.spec.js b/tests/cart/storage.spec.js new file mode 100644 index 00000000..6b1dd087 --- /dev/null +++ b/tests/cart/storage.spec.js @@ -0,0 +1,428 @@ +/* eslint-disable no-console */ +import { test, expect } from '@playwright/test'; +import { + getCurrentBranch, + getBaseUrl, +} from '../utils/test-helpers.js'; + +/** + * Tests for cart storage isolation across store views + * Verifies that: + * 1. US store uses original 'mage-cache-storage' key + * 2. Non-US stores use store-specific keys like 'mage-cache-storage-ca-fr_ca' + * 3. Carts are isolated between store views + */ + +test.describe('Cart Storage Tests', () => { + let currentBranch; + let baseUrl; + + test.beforeAll(async () => { + currentBranch = await getCurrentBranch(); + baseUrl = getBaseUrl(currentBranch); + console.log(`Running tests against branch: ${currentBranch}`); + console.log(`Base URL: ${baseUrl}`); + }); + + test.describe('US Store (/us/en_us/)', () => { + const storePath = '/us/en_us'; + + test('should use original mage-cache-storage key', async ({ page }) => { + await page.goto(`${baseUrl}${storePath}/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + // Trigger a cart interaction to populate localStorage + // We'll mock the section load response + await page.route('**/customer/section/load/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + cart: { items: [], summary_count: 0 }, + customer: {}, + 'side-by-side': { cart_id: 'us-test-cart-123' }, + }), + }); + }); + + // Evaluate in page context to check localStorage + const storageKeys = await page.evaluate(() => + // Trigger the storage by accessing getMagentoCache indirectly + // eslint-disable-next-line implicit-arrow-linebreak + Object.keys(localStorage).filter((key) => key.includes('mage-cache'))); + + console.log('US Store localStorage keys:', storageKeys); + + // For US store, we should NOT see store-specific keys + const hasUSSpecificKey = storageKeys.some((key) => key.includes('-us-en_us')); + expect(hasUSSpecificKey).toBe(false); + + console.log('✓ US store uses original mage-cache-storage key (no suffix)'); + }); + + test('should store cart data in mage-cache-storage', async ({ page }) => { + // Mock the GraphQL and section load responses + await page.route('**/customer/section/load/**', async (route) => { + const url = new URL(route.request().url()); + expect(url.pathname).toContain('/us/en_us/customer/section/load'); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + cart: { items: [], summary_count: 0, data_id: 12345 }, + customer: { data_id: 12345 }, + 'side-by-side': { cart_id: 'us-cart-abc123', data_id: 12345 }, + }), + }); + }); + + await page.route('**/graphql', async (route) => { + const headers = route.request().headers(); + // Verify Store header is correct for US + expect(headers.store).toBe('en_us'); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + addProductsToCart: { + cart: { items: [], total_quantity: 0 }, + user_errors: [], + }, + }, + }), + }); + }); + + await page.goto(`${baseUrl}${storePath}/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + // Wait for add to cart button + const addToCartButton = page.locator('.quantity-container button'); + await addToCartButton.waitFor({ state: 'visible', timeout: 10000 }); + + // Click add to cart + await addToCartButton.click(); + + // Wait for the cart redirect or network request + await page.waitForTimeout(2000); + + // Check localStorage for the cart ID + const cartData = await page.evaluate(() => { + const storage = localStorage.getItem('mage-cache-storage'); + return storage ? JSON.parse(storage) : null; + }); + + if (cartData && cartData['side-by-side']) { + expect(cartData['side-by-side'].cart_id).toBe('us-cart-abc123'); + console.log('✓ US cart stored in mage-cache-storage with correct cart_id'); + } else { + expect.fail('US cart not stored in mage-cache-storage'); + } + }); + }); + + test.describe('French Canadian Store (/ca/fr_ca/)', () => { + const storePath = '/ca/fr_ca'; + + test('should use store-specific mage-cache-storage-ca-fr_ca key', async ({ page }) => { + await page.route('**/customer/section/load/**', async (route) => { + const url = new URL(route.request().url()); + expect(url.pathname).toContain('/ca/fr_ca/customer/section/load'); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + cart: { items: [], summary_count: 0, data_id: 67890 }, + customer: { data_id: 67890 }, + 'side-by-side': { cart_id: 'fr-cart-xyz789', data_id: 67890 }, + }), + }); + }); + + await page.route('**/graphql', async (route) => { + const headers = route.request().headers(); + // Verify Store header is correct for FR-CA + expect(headers.store).toBe('fr_ca'); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + addProductsToCart: { + cart: { items: [], total_quantity: 0 }, + user_errors: [], + }, + }, + }), + }); + }); + + await page.goto(`${baseUrl}${storePath}/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + // Wait for add to cart button + const addToCartButton = page.locator('.quantity-container button'); + await addToCartButton.waitFor({ state: 'visible', timeout: 10000 }); + + // Click add to cart + await addToCartButton.click(); + + // Wait for the network requests + await page.waitForTimeout(2000); + + // Check localStorage for store-specific key + const storageKeys = await page.evaluate(() => Object.keys(localStorage).filter((key) => key.includes('mage-cache'))); + + console.log('FR-CA Store localStorage keys:', storageKeys); + + // Should have the store-specific key + const hasFRCAKey = storageKeys.some((key) => key.includes('-ca-fr_ca')); + expect(hasFRCAKey).toBe(true); + + console.log('✓ FR-CA store uses mage-cache-storage-ca-fr_ca key'); + }); + + test('should store cart data in store-specific localStorage', async ({ page }) => { + await page.route('**/customer/section/load/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + cart: { items: [], summary_count: 0, data_id: 67890 }, + customer: { data_id: 67890 }, + 'side-by-side': { cart_id: 'fr-cart-isolated', data_id: 67890 }, + }), + }); + }); + + await page.route('**/graphql', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + addProductsToCart: { + cart: { items: [], total_quantity: 0 }, + user_errors: [], + }, + }, + }), + }); + }); + + await page.goto(`${baseUrl}${storePath}/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + const addToCartButton = page.locator('.quantity-container button'); + await addToCartButton.waitFor({ state: 'visible', timeout: 10000 }); + await addToCartButton.click(); + + await page.waitForTimeout(2000); + + // Check the store-specific localStorage + const cartData = await page.evaluate(() => { + const storage = localStorage.getItem('mage-cache-storage-ca-fr_ca'); + return storage ? JSON.parse(storage) : null; + }); + + if (cartData && cartData['side-by-side']) { + expect(cartData['side-by-side'].cart_id).toBe('fr-cart-isolated'); + console.log('✓ FR-CA cart stored in mage-cache-storage-ca-fr_ca with correct cart_id'); + } else { + expect.fail('FR-CA cart not stored in mage-cache-storage-ca-fr_ca'); + } + }); + }); + + test.describe('Cross-Store Cart Isolation', () => { + test('carts should be isolated between US and FR-CA stores', async ({ page }) => { + // Set up mock responses + await page.route('**/customer/section/load/**', async (route) => { + const url = new URL(route.request().url()); + const isUS = url.pathname.includes('/us/en_us/'); + + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + cart: { items: [], summary_count: 0, data_id: isUS ? 11111 : 22222 }, + customer: { data_id: isUS ? 11111 : 22222 }, + 'side-by-side': { + cart_id: isUS ? 'us-isolated-cart' : 'fr-isolated-cart', + data_id: isUS ? 11111 : 22222, + }, + }), + }); + }); + + await page.route('**/graphql', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + addProductsToCart: { + cart: { items: [], total_quantity: 0 }, + user_errors: [], + }, + }, + }), + }); + }); + + // Step 1: Add to cart on US store + await page.goto(`${baseUrl}/us/en_us/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + let addToCartButton = page.locator('.quantity-container button'); + await addToCartButton.waitFor({ state: 'visible', timeout: 10000 }); + await addToCartButton.click(); + await page.waitForTimeout(2000); + + // Check US cart was stored + const usCartData = await page.evaluate(() => { + const storage = localStorage.getItem('mage-cache-storage'); + return storage ? JSON.parse(storage) : null; + }); + + expect(usCartData?.['side-by-side']?.cart_id).toBe('us-isolated-cart'); + console.log('✓ US cart stored correctly'); + + // Step 2: Navigate to FR-CA store and add to cart + await page.goto(`${baseUrl}/ca/fr_ca/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + addToCartButton = page.locator('.quantity-container button'); + await addToCartButton.waitFor({ state: 'visible', timeout: 10000 }); + await addToCartButton.click(); + await page.waitForTimeout(2000); + + // Check FR-CA cart was stored separately + const frCartData = await page.evaluate(() => { + const storage = localStorage.getItem('mage-cache-storage-ca-fr_ca'); + return storage ? JSON.parse(storage) : null; + }); + + expect(frCartData?.['side-by-side']?.cart_id).toBe('fr-isolated-cart'); + console.log('✓ FR-CA cart stored correctly'); + + // Step 3: Verify US cart is still intact + const usCartAfterFR = await page.evaluate(() => { + const storage = localStorage.getItem('mage-cache-storage'); + return storage ? JSON.parse(storage) : null; + }); + + expect(usCartAfterFR?.['side-by-side']?.cart_id).toBe('us-isolated-cart'); + console.log('✓ US cart still intact after FR-CA interaction'); + + // Step 4: Navigate back to US and verify cart is preserved + await page.goto(`${baseUrl}/us/en_us/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + const usCartFinal = await page.evaluate(() => { + const storage = localStorage.getItem('mage-cache-storage'); + return storage ? JSON.parse(storage) : null; + }); + + expect(usCartFinal?.['side-by-side']?.cart_id).toBe('us-isolated-cart'); + console.log('✓ US cart preserved after returning from FR-CA store'); + + console.log('✓ Cross-store cart isolation verified!'); + }); + }); + + test.describe('GraphQL Store Header', () => { + test('US store should send Store: en_us header', async ({ page }) => { + let storeHeaderValue = null; + + await page.route('**/graphql', async (route) => { + storeHeaderValue = route.request().headers().store; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + addProductsToCart: { + cart: { items: [], total_quantity: 0 }, + user_errors: [], + }, + }, + }), + }); + }); + + await page.route('**/customer/section/load/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + cart: { items: [], summary_count: 0 }, + customer: {}, + 'side-by-side': { cart_id: 'test-cart' }, + }), + }); + }); + + await page.goto(`${baseUrl}/us/en_us/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + const addToCartButton = page.locator('.quantity-container button'); + await addToCartButton.waitFor({ state: 'visible', timeout: 10000 }); + await addToCartButton.click(); + + await page.waitForTimeout(2000); + + expect(storeHeaderValue).toBe('en_us'); + console.log('✓ US store sends correct Store header: en_us'); + }); + + test('FR-CA store should send Store: fr_ca header', async ({ page }) => { + let storeHeaderValue = null; + + await page.route('**/graphql', async (route) => { + storeHeaderValue = route.request().headers().store; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + data: { + addProductsToCart: { + cart: { items: [], total_quantity: 0 }, + user_errors: [], + }, + }, + }), + }); + }); + + await page.route('**/customer/section/load/**', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ + cart: { items: [], summary_count: 0 }, + customer: {}, + 'side-by-side': { cart_id: 'test-cart' }, + }), + }); + }); + + await page.goto(`${baseUrl}/ca/fr_ca/products/ascent-x2?martech=off`); + await page.waitForLoadState('networkidle'); + + const addToCartButton = page.locator('.quantity-container button'); + await addToCartButton.waitFor({ state: 'visible', timeout: 10000 }); + await addToCartButton.click(); + + await page.waitForTimeout(2000); + + expect(storeHeaderValue).toBe('fr_ca'); + console.log('✓ FR-CA store sends correct Store header: fr_ca'); + }); + }); +}); diff --git a/tests/pdp/integration.spec.js b/tests/pdp/integration.spec.js index 1db048dc..2c8e63bc 100644 --- a/tests/pdp/integration.spec.js +++ b/tests/pdp/integration.spec.js @@ -24,9 +24,9 @@ test.describe('PDP Integration Tests', () => { }); test.describe('Configurable Product Page', () => { - const productPath = '/us/en_us/products/ascent-x3'; + const productPath = '/us/en_us/products/ascent-x2'; - test('should load Ascent X3 product page with all required elements', async ({ page }) => { + test('should load Ascent X2 product page with all required elements', async ({ page }) => { const productUrl = buildProductUrl(productPath, currentBranch); console.log(`Testing URL: ${productUrl}`); @@ -35,13 +35,13 @@ test.describe('PDP Integration Tests', () => { // Wait for page to load await page.waitForLoadState('networkidle'); - await expect(page).toHaveTitle(/Ascent X3/i); + await expect(page).toHaveTitle(/Ascent X2/i); await assertPDPElements(page); await assertSaleableElements(page); await assertOptionElements(page); }); - test('should deeplink to Ascent X3 variant', async ({ page }) => { + test('should deeplink to Ascent X2 variant', async ({ page }) => { const productUrl = buildProductUrl(productPath, currentBranch, { color: 'polar-white', }); @@ -53,7 +53,7 @@ test.describe('PDP Integration Tests', () => { // Wait for page to load await page.waitForLoadState('networkidle'); - await expect(page).toHaveTitle(/Ascent X3/i); + await expect(page).toHaveTitle(/Ascent X2/i); await assertPDPElements(page); await assertPDPElements(page); await assertSaleableElements(page); @@ -68,11 +68,11 @@ test.describe('PDP Integration Tests', () => { expect(requestBody.variables).toEqual({ cartItems: [ { - sku: 'Ascent X3', + sku: 'Ascent X2', quantity: '1', selected_options: [ 'Y29uZmlndXJhYmxlLzkzLzUzNA==', - 'Y3VzdG9tLW9wdGlvbi8zMDAyLzM5NDE=', + 'Y3VzdG9tLW9wdGlvbi8zMDAwLzM5Mzk=', ], }, ], @@ -88,7 +88,7 @@ test.describe('PDP Integration Tests', () => { cart: { items: [ { - sku: 'Ascent X3', + sku: 'Ascent X2', quantity: '1', }, ], @@ -126,11 +126,11 @@ test.describe('PDP Integration Tests', () => { expect(requestBody.variables).toEqual({ cartItems: [ { - sku: 'Ascent X3', + sku: 'Ascent X2', quantity: '1', selected_options: [ 'Y29uZmlndXJhYmxlLzkzLzUzNA==', - 'Y3VzdG9tLW9wdGlvbi8zMDAyLzM5NDE=', + 'Y3VzdG9tLW9wdGlvbi8zMDAwLzM5Mzk=', ], }, ], @@ -205,15 +205,15 @@ test.describe('PDP Integration Tests', () => { }); expect(data).toEqual({ index_id: '534', - product: '3641', - item: '3641', + product: '3627', + item: '3627', form_key: 'null', qty: '1', 'super_attribute[93]': '534', - vitamixProductId: '3641', - 'options[3002]': '3941', + vitamixProductId: '3627', + 'options[3000]': '3939', warranty_sku: 'sku-10-year-standard-warranty', - 'warranty_skus[3941]': 'sku-10-year-standard-warranty', + 'warranty_skus[3939]': 'sku-10-year-standard-warranty', }); // Log the arguments that were passed to addToCart @@ -541,7 +541,7 @@ test.describe('PDP Integration Tests', () => { { modal: false, smsOptin: true, - leadSource: 'sub-emsms-footer-us', + leadSource: 'sub-em-footer-us', pageUrl: '/us/en_us/products/20-ounce-travel-cup', }, { @@ -553,7 +553,7 @@ test.describe('PDP Integration Tests', () => { { modal: true, smsOptin: true, - leadSource: 'sub-emsms-modal-us', + leadSource: 'sub-em-modal-us', pageUrl: '/us/en_us/products/20-ounce-travel-cup', }, ]; diff --git a/tests/scripts/pricing.spec.js b/tests/scripts/pricing.spec.js new file mode 100644 index 00000000..b55b302c --- /dev/null +++ b/tests/scripts/pricing.spec.js @@ -0,0 +1,110 @@ +/* eslint-disable no-console */ +import { test, expect } from '@playwright/test'; + +/** + * Unit tests for formatPrice and getOfferPricing (from scripts/scripts.js). + * + * These pure functions are defined inline because scripts.js imports aem.js + * which requires a full browser environment. The functions under test have + * no browser dependencies so they can be tested directly. + */ + +function formatPrice(value, ph) { + const locale = (ph.languageCode || 'en_US').replace('_', '-'); + const currency = ph.currencyCode || 'USD'; + return new Intl.NumberFormat(locale, { style: 'currency', currency }).format(value); +} + +function getOfferPricing(offer) { + if (!offer) return null; + return { + final: parseFloat(offer.price), + regular: offer.priceSpecification?.price || null, + }; +} + +test.describe('formatPrice', () => { + test('formats USD in en_US locale', () => { + const ph = { languageCode: 'en_US', currencyCode: 'USD' }; + expect(formatPrice(399.95, ph)).toBe('$399.95'); + }); + + test('formats USD with thousands separator', () => { + const ph = { languageCode: 'en_US', currencyCode: 'USD' }; + expect(formatPrice(1299.99, ph)).toBe('$1,299.99'); + }); + + test('formats CAD in fr_CA locale', () => { + const ph = { languageCode: 'fr_CA', currencyCode: 'CAD' }; + const result = formatPrice(399.95, ph); + // fr-CA formats as "399,95 $" + expect(result).toContain('399,95'); + expect(result).toContain('$'); + expect(result).not.toContain('US'); + }); + + test('formats CAD in en_CA locale', () => { + const ph = { languageCode: 'en_CA', currencyCode: 'CAD' }; + const result = formatPrice(399.95, ph); + expect(result).toContain('399.95'); + expect(result).toContain('$'); + expect(result).not.toContain('US'); + }); + + test('formats zero price', () => { + const ph = { languageCode: 'en_US', currencyCode: 'USD' }; + expect(formatPrice(0, ph)).toBe('$0.00'); + }); + + test('formats whole number with decimals', () => { + const ph = { languageCode: 'en_US', currencyCode: 'USD' }; + expect(formatPrice(400, ph)).toBe('$400.00'); + }); + + test('falls back to en_US and USD when placeholders are empty', () => { + const ph = {}; + expect(formatPrice(399.95, ph)).toBe('$399.95'); + }); +}); + +test.describe('getOfferPricing', () => { + test('returns final and regular price from offer with sale', () => { + const offer = { + price: '349.95', + priceCurrency: 'USD', + priceSpecification: { + '@type': 'UnitPriceSpecification', + priceType: 'https://schema.org/ListPrice', + price: 399.95, + priceCurrency: 'USD', + }, + }; + const result = getOfferPricing(offer); + expect(result.final).toBe(349.95); + expect(result.regular).toBe(399.95); + }); + + test('returns null regular price when no priceSpecification', () => { + const offer = { + price: '349.95', + priceCurrency: 'USD', + }; + const result = getOfferPricing(offer); + expect(result.final).toBe(349.95); + expect(result.regular).toBeNull(); + }); + + test('returns null for null offer', () => { + expect(getOfferPricing(null)).toBeNull(); + }); + + test('returns null for undefined offer', () => { + expect(getOfferPricing(undefined)).toBeNull(); + }); + + test('parses string price correctly', () => { + const offer = { price: '1299.99' }; + const result = getOfferPricing(offer); + expect(result.final).toBe(1299.99); + }); +}); diff --git a/tools/dealer-geocode.html b/tools/dealer-geocode.html new file mode 100644 index 00000000..8b7e4342 --- /dev/null +++ b/tools/dealer-geocode.html @@ -0,0 +1,71 @@ + + + + + + + + + + Vitamix Dealer Geocode Tool + + + + +
+

Vitamix Dealer Geocode Tool

+

Paste dealer TSV data and look up lat/long for each row

+ +
+
+
+ + +
+ Paste tab-separated values from your spreadsheet. Header must include NAME, ADDRESS_1, ADDRESS_2, CITY, STATE_PROVINCE, POSTAL_CODE, COUNTY, COUNTRY, LAT, LONG. +
+
+ + +
+
+ +
+
+
+
+

Looking up coordinates...

+
+ +
+ +
+

Results

+
+ +
+
+ + + + + + + + + +
#AddressLat / Long
+
+ +
+
+ + diff --git a/tools/dealer-geocode/dealer-geocode.css b/tools/dealer-geocode/dealer-geocode.css new file mode 100644 index 00000000..54c24081 --- /dev/null +++ b/tools/dealer-geocode/dealer-geocode.css @@ -0,0 +1,237 @@ +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; + background-color: #f5f5f5; + height: 100%; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +h1 { + color: #333; + margin-bottom: 10px; +} + +.subtitle { + color: #666; + margin-bottom: 30px; +} + +.form-section { + background: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +.form-group { + margin-bottom: 20px; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #333; +} + +textarea { + width: 100%; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 13px; + font-family: ui-monospace, monospace; + resize: vertical; +} + +textarea:focus { + outline: none; + border-color: #4CAF50; +} + +.help-text { + font-size: 12px; + color: #666; + margin-top: 5px; +} + +button { + background-color: #4CAF50; + color: white; + padding: 12px 24px; + border: none; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + font-weight: 600; + transition: background-color 0.2s; +} + +button:hover { + background-color: #45a049; +} + +button:disabled { + background-color: #ccc; + cursor: not-allowed; +} + +.loading { + display: none; + background: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + margin-bottom: 20px; +} + +.loading.active { + display: block; +} + +.progress-bar-container { + width: 100%; + height: 24px; + background-color: #e0e0e0; + border-radius: 12px; + overflow: hidden; + margin-bottom: 12px; +} + +.progress-bar { + height: 100%; + width: 0%; + background-color: #4CAF50; + border-radius: 12px; + transition: width 0.2s ease; +} + +#progressText { + margin: 0; + color: #666; + font-size: 14px; +} + +.error { + background-color: #f8d7da; + border: 1px solid #f5c6cb; + color: #721c24; + padding: 15px; + border-radius: 4px; + margin-bottom: 20px; + display: none; +} + +.error.active { + display: block; +} + +.results { + background: white; + padding: 25px; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + display: none; +} + +.results.active { + display: block; +} + +.results h2 { + margin-top: 0; + color: #333; +} + +.results-table-wrapper { + overflow-x: auto; + margin-bottom: 20px; +} + +.results-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} + +.results-table th, +.results-table td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid #e0e0e0; + vertical-align: top; +} + +.results-table th { + background-color: #f5f5f5; + font-weight: 600; + color: #333; +} + +.results-table tbody tr:hover { + background-color: #fafafa; +} + +.results-table .row-num { + width: 2.5rem; + color: #666; + font-variant-numeric: tabular-nums; +} + +.results-table .address-cell, +.results-table .coords-cell { + max-width: 0; +} + +.results-table .address-cell .maps-link { + white-space: normal; + word-break: break-word; +} + +.address-text, +.coords-text { + display: inline-block; + margin-right: 8px; + max-width: 100%; +} + +.maps-link { + color: #1a73e8; + text-decoration: none; + white-space: nowrap; +} + +.maps-link:hover { + text-decoration: underline; +} + +.actions { + margin-top: 0; + margin-bottom: 12px; + display: flex; + gap: 10px; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/tools/dealer-geocode/dealer-geocode.js b/tools/dealer-geocode/dealer-geocode.js new file mode 100644 index 00000000..a9b80388 --- /dev/null +++ b/tools/dealer-geocode/dealer-geocode.js @@ -0,0 +1,360 @@ +const GEOCODE_BASE = 'https://helix-geocode.adobeaem.workers.dev/'; + +const ADDRESS_FIELDS = [ + 'NAME', + 'ADDRESS_1', + 'ADDRESS_2', + 'CITY', + 'STATE_PROVINCE', + 'POSTAL_CODE', + 'COUNTY', + 'COUNTRY', +]; + +/** + * Split TSV string into row strings. Newlines inside double-quoted fields are not row separators. + * Handles \n, \r\n, and \r line endings. Strips BOM from first row. + * @param {string} tsv + * @returns {string[]} + */ +function splitTSVRows(tsv) { + const rows = []; + let current = ''; + let inQuotes = false; + const s = tsv.trimEnd(); + for (let i = 0; i < s.length; i += 1) { + const c = s[i]; + if (c === '"') { + inQuotes = !inQuotes; + current += c; + } else if (!inQuotes && (c === '\n' || c === '\r')) { + if (c === '\r' && s[i + 1] === '\n') i += 1; + rows.push(current); + current = ''; + } else { + current += c; + } + } + if (current.length > 0) rows.push(current); + if (rows.length > 0 && rows[0].charCodeAt(0) === 0xFEFF) { + rows[0] = rows[0].slice(1); + } + return rows; +} + +/** + * Split a single TSV row string into fields. Tabs inside double-quoted fields are not separators. + * Handles "" as escaped quote within quoted field. + * @param {string} row + * @returns {string[]} + */ +function splitTSVRow(row) { + const fields = []; + let current = ''; + let inQuotes = false; + for (let i = 0; i < row.length; i += 1) { + const c = row[i]; + if (c === '"') { + if (inQuotes && row[i + 1] === '"') { + current += '"'; + i += 1; + } else { + inQuotes = !inQuotes; + } + } else if (c === '\t' && !inQuotes) { + fields.push(current); + current = ''; + } else { + current += c; + } + } + fields.push(current); + return fields; +} + +/** + * Unquote a TSV field value: remove surrounding quotes and unescape "" to ". + * Trims whitespace and trailing \r from unquoted values. + * @param {string} field + * @returns {string} + */ +function unquoteField(field) { + const trimmed = field.trim().replace(/\r+$/, ''); + if (trimmed.length >= 2 && trimmed.startsWith('"') && trimmed.endsWith('"')) { + return trimmed.slice(1, -1).replace(/""/g, '"'); + } + return trimmed; +} + +/** + * Parse TSV into { header, columnIndex, rows }. + * Handles quoted fields that contain newlines, tabs, and double-quote escapes. + * @param {string} tsv + * @returns {{ header: string[], columnIndex: Record, rows: string[][] }} + */ +export function parseTSV(tsv) { + const rowStrings = splitTSVRows(tsv); + if (rowStrings.length < 2) { + throw new Error('TSV must have a header row and at least one data row.'); + } + const header = splitTSVRow(rowStrings[0]).map((col) => unquoteField(col).trim()); + const columnIndex = {}; + header.forEach((col, i) => { + columnIndex[col] = i; + }); + + const required = ['NAME', 'ADDRESS_1', 'LAT', 'LONG']; + required.forEach((col) => { + if (columnIndex[col] === undefined) { + throw new Error(`Missing required column: ${col}. Header: ${header.join(', ')}`); + } + }); + + const numCols = header.length; + const rows = rowStrings.slice(1).map((line) => { + const raw = splitTSVRow(line); + const cells = raw.map((cell) => unquoteField(cell)); + while (cells.length < numCols) cells.push(''); + return cells.slice(0, numCols); + }); + return { header, columnIndex, rows }; +} + +/** + * Normalize whitespace (including newlines) to single spaces. + * @param {string} s + * @returns {string} + */ +function normalizeWhitespace(s) { + return s.replace(/\s+/g, ' ').trim(); +} + +/** + * Build address string for geocode from row and column index. + * Normalizes newlines and other whitespace to single spaces. + * @param {string[]} row + * @param {Record} columnIndex + * @returns {string} + */ +export function buildAddress(row, columnIndex) { + const parts = ADDRESS_FIELDS.map((field) => { + const i = columnIndex[field]; + if (i === undefined) return ''; + return normalizeWhitespace(row[i] || ''); + }).filter(Boolean); + return parts.join(' '); +} + +/** + * Call geocode API and return { lat, lng } or null. + * @param {string} address + * @returns {Promise<{ lat: number, lng: number } | null>} + */ +export async function geocodeAddress(address) { + if (!address || !address.trim()) return null; + const url = `${GEOCODE_BASE}?address=${encodeURIComponent(address)}`; + const response = await fetch(url); + if (!response.ok) return null; + const data = await response.json(); + + // Google Geocoding API (via proxy): results[0].geometry.location.{ lat, lng } + if (data.results && Array.isArray(data.results) && data.results.length > 0) { + const location = data.results[0].geometry?.location; + if (location && location.lat != null && location.lng != null) { + return { lat: Number(location.lat), lng: Number(location.lng) }; + } + } + + return null; +} + +/** + * Quote a field for TSV if it contains newline, tab, or double-quote. + * @param {string} field + * @returns {string} + */ +function quoteField(field) { + const s = String(field); + if (/[\r\n\t"]/.test(s)) { + return `"${s.replace(/"/g, '""')}"`; + } + return s; +} + +/** + * Serialize parsed TSV back to string. + * Fields that contain newlines, tabs, or quotes are quoted and escaped. + * @param {{ header: string[], rows: string[][] }} parsed + * @returns {string} + */ +export function serializeTSV(parsed) { + const headerLine = parsed.header.map(quoteField).join('\t'); + const dataLines = parsed.rows.map((row) => row.map(quoteField).join('\t')); + return [headerLine, ...dataLines].join('\n'); +} + +/** + * Show error message. + * @param {string} message + */ +export function showError(message) { + const errorDiv = document.getElementById('error'); + errorDiv.textContent = message; + errorDiv.classList.add('active'); + setTimeout(() => { + errorDiv.classList.remove('active'); + }, 8000); +} + +/** + * Update progress bar and text. + * @param {number} current + * @param {number} total + * @param {string} [text] + */ +export function updateProgress(current, total, text) { + const progressBar = document.getElementById('progressBar'); + const progressText = document.getElementById('progressText'); + const pct = total > 0 ? Math.round((current / total) * 100) : 0; + progressBar.style.width = `${pct}%`; + progressText.textContent = text ?? `Looking up coordinates... ${current} of ${total}`; +} + +function escapeHtml(str) { + if (!str) return ''; + const div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; +} + +const GOOGLE_MAPS_SEARCH = 'https://www.google.com/maps/search/?api=1&query='; + +/** + * Render results table: numbered rows, address (with Maps link), lat/long (with Maps link). + * @param {{ header: string[], columnIndex: Record, rows: string[][] }} parsed + */ +export function renderResultsTable(parsed) { + const tbody = document.getElementById('resultsTableBody'); + tbody.innerHTML = ''; + const { columnIndex, rows } = parsed; + const latIdx = columnIndex.LAT; + const lngIdx = columnIndex.LONG; + + rows.forEach((row, i) => { + const address = buildAddress(row, columnIndex); + const lat = (row[latIdx] || '').trim(); + const lng = (row[lngIdx] || '').trim(); + const hasCoords = lat && lng; + + const addressUrl = GOOGLE_MAPS_SEARCH + encodeURIComponent(address); + const coordsQuery = hasCoords ? `${lat},${lng}` : ''; + const coordsUrl = hasCoords ? GOOGLE_MAPS_SEARCH + encodeURIComponent(coordsQuery) : ''; + + const tr = document.createElement('tr'); + const addressDisplay = address ? `${escapeHtml(address)}` : '—'; + const coordsDisplay = hasCoords ? `${escapeHtml(lat)}, ${escapeHtml(lng)}` : '—'; + tr.innerHTML = ` + ${i + 1} + ${addressDisplay} + ${coordsDisplay} + `; + tbody.appendChild(tr); + }); +} + +/** + * Run geocode lookup on TSV and fill LAT/LONG. + */ +export async function runLookup() { + const tsvInput = document.getElementById('tsvInput'); + const tsv = tsvInput.value.trim(); + if (!tsv) { + showError('Please paste TSV data first.'); + return; + } + + let parsed; + try { + parsed = parseTSV(tsv); + } catch (e) { + showError(e.message); + return; + } + + const { columnIndex, rows } = parsed; + const latIdx = columnIndex.LAT; + const lngIdx = columnIndex.LONG; + const total = rows.length; + const loadingDiv = document.getElementById('loading'); + const lookupBtn = document.getElementById('lookupBtn'); + const resultsDiv = document.getElementById('results'); + const tsvOutput = document.getElementById('tsvOutput'); + + document.getElementById('error').classList.remove('active'); + resultsDiv.classList.remove('active'); + loadingDiv.classList.add('active'); + lookupBtn.disabled = true; + updateProgress(0, total); + + const address1Idx = columnIndex.ADDRESS_1; + let done = 0; + const processRow = async (row, i) => { + const address1 = (row[address1Idx] || '').trim(); + if (!address1) return; + const lat = (row[latIdx] || '').trim(); + const lng = (row[lngIdx] || '').trim(); + if (!(lat && lng)) { + const address = buildAddress(row, columnIndex); + try { + const result = await geocodeAddress(address); + if (result) { + row[latIdx] = String(result.lat); + row[lngIdx] = String(result.lng); + } + } catch (err) { + // eslint-disable-next-line no-console + console.warn(`Geocode failed for row ${i + 2}:`, err); + } + } + }; + await rows.reduce((promise, row, i) => promise.then(async () => { + await processRow(row, i); + done += 1; + updateProgress(done, total); + }), Promise.resolve()); + + loadingDiv.classList.remove('active'); + lookupBtn.disabled = false; + tsvOutput.value = serializeTSV(parsed); + renderResultsTable(parsed); + resultsDiv.classList.add('active'); +} + +/** + * Copy output TSV to clipboard. + */ +export function copyToClipboard() { + const tsvOutput = document.getElementById('tsvOutput'); + navigator.clipboard.writeText(tsvOutput.value).then(() => { + // eslint-disable-next-line no-alert + alert('Copied to clipboard!'); + }).catch((err) => { + // eslint-disable-next-line no-console + console.error('Failed to copy:', err); + // eslint-disable-next-line no-alert + alert('Failed to copy to clipboard'); + }); +} + +/** + * Initialize on page load. + */ +export async function init() { + document.getElementById('geocodeForm').addEventListener('submit', (e) => { + e.preventDefault(); + runLookup(); + }); + document.getElementById('copyBtn').addEventListener('click', copyToClipboard); +} + +init(); diff --git a/tools/dealer-geocode/index.html b/tools/dealer-geocode/index.html new file mode 100644 index 00000000..745d91c2 --- /dev/null +++ b/tools/dealer-geocode/index.html @@ -0,0 +1,14 @@ + + + + + + Vitamix Dealer Geocode + + +

Vitamix Dealer Geocode

+ + + diff --git a/tools/linkchecker/linkchecker.css b/tools/linkchecker/linkchecker.css new file mode 100644 index 00000000..f01191be --- /dev/null +++ b/tools/linkchecker/linkchecker.css @@ -0,0 +1,17 @@ +.linkchecker-invalid-link { + outline: 3px solid red; + outline-offset: 2px; + animation: linkchecker-pulse 1.5s ease-in-out infinite; +} + +@keyframes linkchecker-pulse { + 0%, 100% { + outline-color: #f00; + outline-offset: 2px; + } + + 50% { + outline-color: #f60; + outline-offset: 6px; + } +} \ No newline at end of file diff --git a/tools/linkchecker/linkchecker.js b/tools/linkchecker/linkchecker.js new file mode 100644 index 00000000..db08654b --- /dev/null +++ b/tools/linkchecker/linkchecker.js @@ -0,0 +1,22 @@ +import { loadCSS } from '../../scripts/aem.js'; + +loadCSS('/tools/linkchecker/linkchecker.css'); + +function checkLinks() { + if (window.location.pathname.startsWith('/drafts/')) return; + const locale = window.location.pathname.split('/').slice(0, 3).join('/'); + const links = document.querySelectorAll('a[href]'); + links.forEach((link) => { + const url = new URL(link.href); + if (link.href.startsWith('https://www.vitamix.com/content/dam/')) return; + if (url.origin.includes('vitamix.com') + || url.origin.includes('.aem.') + || url.origin.includes('localhost')) { + if (!url.pathname.startsWith(locale)) { + link.classList.add('linkchecker-invalid-link'); + } + } + }); +} + +setTimeout(checkLinks, 2000); diff --git a/tools/quick-edit/quick-edit.js b/tools/quick-edit/quick-edit.js new file mode 100644 index 00000000..2e33ffc8 --- /dev/null +++ b/tools/quick-edit/quick-edit.js @@ -0,0 +1,33 @@ +// eslint-disable-next-line import/no-cycle +import { loadPage } from '../../scripts/scripts.js'; + +const importMap = { + imports: { + 'da-lit': 'https://da.live/deps/lit/dist/index.js', + 'da-y-wrapper': 'https://da.live/deps/da-y-wrapper/dist/index.js', + }, +}; + +function addImportmap() { + const importmapEl = document.createElement('script'); + importmapEl.type = 'importmap'; + importmapEl.textContent = JSON.stringify(importMap); + document.head.appendChild(importmapEl); +} + +async function loadMoudle(origin, payload) { + document.body.classList.add('quick-edit'); + const { default: loadQuickEdit } = await import(`${origin}/nx/public/plugins/quick-edit/quick-edit.js`); + loadQuickEdit(payload, loadPage); +} + +export default function init(payload) { + const { search } = window.location; + const ref = new URLSearchParams(search).get('quick-edit'); + let origin; + if (ref === 'on' || !ref) origin = 'https://da.live'; + if (ref === 'local') origin = 'http://localhost:6456'; + if (!origin) origin = `https://${ref}--da-nx--adobe.aem.live`; + addImportmap(); + loadMoudle(origin, payload); +} diff --git a/tools/sidekick/sync/sync.js b/tools/sidekick/sync/sync.js index 4af5f041..33c6c2a6 100644 --- a/tools/sidekick/sync/sync.js +++ b/tools/sidekick/sync/sync.js @@ -22,9 +22,13 @@ export async function createSyncModal() { // Expected format: /ca/en_us/products/ascent-x5 const defaultStoreCode = pathParts[0] || ''; - const defaultStoreViewCode = pathParts[1] || ''; + let defaultStoreViewCode = pathParts[1] || ''; const defaultUrlKey = pathParts[3] || ''; + if (defaultStoreCode === 'ca' && defaultStoreViewCode === 'en_us') { + defaultStoreViewCode = 'en_ca'; + } + // pull the sku from the sku meta tag const skuMeta = document.querySelector('meta[name="sku"]'); const defaultSku = skuMeta ? skuMeta.content : ''; diff --git a/tools/translate/app.css b/tools/translate/app.css new file mode 100644 index 00000000..03e0ae15 --- /dev/null +++ b/tools/translate/app.css @@ -0,0 +1,207 @@ +@import url('./shared.css'); + +body { + align-items: flex-start; +} + +.app-container { + width: 100%; + max-width: 1200px; +} + +.app-form { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); + display: grid; + gap: 16px; +} + +.app-input { + display: grid; + gap: 12px; + grid-template-columns: 1fr; + align-items: start; +} + +.app-input-loader { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.3s ease-out; + overflow: hidden; +} + +.app-input-loader.open { + grid-template-rows: 1fr; +} + +.app-input-loader-content { + min-height: 0; + display: flex; + gap: 12px; + align-items: center; + padding-bottom: 0; + transition: padding-bottom 0.3s ease-out; + flex-wrap: wrap; +} + +.app-input-loader.open .app-input-loader-content { + padding-bottom: 12px; +} + +.app-input-loader input { + flex: 1; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 10px 14px; + font-size: 14px; + color: var(--text); + background: #fff; +} + +.app-input-loader input:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--ring); +} + +.app-output { + display: grid; + gap: 12px; +} + +.app-output-list { + list-style: none; + padding: 0; + margin: 0; +} + +.app-output-list li { + display: flex; + align-items: center; + gap: 12px; + position: relative; + padding-left: 16px; +} + +.app-li-number { + font-weight: bold; + margin-right: 8px; +} + +textarea { + width: 100%; + min-height: 200px; + resize: vertical; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px 16px; + font-size: 15px; + line-height: 1.5; + color: var(--text); + background: #fff; + box-shadow: none; +} + +select { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 14px; + font-size: 14px; + color: var(--text); + background: #fff; + box-shadow: none; +} + +textarea:focus, +select:focus, +button:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--ring); +} + +button { + border: none; + border-radius: var(--radius-sm); + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.08s ease, box-shadow 0.2s ease, background 0.2s ease; + background: var(--primary); + color: #fff; + box-shadow: none; +} + +button:hover { + background: var(--primary-dark); +} + +.status { + font-size: 14px; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + display: inline-flex; + align-items: center; + white-space: nowrap; +} + +.status.loading, +.status.translating { + color: #0052cc; + background: #deebff; +} + +.status.translated, +.status.saved { + color: #064; + background: #e3fcef; +} + +.status.error { + color: #bf2600; + background: #ffebe6; +} + +.status svg { + vertical-align: middle; + margin-right: 4px; + margin-left: 4px; + width: 16px; + height: 16px; +} + +.app-error { + color: #c62828; + font-size: 13px; + min-height: 16px; + display: none; + margin-top: 4px; +} + +.app-folder-loader-error { + color: #c62828; + font-size: 13px; + flex-basis: 100%; + margin-top: 4px; + display: none; +} + +@media (width <= 720px) { + .app-form { + padding: 20px; + } + + .app-input { + grid-template-columns: 1fr; + } + + button { + width: 100%; + } +} diff --git a/tools/translate/app.html b/tools/translate/app.html new file mode 100644 index 00000000..4a5bb25c --- /dev/null +++ b/tools/translate/app.html @@ -0,0 +1,37 @@ + + + + Translation tool + + + + + +
+

Translation tool

+

Enter list of URLs to translate or load from folder

+
+
+
+
+ + +
+
+
+ + + +
+
+
+
    +
    +
    +
    + + \ No newline at end of file diff --git a/tools/translate/app.js b/tools/translate/app.js new file mode 100644 index 00000000..0d0d40f8 --- /dev/null +++ b/tools/translate/app.js @@ -0,0 +1,185 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +/* eslint-disable no-continue */ +/* eslint-disable no-await-in-loop */ + +// eslint-disable-next-line import/no-unresolved +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { translate, ADMIN_URL } from './shared.js'; + +const EDIT_ICON_SVG = ''; + +(async function init() { + const { context, actions } = await DA_SDK; + const { daFetch } = actions; + + const urlsTextarea = document.querySelector('textarea[name="urls"]'); + const languageSelect = document.querySelector('select[name="language"]'); + const translateButton = document.querySelector('button[name="translate"]'); + const outputList = document.querySelector('.app-output-list'); + const errorMessage = document.querySelector('.app-error'); + const loadFromFolderLink = document.querySelector('#load-from-folder'); + const loaderRow = document.querySelector('.app-input-loader'); + const folderInput = document.querySelector('input[name="folder"]'); + const loadFromFolderButton = document.querySelector('button[name="load-from-folder"]'); + const folderLoaderErrorMessage = document.querySelector('.app-folder-loader-error'); + + loadFromFolderLink.addEventListener('click', (e) => { + e.preventDefault(); + folderInput.value = `https://da.live/#/${context.org}/${context.repo}/drafts`; + loaderRow.classList.toggle('open'); + }); + + loadFromFolderButton.addEventListener('click', async (e) => { + folderLoaderErrorMessage.textContent = ''; + folderLoaderErrorMessage.style.display = 'none'; + + e.preventDefault(); + const folder = folderInput.value; + let url; + try { + url = new URL(folder); + } catch (error) { + folderLoaderErrorMessage.textContent = `Invalid folder URL: ${error.message}`; + folderLoaderErrorMessage.style.display = 'block'; + return; + } + + if (!folder.startsWith(`https://da.live/#/${context.org}/${context.repo}`)) { + folderLoaderErrorMessage.textContent = `Folder URL must be a valid da.live folder URL - example: https://da.live/#/${context.org}/${context.repo}/...`; + folderLoaderErrorMessage.style.display = 'block'; + return; + } + + const pathname = url.hash.replace(`#/${context.org}/${context.repo}`, ''); + const listUrl = `${ADMIN_URL}/list/${context.org}/${context.repo}${pathname}`; + const resp = await daFetch(listUrl); + if (!resp.ok) { + folderLoaderErrorMessage.textContent = `Failed to load folder list: ${resp.statusText}`; + folderLoaderErrorMessage.style.display = 'block'; + return; + } + + const list = await resp.json(); + urlsTextarea.value = list.filter((item) => item.ext === 'html').map((item) => `https://da.live/edit#${item.path.replace(/\.html$/, '')}`).join('\n'); + loaderRow.classList.toggle('open'); + }); + + const updateStatus = (listItem, status, text) => { + let statusEl = listItem.querySelector('.status'); + if (!statusEl) { + statusEl = document.createElement('span'); + statusEl.className = 'status'; + listItem.appendChild(statusEl); + } + statusEl.className = `status ${status}`; + statusEl.innerHTML = text; + }; + + translateButton.addEventListener('click', async (e) => { + e.preventDefault(); + if (errorMessage) { + errorMessage.textContent = ''; + errorMessage.style.display = 'none'; + } + const urls = urlsTextarea.value.split('\n').filter((url) => url.trim() !== ''); + if (urls.length === 0) { + errorMessage.textContent = 'Please enter a list of URLs to translate'; + errorMessage.style.display = 'block'; + return; + } + + outputList.innerHTML = ''; + + // eslint-disable-next-line no-restricted-syntax + for (let i = 0; i < urls.length; i += 1) { + // Add a numbered badge to the left of the list item + const badge = document.createElement('span'); + badge.className = 'app-li-number'; + badge.textContent = `${i + 1}.`; + badge.style.fontWeight = 'bold'; + badge.style.marginRight = '8px'; + + const listItem = document.createElement('li'); + const a = document.createElement('a'); + a.href = urls[i]; + a.textContent = urls[i]; + listItem.appendChild(a); + listItem.insertBefore(badge, listItem.firstChild); + outputList.appendChild(listItem); + + let url; + try { + url = new URL(urls[i]); + } catch (error) { + updateStatus(listItem, 'error', 'Invalid URL format'); + continue; + } + + // Validate URL format + // Expected: https://----. or https://da.live/edit#// + const isDaLiveEditUrl = url.toString().startsWith(`https://da.live/edit#/${context.org}/${context.repo}/`); + const isPreviewUrl = url.hostname.includes(`${context.repo}--${context.org}`); + if (!isPreviewUrl && !isDaLiveEditUrl) { + updateStatus(listItem, 'error', 'Must be a URL from this organization and repository'); + } else { + updateStatus(listItem, 'loading', 'Loading'); + + try { + const resourcePath = isDaLiveEditUrl ? url.hash.replace(/^#/, '').replace(`/${context.org}/${context.repo}`, '') : url.pathname; + let sourceUrl = `${ADMIN_URL}/source/${context.org}/${context.repo}${resourcePath}`; + + // if needed, append .html + if (!sourceUrl.endsWith('.html')) { + sourceUrl += '.html'; + } + + let resp = await daFetch(sourceUrl); + if (!resp.ok) { + updateStatus(listItem, 'error', `Page content cannot be retrieved: (${resp.statusText})`); + continue; + } + const html = await resp.text(); + + updateStatus(listItem, 'translating', 'Translating'); + + context.sourcePath = resourcePath; + + const translatedHtml = await translate( + html, + languageSelect.value, + context, + undefined, // could be both + daFetch, + ); + + updateStatus(listItem, 'translated', 'Translated'); + + const blob = new Blob([translatedHtml], { type: 'text/html' }); + const formData = new FormData(); + formData.append('data', blob); + const opts = { method: 'PUT', body: formData }; + resp = await daFetch(sourceUrl, opts); + if (!resp.ok) { + updateStatus(listItem, 'error', `Failed to save translated HTML: (${resp.statusText})`); + } + const daHref = `https://da.live/edit#/${context.org}/${context.repo}${resourcePath}`; + updateStatus(listItem, 'saved', `Translated page saved! ${EDIT_ICON_SVG}`); + } catch (error) { + // eslint-disable-next-line no-console + console.error('Error retrieving page content', error); + updateStatus(listItem, 'error', `Page content cannot be retrieved: (${error.message || 'Check console for details'})`); + } + } + } + }); +}()); diff --git a/tools/translate/plugin.css b/tools/translate/plugin.css new file mode 100644 index 00000000..a86838fb --- /dev/null +++ b/tools/translate/plugin.css @@ -0,0 +1,122 @@ +@import url('./shared.css'); + +.translate-container { + width: min(560px, 100%); +} + +.translate-form { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 20px; + box-shadow: var(--shadow); + display: grid; + gap: 16px; +} + +.translate-input, +.translate-output { + display: grid; + gap: 12px; +} + +.translate-error { + color: #c62828; + font-size: 13px; + min-height: 16px; + display: none; +} + +.translate-input { + grid-template-columns: minmax(0, 1fr) 200px; + align-items: start; +} + +textarea { + width: 100%; + min-height: 120px; + resize: vertical; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 14px 16px; + font-size: 15px; + line-height: 1.5; + color: var(--text); + background: #fff; + box-shadow: none; +} + +textarea[name="output"] { + background: #f7f8fa; +} + +select { + width: 100%; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 14px; + font-size: 14px; + color: var(--text); + background: #fff; + box-shadow: none; +} + +textarea:focus, +select:focus, +button:focus { + outline: none; + border-color: var(--primary); + box-shadow: 0 0 0 3px var(--ring); +} + +button { + border: none; + border-radius: var(--radius-sm); + padding: 10px 16px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: transform 0.08s ease, box-shadow 0.2s ease, background 0.2s ease; +} + +button[name="translate"] { + background: var(--primary); + color: #fff; + box-shadow: none; +} + +button[name="translate"]:hover { + background: var(--primary-dark); +} + +button[name="replace"] { + background: #f3f4f6; + color: var(--text); + border: 1px solid var(--border); +} + +button[name="replace"]:hover { + background: #e7eaee; +} + +@media (width <= 720px) { + .translate-form { + padding: 20px; + } + + .translate-input { + grid-template-columns: 1fr; + } + + button { + width: 100%; + } +} + +@media (width <= 500px) { + .translate-input textarea, + .translate-output textarea, + button[name="replace"] { + display: none; + } +} \ No newline at end of file diff --git a/tools/translate/plugin.html b/tools/translate/plugin.html new file mode 100644 index 00000000..6512f532 --- /dev/null +++ b/tools/translate/plugin.html @@ -0,0 +1,30 @@ + + + + Translation plugin + + + + + + +
    +
    +
    + + +
    +
    + +
    + +
    + +
    +
    + + diff --git a/tools/translate/plugin.js b/tools/translate/plugin.js new file mode 100644 index 00000000..7040ca60 --- /dev/null +++ b/tools/translate/plugin.js @@ -0,0 +1,82 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +// eslint-disable-next-line import/no-unresolved +import DA_SDK from 'https://da.live/nx/utils/sdk.js'; +import { preprocess, translate, EDITOR_FORMAT } from './shared.js'; + +(async function init() { + // eslint-disable-next-line no-unused-vars + const { context, token, actions } = await DA_SDK; + + const isLightVersion = window.innerWidth < 500; + + let selection = 'No text selected.'; + try { + selection = await actions.getSelection(); + selection = await preprocess(selection, EDITOR_FORMAT, context, actions.daFetch); + } catch (error) { + // ignore + } + + const inputTextarea = document.querySelector('textarea[name="input"]'); + inputTextarea.value = selection; + + const outputTextarea = document.querySelector('textarea[name="output"]'); + outputTextarea.value = ''; + + const languageSelector = document.querySelector('select[name="language"]'); + + const translateBtn = document.querySelector('button[name="translate"]'); + const errorMessage = document.querySelector('.translate-error'); + + translateBtn.addEventListener('click', async (e) => { + e.preventDefault(); + if (errorMessage) { + errorMessage.textContent = ''; + errorMessage.style.display = 'none'; + } + let translation = ''; + context.sourcePath = context.path; + try { + translation = await translate( + inputTextarea.value, + languageSelector.value, + context, + EDITOR_FORMAT, + actions.daFetch, + true, + ); + } catch (error) { + if (errorMessage) { + errorMessage.textContent = error?.message || 'Translation failed.'; + errorMessage.style.display = 'block'; + } + return; + } + + if (isLightVersion) { + actions.sendHTML(translation); + actions.closeLibrary(); + } else { + outputTextarea.value = translation; + } + }); + + const replaceBtn = document.querySelector('button[name="replace"]'); + replaceBtn.textContent = 'Replace'; + replaceBtn.addEventListener('click', async (e) => { + e.preventDefault(); + actions.sendHTML(outputTextarea.value); + actions.closeLibrary(); + }); +}()); diff --git a/tools/translate/shared.css b/tools/translate/shared.css new file mode 100644 index 00000000..c017a5b6 --- /dev/null +++ b/tools/translate/shared.css @@ -0,0 +1,34 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + color-scheme: light; + + --bg: #f5f6f7; + --card: #fff; + --text: #1d1d1f; + --muted: #5b6169; + --border: #d9dde3; + --primary: #2a64d3; + --primary-dark: #1f4da3; + --ring: rgb(42 100 211 / 20%); + --shadow: 0 2px 8px rgb(28 39 52 / 8%); + --radius: 10px; + --radius-sm: 8px; +} + +body { + margin: 0; + font-family: "Adobe Clean", Inter, "Helvetica Neue", Arial, sans-serif; + color: var(--text); + background: var(--bg); + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; +} + diff --git a/tools/translate/shared.js b/tools/translate/shared.js new file mode 100644 index 00000000..adab58ea --- /dev/null +++ b/tools/translate/shared.js @@ -0,0 +1,446 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +const ADMIN_URL = 'https://admin.da.live'; +// const ADMIN_URL = 'https://stage-admin.da.live'; +// const ADMIN_URL = 'http://localhost:8787'; + +const TRANSLATION_SERVICE_URL = 'https://translate.da.live/google'; + +const CONFIG_PATH = '/.da/translate.json'; +const CONFIG_CONTENT_DNT_SHEET = 'dnt-content-rules'; +const CONFIG_METADATA_FIELDS_SHEET = 'dt-metadata-fields'; +const METADATA_FIELDS_TO_TRANSLATE = ['title', 'description']; + +const EDITOR_FORMAT = 'table'; +const ADMIN_FORMAT = 'div'; + +const HELPERS = { + [EDITOR_FORMAT]: { + getMetadataTable: (html) => { + const tables = [...html.querySelectorAll('table')]; + for (let i = 0; i < tables.length; i += 1) { + const table = tables[i]; + const rows = table.querySelectorAll('tr'); + if (rows.length > 0) { + const metadataTable = rows[0].textContent.toLowerCase().trim() === 'metadata'; + if (metadataTable) { + return table; + } + } + } + return null; + }, + }, + [ADMIN_FORMAT]: { + getMetadataTable: (html) => html.querySelector('div[class=metadata]'), + }, +}; + +const ICONS_RULE = { + description: 'Icon names should be not translated', + apply: (html) => { + const text = html.body.textContent; + const regex = /:([a-zA-Z0-9-_]+):/g; + const matches = text.match(regex); + + if (matches) { + matches.forEach((match) => { + // ignore if only digits (could be a timestamp) + if (/^:[\d:]+:$/i.test(match)) { + return; + } + html.body.innerHTML = html.body.innerHTML.replace(match, `${match}`); + }); + } + return html; + }, +}; + +const CONTENT_DNT_RULE = { + description: 'Specific content fragments should be not translated', + apply: (html, config) => { + const fragments = config[CONFIG_CONTENT_DNT_SHEET]?.data || []; + if (fragments.length === 0) { + return html; + } + const elements = html.querySelectorAll('p, h1, h2, h3, h4, h5, h6, li, a'); + elements.forEach((element) => { + const text = element.textContent; + fragments.forEach((fragment) => { + const { content } = fragment; + if (content && text.includes(content)) { + element.innerHTML = element.innerHTML.replace(content, `${content}`); + } + }); + }); + return html; + }, +}; + +/** Rules applied once in all cases (editor and admin format). */ +const GENERIC_RULES = [ICONS_RULE, CONTENT_DNT_RULE]; + +const RULES = { + [EDITOR_FORMAT]: [{ + description: 'First row of any table should be not translated', + apply: (html) => { + const tables = html.querySelectorAll('table'); + tables.forEach((table) => { + const rows = table.querySelectorAll('tr'); + if (rows.length > 0) { + rows[0].setAttribute('translate', 'no'); + } + }); + return html; + }, + }, { + description: 'Property names of the "metadata" table should be not translated (except Title and Description or the ones in the config)', + apply: (html, config) => { + const keys = (() => { + const data = config[CONFIG_METADATA_FIELDS_SHEET]?.data || []; + const ks = data.filter((f) => f.metadata).map((f) => f.metadata.toLowerCase().trim()); + ks.push(...METADATA_FIELDS_TO_TRANSLATE.map((f) => f.toLowerCase().trim())); + return [...new Set(ks)]; + })(); + + const table = HELPERS[EDITOR_FORMAT].getMetadataTable(html); + if (table) { + table.setAttribute('translate', 'no'); + const rows = table.querySelectorAll('tr'); + rows.forEach((row) => { + const keyEl = row.querySelector('td:first-child'); + const valueEl = row.querySelector('td:last-child'); + const key = keyEl?.textContent.toLowerCase().trim(); + const value = valueEl?.textContent.toLowerCase().trim(); + if (key && value && keys.includes(key)) { + valueEl.setAttribute('translate', 'yes'); + } + }); + } + + return html; + }, + }, { + description: 'section metadata table should be not translated', + apply: (html) => { + const tables = html.querySelectorAll('table'); + tables.forEach((table) => { + const rows = table.querySelectorAll('tr'); + if (rows.length > 0) { + const name = rows[0].textContent.toLowerCase().trim(); + const sectionMetadata = name === 'section metadata' || name === 'section-metadata'; + if (sectionMetadata) { + table.setAttribute('translate', 'no'); + } + } + }); + return html; + }, + }], + [ADMIN_FORMAT]: [{ + description: 'First column of all rows in "metadata" block should be not translated', + apply: (html, config) => { + const keys = (() => { + const data = config[CONFIG_METADATA_FIELDS_SHEET]?.data || []; + const ks = data.filter((f) => f.metadata).map((f) => f.metadata.toLowerCase().trim()); + ks.push(...METADATA_FIELDS_TO_TRANSLATE.map((f) => f.toLowerCase().trim())); + return [...new Set(ks)]; + })(); + + const table = HELPERS[ADMIN_FORMAT].getMetadataTable(html); + if (table) { + table.setAttribute('translate', 'no'); + table.querySelectorAll('& > div').forEach((row) => { + const keyEl = row.querySelector('div:first-child'); + const valueEl = row.querySelector('div:last-child'); + const key = keyEl?.textContent.toLowerCase().trim(); + const value = valueEl?.textContent.toLowerCase().trim(); + if (key && value && keys.includes(key)) { + valueEl.setAttribute('translate', 'yes'); + } + }); + } + return html; + }, + }, { + description: 'section metadata block should be not translated', + apply: (html) => { + const divs = html.querySelectorAll('div[class=section-metadata]'); + divs.forEach((div) => { + div.setAttribute('translate', 'no'); + }); + return html; + }, + }], +}; + +const FORMAT_RULES = { + [EDITOR_FORMAT]: [ + { + description: 'Metadata property: convert commat separated list to ul list', + preProcess: (html) => { + const table = HELPERS[EDITOR_FORMAT].getMetadataTable(html); + if (table) { + const rows = table.querySelectorAll('tr'); + rows.forEach((row) => { + const keyEl = row.querySelector('td:first-child'); + const valueEl = row.querySelector('td:last-child'); + const key = keyEl?.textContent.toLowerCase().trim(); + const value = valueEl?.textContent.toLowerCase().trim(); + if (key && value && key !== 'title' && key !== 'description') { + const list = value.split(',').map((item) => item.trim()); + if (list.length > 1) { + valueEl.innerHTML = `
      ${list.map((item) => `
    • ${item}
    • `).join('')}
    `; + } + } + }); + } + return html; + }, + postProcess: (html) => { + // revert operation of preProcess + const table = HELPERS[EDITOR_FORMAT].getMetadataTable(html); + if (table) { + const uls = table.querySelectorAll('ul'); + uls.forEach((ul) => { + const list = []; + const lis = ul.querySelectorAll('li'); + lis.forEach((li) => { + list.push(li.textContent.trim()); + }); + ul.parentElement.textContent = list.join(', '); + }); + } + return html; + }, + }, + ], + [ADMIN_FORMAT]: [{ + description: 'Metadata property: convert commat separated list to ul list', + preProcess: (html) => { + const table = HELPERS[ADMIN_FORMAT].getMetadataTable(html); + if (table) { + const rows = table.querySelectorAll('& > div'); + rows.forEach((row) => { + const keyEl = row.querySelector('div:first-child'); + const key = keyEl?.textContent.toLowerCase().trim(); + const valueEl = row.querySelector('div:last-child'); + const value = valueEl?.textContent.toLowerCase().trim(); + if (key && value && key !== 'title' && key !== 'description') { + const list = value.split(',').map((item) => item.trim()); + if (list.length > 1) { + valueEl.innerHTML = `
      ${list.map((item) => `
    • ${item}
    • `).join('')}
    `; + } + } + }); + } + return html; + }, + postProcess: (html) => { + // revert operation of preProcess + const table = HELPERS[ADMIN_FORMAT].getMetadataTable(html); + if (table) { + const uls = table.querySelectorAll('ul'); + uls.forEach((ul) => { + const list = []; + const lis = ul.querySelectorAll('li'); + lis.forEach((li) => { + list.push(li.textContent.trim()); + }); + ul.parentElement.textContent = list.join(', '); + }); + } + return html; + }, + }], +}; + +const getConfig = async (context, daFetch) => { + const resp = await daFetch(`${ADMIN_URL}/source/${context.org}/${context.repo}${CONFIG_PATH}`); + if (!resp.ok) { + return {}; + } + const config = await resp.json(); + return config || {}; +}; + +const addDnt = async (html, format, context, daFetch) => { + let result = html; + const config = await getConfig(context, daFetch); + + // Generic rules: applied once in all cases + GENERIC_RULES.forEach((rule) => { + result = rule.apply(result, config); + }); + + // Format-specific rules + let rules; + if (format) { + rules = RULES[format]; + } else { + rules = [...RULES[ADMIN_FORMAT], ...RULES[EDITOR_FORMAT]]; + } + if (rules) { + rules.forEach((rule) => { + result = rule.apply(result, config); + }); + } + return result; +}; + +const reformat = (html, format) => { + let result = html; + let rules; + if (format) { + rules = FORMAT_RULES[format]; + } else { + rules = [...FORMAT_RULES[ADMIN_FORMAT], ...FORMAT_RULES[EDITOR_FORMAT]]; + } + if (rules) { + rules.forEach((rule) => { + result = rule.preProcess(html); + }); + } + return result; +}; + +const unformat = (html, format) => { + let result = html; + let rules; + if (format) { + rules = FORMAT_RULES[format]; + } else { + rules = [...FORMAT_RULES[ADMIN_FORMAT], ...FORMAT_RULES[EDITOR_FORMAT]]; + } + if (rules) { + rules.forEach((rule) => { + result = rule.postProcess(html); + }); + } + return result; +}; + +const preprocess = async (text, format, context, daFetch) => { + let html = new DOMParser().parseFromString(text, 'text/html'); + + // if body has one single table, with one single row and one single cell, unwrap the cell + // most likely a cell content selection (read as a single cell table) + const { body } = html; + const table = body.querySelector('table'); + if (table && table.rows.length === 1 && table.rows[0].cells.length === 1) { + const cell = table.rows[0].cells[0]; + body.innerHTML = cell.innerHTML; + } + + html = await addDnt(html, format, context, daFetch); + html = reformat(html, format); + return html.documentElement.outerHTML; +}; + +const removeDnt = (html) => { + html.querySelectorAll('[translate]').forEach((element) => { + element.removeAttribute('translate'); + + if (element.tagName === 'SPAN') { + element.replaceWith(element.textContent); + } + }); + return html; +}; + +const adjustURLs = (html, context) => { + const { sourcePath } = context; + // test if path starts with //. + const pathPrefixRegex = /^\/?[a-z]{2}\/[a-z]{2}[-_][a-z]{2}(?=\/|$)/; + const isLocalPath = pathPrefixRegex.test(sourcePath); + const pathSegments = sourcePath.replace(/^\/+/, '').split('/'); + const basePrefix = pathSegments.length >= 2 ? `/${pathSegments[0]}/${pathSegments[1]}` : ''; + if (isLocalPath && basePrefix) { + html.querySelectorAll('a[href]').forEach((element) => { + if (!element.href) return; + const { pathname } = new URL(element.href); + + if (pathPrefixRegex.test(pathname)) { + // replace the first 2 segments of the pathname with the first 2 segments of the path + const newPathname = pathname.replace(pathPrefixRegex, basePrefix); + const newHref = element.href.replace(pathname, newPathname); + if (element.textContent === element.href) { + element.textContent = newHref; + } + element.href = newHref; + } + }); + } + return html; +}; + +const postProcess = (text, context, format) => { + let html = new DOMParser().parseFromString(text, 'text/html'); + html = removeDnt(html); + html = unformat(html, format); + html = adjustURLs(html, context); + // remove start tag and end tag + return html.documentElement.outerHTML.replace(/^<\/head>/, '').replace(/<\/body><\/html>$/, ''); +}; + +const translate = async (htmlInput, language, context, format, daFetch, skipPreprocess = false) => { + let html = htmlInput; + if (!skipPreprocess) { + html = await preprocess(html, format, context, daFetch); + } + const splits = []; + const maxChunk = 30000; + let start = 0; + while (start < html.length) { + const target = Math.min(start + maxChunk, html.length); + let splitIndex = target; + if (target < html.length) { + // find the last closing tag before the target + const chunk = html.slice(start, target); + const matches = [...chunk.matchAll(/<\/[a-zA-Z][^>]*>/g)]; + if (matches.length > 0) { + const last = matches[matches.length - 1]; + splitIndex = start + last.index + last[0].length; + } + } + splits.push(html.slice(start, splitIndex)); + start = splitIndex; + } + + const translateSplit = async (split) => { + const body = new FormData(); + body.append('data', split); + body.append('fromlang', 'en'); + body.append('tolang', language); + + const opts = { method: 'POST', body }; + const resp = await fetch(TRANSLATION_SERVICE_URL, opts); + if (!resp.ok) { + throw new Error(`Translation failed: ${resp.status}`); + } + + const json = await resp.json(); + if (!json.translated) { + throw new Error(json.error || 'Failed to translate'); + } + return json.translated; + }; + + const translatedParts = await Promise.all(splits.map((split) => translateSplit(split))); + const combined = translatedParts.join(''); + return postProcess(combined, context, format); +}; + +export { + preprocess, translate, EDITOR_FORMAT, ADMIN_FORMAT, ADMIN_URL, +}; diff --git a/widgets/WIDGETS-ENGLISH-LITERALS.md b/widgets/WIDGETS-ENGLISH-LITERALS.md new file mode 100644 index 00000000..4e50df7f --- /dev/null +++ b/widgets/WIDGETS-ENGLISH-LITERALS.md @@ -0,0 +1,245 @@ +# English literals in `/widgets` (copy & fallbacks) + +All user-facing English strings used as copy, fallbacks (`|| '...'`, `?? '...'`), or hardcoded in HTML/JS across widgets. Forms use locale JSON for labels and input hints; their **default English fallbacks** are included where set in JS. Literals are listed once with every widget that uses them. + +--- + +## Literal → Widget(s) + +| English literal | Widget(s) | +|-----------------|-----------| +| **—** (em dash, empty value) | compare-products | +| **×** (remove button) | compare-products | +| **✓** (checkmark) | compare-products | +| **0 of 5 stars** | recipe-center | +| **1** (pagination) | search-results, recipe-center, article-center | +| **2-4 Individuals** | blender-recommender | +| **4+ Individuals** | blender-recommender | +| **Address, City, or Zipcode** | locator | +| **All** | search-results | +| **Add a product to compare** | compare-products (translation key) | +| **Add to comparison** | compare-products (translation key) | +| **All selected. Remove one to add another.** | compare-products (translation key) | +| **Article** | search-results | +| **Articles** (e.g. Newest Articles) | article-center | +| **Back** | blender-recommender | +| **Blending Needs** | blender-recommender | +| **Blending Programs** | compare-products | +| **By** | article-center | +| **CAD** | compare-products | +| **Check** | compare-products (translation key) | +| **Clear All** | recipe-center | +| **Color** / **Colors** | compare-products | +| **Commercial Events** | locator | +| **Commercial Products** | locator | +| **Compatible Containers** | recipe-center | +| **Continue** | blender-recommender | +| **Course** | recipe-center | +| **Could not load this product.** | compare-products (translation key) | +| **Countertop Blender** | compare-products | +| **Default** | article-center | +| **Demonstrations** | locator | +| **Dietary** | recipe-center | +| **Dietary Interests** | recipe-center | +| **Digital Timer** | compare-products | +| **Dimensions (L × W × H)** | compare-products | +| **Distributors** | locator | +| **Difficulty** | recipe-center | +| **Dressings** (and other q2 options) | blender-recommender | +| **En savoir plus** | compare-products (translation key) | +| **Featured** | recipe-center | +| **Find Locally** | locator | +| **Finish** | blender-recommender | +| **from comparison** | compare-products (translation key) | +| **Go** | simple-search, recipe-center | +| **Household Events** | locator | +| **I Prefer Basic Colors** | blender-recommender | +| **I Want a Variety of Color Options** | blender-recommender | +| **Items** | recipe-center | +| **Just Me** | blender-recommender | +| **Local Representatives** | locator | +| **miles away** | locator | +| **Name (A-Z)** | recipe-center | +| **Name (Z-A)** | recipe-center | +| **Name (optional)** | blender-recommender | +| **Newest Articles** | article-center | +| **Next** | search-results, recipe-center, article-center, blender-recommender | +| **No countertop blenders found.** | compare-products (translation key) | +| **Now** | compare-products (translation key) | +| **of** | search-results, recipe-center, article-center | +| **Oldest Articles** | article-center | +| **Online Retailers** | locator | +| **Page** | search-results | +| **Phone: ** | locator | +| **Previous** | search-results, recipe-center, article-center | +| **product** | compare-products (translation key) | +| **Product** | search-results | +| **Product not found (404).** | compare-products (translation key) | +| **Pulse** | compare-products | +| **Recipe** | search-results | +| **Recipe Type** | recipe-center | +| **Refine Your Recipe** | recipe-center | +| **Refine Your Search** | recipe-center | +| **Remove** | compare-products (translation key) | +| **Remove from comparison** | compare-products (translation key) | +| **Retailers** | locator | +| **Save** | compare-products (translation key) | +| **Search** | simple-search, search-results, article-center, locator | +| **See Results** | recipe-center | +| **Self-Detect Technology** | compare-products | +| **Series** | compare-products | +| **Show** / **Showing** | search-results, article-center | +| **Sort By** / **Sort by** | recipe-center, article-center | +| **Starting at** | compare-products (translation key) | +| **Tamper Indicator** | compare-products | +| **Time (High to Low)** | recipe-center | +| **Time (Low to High)** | recipe-center | +| **Touch Buttons** | compare-products | +| **Type to search recipes** | recipe-center | +| **Variable Speed Control** | compare-products | +| **View Details** | compare-products (translation key) | +| **Was** | compare-products (translation key) | +| **Warranty** | compare-products | +| **Website: ** | locator | +| **What are you looking for?** | locator | +| **Yes** | compare-products (translation key) | +| **Your Location** | locator | +| **…** (ellipsis) | search-results, recipe-center, article-center | +| **+15 secondes** / **Plus 15 Second Button** | compare-products | +| ** - ** (dash between range) | recipe-center | +| **USD** | compare-products | + +--- + +## Used by multiple widgets + +These literals appear in more than one widget (good candidates for shared i18n). + +### Non-form widgets + +| Literal | Widgets | +|---------|---------| +| **1** (pagination) | search-results, recipe-center, article-center | +| **Go** | simple-search, recipe-center | +| **Next** | search-results, recipe-center, article-center, blender-recommender | +| **of** | search-results, recipe-center, article-center | +| **Previous** | search-results, recipe-center, article-center | +| **Search** | simple-search, search-results, article-center, locator | +| **Showing** | search-results, article-center | +| **…** (ellipsis) | search-results, recipe-center, article-center | + +### Forms + +| Literal | Widgets | +|---------|---------| +| **Address** | product-registration, manage-address | +| **Address Line 2** | product-registration, manage-address | +| **Additional comments** | wellness-program, media-contact | +| **City** | product-registration, manage-address | +| **Do you own a Vitamix?** | edit-account, create-account | +| **Email Address** | product-registration, media-contact, login, edit-account, create-account, contact-us | +| **First Name** | product-registration, media-contact, manage-address, edit-account, create-account, contact-us | +| **Last Name** | product-registration, media-contact, manage-address, edit-account, create-account, contact-us | +| **Phone Number** | product-registration, wellness-program, media-contact, manage-address | +| **Postal code** | product-registration, manage-address, edit-account, create-account | +| **Submit** | wellness-program, media-contact, login, contact-us | +| **Yes** | edit-account, create-account | +| **No** | edit-account, create-account | + +--- + +## By widget (summary) + +### compare-products +Translation keys (via `t()`) used as UI text; actual copy comes from the widget’s local JSON. Hardcoded literals: feature row names (Series, Blending Programs, Variable Speed Control, Touch Buttons, Pulse, Digital Timer, Self-Detect Technology, Tamper Indicator, Plus 15 Second Button, Warranty, Dimensions (L × W × H), Colors), currency (USD, CAD), Countertop Blender, Color, and symbols ×, ✓, —. + +### search-results +Placeholder/fallback literals: Article, Recipe, Page, Product, All, Previous, Next, Search, Showing, of; pagination uses numeric strings and ellipsis (…). + +### simple-search (form) +Hardcoded in HTML: **Search** (input hint, label, aria-label), **Go** (button). + +### locator +HTML: Find Locally, Your Location, Address, City, or Zipcode, What are you looking for?, Commercial Products, Demonstrations, Search, Retailers, Online Retailers, Distributors, Local Representatives, Household Events, Commercial Events. JS: "miles away", "Phone: ", "Website: ". + +### recipe-center +Placeholder fallbacks: Difficulty, Compatible Containers, Dietary Interests, Course, Recipe Type, Previous, Next, Clear All, Type to search recipes, Go, Items, of, Featured, Sort By, Name (A-Z), Name (Z-A), Time (Low to High), Time (High to Low), Refine Your Recipe, See Results, Refine Your Search, Dietary, Course, Recipe Type. HTML defaults: same in recipe-center.html. Accessibility: "0 of 5 stars", "☆". + +### article-center +Placeholder fallbacks: By, Previous, Next, Search, Showing, of, Sort by, Newest Articles, Default, Oldest Articles, Name A-Z, Name Z-A. + +### blender-recommender +Large set of English (and French) strings in default content: intro (eyebrow, headline, name/email labels, promo, Skip, Continue), quiz (tab names, question text, options e.g. Durable & Simple, Smoothies, Meal Prep, Just Me, 2-4 Individuals, 4+ Individuals, color options), results (Meet Your New Blender, More Details, Start Over, etc.), and static blocks (Features & Details, Why Buy a Vitamix, Free Shipping, etc.). All are literals in `blender-recommender.js`. + +--- + +## Forms (locale-driven; English fallbacks in JS/JSON) + +Forms use `labels` and `inputPlaceholders` from locale JSON; the following are the **default English** values used when locale strings are missing (`?? '...'` in JS or in JSON). + +| Literal | Widget (form) | +|---------|----------------| +| About your blender | product-registration | +| About you | product-registration | +| Account Information | edit-account | +| Additional Comments (Optional) | media-contact | +| Address | product-registration, manage-address | +| Address Line 2 | product-registration, manage-address | +| All fields are mandatory... | edit-account | +| At home | product-registration | +| Business Line | media-contact | +| Cancel | manage-address | +| Choose your province | product-registration | +| City | product-registration, manage-address | +| Clear form | product-registration | +| Click here to consult our | product-registration | +| Commercial | contact-us | +| Company | manage-address | +| Confirm Email Address | create-account | +| Contact Information | manage-address | +| Create account | create-account | +| Default | article-center (sort) | +| Domestic | contact-us | +| Do you own a Vitamix? | edit-account, create-account | +| Email Address | product-registration, media-contact, login, edit-account, create-account, contact-us | +| Find your serial number | product-registration | +| First Name | product-registration, media-contact, manage-address, edit-account, create-account, contact-us | +| For commercial products | create-account | +| For domestic products | create-account | +| I accept the terms & conditions... | product-registration | +| I plan to use it | product-registration | +| In a business | product-registration | +| Last Name | (same forms as First Name) | +| Manage address | manage-address | +| No, do not send me electronic mail | edit-account | +| Order Number | order-status | +| Other remarks / Additional comments | wellness-program, media-contact | +| Phone Number | product-registration, wellness-program, media-contact, manage-address | +| Please select a region, state or province | manage-address | +| Postal code | product-registration, manage-address, edit-account, create-account | +| Postal code (optional) | edit-account | +| Privacy policy / terms links | product-registration | +| Publication / Company (Optional) | media-contact | +| Purchased from | product-registration | +| Purchased on | product-registration | +| Reason for communication | contact-us | +| Reason for Contact | media-contact | +| Register | product-registration | +| Save address | manage-address | +| Save changes | edit-account | +| Search Order | order-status | +| Select / Select an option | contact-us, product-registration | +| Serial number / (18 digits) | product-registration | +| Sending... / Searching... | multiple forms | +| Submit | wellness-program, media-contact, login, contact-us | +| Type of request | contact-us | +| Use as default billing/shipping address | manage-address | +| Verify Your Email / Enter verification code... | login | +| Yes / No | edit-account, create-account | +| * Required fields | create-account | + +*(Form input-hint values are in each form’s `.json` under `inputPlaceholders`; the table above reflects the English defaults used in JS when the key is missing.)* + +--- + +*Generated from codebase scan of `widgets/` (compare-products, search-results, simple-search, locator, recipe-center, article-center, blender-recommender, and forms).* diff --git a/widgets/article-center/article-center.css b/widgets/article-center/article-center.css new file mode 100644 index 00000000..1e70ea81 --- /dev/null +++ b/widgets/article-center/article-center.css @@ -0,0 +1,335 @@ +/* Article Center Widget */ +.article-center { + max-width: 1200px; + margin: 0 auto; + padding: var(--spacing-600) var(--spacing-400); +} + +/* Title */ +.article-center .title { + font-family: var(--heading-font-family); + font-size: var(--font-size-900); + font-weight: var(--weight-regular); + text-align: center; + margin: 0 0 var(--spacing-400); + color: var(--color-charcoal); +} + +/* Search Controls */ +.article-center .controls { + display: flex; + justify-content: center; + margin-bottom: var(--spacing-400); +} + +.article-center .search { + display: flex; + position: relative; + width: 100%; + max-width: 600px; +} + +.article-center .search input { + flex: 1; + padding: 14px 50px 14px 20px; + border: var(--border-s) solid var(--color-gray-400); + border-radius: 0; + font-size: var(--body-size-m); + background-color: var(--color-white); +} + +.article-center .search input:focus { + outline: none; + border-color: var(--color-charcoal); +} + +.article-center .search-btn { + position: absolute; + right: 0; + top: 0; + bottom: 0; + background: var(--color-charcoal); + border: none; + padding: 0 var(--spacing-100); + cursor: pointer; + color: var(--color-white); + display: flex; + align-items: center; + justify-content: center; +} + +.article-center .search-btn:hover { + background-color: var(--color-gray-800); +} + +/* Results Info Bar */ +.article-center .info { + display: flex; + justify-content: space-between; + align-items: center; + padding: var(--spacing-100) 0; + margin-bottom: var(--spacing-200); + font-size: var(--body-size-s); + color: var(--color-gray-900); +} + +.article-center .info p { + margin: 0; +} + +/* Sort Dropdown */ +.article-center .sort { + position: relative; + font-size: var(--body-size-s); +} + +.article-center .sort summary { + cursor: pointer; + list-style: none; + display: inline-block; + padding: var(--spacing-60) var(--spacing-100); + border: var(--border-s) solid var(--color-gray-400); + border-radius: var(--rounding-s); + background: var(--color-white); +} + +.article-center .sort summary::-webkit-details-marker { + display: none; +} + +.article-center .sort summary::after { + content: ' ▼'; + font-size: 10px; +} + +.article-center .sort[open] summary::after { + content: ' ▲'; +} + +.article-center .sort menu { + position: absolute; + top: 100%; + right: 0; + z-index: 100; + background-color: var(--color-background); + border: var(--border-s) solid var(--color-gray-300); + list-style: none; + padding: 0; + margin: var(--spacing-40) 0 0; + border-radius: var(--rounding-s); + box-shadow: 0 2px 8px var(--shadow-100); + font-size: var(--body-size-m); + min-width: 180px; +} + +.article-center .sort li { + border-bottom: var(--border-s) solid var(--color-gray-200); +} + +.article-center .sort li:last-child { + border-bottom: none; +} + +.article-center .sort button { + display: block; + width: 100%; + padding: var(--spacing-80) var(--spacing-100); + background: none; + border: none; + text-align: left; + cursor: pointer; + font-size: inherit; +} + +.article-center .sort button[aria-pressed="true"]::before { + content: '✓ '; +} + +.article-center .sort button:hover { + background-color: var(--color-gray-100); +} + +/* Pagination */ +.article-center .pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-60); + padding: var(--spacing-600) 0; +} + +/* stylelint-disable-next-line no-descending-specificity */ +.article-center .pagination button { + padding: var(--spacing-60) var(--spacing-100); + background-color: var(--color-white); + color: var(--color-charcoal); + border: var(--border-s) solid var(--color-gray-400); + border-radius: var(--rounding-m); + cursor: pointer; + font-size: var(--body-size-m); + transition: all 0.2s ease; + min-width: 40px; +} + +.article-center .pagination button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.article-center .pagination button:hover:not(:disabled) { + background-color: var(--color-gray-100); + border-color: var(--color-charcoal); +} + +.article-center .pagination button[aria-current="page"] { + background-color: var(--color-charcoal); + color: var(--color-white); + border-color: var(--color-charcoal); +} + +.article-center .pagination .pages { + display: flex; + gap: var(--spacing-40); + align-items: center; +} + +/* Results Grid */ +.article-center .results { + display: flex; + flex-direction: column; + gap: var(--spacing-400); + margin: 0; + padding: 0; + list-style: none; +} + +/* Article Card */ +.article-center .card { + border-bottom: var(--border-s) solid var(--color-gray-200); + padding-bottom: var(--spacing-400); +} + +.article-center .card:last-child { + border-bottom: none; +} + +.article-center .card a { + text-decoration: none; + color: inherit; + display: flex; + gap: var(--spacing-300); + align-items: flex-start; +} + +.article-center .card img { + width: 140px; + height: 100px; + object-fit: cover; + flex-shrink: 0; + background-color: var(--color-gray-200); +} + +.article-center .card .placeholder { + width: 140px; + height: 100px; + flex-shrink: 0; + background-color: var(--color-gray-200); + display: flex; + align-items: center; + justify-content: center; +} + +.article-center .card .content { + flex: 1; + min-width: 0; +} + +.article-center .card h3 { + font-family: var(--heading-font-family); + font-size: var(--font-size-400); + font-weight: var(--weight-medium); + margin: 0 0 var(--spacing-60); + color: var(--color-blue-700, #1a5dab); + line-height: var(--line-height-m); +} + +.article-center .card a:hover h3 { + text-decoration: underline; +} + +.article-center .card .meta { + margin: 0 0 var(--spacing-80); + font-size: var(--body-size-s); + color: var(--color-gray-900); +} + +.article-center .card .meta a { + display: inline; + color: var(--color-blue-700, #1a5dab); +} + +.article-center .card .meta a:hover { + text-decoration: underline; +} + +.article-center .card .description { + margin: 0; + font-size: var(--body-size-m); + color: var(--color-charcoal); + line-height: var(--line-height-l); +} + +/* Highlight search terms */ +.article-center .results .highlight { + background-color: var(--color-yellow, #fff3cd); + padding: 0 2px; +} + +/* Matched tags display */ +.article-center .card .matched-tags { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-60); + margin-top: var(--spacing-80); +} + +.article-center .card .matched-tags .tag { + display: inline-block; + padding: var(--spacing-40) var(--spacing-80); + background-color: var(--color-gray-200); + color: var(--color-charcoal); + font-size: var(--body-size-xs); + border-radius: var(--rounding-s); +} + +.article-center .card .matched-tags .tag .highlight { + background-color: var(--color-yellow, #fff3cd); + padding: 0 2px; +} + +/* Responsive */ +@media (width <= 600px) { + .article-center { + padding: var(--spacing-400) var(--spacing-200); + } + + .article-center .title { + font-size: var(--font-size-700); + } + + .article-center .card a { + flex-direction: column; + } + + .article-center .card img, + .article-center .card .placeholder { + width: 100%; + height: 180px; + } + + .article-center .info { + flex-direction: column; + gap: var(--spacing-100); + align-items: flex-start; + } +} \ No newline at end of file diff --git a/widgets/article-center/article-center.html b/widgets/article-center/article-center.html new file mode 100644 index 00000000..8fc9ecda --- /dev/null +++ b/widgets/article-center/article-center.html @@ -0,0 +1,28 @@ + +
    +
    + +
    +
    +

    1-12 0

    +
    + + +
  • +
  • +
  • +
  • +
  • +
    +
    +
    +
      + +
      diff --git a/widgets/article-center/article-center.js b/widgets/article-center/article-center.js new file mode 100644 index 00000000..5a6bf13e --- /dev/null +++ b/widgets/article-center/article-center.js @@ -0,0 +1,646 @@ +/* eslint-disable max-len */ + +import { loadCSS } from '../../scripts/aem.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** + * Load widget copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (e.g. en, fr) + * @returns {Promise} Copy for that language (flat key-value) + */ +async function loadWidgetCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key] || {}; +} + +/** + * Parses a publication date string (MM.DD.YYYY) into a Date object. + * @param {string} dateString - Date in MM.DD.YYYY format + * @returns {Date|null} Parsed date or null if invalid + */ +function parsePublicationDate(dateString) { + if (!dateString || !dateString.trim()) return null; + + const parts = dateString.split('.'); + if (parts.length !== 3) return null; + + const month = parseInt(parts[0], 10) - 1; // months are 0-indexed + const day = parseInt(parts[1], 10); + const year = parseInt(parts[2], 10); + + const date = new Date(year, month, day); + return Number.isNaN(date.getTime()) ? null : date; +} + +/** + * Formats a publication date for display. + * @param {string} dateString - Date in MM.DD.YYYY format + * @returns {string} Formatted date string + */ +function formatPublicationDate(dateString) { + if (!dateString || !dateString.trim()) return ''; + return dateString; // Keep original format for display +} + +/** + * Parses tags from a comma-separated string into an array. + * @param {string} tagsString - Comma-separated tags + * @returns {Array} Array of trimmed tag strings + */ +function parseTags(tagsString) { + if (!tagsString || !tagsString.trim()) return []; + return tagsString.split(',').map((tag) => tag.trim()).filter((tag) => tag); +} + +/** + * Fetches and filters articles from the article index. + * @param {Object} config - Object with filter criteria + * @returns {Promise>} Array of filtered article objects with match info + */ +async function lookupArticles(config = {}) { + const { locale, language } = getLocaleAndLanguage(); + + if (!window.articleIndex) { + const resp = await fetch(`/${locale}/${language}/articles/query-index.json`); + const { data } = await resp.json(); + + window.articleIndex = { + data: data.map((article) => ({ + path: article.path || '', + title: article.title || '', + image: article.image || '', + description: article.description || '', + author: article.author || '', + 'publication-date': article['publication-date'] || '', + tags: parseTags(article.tags || ''), + })), + }; + } + + // Filter by search if provided + let results = window.articleIndex.data.map((article) => ({ ...article })); + + if (config.search && config.search.trim()) { + const searchTerm = config.search.toLowerCase().trim(); + results = results.filter((article) => { + const titleMatch = article.title.toLowerCase().includes(searchTerm); + const descMatch = article.description.toLowerCase().includes(searchTerm); + const authorMatch = article.author.toLowerCase().includes(searchTerm); + + // Check which tags match + const matchedTags = article.tags.filter((tag) => tag.toLowerCase().includes(searchTerm)); + const tagsMatch = matchedTags.length > 0; + + // Store match info on the article for highlighting + article.matchedAuthor = authorMatch; + article.matchedTags = matchedTags; + article.searchTerm = searchTerm; + + return titleMatch || descMatch || authorMatch || tagsMatch; + }); + } else { + // No search term - clear match info + results.forEach((article) => { + article.matchedAuthor = false; + article.matchedTags = []; + article.searchTerm = ''; + }); + } + + return results; +} + +/** + * Highlights matching substring within text. + * @param {string} text - The full text + * @param {string} searchTerm - The term to highlight + * @returns {string} HTML string with highlighted match + */ +function highlightMatch(text, searchTerm) { + if (!text || !searchTerm) return text; + + const lowerText = text.toLowerCase(); + const lowerSearch = searchTerm.toLowerCase(); + const index = lowerText.indexOf(lowerSearch); + + if (index === -1) return text; + + const before = text.substring(0, index); + const match = text.substring(index, index + searchTerm.length); + const after = text.substring(index + searchTerm.length); + + return `${before}${match}${after}`; +} + +/** + * Converts an absolute image URL to a relative path. + * @param {string} imageUrl - Absolute or relative image URL + * @returns {string} Relative image path + */ +function getRelativeImagePath(imageUrl) { + if (!imageUrl) return ''; + + try { + const url = new URL(imageUrl, window.location.origin); + // Return pathname with query string (for image optimization params) + return url.pathname + url.search; + } catch { + // If URL parsing fails, return the original + return imageUrl; + } +} + +/** + * Creates an article card DOM element for display in the article listing. + * @param {Object} article - Article data object with title, image, description, etc. + * @param {Object} copy - Widget copy (i18n labels) + * @returns {HTMLElement} Article card element + */ +function createArticleCard(article, copy = {}) { + const li = document.createElement('li'); + li.className = 'card'; + + const link = document.createElement('a'); + link.href = article.path || '#'; + + // Image - convert to relative path + let imageElement; + if (article.image) { + const imagePath = getRelativeImagePath(article.image); + const isDefaultImage = imagePath.includes('default-meta-image'); + + if (isDefaultImage || !imagePath) { + imageElement = document.createElement('div'); + imageElement.className = 'img placeholder'; + } else { + imageElement = document.createElement('img'); + imageElement.src = imagePath; + imageElement.alt = ''; + imageElement.loading = 'lazy'; + } + } else { + imageElement = document.createElement('div'); + imageElement.className = 'img placeholder'; + } + + const content = document.createElement('div'); + content.className = 'content'; + + const title = document.createElement('h3'); + title.textContent = article.title || ''; + + const meta = document.createElement('p'); + meta.className = 'meta'; + + const metaParts = []; + if (article['publication-date']) { + metaParts.push(formatPublicationDate(article['publication-date'])); + } + if (article.author) { + // Highlight matching portion of author if it matched the search + const authorDisplay = article.matchedAuthor && article.searchTerm + ? highlightMatch(article.author, article.searchTerm) + : article.author; + const byLabel = copy.by || 'By'; + metaParts.push(`${byLabel}: ${authorDisplay}`); + } + meta.innerHTML = metaParts.join(' | '); + + const description = document.createElement('p'); + description.className = 'description'; + description.textContent = article.description || ''; + + content.append(title, meta, description); + + // Display matched tags if any, with matching portion highlighted + if (article.matchedTags && article.matchedTags.length > 0) { + const tagsContainer = document.createElement('div'); + tagsContainer.className = 'matched-tags'; + article.matchedTags.forEach((tag) => { + const tagSpan = document.createElement('span'); + tagSpan.className = 'tag'; + tagSpan.innerHTML = article.searchTerm ? highlightMatch(tag, article.searchTerm) : tag; + tagsContainer.appendChild(tagSpan); + }); + content.appendChild(tagsContainer); + } + + link.append(imageElement, content); + li.appendChild(link); + + return li; +} + +/** + * Reads query parameters from URL and returns as config object. + * @returns {Object} Configuration object from URL params + */ +function getConfigFromURL() { + const params = new URLSearchParams(window.location.search); + const config = {}; + + params.forEach((value, key) => { + config[key] = value; + }); + + return config; +} + +/** + * Updates URL query parameters to reflect current filter state. + * @param {Object} filterConfig - Current filter configuration + */ +function updateURL(filterConfig) { + const params = new URLSearchParams(); + + Object.keys(filterConfig).forEach((key) => { + // Skip page if it's 1 (default) + if (key === 'page' && filterConfig[key] === 1) { + return; + } + + if (filterConfig[key] && filterConfig[key].trim && filterConfig[key].trim()) { + params.set(key, filterConfig[key]); + } else if (filterConfig[key] && !filterConfig[key].trim) { + if (key !== 'page' || filterConfig[key] !== 1) { + params.set(key, filterConfig[key]); + } + } + }); + + const newURL = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname; + window.history.pushState({ filterConfig }, '', newURL); +} + +/** + * Builds complete article listing with search and sorting functionality. + * @param {HTMLElement} container - Container element to transform into an article listing + * @param {Object} config - Initial filter configuration + * @param {Object} copy - Widget copy (i18n labels) + */ +function buildArticleFiltering(container, config = {}, copy = {}) { + const ITEMS_PER_PAGE = 12; + let currentPage = 1; + + const resultsElement = container.querySelector('.results'); + + // Sort dropdown + const sortDetails = container.querySelector('.sort'); + const sortMenu = container.querySelector('.sort menu'); + const sortLabel = container.querySelector('#sortby'); + + const selectSort = (btn) => { + sortMenu.querySelectorAll('button').forEach((b) => b.removeAttribute('aria-pressed')); + btn.setAttribute('aria-pressed', 'true'); + sortLabel.textContent = btn.textContent; + sortLabel.dataset.sort = btn.dataset.sort; + sortDetails.open = false; + // eslint-disable-next-line no-use-before-define + const filterConfig = createFilterConfig(); + filterConfig.sort = btn.dataset.sort; + // eslint-disable-next-line no-use-before-define + runSearch(filterConfig); + }; + + sortMenu.addEventListener('click', (event) => { + const btn = event.target.closest('button[data-sort]'); + if (btn) selectSort(btn); + }); + + // Highlights search terms in article titles + const highlightResults = (res) => { + const search = document.getElementById('fulltext').value; + if (search) { + res.querySelectorAll('h3').forEach((title) => { + const content = title.textContent; + const offset = content.toLowerCase().indexOf(search.toLowerCase()); + if (offset >= 0) { + title.innerHTML = `${content.substring(0, offset)}${content.substring(offset, offset + search.length)}${content.substring(offset + search.length)}`; + } + }); + } + }; + + // Renders article cards to the results area + const displayResults = async (results, page = 1) => { + resultsElement.innerHTML = ''; + + const startIndex = (page - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + const paginatedResults = results.slice(startIndex, endIndex); + + paginatedResults.forEach((article) => { + resultsElement.append(createArticleCard(article, copy)); + }); + highlightResults(resultsElement); + + if (page > 1) { + container.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + + // Renders pagination controls + const displayPagination = (totalResults, page = 1) => { + const paginationElement = container.querySelector('.pagination'); + if (!paginationElement) return; + + const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE); + paginationElement.innerHTML = ''; + + if (totalPages <= 1) return; + + const prevBtn = document.createElement('button'); + prevBtn.textContent = copy.previous || 'Previous'; + prevBtn.disabled = page <= 1; + if (page > 1) prevBtn.dataset.page = page - 1; + paginationElement.appendChild(prevBtn); + + const pages = document.createElement('span'); + pages.className = 'pages'; + + const ellipsis = () => { + const span = document.createElement('span'); + span.textContent = '…'; + span.setAttribute('aria-hidden', 'true'); + return span; + }; + + // First page + if (page > 3) { + const btn = document.createElement('button'); + btn.textContent = '1'; + btn.dataset.page = '1'; + pages.appendChild(btn); + if (page > 4) pages.appendChild(ellipsis()); + } + + // Pages around current + for (let i = Math.max(1, page - 2); i <= Math.min(totalPages, page + 2); i += 1) { + const btn = document.createElement('button'); + btn.textContent = i; + btn.dataset.page = i; + if (i === page) btn.setAttribute('aria-current', 'page'); + pages.appendChild(btn); + } + + // Last page + if (page < totalPages - 2) { + if (page < totalPages - 3) pages.appendChild(ellipsis()); + const btn = document.createElement('button'); + btn.textContent = totalPages; + btn.dataset.page = totalPages; + pages.appendChild(btn); + } + + paginationElement.appendChild(pages); + + const nextBtn = document.createElement('button'); + nextBtn.textContent = copy.next || 'Next'; + nextBtn.disabled = page >= totalPages; + if (page < totalPages) nextBtn.dataset.page = page + 1; + paginationElement.appendChild(nextBtn); + + // Add event listeners + paginationElement.querySelectorAll('button[data-page]').forEach((btn) => { + btn.addEventListener('click', () => { + const newPage = parseInt(btn.dataset.page, 10); + currentPage = newPage; + // eslint-disable-next-line no-use-before-define + const filterConfig = createFilterConfig(false); + filterConfig.page = newPage; + // eslint-disable-next-line no-use-before-define + runSearch(filterConfig); + }); + }); + }; + + // Creates a filter configuration object from search input + const createFilterConfig = (resetPage = false) => { + const filterConfig = { ...config }; + + filterConfig.search = document.getElementById('fulltext').value; + + if (resetPage) { + currentPage = 1; + filterConfig.page = 1; + } else { + filterConfig.page = currentPage; + } + + return filterConfig; + }; + + // Main search function that filters, sorts, and displays articles + const runSearch = async (filterConfig = config, updateURLState = true) => { + const sorts = { + default: (a, b) => a.title.localeCompare(b.title), + newest: (a, b) => { + const dateA = parsePublicationDate(a['publication-date']); + const dateB = parsePublicationDate(b['publication-date']); + if (!dateA && !dateB) return 0; + if (!dateA) return 1; + if (!dateB) return -1; + return dateB.getTime() - dateA.getTime(); + }, + oldest: (a, b) => { + const dateA = parsePublicationDate(a['publication-date']); + const dateB = parsePublicationDate(b['publication-date']); + if (!dateA && !dateB) return 0; + if (!dateA) return 1; + if (!dateB) return -1; + return dateA.getTime() - dateB.getTime(); + }, + 'name-asc': (a, b) => a.title.localeCompare(b.title), + 'name-desc': (a, b) => b.title.localeCompare(a.title), + }; + + const results = await lookupArticles(filterConfig); + + // Check for sort in filterConfig first, then fall back to UI element + let sortBy = filterConfig.sort || 'newest'; + if (!filterConfig.sort && sortLabel) { + sortBy = sortLabel.dataset.sort; + } + results.sort(sorts[sortBy] || sorts.newest); + + // Get page number from filterConfig or default to 1 + const page = parseInt(filterConfig.page, 10) || 1; + currentPage = page; + + // Update results count and pagination info + const totalResults = results.length; + const startNum = totalResults > 0 ? ((page - 1) * ITEMS_PER_PAGE) + 1 : 0; + const endNum = Math.min(page * ITEMS_PER_PAGE, totalResults); + + container.querySelector('#results-count').textContent = totalResults; + container.querySelector('#results-start').textContent = startNum; + container.querySelector('#results-end').textContent = endNum; + + displayResults(results, page); + displayPagination(totalResults, page); + + if (updateURLState) { + updateURL(filterConfig); + } + }; + + const searchElement = container.querySelector('#fulltext'); + searchElement.addEventListener('input', () => { + runSearch(createFilterConfig(true)); + }); + + // Search button click (form submit) + const form = container.querySelector('form.controls'); + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + runSearch(createFilterConfig(true)); + }); + } + + // Also trigger search on Enter key + searchElement.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + runSearch(createFilterConfig(true)); + } + }); + + // Read initial config from URL params + const urlConfig = getConfigFromURL(); + const initialConfig = { ...config, ...urlConfig }; + + // Set initial page from URL + if (urlConfig.page) { + currentPage = parseInt(urlConfig.page, 10); + } + + // Apply URL params to UI elements + if (urlConfig.search) { + searchElement.value = urlConfig.search; + } + + if (urlConfig.sort) { + if (sortLabel) { + sortLabel.dataset.sort = urlConfig.sort; + const sortBtn = sortMenu.querySelector(`button[data-sort="${urlConfig.sort}"]`); + if (sortBtn) { + sortLabel.textContent = sortBtn.textContent; + sortMenu.querySelectorAll('button').forEach((b) => b.removeAttribute('aria-pressed')); + sortBtn.setAttribute('aria-pressed', 'true'); + } + } + } + + runSearch(initialConfig); + + // Handle browser back/forward buttons + window.addEventListener('popstate', (event) => { + if (event.state && event.state.filterConfig) { + const savedConfig = event.state.filterConfig; + + // Update search input + if (savedConfig.search) { + searchElement.value = savedConfig.search; + } else { + searchElement.value = ''; + } + + // Update sort + if (savedConfig.sort) { + if (sortLabel) { + sortLabel.dataset.sort = savedConfig.sort; + const sortBtn = sortMenu.querySelector(`button[data-sort="${savedConfig.sort}"]`); + if (sortBtn) { + sortLabel.textContent = sortBtn.textContent; + } + } + } + + // Restore page number + if (savedConfig.page) { + currentPage = parseInt(savedConfig.page, 10); + } else { + currentPage = 1; + } + + runSearch(savedConfig, false); + } + }); +} + +// Initialize the article center +async function init() { + const articleCenter = document.querySelector('.article-center'); + if (articleCenter) { + const { language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadWidgetCopy(lang); + + // Move existing H1 into article-center if it exists + const existingH1 = document.querySelector('main h1'); + if (existingH1 && !articleCenter.contains(existingH1)) { + existingH1.classList.add('title'); + articleCenter.insertBefore(existingH1, articleCenter.firstChild); + } + + // Apply copy to HTML + const searchInput = articleCenter.querySelector('#fulltext'); + if (searchInput) { + searchInput.placeholder = copy.search || 'Search'; + } + + const showingLabel = articleCenter.querySelector('.showing-label'); + if (showingLabel) { + showingLabel.textContent = copy.showing || 'Showing'; + } + + const ofLabel = articleCenter.querySelector('.of-label'); + if (ofLabel) { + ofLabel.textContent = copy.of || 'of'; + } + + const sortByLabel = articleCenter.querySelector('.sort-by-label'); + if (sortByLabel) { + sortByLabel.textContent = `${copy.sortBy || 'Sort by'}:`; + } + + // Populate sort options + const sortLabel = articleCenter.querySelector('#sortby'); + if (sortLabel) { + sortLabel.textContent = copy.newestArticles || 'Newest Articles'; + } + + const sortButtons = articleCenter.querySelectorAll('.sort menu button'); + sortButtons.forEach((btn) => { + const sortType = btn.dataset.sort; + if (sortType === 'default') { + btn.textContent = copy.default || 'Default'; + } else if (sortType === 'newest') { + btn.textContent = copy.newestArticles || 'Newest Articles'; + } else if (sortType === 'oldest') { + btn.textContent = copy.oldestArticles || 'Oldest Articles'; + } else if (sortType === 'name-asc') { + btn.textContent = copy.nameAZ || 'Name A-Z'; + } else if (sortType === 'name-desc') { + btn.textContent = copy.nameZA || 'Name Z-A'; + } + }); + + buildArticleFiltering(articleCenter, {}, copy); + } +} + +// Wait for DOM to be ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + loadCSS('/widgets/article-center/article-center.css'); + init(); +} + +export default { lookupArticles }; diff --git a/widgets/article-center/article-center.json b/widgets/article-center/article-center.json new file mode 100644 index 00000000..59f8959a --- /dev/null +++ b/widgets/article-center/article-center.json @@ -0,0 +1,44 @@ +{ + "en": { + "search": "Search", + "showing": "Showing", + "of": "of", + "sortBy": "Sort by", + "newestArticles": "Newest Articles", + "default": "Default", + "oldestArticles": "Oldest Articles", + "nameAZ": "Name A-Z", + "nameZA": "Name Z-A", + "previous": "Previous", + "next": "Next", + "by": "By" + }, + "fr": { + "search": "Rechercher", + "showing": "Affichage", + "of": "sur", + "sortBy": "Trier par", + "newestArticles": "Articles les plus récents", + "default": "Par défaut", + "oldestArticles": "Articles les plus anciens", + "nameAZ": "Nom A-Z", + "nameZA": "Nom Z-A", + "previous": "Précédent", + "next": "Suivant", + "by": "Par" + }, + "es": { + "search": "Buscar", + "showing": "Mostrando", + "of": "de", + "sortBy": "Ordenar por", + "newestArticles": "Artículos más recientes", + "default": "Predeterminado", + "oldestArticles": "Artículos más antiguos", + "nameAZ": "Nombre A-Z", + "nameZA": "Nombre Z-A", + "previous": "Anterior", + "next": "Siguiente", + "by": "Por" + } +} diff --git a/widgets/blender-recommender/blender-recommender.css b/widgets/blender-recommender/blender-recommender.css new file mode 100644 index 00000000..1550a1a5 --- /dev/null +++ b/widgets/blender-recommender/blender-recommender.css @@ -0,0 +1,4 @@ +.blender-recommender { + max-width: var(--site-width); + margin: 0 auto; +} \ No newline at end of file diff --git a/widgets/blender-recommender/blender-recommender.html b/widgets/blender-recommender/blender-recommender.html new file mode 100644 index 00000000..22ba7c20 --- /dev/null +++ b/widgets/blender-recommender/blender-recommender.html @@ -0,0 +1,825 @@ + + +
      +
      + + +
      + +
      + + +
      +
      + +
      + + + +

      +

      +

      +

      + +
      +
      +
      +
      + +
      +
      + + +
      +
      +
      +
      + +

      +

      + +
      +
      +
      +
      + +
      +
      + + +
      +
      +
      +
      + +
      +

      + +

      +
      +
      + +
      +
      +
      + + +
      + + + +
      +
      + +
      + + + + +
      + + +
      + +
      + + +
      + +
      +
      + +
      +
      + +
      + +
      + +
      + + +
      + +
      + + + + +
      +

      +

      +
      +
      + +
      + +
      + +
      +
      + +
      + + +
      +
      + + +
      + +
      + + + + +
      + +
      +
      + +
      +
      + +
      + + +
      +
      + + +
      + +
      + + + + +
      + +
      +
      + +
      +
      + +
      + + +
      +
      +
      + + +
      + +
      + + +
      + + + + + + +
      +
      + +
      + +
      +
      + +
      + + +
      +
      +
      +
      + +
      + + + + +
      +
      + +
      + + + + + +
      + +
      +
      + +
      +
      + +
      +
      +
      +
      + + Watermark Image of Blender Blade + + +
      +
      + + + + +
      +
      +
      + +
      + +
      +
      +
      +
      +
      +

      + + +

      +

      + + +

      +
      + +
      +
      +
      +
      +
      + + + +
      + +
      +
      +
      +
      +
      + + + + + + + +

      +

      +

      +

      +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      +
      +
      + +
      +
      +
      +
      +

      +

      +

      +

      +
      +

      +

      +
      +
      +
      +
      + +
      + +
      +
      + + +
      +
      +
      +
      + +
      +
      +
      +
      +

      +

      +

      +

      +
      +
      +
      + + +
      +
      +
      +
      +
      +
      + + + + + +
      +
      +
      +
      +

      + + +

      +
      +
      +
      +
      + +
      + + +
      +
      +
      + +
      + + +
      +
      +
      +
      +
      +
      + +
      +
      +
      +
      +
      +
      + +
      +
      +
      +
      + +
      +
      +
      +
      + +
      +
      +
      +
      +

      +

      +

      +
      +

      +
      +
      +
      +
      +
      + +
      +
      +
      +
      + + + + +
      + + + +
      +
      +
      + + + +
      + + + +
      + +
      +
      + + +
      + +
      + + +
      + + +
      + + + + + \ No newline at end of file diff --git a/widgets/blender-recommender/blender-recommender.js b/widgets/blender-recommender/blender-recommender.js new file mode 100644 index 00000000..0d071ca0 --- /dev/null +++ b/widgets/blender-recommender/blender-recommender.js @@ -0,0 +1,695 @@ +/* + * WARNING: This widget is imported AS IS. + * The code should be considered legacy tech debt + * If there is significant use it should be refactored + */ + +import { loadCSS, loadScript } from '../../scripts/aem.js'; + +/* eslint-disable */ +window.alpineJsBlenderRecommender = (paramLang = 0, paramPrice = 0) => { + return { + language: [ + // English + { + intro: { + eyebrow: 'My Blender Recommender', + headline1: 'First, let’s get acquainted. What’s your name?', + nameLabel: 'Name (optional)', + nameError: 'A valid name is required', + headline2: 'And your email address?', + emailLabel: 'Email Address (optional)', + emailError: 'A valid email address is required', + promo: 'Not signed up for Vitamix emails yet? You’ll get a promo code for ' + ((paramPrice > 0) ? '$20 off' : '$25 off & Free Shipping') + ' on orders of $' + ((paramPrice > 0) ? 300 : 200) + ' or more. Some exclusions may apply.', + skipButton: 'Skip', + submitButton: 'Continue', + submitError: 'Please Correct the Form to Proceed', + }, + didYouKnow: [ + { eyebrow: 'Why Vitamix?', title: 'Replace over 13 Household Kitchen tools...', desc: 'and simplify the way you prepare whole-foods at home!' }, + { eyebrow: 'Did You Know?', title: 'Vitamix has programmed settings that ensure...', desc: 'walk-away convenience and consistent results for a variety of recipes.' }, + { eyebrow: 'Need Flexibility?', title: 'With 25+ Accessories & Attachments...', desc: 'Vitamix gives you the flexibility to create your own personalized blending system.' }, + { eyebrow: 'Did You Know?', title: 'Vitamix Offers a Wide Range of Colors…', desc: 'to match with your kitchen aesthetic. Available only in select models.' }, + ], + questions: { + prevButton: 'Back', + nextButton: 'Next', + submitButton: 'Finish', + q1TabName: 'Blending Needs', + q1QuestionText: 'what’s most important in your new blender?', + q2TabName: 'The Possibilities', + q2QuestionText: 'What will you make most?', + q2QuestionDesc: 'Check all that apply', + q3TabName: 'Serving Size', + q3QuestionText: 'How many people will you be blending for?', + q4TabName: 'Style Preferences', + q4QuestionText: 'What about color and finish options?', + }, + choices: { + q1: [ + ['q1_durability', 'Durable & Simple', 'It’s powerful, reliable, and has basic features', '/content/dam/vitamix/home/design-system/illustration/durable.svg'], + ['q1_convenience', 'Convenience', 'Automatic blending programs are important to me', '/content/dam/vitamix/home/design-system/illustration/convenience.svg'], + ['q1_style', 'Style & Premium Features', 'Premium color options, attachment compatibility, and the most advanced Blending Programs', '/content/dam/vitamix/home/design-system/illustration/stylish.svg'] + ], + q2: [ + ['q2_smoothies', 'Smoothies'], + ['q2_mealprep', 'Meal Prep'], + ['q2_babyfoods', 'Baby Food'], + ['q2_baking', 'Baked Goods'], + ['q2_hotsoups', 'Hot Soups'], + ['q2_nutbutters', 'Nut butters'], + ['q2_dressings', 'Dressings'], + ['q2_desserts', 'Desserts'], + ['q2_cocktails', 'Cocktails'], + ['q2_seasonings', 'Spice Blends'], + ['q2_foodprocessing', 'Food Processing'], + ['q2_frozendrinks', 'Frozen/Café Drinks'], + ['q2_foodjuices', 'Whole Food Juices'], + ['q2_nondairy', 'Non-Dairy Milk'], + ['q2_smoothiebowls', 'Smoothie Bowls'], + ], + q3: [ + ['q3_self', 'Just Me', '', '/content/dam/vitamix/home/design-system/illustration/individual.svg'], + ['q3_2-4', '2-4 Individuals', '', '/content/dam/vitamix/home/design-system/illustration/2-4.svg'], + ['q3_4+', '4+ Individuals', '', '/content/dam/vitamix/home/design-system/illustration/4+.svg'], + ], + q4: [ + ['q4_colors', 'I Want a Variety of Color Options', '', '/content/dam/vitamix/home/design-system/illustration/color-variety.svg'], + ['q4_basic', 'I Prefer Basic Colors', '', '/content/dam/vitamix/home/design-system/illustration/color-basic.svg'], + ] + }, + results: { + loadingText1: 'Thanks', + loadingText2: '! Mixing up your results now...', + title: 'Meet Your New Blender', + desc: 'Personalized Blender Recommendations', + summary1: 'What’s most important to me is ', + summary2: ' and making things like ', + summary3: ' for ', + summary4: '. And', + buttonTitle: 'More Details', + recommendationsTitle: 'Recommended Because', + startoverEyebrow: 'Start Over?', + startoverTitle: 'Shake Up Your Results', + startoverButton: 'Restart', + }, + iconSection: { + eyebrow: 'Features & Details', + title: 'Simple & Versatile', + desc: 'Vitamix blenders are designed to break down any ingredient - fibrous greens, frozen - solid strawberries, almonds, and more', + ctaText: 'Why Buy a Vitamix', + ctaLink: '/why-vitamix', + items: [ + { title: 'Free Shipping', desc: 'Free Shipping for All Blenders & Containers', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/free-shipping.svg' }, + { title: 'Take it For a Spin', desc: 'We know you’ll love our blenders so we offer a 60-day worry-free trial period.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/60-day-trial.svg' }, + { title: 'Flexible Payment', desc: 'Choose how to pay at your own pace. Affirm helps start blending sooner.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/affirm.svg' }, + { title: 'Automatic Registration', desc: '', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/auto-registration.svg' }, + { title: 'Industry Leading Support', desc: 'We offer a wide range of support options to Vitamix customers, from full warranties to on-call Vitamix experts to blending tips, guides, and recipes.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/support.svg' }, + { title: 'Expert Customer Service', desc: 'Our customer care team consists of trained Vitamix experts who can help troubleshoot issues, and even suggest new recipes to try.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/customer-service.svg' }, + { title: 'Free Shipping on Repairs', desc: 'If your machine is ever in need of repair, we’ll pay for shipping both ways.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/shipping-repairs.svg' }, + { title: 'Less Than 2% Sent in for Repair', desc: 'Our products are built to last, even when blending the toughest ingredients.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/percentage-repaired.svg' }, + ] + }, + recipeSection: { + eyebrow: 'Recipes to Try With', + title: 'Your New Machine', + ctaText: 'View all Recipes', + ctaLink: '/recipes', + articleCTAText: 'View Recipe', + items: [ + { eyebrow: 'Appetizers', title: 'Sweet and Sour Cucumber and Watermelon Salad', difficulty: 'Intermediate', time: '15 Minutes', image: 'https://www.vitamix.com/content/dam/vitamix/migration/media/other/images/s/Sweet-and-Sour-Cucumber-and-Watermelon-Salad.jpg' }, + { eyebrow: 'Appetizers', title: 'Recipe 2', difficulty: 'Intermediate', time: '15 Minutes', image: 'https://www.vitamix.com/content/dam/vitamix/migration/media/other/images/s/Sweet-and-Sour-Cucumber-and-Watermelon-Salad.jpg' }, + { eyebrow: 'Appetizers', title: 'Recipe 3', difficulty: 'Intermediate', time: '15 Minutes', image: 'https://www.vitamix.com/content/dam/vitamix/migration/media/other/images/s/Sweet-and-Sour-Cucumber-and-Watermelon-Salad.jpg' }, + { eyebrow: 'Appetizers', title: 'Recipe 4', difficulty: 'Intermediate', time: '15 Minutes', image: 'https://www.vitamix.com/content/dam/vitamix/migration/media/other/images/s/Sweet-and-Sour-Cucumber-and-Watermelon-Salad.jpg' }, + + ] + }, + warrantySection: { + eyebrow: 'We Have You Covered', + title: 'Up To a 10-Year Full Warranty', + desc: 'Our full warranties cover parts, performance, and return shipping both ways. Since fewer than 2% of the products in the U.S. currently under warranty have been returned to us for service, it’s the best warranty you’ll probably never need.', + image1: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/component/ognm-merchandising-banner-two-image/warranty-10-years.svg', + image1Alt: '10 Year Warranty', + image2: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/component/ognm-merchandising-banner-two-image/knockout-replacement-warranty-banner-sq-2@2x.jpg', + image2Alt: 'Image of 3 Blenders' + }, + articleSection: { + items: [ + { eyebrow: '11.10.2022', title: 'How We Built a Better Blender', desc: 'Get an inside look at what sets Vitamix blenders apart. Among people who like to cook, or are interested in chef-quality…', ctaText: 'Read More', link: '/articles/how-we-built-a-better-blender', image: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/component/ognm-cardlist-articles-featured/better-blender-article-sml.jpg' }, + { eyebrow: '03.10.2023', title: 'Pay at Your Own Pace', desc: 'Next time you’re shopping with us, just select Affirm at checkout and choose how you want to pay — from 4 interest-free…', ctaText: 'Read More', link: '/about-affirm', image: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/component/ognm-cardlist-articles-featured/payment-plans-article-sml.jpg' }, + ] + }, + reviews: [ + { name: 'Customer 1', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + { name: 'Customer 2', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + { name: 'Customer 3', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + { name: 'Customer 4', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + { name: 'Customer 5', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + ], + }, + // French + { + intro: { + eyebrow: 'Guide des mélangeurs', + headline1: 'D’abord, faisons connaissance. Quel est votre nom', + nameLabel: 'Nom (en option)', + nameError: 'Une nom valide est requise', + headline2: 'Et votre adresse de courriel?', + emailLabel: 'Adresse Courriel (en option)', + emailError: 'Une adresse de courriel valide est requise', + promo: 'Vous n’êtes pas encore inscrit aux courriels de Vitamix? Vous obtiendrez un code promotionnel de 20 $ de rabais sur les commandes de 300 $ ou plus. Certaines exclusions pourraient s’appliquer.', + skipButton: 'Passer', + submitButton: 'Continuer', + submitError: 'Veuillez corriger les erreurs ci-dessous', + }, + didYouKnow: [ + { eyebrow: 'Pourquoi choisir Vitamix', title: 'Construit pour durer', desc: 'Les mélangeurs Vitamix durent jusqu’à 10 fois plus longtemps qu’un mélangeur moyen.' }, + { eyebrow: 'Le saviez-vous?', title: 'Vitamix a des programmes de mélange prédéfinis qui garantissent...', desc: 'Commodité immédiate et résultats cohérents pour une variété de types de recettes.' }, + { eyebrow: 'Besoin de flexibilité?', title: 'Avec une gamme de contenants, d’accessoires, de & pièces jointes...', desc: 'Vitamix vous offre la flexibilité de créer votre propre système de cuisine personnalisé.' }, + { eyebrow: 'Le saviez-vous?', title: 'Vitamix offre une large gamme de couleurs...', desc: 'Pour s’agencer à l’esthétique de votre cuisine. Disponible sur certains modèles.' }, + ], + questions: { + prevButton: 'Le Verso', + nextButton: 'Suivant', + submitButton: 'Conclure', + q1TabName: 'Mélanger les besoins', + q1QuestionText: 'qu’est-ce qui est le plus important dans votre nouveau mixeur?', + q2TabName: 'Les possibilités', + q2QuestionText: 'Qu’allez-vous faire le plus?', + q2QuestionDesc: 'Cochez ce qui s’applique', + q3TabName: 'taille de service', + q3QuestionText: 'Pour combien de personnes allez-vous mélanger?', + q4TabName: 'Préférences de style', + q4QuestionText: 'Qu’en est-il des options de couleur et de finition?', + }, + choices: { + q1: [ + ['q1_durability', 'Durable et simple', 'Il est puissant, fiable et possède des fonctionnalités de base', '/content/dam/vitamix/home/design-system/illustration/durable.svg'], + ['q1_convenience', 'Commodité', 'Les programmes de mélange automatique sont importants pour moi', '/content/dam/vitamix/home/design-system/illustration/convenience.svg'], + ['q1_style', 'Style et fonctionnalités premium', 'Options de couleurs de qualité supérieure, compatibilité avec les accessoires et les programmes de mélange les plus avancés', '/content/dam/vitamix/home/design-system/illustration/stylish.svg'] + ], + q2: [ + ['q2_smoothies', 'Smoothies'], + ['q2_mealprep', 'Préparation des repas'], + ['q2_babyfoods', 'Aliments pour bébés'], + ['q2_baking', 'Boulangerie'], + ['q2_hotsoups', 'Soupes chaudes'], + ['q2_nutbutters', 'Beurres de noix'], + ['q2_dressings', 'Pansements'], + ['q2_desserts', 'Desserts'], + ['q2_seasonings', 'Assaisonnements'], + ['q2_cocktails', 'Cocktails'], + ['q2_foodprocessing', 'Traitement des aliments'], + ['q2_frozendrinks', 'Boissons Glacées/Café'], + ['q2_foodjuices', 'Jus d’aliments entiers'], + ['q2_nondairy', 'Le lait sans produits laitiers'], + ['q2_smoothiebowls', 'Bols à smoothies'], + ], + q3: [ + ['q3_self', 'Juste moi', '', '/content/dam/vitamix/home/design-system/illustration/individual.svg'], + ['q3_2-4', '2 à 4 personnes', '', '/content/dam/vitamix/home/design-system/illustration/2-4.svg'], + ['q3_4+', 'Plus de 4 personnes', '', '/content/dam/vitamix/home/design-system/illustration/4+.svg'], + ], + q4: [ + ['q4_colors', 'Je veux une variété d’options', '', '/content/dam/vitamix/home/design-system/illustration/color-variety.svg'], + ['q4_basic', 'Je préfère les couleurs de base', '', '/content/dam/vitamix/home/design-system/illustration/color-basic.svg'], + ] + }, + results: { + loadingText1: 'Merci', + loadingText2: '! Mélanger vos résultats maintenant...', + title: 'voici votre nouveau mélangeur', + desc: 'Recommandations personnalisées sur les mélangeurs', + summary1: 'Ce qui est le plus important pour moi est ', + summary2: ' et de faire des choses comme ', + summary3: ' pour ', + summary4: '. Et ', + buttonTitle: 'Plus de détails', + recommendationsTitle: 'Recommandé, car', + startoverEyebrow: 'Recommencer?', + startoverTitle: 'Bousculez vos résultats', + startoverButton: 'Repartir', + }, + iconSection: { + eyebrow: 'Caractéristiques et détails', + title: 'Simple et polyvalent', + desc: 'Les mélangeurs Vitamix sont conçus pour décomposer tous les ingrédients : légumes verts fibreux, surgelés, fraises solides, amandes, etc.', + ctaText: 'Pourquoi acheter chez Vitamix?', + ctaLink: '/why-vitamix', + items: [ + { title: 'Livraison gratuite', desc: 'Livraison gratuite pour tous les mélangeurs et récipients', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/free-shipping.svg' }, + { title: 'Prend le comme une occasion de changement', desc: 'Nous savons que vous allez adorer nos mélangeurs, nous offrons donc une période d’essai de jours sans souci.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/60-day-trial.svg' }, + { title: 'Avantage flexible du paiement', desc: 'Choisissez comment payer à votre rythme. Affirm aide à commencer à mélanger plus tôt.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/affirm.svg' }, + { title: 'Enregistrement automatique', desc: '', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/auto-registration.svg' }, + { title: 'Un soutien de premier plan dans l’industrie', desc: 'Chez Vitamix, nous sommes là pour vous, que ce soit en vous offrant des garanties complètes ou en mettant à votre disposition des experts Vitamix, ou encore en vous donnant des conseils, des guides et des recettes pour mélanger comme un pro.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/support.svg' }, + { title: 'Service à la clientèle expert', desc: 'Notre équipe de service à la clientèle se compose d’experts Vitamix formés qui peuvent vous aider à résoudre les problèmes, et même suggérer de nouvelles recettes à essayer.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/customer-service.svg' }, + { title: 'Livraison gratuite sur les réparations', desc: 'Si votre appareil a besoin d’être réparé, nous paierons les deux modes d’expédition.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/shipping-repairs.svg' }, + { title: 'Moins de 2 % d’articles envoyés pour réparation', desc: 'Nos produits sont conçus pour durer, même en mélangeant les ingrédients les plus coriaces.', icon: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/illustration/percentage-repaired.svg' }, + ] + }, + recipeSection: { + eyebrow: 'Recettes à essayer avec', + title: 'Votre nouvelle machine', + ctaText: 'Voir les Recettes', + ctaLink: '/recipes', + articleCTAText: 'Voir la Recette', + items: [ + { eyebrow: 'Appetizers', title: 'Sweet and Sour Cucumber and Watermelon Salad', difficulty: 'Intermediate', time: '15 Minutes', image: 'https://www.vitamix.com/content/dam/vitamix/migration/media/other/images/s/Sweet-and-Sour-Cucumber-and-Watermelon-Salad.jpg' }, + { eyebrow: 'Appetizers', title: 'Recipe 2', difficulty: 'Intermediate', time: '15 Minutes', image: 'https://www.vitamix.com/content/dam/vitamix/migration/media/other/images/s/Sweet-and-Sour-Cucumber-and-Watermelon-Salad.jpg' }, + { eyebrow: 'Appetizers', title: 'Recipe 3', difficulty: 'Intermediate', time: '15 Minutes', image: 'https://www.vitamix.com/content/dam/vitamix/migration/media/other/images/s/Sweet-and-Sour-Cucumber-and-Watermelon-Salad.jpg' }, + { eyebrow: 'Appetizers', title: 'Recipe 4', difficulty: 'Intermediate', time: '15 Minutes', image: 'https://www.vitamix.com/content/dam/vitamix/migration/media/other/images/s/Sweet-and-Sour-Cucumber-and-Watermelon-Salad.jpg' }, + + ] + }, + warrantySection: { + eyebrow: 'NOUS AVONS CE QU’IL VOUS FAUT', + title: 'Garantie complète de 10 ans', + desc: 'Notre garantie complète couvre les pièces, la performance et les frais de port aller-retour. Moins de 2 % des appareils garantis aux États-Unis ont été retournés à des fins d’entretien. C’est donc une garantie optimale dont vous n’aurez probablement jamais besoin.', + image1: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/component/ognm-merchandising-banner-two-image/warranty-10-years.svg', + image1Alt: '10 Year Warranty', + image2: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/component/ognm-merchandising-banner-two-image/knockout-replacement-warranty-banner-sq-2@2x.jpg', + image2Alt: 'Image of 3 Blenders' + }, + articleSection: { + items: [ + { eyebrow: '11.10.2022', title: 'Comment nous avons conçu un meilleur mélangeur', desc: 'Découvrez de l’intérieur ce qui distingue les blenders Vitamix. Parmi les personnes qui aiment cuisiner ou qui s’intéressent à la', ctaText: 'Lire Davantage', link: '/articles/how-we-built-a-better-blender', image: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/component/ognm-cardlist-articles-featured/better-blender-article-sml.jpg' }, + { eyebrow: '03.10.2023', title: 'Paiement à Votre Rythme', desc: 'La prochaine fois que vous ferez vos achats chez nous, il vous suffira de sélectionner Affirm à la caisse et de choisir votre mod', ctaText: 'Lire Davantage', link: '/about-affirm', image: 'https://www.vitamix.com/content/dam/vitamix/home/design-system/component/ognm-cardlist-articles-featured/payment-plans-article-sml.jpg' }, + ] + }, + reviews: [ + { name: 'Customer 1', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + { name: 'Customer 2', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + { name: 'Customer 3', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + { name: 'Customer 4', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + { name: 'Customer 5', title: 'Home Chef', text: 'I bought my first Vitamix shortly after I was married in 1960. The Vitamix made my life easier because, at the time, nothing like it existed! 62 years later, we’re still going strong and the blender works as well as it did in 1960.' }, + ], + } + ], + + // UI Vars + baseURl: window.location.href, + timestamp: new Date().valueOf(), + initialized: false, + minHeight: 0, + compact: false, + currentLanguage: paramLang, // 0 - English, 1 - French + pricing: paramPrice, // 0 - English, 1 - CAD + linkPrefix1: '/us', + linkPrefix2: '/en_us', + modalLeadSource: 'sub-em-modal-us', + modalActionURL: '/rest/V1/vitamix-api/newslettersubscribe', + screen: 1, // 1 - Personal Info, 2 - Questions, 3 - Result Loader, 4 - All Done + question: 1, + totalQuestions: 3, + dykExpanded: false, + complete: false, + + // inputs + namefieldValue: '', + namefieldError: true, + namefieldCanShowError: false, + + emailfieldValue: '', + emailfieldError: true, + emailfieldCanShowError: false, + + emailSubmitted: false, + + // answer data + answers: { + question1: '', + question2: [], + question3: '', + question4: '', + }, + + weightedItems: [], + resultUtm: [ + '?src=vbr&utm_medium-first', + '?src=vbr&utm_medium-second', + '?src=vbr&utm_medium-third', + ], + + // Functions + init() { + this.loadData(); + this.loadPosition(); + this.setAppHeight(); + if (this.baseURl.indexOf('vitamix.com') > -1) { + // Update English / French + if (this.baseURl.indexOf('/en_us') > -1) { + this.currentLanguage = 0; + this.linkPrefix2 = '/en_us'; + } + else if (this.baseURl.indexOf('/fr_ca') > -1) { + this.currentLanguage = 1; + this.linkPrefix2 = '/fr_ca'; + } + // Update USA / Canada + if (this.baseURl.indexOf('vitamix.com/us/') > -1) { + this.pricing = 0; + this.modalLeadSource = 'sub-em-modal-us'; + this.linkPrefix1 = '/us'; + } + else if (this.baseURl.indexOf('vitamix.com/ca/') > -1) { + this.pricing = 1; + this.modalLeadSource = 'sub-em-modal-ca'; + this.linkPrefix1 = '/ca'; + } + } + this.initialized = true; + + this.fetchWeightedItems(); + + setTimeout(() => { + this.$refs.componentbody.classList.remove('is-loading'); + }, 400); + + this.$watch('namefieldValue', () => { + this.namefieldError = !this.namefieldValue; + }); + this.namefieldError = !this.namefieldValue; + + this.$watch('emailfieldValue', () => { + this.emailfieldError = !(/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(this.emailfieldValue)); + }); + this.emailfieldError = !(/^\w+([\.-]?\w+)*@\w+([\.-]?\w+)*(\.\w{2,3})+$/.test(this.emailfieldValue)); + + this.$watch('answers', () => { + this.weightAndSort(); + }); + + window.onresize = this.setAppHeight(); + }, + + saveData() { + try { + if (this.namefieldValue.length) { + localStorage.setItem("user", this.namefieldValue); + } + if (this.emailfieldValue.length) { + localStorage.setItem("email", this.emailfieldValue); + } + localStorage.setItem("answers", JSON.stringify(this.answers)); + } + catch (e) { + console.warn('Error Saving Data:\n' + e); + } + }, + + loadData() { + try { + if (localStorage.getItem("user") !== null) { + this.namefieldValue = localStorage.getItem("user"); + } + if (localStorage.getItem("email") !== null) { + this.emailfieldValue = localStorage.getItem("email"); + } + if (localStorage.getItem("answers") !== null) { + this.answers = JSON.parse(localStorage.getItem("answers")); + } + } + catch (e) { + console.warn('Error Loading Data:\n' + e); + this.weightedItems.push(JSON.parse(JSON.stringify(item))) + } + }, + + + + savePosition() { + try { + localStorage.setItem("screen", this.screen); + localStorage.setItem("question", this.question); + localStorage.setItem("complete", this.complete); + } + catch (e) { + console.warn('Error Saving Position:\n' + e); + } + }, + + loadPosition() { + try { + if (localStorage.getItem("screen") !== null) { + this.screen = parseInt(localStorage.getItem("screen")); + } + if (localStorage.getItem("question") !== null) { + this.question = parseInt(localStorage.getItem("question")); + } + if (localStorage.getItem("complete") !== null && (localStorage.getItem("complete") == true || localStorage.getItem("complete") == 'true' || localStorage.getItem("complete") == 1)) { + this.complete = true; + } + } + catch (e) { + console.warn('Error Loading Position:\n' + e); + this.weightedItems.push(JSON.parse(JSON.stringify(item))) + } + }, + + + getLanguageSuffix() { + switch (this.currentLanguage) { + case 0: return 'en'; break; + case 1: return 'fr'; break; + } + }, + + fetchWeightedItems: async function () { + let response = await fetch('https://www.vitamix.com/content/dam/vitamix/files/blender-recommender-' + this.getLanguageSuffix() + '.json?=' + this.timestamp); + let responseJSON = await response.json(); + responseJSON.forEach((item, index) => { + item.calculatedScore = 0; + // Show em all if this column in the spreadsheet is not present + if (typeof item.show_us == 'undefined') { + this.weightedItems.push(JSON.parse(JSON.stringify(item))); + } + else if (this.pricing == 0 && typeof item.show_us != 'undefined' && item.show_us == 1) { + this.weightedItems.push(JSON.parse(JSON.stringify(item))); // clone + } + else if (this.pricing == 1 && typeof item.show_ca != 'undefined' && item.show_ca == 1) { + this.weightedItems.push(JSON.parse(JSON.stringify(item))); // clone + } + }); + this.weightAndSort(); + }, + + weightAndSort() { + this.weightedItems.forEach((item, index) => { + let tally = 0; + + // Answer 1 + if (this.answers.question1.length > 0 && item[this.answers.question1] != 'undefined') { + tally += item[this.answers.question1]; + } + + // Answer 2 + if (this.answers.question2.length > 0) { + this.answers.question2.forEach((answerItem, answerIndex) => { + if (typeof item[answerItem] != 'undefined') { + tally += item[answerItem]; + } + }); + + } + + // Answer 3 + if (this.answers.question3.length > 0 && item[this.answers.question3] != 'undefined') { + tally += item[this.answers.question3] + } + + // Answer 4 + if (this.answers.question4.length > 0 && item[this.answers.question4] != 'undefined') { + tally += item[this.answers.question4] + } + + item.calculatedScore = tally; + }); + this.weightedItems.sort((a, b) => a['result_id'] - b['result_id']); + this.weightedItems.sort((a, b) => b['calculatedScore'] - a['calculatedScore']); + }, + + getChoice(question, value) { + return this.language[this.currentLanguage].choices[question][(this.language[this.currentLanguage].choices[question].findIndex((item) => item[0] == value))][1]; + }, + + goNext() { + this.saveData(); + switch (this.screen) { + case 1: + if (false/*this.namefieldError || this.emailfieldError */) { + window.alert(this.language[this.currentLanguage].intro.submitError); + } else { + if (!this.emailfieldError) { + this.submitEmail(); + this.emailSubmitted = true; + } + this.screen = 2; + } + break; + case 2: + if (this.answers['question' + this.question].length > 0) { + if (this.question >= this.totalQuestions) { + this.screen = 3; + window.scrollTo({ top: 0, behavior: 'smooth' }); + setTimeout(() => { + this.complete = true; + this.savePosition(); + }, 2000); + } else { + this.question++; + }; + } + else { + console.warn('Can\'t Skip Steps'); + } + break; + default: + //console.log('Default Action'); + } + this.savePosition(); + }, + + goPrev() { + this.saveData(); + switch (this.screen) { + case 3: + this.screen = 2; + break; + case 2: + if (this.question < 2) { + this.screen = 1; + } else { + this.question--; + }; + break; + default: + } + this.savePosition(); + }, + + goToQuestion(questionNumber) { + this.screen = 2; + this.complete = false; + if (questionNumber < this.question) { + this.question = questionNumber; + } else { + + switch (questionNumber) { + case 1: this.question = questionNumber; + break; + case 2: + if (this.answers.question1.length > 0) { + this.question = questionNumber; + } + else { + console.warn('Can\'t skip steps...'); + } + break; + case 3: + if (this.answers.question1.length > 0 && this.answers.question2.length > 0) { + this.question = questionNumber; + } + else { + console.warn('Can\'t skip steps...'); + } + break; + case 4: + if (this.answers.question1.length > 0 && this.answers.question2.length > 0 && this.answers.question3.length > 0) { + this.question = questionNumber; + } + else { + console.warn('Can\'t skip steps...'); + } + break; + } + } + this.savePosition(); + }, + + qPoz(x) { + let className = ''; + if (x > this.question) { + className = 'is-ahead'; + } + if (x < this.question) { + className = 'is-behind'; + } + return className; + }, + + setAppHeight() { + let headerHeight = 0; + try { + headerHeight = window.getComputedStyle(document.querySelector('header'), null).getPropertyValue('height'); + } + catch (e) { + console.warn('Failed to Set Header Height: \n' + e); + } + this.minHeight = 'min-height: calc(100vh - ' + headerHeight + '); min-height: calc(100dvh - ' + headerHeight + ' );' + }, + + submitEmail() { + try { + $.ajax({ + type: "GET", + url: "https://www.vitamix.com/bin/vitamix/newslettersubscription", + data: { + email: this.emailfieldValue, + mobile: '', + sms_optin: '', + lead_source: this.modalLeadSource, + actionUrl: this.linkPrefix1 + this.linkPrefix2 + this.modalActionURL, + pageUrl: window.location.href + }, + success: function (s) { + console.log('Successfully submitted email:\n'); + console.log(s) + }, + error: function (e) { + console.log('Failed to submit email:\n' + e) + } + }) + } + catch (e) { + console.warn('Unable to submit email to endpoint:\n' + e); + } + }, + + //Carousel + carouselCurrentPosition: 1, + carouselMaxPosition: 3, + carouselistWrapWidth: 0, + carouselLastListWrapWidth: 0, + carouselItemPosition: 0, + + carouselPrev() { + if (this.carouselCurrentPosition > 1) { + this.carouselCurrentPosition--; + this.carouselUpdatePosition(); + } + }, + carouselNext() { + if (this.carouselCurrentPosition < this.carouselMaxPosition) { + this.carouselCurrentPosition++; + this.carouselUpdatePosition(); + } + }, + carouselUpdatePosition() { + if (this.carouselCurrentPosition > 1) { + this.carouselItemPosition = Math.round(document.getElementById('resultCarouselScroller-item' + this.carouselCurrentPosition).getBoundingClientRect().left - document.getElementById('resultCarouselScroller').getBoundingClientRect().left); + } + else { + this.carouselItemPosition = 0 + } + } + + + } + }; + + loadScript('https://unpkg.com/alpinejs@3.10.3/dist/cdn.min.js', () => { + alpineJsBlenderRecommender(window.location.pathname.includes('/fr_') ? 1 : 0, 1); + }); + + loadCSS('https://www.vitamix.com/content/dam/vitamix/client-library/css/design-system.min.css?q=1765483391'); + loadCSS('https://www.vitamix.com/content/dam/vitamix/client-library/css/design-system.min.css?v=1699551075845'); \ No newline at end of file diff --git a/widgets/compare-products/compare-products.css b/widgets/compare-products/compare-products.css new file mode 100644 index 00000000..859d65fd --- /dev/null +++ b/widgets/compare-products/compare-products.css @@ -0,0 +1,320 @@ +/* Compare Products Widget – layout only; typography, buttons, colors inherit from site */ + +/* Swatch colors aligned with blocks/pdp/color-swatches.css */ +.compare-products-product-color { + width: 24px; + height: 24px; + border: var(--border-s) solid var(--color-gray-400); + border-radius: 50%; + padding: 0; + cursor: pointer; + transition: transform 0.15s, box-shadow 0.15s; +} + +.compare-products-product-color:hover { + transform: scale(1.1); +} + +.compare-products-product-color.selected { + border-width: 2px; + border-color: var(--color-charcoal); + box-shadow: 0 0 0 2px var(--color-background); +} + +.compare-products .compare-products-product-color { + /* blacks */ + --color-black: #000; + --color-shadow-black: #000; + --color-1100001: #100; + --color-1100002: #100; + --color-black-stainless-metal-finish: #161616; + + /* grays */ + --color-onyx: #353935; + --color-abalone-grey: #3b363b; + --color-graphite: #414141; + --color-nano-gray: #5b6770; + --color-graphite-metal-finish: #606060; + --color-slate: #666; + --color-pearl-gray: #858583; + --color-black-diamond: #928b8b; + --color-brushed-stainless: #b0b3b7; + --color-grey: #e5e4e2; + --color-platinum: #e5e4e2; + + /* whites */ + --color-white: #fff; + --color-polar-white: #fff; + + /* browns */ + --color-espresso: #67564e; + --color-copper-metal-finish: #f2a57e; + --color-reflection: #f2a57e; + --color-brushed-stainless-metal-finish: #b5aa9d; + --color-brushed-gold: #ccba78; + --color-cream: #fffdd0; + + /* reds */ + --color-red: #c03; + --color-candy-apple: #c00310; + --color-candy-apple-red: #c00310; + --color-ruby: #e01160; + + /* oranges */ + --color-orange: #ffa600; + --color-cinnamon: #d2691e; + + /* yellows */ + --color-yellow: #f7f700; + + /* greens */ + --color-sour-apple-green: #e3edb5; + --color-turquoise: #30d5c7; + + /* blues */ + --color-cobalt: #0047ab; + --color-blue: #1131d4; + --color-matte-navy: #00263e; + --color-midnight-blue: #00263e; + + /* purples */ + --color-purple: #800080; + + /* undefined */ + --color-color-not-available: ; +} + +.compare-products { + max-width: var(--site-width); + margin: 0 auto; + padding: var(--spacing-400) var(--spacing-600); + overflow-x: auto; + position: relative; + -webkit-overflow-scrolling: touch; +} + +.compare-products::after { + content: ''; + position: absolute; + top: 0; + right: 0; + bottom: 0; + width: 48px; + background: linear-gradient(to right, transparent, var(--color-background)); + pointer-events: none; + z-index: 1; +} + +@media (width >= 900px) { + .compare-products::after { + display: none; + } +} + +.compare-products-scroll { + min-width: 900px; + display: grid; + grid-template-columns: minmax(120px, auto) repeat(var(--compare-cols, 2), 1fr); + gap: 0; +} + +.compare-products-empty { + margin: var(--spacing-200) 0; +} + +/* Add-a-product grid (Full Size Blenders from index.json) */ +.compare-products-products { + grid-column: 2 / -1; + display: grid; + grid-template-columns: repeat(var(--compare-cols, 2), 1fr); + gap: var(--spacing-400); + border-bottom: var(--border-m) solid var(--color-gray-300); + padding-bottom: var(--spacing-400); + margin-bottom: var(--spacing-300); +} + +.compare-products-products:has(.compare-products-add-heading) { + display: block; + grid-column: 1 / -1; +} + +.compare-products-add-heading { + margin: 0 0 var(--spacing-300); + font-size: var(--heading-font-size-m); +} + +.compare-products-add-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: var(--spacing-400); +} + +.compare-products-add-card .compare-products-add-to-comparison { + margin-top: var(--spacing-200); + margin-bottom: var(--spacing-200); +} + +.compare-products-add-empty { + margin: var(--spacing-200) 0; +} + +.compare-products-add-section { + margin-top: var(--spacing-400); + padding-top: var(--spacing-400); + border-top: var(--border-m) solid var(--color-gray-300); +} + +.compare-products-product { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + padding: var(--spacing-200); +} + +.compare-products-product-remove { + position: absolute; + top: var(--spacing-80); + right: var(--spacing-80); +} + +.compare-products-product-image-wrap { + width: 100%; + max-width: 280px; + margin-bottom: 0; + aspect-ratio: 1; +} + +.compare-products-product-image-wrap img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.compare-products-product-colors { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--spacing-60); + margin-bottom: var(--spacing-200); +} + +.compare-products-product-name { + margin: 0 0 var(--spacing-100); + text-align: center; +} + +.compare-products-product-price { + margin-bottom: var(--spacing-100); + text-align: center; +} + +.compare-products-product-price-now { + display: block; +} + +.compare-products-product-placeholder { + justify-content: center; + min-height: 200px; +} + +.compare-products-product-placeholder-msg { + margin: var(--spacing-200) 0; + text-align: center; +} + +/* Features section */ +.compare-products-features { + grid-column: 1 / -1; +} + +.compare-products-features-table { + display: grid; + grid-template-columns: minmax(120px, auto) repeat(var(--compare-cols, 2), minmax(0, 1fr)); + gap: 0; + min-width: 0; +} + +.compare-products-features-row { + display: contents; +} + +.compare-products-features-cell { + padding: var(--spacing-100) var(--spacing-200); + border: var(--border-s) solid var(--color-gray-200); + border-top: 0; + border-left: 0; +} + +.compare-products-features-row:first-of-type .compare-products-features-cell { + border-top: var(--border-s) solid var(--color-gray-200); +} + +.compare-products-features-cell.row-even { + background-color: var(--color-background); +} + +.compare-products-features-cell.row-odd { + background-color: var(--color-gray-200); +} + +.compare-products-features-cell.feature-name { + border-left: var(--border-s) solid var(--color-gray-200); + font-weight: var(--weight-medium); +} + +.compare-products-features-cell-check { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25em; + height: 1.25em; + border-radius: 50%; + background-color: var(--color-charcoal); + color: var(--color-white); + font-size: 0.75em; + line-height: 1; + font-weight: bold; +} + +/* When compare-products param filtered columns: table reflows so visible columns share width */ +.widget-container.compare-products-columns-filtered .table.comparison .table-comparison-scroll table { + table-layout: auto; + width: 100%; +} + +.widget-container.compare-products-columns-filtered .table.comparison .table-comparison-scroll th.compare-products-column-visible, +.widget-container.compare-products-columns-filtered .table.comparison .table-comparison-scroll td.compare-products-column-visible { + min-width: 120px; +} + +/* Remove-from-comparison button on each product column (compare-products param flow) */ +.table.comparison .table-comparison-scroll thead th.compare-products-column-with-remove { + position: relative; +} + +.table.comparison .table-comparison-scroll .compare-products-remove-from-comparison { + position: absolute; + top: var(--spacing-80); + right: var(--spacing-80); + z-index: 1; + width: 32px; + height: 32px; + padding: 0; + border: none; + border-radius: 50%; + background: var(--color-gray-200); + color: var(--color-charcoal); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} + +.table.comparison .table-comparison-scroll .compare-products-remove-from-comparison:hover { + background: var(--color-gray-300); +} + diff --git a/widgets/compare-products/compare-products.html b/widgets/compare-products/compare-products.html new file mode 100644 index 00000000..3fea5ed1 --- /dev/null +++ b/widgets/compare-products/compare-products.html @@ -0,0 +1,8 @@ +
      +
      +
      +
      +
      +
      +
      +
      diff --git a/widgets/compare-products/compare-products.js b/widgets/compare-products/compare-products.js new file mode 100644 index 00000000..7d9ae411 --- /dev/null +++ b/widgets/compare-products/compare-products.js @@ -0,0 +1,1395 @@ +import { loadCSS, toClassName } from '../../scripts/aem.js'; +import { formatPrice as formatPriceValue, getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { lookupProducts } from '../../blocks/plp/plp.js'; + +const DEBUG = typeof window !== 'undefined' && ( + new URLSearchParams(window.location.search).has('compare-products-debug') + || new URLSearchParams(window.location.search).has('compare-products') +); + +function debug(...args) { + if (DEBUG) { + // eslint-disable-next-line no-console + console.log('[compare-products]', ...args); + } +} + +/** productType used to show "add a product" grid (from products index) */ +const ADD_TO_COMPARE_PRODUCT_TYPE = 'Countertop Blender'; + +/** Show "add a product" grid until this many products are in the comparison */ +const MAX_COMPARISON_PRODUCTS = 4; + +const FEATURE_KEYS = [ + 'Series', + 'Blending Programs', + 'Variable Speed Control', + 'Touch Buttons', + 'Pulse', + 'Digital Timer', + 'Self-Detect Technology', + 'Tamper Indicator', + 'Plus 15 Second Button', + 'Warranty', + 'Dimensions (L × W × H)', +]; + +const FEATURES_BY_PRODUCT_PATH_DEFAULT = '/us/en_us/products/config/features-by-product.json'; + +/** Merged Key->Text from locale JSON (translations) */ +let comparisonTranslations = {}; +/** Normalize terms merged from all locales (for regex matching). */ +let mergedNormalize = null; +/** Default normalize literals when JSON has no normalize key or before load. */ +const DEFAULT_NORMALIZE = { + trailingSeries: ['series', 'série'], + leadingSeries: ['series', 'séries'], + brandName: 'Vitamix', + warrantyHeading: 'warranty', +}; + +/** + * Load translations from the widget's local JSON (same name as the script). + * Sets comparisonTranslations. Call once before rendering; safe to call multiple times. + */ +async function loadComparisonTranslations() { + const { language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + try { + const resp = await fetch(url); + if (!resp.ok) return; + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + comparisonTranslations = data[key] || {}; + // Merge normalize terms from all locales so regex matches any language + const localeKeys = ['en', 'fr', 'es'].filter((k) => data[k]?.normalize); + const normalizeKeys = ['trailingSeries', 'leadingSeries', 'brandName', 'warrantyHeading']; + mergedNormalize = {}; + normalizeKeys.forEach((nk) => { + const values = localeKeys + .map((lk) => data[lk].normalize[nk]) + .filter((v) => v != null && String(v).trim() !== ''); + const unique = [...new Set(values)]; + mergedNormalize[nk] = unique.length > 0 ? unique : DEFAULT_NORMALIZE[nk]; + }); + } catch { + comparisonTranslations = {}; + mergedNormalize = null; + } +} + +/** + * Translate a key if present in loaded translations; otherwise return key. + * @param {string} key - English (or source) string + * @returns {string} + */ +function t(key) { + if (!key || typeof key !== 'string') return key; + return comparisonTranslations[key] ?? key; +} + +/** Escape string for safe use in RegExp. */ +function escapeRegex(s) { + return String(s).replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); +} + +function getNormalizeConfig() { + if (mergedNormalize && typeof mergedNormalize === 'object') return mergedNormalize; + return DEFAULT_NORMALIZE; +} + +/** Path to checkmark icon SVG (comparison table). */ +const CHECK_ICON_SVG = '/widgets/compare-products/icon-check.svg'; + +/** + * Get features-by-product config path for current locale. + * E.g. /ca/fr_ca/... -> /ca/fr_ca/products/config/features-by-product.json + * @returns {string} + */ +function getFeaturesByProductPath() { + const match = window.location.pathname.match(/^(\/[^/]+\/[^/]+)\//); + if (match) { + return `${match[1]}/products/config/features-by-product.json`; + } + return FEATURES_BY_PRODUCT_PATH_DEFAULT; +} + +/** + * Fetch features-by-product config (same-origin only; no fcors). + * @returns {Promise} { data: Array<{ Path, Series, ... }> } or null + */ +async function fetchFeaturesByProduct() { + const path = getFeaturesByProductPath(); + const url = new URL(path, window.location.origin).href; + const resp = await fetch(url); + if (!resp.ok) return null; + try { + return await resp.json(); + } catch { + return null; + } +} + +/** + * Normalize product path for lookup (strip hash/query, trailing slash, ensure leading slash). + * @param {string} path - Product path or URL + * @returns {string} + */ +function normalizePathForLookup(path) { + if (!path || typeof path !== 'string') return ''; + const p = path.replace(/#.*$/, '').replace(/\?.*$/, '').trim(); + const slash = p.startsWith('/') ? p : `/${p}`; + const normalized = slash.endsWith('/') && slash.length > 1 ? slash.slice(0, -1) : slash; + return normalized.toLowerCase(); +} + +/** + * Get the last path segment (slug) from a path. + * @param {string} path - e.g. /ca/fr_ca/products/vx1-and-pca-bundle-ca + * @returns {string} e.g. vx1-and-pca-bundle-ca + */ +function getPathSlug(path) { + const p = (path || '').trim().replace(/\/+$/, ''); + if (!p) return ''; + const segments = p.split('/').filter(Boolean); + return segments[segments.length - 1] || ''; +} + +/** + * Get the features row for a product path from features-by-product.data. + * Paths are expected to match the index; lookup is exact (normalized) with slug fallback. + * @param {Object} featuresByProduct - { data: Array<{ Path: string, Series: string, ... }> } + * @param {string} productPath - Product path (e.g. /ca/fr_ca/products/propel-series-510) + * @returns {Object|null} Row object or null + */ +function getFeaturesRowByPath(featuresByProduct, productPath) { + const { data } = featuresByProduct || {}; + if (!data?.length || !productPath) return null; + const key = normalizePathForLookup(productPath); + + const exact = data.find((row) => normalizePathForLookup(row.Path) === key); + if (exact) return exact; + + const keySlug = getPathSlug(key).toLowerCase(); + if (!keySlug) return null; + return data.find((row) => getPathSlug(row.Path).toLowerCase() === keySlug) || null; +} + +/** + * Normalize series name for matching: remove Vitamix, Series/Séries, trim, strip ®™. + * @param {string} s - Series or product name + * @returns {string} Normalized string for comparison + */ +function normalizeSeriesForMatch(s) { + if (!s || typeof s !== 'string') return ''; + const cfg = getNormalizeConfig(); + const trailingArr = cfg.trailingSeries ?? DEFAULT_NORMALIZE.trailingSeries; + const leadingArr = cfg.leadingSeries ?? DEFAULT_NORMALIZE.leadingSeries; + const brandRaw = cfg.brandName ?? DEFAULT_NORMALIZE.brandName; + const trailing = (Array.isArray(trailingArr) ? trailingArr : [trailingArr]).map(escapeRegex).join('|'); + const leading = (Array.isArray(leadingArr) ? leadingArr : [leadingArr]).map(escapeRegex).join('|'); + const brand = (Array.isArray(brandRaw) ? brandRaw : [brandRaw]).map(escapeRegex).join('|'); + let norm = s + .replace(/\s*®\s*|\s*™\s*/gi, ' ') + .replace(new RegExp(`\\b${brand}\\b`, 'gi'), '') + .replace(new RegExp(`\\s+(${trailing})\\s*$`, 'gi'), '') + .replace(/\s+/g, ' ') + .trim(); + if (leading) { + norm = norm.replace(new RegExp(`^\\s*(${leading})\\s+`, 'gi'), '').trim(); + } + return norm; +} + +/** Map our feature keys to features-by-product.json column names (locale may vary) */ +const FEATURE_KEY_TO_JSON_KEY = { + 'Blending Programs': 'Programmes de fusion', + 'Variable Speed Control': 'Commande de vitesse variable', + 'Touch Buttons': 'Boutons tactiles', + Pulse: 'Impulsion', + 'Digital Timer': 'Minuteur numérique', + 'Self-Detect Technology': "Technologie d'autodétection", + 'Tamper Indicator': 'Indicateur de falsification', + 'Plus 15 Second Button': '+15 secondes', + Warranty: 'Garantie', + 'Dimensions (L × W × H)': 'Dimensions', + 'Ce que vous pouvez fabriquer': 'Ce que vous pouvez fabriquer', +}; + +/** + * Find the value for a feature in a row by matching keys (exact, then by normalized match). + * Handles sheet keys that differ slightly (e.g. with ™ or "Dimensions (L × W × H)"). + * @param {Object} featuresRow - Row from features-by-product.data + * @param {string} jsonKey - Preferred key (e.g. "Technologie d'autodétection", "Dimensions") + * @returns {*} Raw value or undefined + */ +function getFeatureValueFromRow(featuresRow, jsonKey) { + if (!featuresRow || typeof featuresRow !== 'object') return undefined; + const exact = featuresRow[jsonKey]; + if (exact !== undefined && exact !== null && String(exact).trim() !== '') return exact; + const norm = (s) => String(s || '').replace(/\s*®\s*|\s*™\s*/gi, ' ').toLowerCase().trim(); + const targetNorm = norm(jsonKey); + if (!targetNorm) return undefined; + const rowKey = Object.keys(featuresRow).find((k) => { + const kn = norm(k); + return kn === targetNorm || kn.startsWith(targetNorm) || targetNorm.startsWith(kn); + }); + return rowKey != null ? featuresRow[rowKey] : undefined; +} + +/** + * Get display value from a features-by-product row for a feature key. + * Passes through :check: for UI to render as checkmark; - or empty -> '—'; else verbatim. + * @param {Object} featuresRow - Row from features-by-product.data + * @param {string} featureKey - Our feature key (e.g. 'Blending Programs') + * @returns {string} + */ +function getFeatureDisplayFromRow(featuresRow, featureKey) { + if (!featuresRow) return '—'; + const jsonKey = FEATURE_KEY_TO_JSON_KEY[featureKey] ?? featureKey; + const raw = getFeatureValueFromRow(featuresRow, jsonKey) ?? featuresRow[featureKey]; + if (raw == null || String(raw).trim() === '') return '—'; + const s = String(raw).trim(); + if (s === '-') return '—'; + return s; +} + +/** + * Resolve product comparison paths from window.location query + * (e.g. ?productComparison=/path1,/path2). + * @returns {string[]} Array of product paths (e.g. /us/en_us/products/ascent-x2) + */ +function getProductComparisonPaths() { + const params = new URLSearchParams(window.location.search); + const raw = params.get('productComparison'); + if (!raw || typeof raw !== 'string') return []; + return raw + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => { + try { + const url = new URL(s, window.location.origin); + return url.pathname; + } catch { + return s; + } + }); +} + +/** + * Product paths from ?compare-products= (comma-separated). + * Each path replaces that product's series column. + * @returns {string[]} Array of product paths; empty if not set + */ +function getCompareProductsParamPaths() { + const params = new URLSearchParams(window.location.search); + const raw = params.get('compare-products'); + debug('getCompareProductsParamPaths: raw=', raw); + if (!raw || typeof raw !== 'string') return []; + let decoded = raw; + try { + decoded = decodeURIComponent(raw); + } catch { + // keep raw if invalid encoding + } + const paths = decoded + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + .map((s) => { + try { + if (s.startsWith('http')) return new URL(s).pathname; + const url = new URL(s, window.location.origin); + return url.pathname; + } catch { + return s.startsWith('/') ? s : `/${s}`; + } + }); + debug('getCompareProductsParamPaths: parsed paths=', paths); + return paths; +} + +/** True when ?compare-products= was set (single or multiple paths). */ +function getCompareProductsParam() { + return getCompareProductsParamPaths().length > 0; +} + +/** Map row header text (EN/FR) to FEATURE_KEYS for table column replacement */ +const ROW_LABEL_TO_FEATURE = { + series: 'Series', + série: 'Series', + 'blending programs': 'Blending Programs', + 'programmes de fusion': 'Blending Programs', + 'variable speed control': 'Variable Speed Control', + 'commande de vitesse variable': 'Variable Speed Control', + 'touch buttons': 'Touch Buttons', + 'boutons tactiles': 'Touch Buttons', + pulse: 'Pulse', + impulsion: 'Pulse', + 'digital timer': 'Digital Timer', + 'minuteur numérique': 'Digital Timer', + 'self-detect technology': 'Self-Detect Technology', + "technologie d'autodétection": 'Self-Detect Technology', + 'tamper indicator': 'Tamper Indicator', + 'indicateur de falsification': 'Tamper Indicator', + 'plus 15 second': 'Plus 15 Second Button', + '+15 secondes': 'Plus 15 Second Button', + warranty: 'Warranty', + garantie: 'Warranty', + dimensions: 'Dimensions (L × W × H)', + couleurs: 'Colors', + colors: 'Colors', + 'ce que vous pouvez fabriquer': 'Ce que vous pouvez fabriquer', +}; + +const AEM_NETWORK_ORIGIN = 'https://main--vitamix--aemsites.aem.network'; + +/** + * Whether to use fcors proxy (localhost, .aem.page, .aem.live). + * @returns {boolean} + */ +function useFcors() { + const { hostname } = window.location; + return hostname === 'localhost' + || hostname.endsWith('.aem.page') + || hostname.endsWith('.aem.live'); +} + +/** + * Resolve image URL for display: when on localhost / .aem.page / .aem.live, load from aem.network. + * @param {string} url - Image URL (absolute or path) + * @returns {string} URL to use for img src + */ +function resolveImageUrlForDisplay(url) { + if (!url) return ''; + if (!useFcors()) return url; + if (url.startsWith('http')) return url; + const path = url.startsWith('/') ? url : `/${url}`; + return `${AEM_NETWORK_ORIGIN}${path}`; +} + +/** + * Fetch product page HTML (no .json). Uses fcors from aem.network on + * localhost / .aem.page / .aem.live. + * @param {string} path - Product path (e.g. /us/en_us/products/ascent-x2) + * @returns {Promise<{ html: string|null, status: number }>} + */ +async function fetchProductPage(path) { + const pathOnly = path.startsWith('http') ? new URL(path).pathname : path; + const fullUrl = path.startsWith('http') ? path : `${AEM_NETWORK_ORIGIN}${pathOnly}`; + + debug('fetchProductPage: path=', path, 'fullUrl=', fullUrl, 'useFcors=', useFcors()); + + let resp; + if (useFcors()) { + const corsProxy = 'https://fcors.org/?url='; + const corsKey = '&key=Mg23N96GgR8O3NjU'; + const proxyUrl = `${corsProxy}${encodeURIComponent(fullUrl)}${corsKey}`; + resp = await fetch(proxyUrl); + } else { + const url = path.startsWith('http') ? path : new URL(path, window.location.origin).href; + resp = await fetch(url); + } + + debug('fetchProductPage: path=', path, 'status=', resp.status, 'ok=', resp.ok); + if (!resp.ok) return { html: null, status: resp.status }; + const html = await resp.text(); + debug('fetchProductPage: path=', path, 'htmlLength=', html?.length); + return { html, status: resp.status }; +} + +/** + * Parse Product Specifications from main content (h3#product-specifications + ul > li). + * @param {Document} doc - Parsed document + * @returns {Object.} Map of spec label -> value + */ +function parseSpecsFromPage(doc) { + const specs = {}; + const heading = doc.querySelector('h3#product-specifications, [id="product-specifications"]'); + if (!heading) return specs; + const list = heading.closest('div')?.querySelector('ul'); + if (!list) return specs; + list.querySelectorAll(':scope > li').forEach((li) => { + const strong = li.querySelector('strong'); + const label = strong?.textContent?.replace(/:$/, '').trim(); + if (!label) return; + let value = ''; + const next = strong?.nextSibling; + if (next?.nodeType === Node.TEXT_NODE) { + value = next.textContent.trim(); + } + const nextP = li.querySelector('p'); + if (nextP && (value === '' || value.length < 3)) { + value = nextP.textContent.trim(); + } + if (value === '' && strong?.nextElementSibling) { + value = strong.nextElementSibling.textContent.trim(); + } + if (label && value) specs[label] = value; + }); + return specs; +} + +/** + * Parse warranty text from a section heading (e.g. "10-Year Full Warranty"). + * @param {Document} doc - Parsed document + * @returns {string} Warranty text or '' + */ +function parseWarrantyFromPage(doc) { + const cfg = getNormalizeConfig(); + const warrantyHeadings = cfg.warrantyHeading ?? DEFAULT_NORMALIZE.warrantyHeading; + const warrantyPattern = (Array.isArray(warrantyHeadings) ? warrantyHeadings : [warrantyHeadings]).map(escapeRegex).join('|'); + const warrantyRe = new RegExp(warrantyPattern, 'i'); + const headings = [...doc.querySelectorAll('main h3')]; + const h = headings.find((el) => warrantyRe.test(el.textContent || '')); + if (!h) return ''; + const strong = h.querySelector('strong'); + return (strong?.textContent || h.textContent || '').trim(); +} + +/** + * Parse variant sections (main .section[data-sku][data-color]) for first image per variant. + * @param {Document} doc - Parsed document + * @param {string} baseUrl - Base URL for resolving relative image src + * @returns {Array<{sku:string, color:string, imageUrl:string}>} + */ +function parseVariantSectionsFromPage(doc, baseUrl) { + const variants = []; + doc.querySelectorAll('main .section[data-sku][data-color]').forEach((section) => { + const { sku, color } = section.dataset; + const img = section.querySelector('picture img, img'); + let imageUrl = img?.getAttribute('src') || ''; + if (imageUrl && !imageUrl.startsWith('http') && baseUrl) { + try { + imageUrl = new URL(imageUrl, baseUrl).href; + } catch { + // keep relative + } + } + variants.push({ sku, color, imageUrl }); + }); + return variants; +} + +/** + * Build product object from fetched HTML (JSON-LD + parsed specs and variants). + * @param {string} html - Full page HTML + * @param {string} path - Product path + * @returns {Object|null} Normalized product object or null + */ +function parseProductFromPage(html, path) { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const canonical = doc.querySelector('link[rel="canonical"]')?.href; + const baseUrl = canonical || new URL(path, window.location.origin).href; + + const jsonLdScript = doc.querySelector('script[type="application/ld+json"]'); + if (!jsonLdScript?.textContent) { + debug('parseProductFromPage: no JSON-LD script', path); + return null; + } + let ld; + try { + ld = JSON.parse(jsonLdScript.textContent); + } catch (e) { + debug('parseProductFromPage: JSON parse error', path, e); + return null; + } + if (ld['@type'] !== 'Product' || !ld.name) { + debug('parseProductFromPage: not Product or no name', path, '@type=', ld['@type'], 'name=', ld?.name); + return null; + } + + const offers = ld.offers || []; + const firstOffer = offers[0]; + const listPrice = firstOffer?.priceSpecification?.price; + const finalPrice = firstOffer?.price ?? listPrice; + const price = { + currency: firstOffer?.priceCurrency || 'USD', + regular: listPrice != null ? String(listPrice) : String(finalPrice), + final: String(finalPrice ?? '0'), + }; + + const pageSpecs = parseSpecsFromPage(doc); + const warrantyHeading = parseWarrantyFromPage(doc); + if (warrantyHeading) pageSpecs.Warranty = warrantyHeading; + + const variantSections = parseVariantSectionsFromPage(doc, baseUrl); + + const variants = offers.map((offer) => { + const sectionMatch = variantSections.find((v) => v.sku === offer.sku); + const imageUrl = sectionMatch?.imageUrl || (Array.isArray(offer.image) ? offer.image[0] : '') || ld.image?.[0] || ''; + const colorOpt = offer.options?.find((o) => o.id === 'color'); + return { + sku: offer.sku, + name: offer.name, + options: offer.options || [], + images: imageUrl ? [{ url: imageUrl }] : (offer.image || []).map((u) => ({ url: u })), + price: { + currency: offer.priceCurrency || 'USD', + regular: String(offer.priceSpecification?.price ?? offer.price ?? price.regular), + final: String(offer.price ?? price.final), + }, + color: colorOpt?.value, + }; + }); + + const colorValues = variants + .filter((v) => v.color) + .map((v) => ({ value: v.color, uid: v.options?.find((o) => o.id === 'color')?.uid || '' })); + let options; + if (colorValues.length) { + options = [{ + id: 'color', label: 'Color', position: 1, values: colorValues, + }]; + } else if (ld.custom?.options) { + options = [{ id: 'color', label: 'Color', values: colorValues }]; + } else { + options = []; + } + + const images = Array.isArray(ld.image) ? ld.image.map((u) => ({ url: u })) : []; + + return { + name: ld.name, + path, + url: ld.url || baseUrl, + images, + price, + variants, + options, + custom: ld.custom || {}, + specs: pageSpecs, + }; +} + +/** + * Fetch product by loading the live page (no .json) and parsing HTML. + * @param {string} path - Product path + * @returns {Promise<{ product: Object|null, errorStatus?: number }>} + */ +async function fetchProduct(path) { + debug('fetchProduct: start', path); + const { html, status } = await fetchProductPage(path); + if (!html) { + debug('fetchProduct: no html', path, 'status=', status); + return { product: null, errorStatus: status }; + } + const product = parseProductFromPage(html, path); + debug('fetchProduct: parsed', path, 'product=', product ? { name: product.name } : null); + return { product, errorStatus: product ? undefined : status }; +} + +/** Placeholders for locale-aware price formatting (languageCode + currencyCode). */ +function getPricePlaceholders() { + const { locale, language } = getLocaleAndLanguage(); + return { + languageCode: language, + currencyCode: locale === 'ca' ? 'CAD' : 'USD', + }; +} + +/** + * Format price for display using central Intl-based formatter (e.g. "50 $" for fr-CA). + * @param {Object} price - { currency, regular, final } + * @returns {{ now: string, save: string|null }} + */ +function formatPrice(price) { + if (!price || price.final == null) return { now: '', save: null }; + const ph = getPricePlaceholders(); + const finalVal = parseFloat(price.final); + const regular = parseFloat(price.regular); + const now = formatPriceValue(finalVal, ph); + const save = regular > finalVal + ? `${t('Save')} ${formatPriceValue(regular - finalVal, ph)} | ${t('Was')} ${formatPriceValue(regular, ph)}` + : null; + return { now, save }; +} + +/** + * Find which table column index (1-based, 0 = row header) matches the given series. + * Uses first table's first tbody row and second table's thead for labels. + * @param {HTMLElement} container - .widget-container (section that has tables + widget) + * @param {string} series - Product series name + * @returns {number} 1-based column index or 1 if no match + */ +function findColumnIndexForSeries(container, series) { + const tables = container.querySelectorAll('.table.comparison .table-comparison-scroll table'); + const firstTable = tables[0]; + if (!firstTable) { + debug('findColumnIndexForSeries: no first table'); + return 1; + } + const firstDataRow = firstTable.querySelector('tbody tr'); + if (!firstDataRow) { + debug('findColumnIndexForSeries: no first tbody tr'); + return 1; + } + const headerTable = tables[1] || firstTable; + const theadRow = headerTable.querySelector('thead tr'); + const bodyCells = [...firstDataRow.children]; + const targetNorm = normalizeSeriesForMatch(series).toLowerCase(); + debug('findColumnIndexForSeries: series=', series, 'targetNorm=', targetNorm, 'cellCount=', bodyCells.length); + if (!targetNorm) return 1; + + // Explicit match for "venturist" -> "Séries Ascent et Venturist" column (column 2) + if (targetNorm === 'venturist' && theadRow) { + for (let i = 1; i < theadRow.children.length; i += 1) { + const thText = (theadRow.children[i]?.textContent || '').toLowerCase(); + if (thText.includes('venturist') && !thText.includes('ascent x')) { + debug('findColumnIndexForSeries: venturist match at column', i, 'thText=', thText.slice(0, 50)); + return i; + } + } + } + + const isAscentNonX = targetNorm === 'ascent' || (targetNorm.startsWith('ascent') && !targetNorm.includes('x')); + const isAscentX = targetNorm.includes('ascent') && targetNorm.includes('x'); + const isVenturist = targetNorm.includes('venturist'); + + for (let i = 1; i < bodyCells.length; i += 1) { + let columnText = (firstDataRow.children[i]?.textContent || '').trim(); + if (theadRow?.children[i]) { + const thText = (theadRow.children[i].textContent || '').trim(); + if (thText) columnText = `${thText} ${columnText}`; + } + const cellNorm = normalizeSeriesForMatch(columnText).toLowerCase(); + const matches = cellNorm && ( + cellNorm === targetNorm + || cellNorm.includes(targetNorm) + || targetNorm.includes(cellNorm) + ); + if (matches + && !(isAscentNonX && cellNorm.includes('ascent x')) + && !(isAscentX && cellNorm.includes('venturist')) + && !(isVenturist && cellNorm.includes('ascent x'))) { + debug('findColumnIndexForSeries: match at column', i, 'cellText=', columnText.slice(0, 60)); + return i; + } + } + debug('findColumnIndexForSeries: no match, using column 1'); + return 1; +} + +/** + * Create color swatches DOM for comparison table (same structure as table block). + * @param {Object} product - Product with options[].values (color names) + * @returns {DocumentFragment} + */ +function createColorSwatchesForProduct(product) { + const frag = document.createDocumentFragment(); + const wrap = document.createElement('div'); + wrap.className = 'table-comparison-color-swatches'; + const colorOpt = product?.options?.find((o) => o.id === 'color'); + const values = colorOpt?.values || []; + values.forEach((opt) => { + const label = opt.value || ''; + const slug = toClassName(label); + const swatch = document.createElement('div'); + swatch.className = 'table-comparison-color-swatch'; + swatch.title = label; + const inner = document.createElement('div'); + inner.className = 'table-comparison-color-inner'; + inner.style.backgroundColor = slug ? `var(--color-${slug}, #888)` : '#888'; + swatch.appendChild(inner); + wrap.appendChild(swatch); + }); + frag.appendChild(wrap); + return frag; +} + +/** + * Resolve image URL for display (variant or product level). + * @param {Object} product - Product JSON + * @param {number} variantIndex - Selected variant index + * @returns {string} Image URL + */ +function getProductImageUrl(product, variantIndex = 0) { + const variants = product?.variants; + if (Array.isArray(variants) && variants[variantIndex]?.images?.length > 0) { + const [{ url }] = variants[variantIndex].images; + return url.startsWith('http') || url.startsWith('/') + ? url : new URL(url, window.location.origin).pathname; + } + const images = product?.images; + if (Array.isArray(images) && images.length > 0) { + const [{ url }] = images; + return url.startsWith('http') || url.startsWith('/') + ? url : new URL(url, window.location.origin).pathname; + } + return ''; +} + +/** + * Snapshot original table cell content (by table, row, column) before any replacement. + * @param {HTMLElement} container - .widget-container + * @returns {Array>} snapshot[tableIndex][rowIndex][colIndex] = cell innerHTML + */ +function snapshotTableContent(container) { + const tables = container.querySelectorAll('.table.comparison .table-comparison-scroll table'); + return [...tables].map((table) => { + const rows = table.querySelectorAll('tbody tr'); + return [...rows].map((row) => { + const cells = [...row.children]; + return cells.map((cell) => (cell.tagName === 'TD' ? cell.innerHTML : '')); + }); + }); +} + +/** + * Replace one column in all comparison tables with the specific product's data. + * First table: from features-by-product.json; others: copy from series column. + * @param {HTMLElement} container - .widget-container (section that has tables + widget) + * @param {number} columnIndex - Column index (0 = row header, 1 = first product) + * @param {Object} product - Parsed product from fetchProduct + * @param {number} [sourceSeriesColumnIndex] - For tables 1+: column index to copy from + * @param {Array>} [originalContent] - Snapshot; for tables 1+ + * @param {Object} [featuresByProduct] - { data } from features-by-product.json + */ +function replaceColumnWithProduct( + container, + columnIndex, + product, + sourceSeriesColumnIndex, + originalContent, + featuresByProduct, +) { + const tables = container.querySelectorAll('.table.comparison .table-comparison-scroll table'); + if (!tables.length) return; + + const productUrl = product.path?.startsWith('http') + ? product.path + : new URL(product.path || '', window.location.origin).href; + const imageUrl = getProductImageUrl(product, 0); + const priceOrVariant = product?.price || product?.variants?.[0]?.price; + const { now: priceNow, save: priceSave } = formatPrice(priceOrVariant); + const priceText = priceSave + ? `${t('Now')} ${priceNow} | ${priceSave}` + : `${t('Starting at')} ${priceNow}`; + const learnMoreText = t('En savoir plus'); + + tables.forEach((table, tableIndex) => { + const theadRow = table.querySelector('thead tr'); + const isFirstTable = tableIndex === 0; + const th = theadRow?.children[columnIndex]; + + if (th) { + th.innerHTML = ''; + const pathToRemove = product.path; + if (isFirstTable && pathToRemove) { + th.classList.add('compare-products-column-with-remove'); + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'compare-products-remove-from-comparison'; + removeBtn.setAttribute('aria-label', `${t('Remove')} ${product.name || t('product')} ${t('from comparison')}`); + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', () => { + const current = getCompareProductsParamPaths(); + const next = current.filter((p) => p !== pathToRemove); + const url = new URL(window.location.href); + if (next.length) { + url.searchParams.set('compare-products', next.join(',')); + } else { + url.searchParams.delete('compare-products'); + } + window.location.href = url.toString(); + }); + th.appendChild(removeBtn); + } + if (isFirstTable && imageUrl) { + const picture = document.createElement('picture'); + const img = document.createElement('img'); + img.loading = 'lazy'; + img.alt = product.name || ''; + img.src = imageUrl; + img.width = 320; + img.height = 440; + picture.appendChild(img); + th.appendChild(picture); + } else if (!isFirstTable || !imageUrl) { + const label = product.name || product.series || '—'; + const p = document.createElement('p'); + p.textContent = label; + th.appendChild(p); + } + } + + const bodyRows = table.querySelectorAll('tbody tr'); + bodyRows.forEach((row, rowIndex) => { + const cells = [...row.children]; + const cell = cells[columnIndex]; + if (!cell || cell.tagName !== 'TD') return; + + if (isFirstTable && row.classList.contains('table-comparison-row-header-empty') && rowIndex === 0) { + cell.innerHTML = ` +

      ${(product.name || '').replace(/

      +

      ${priceText.replace(/ +

      ${learnMoreText}

      + `; + return; + } + + const headerCell = row.querySelector('th'); + const headerText = (headerCell?.textContent || '').trim().toLowerCase(); + const normalized = headerText.replace(/\s+/g, ' ').trim(); + const featureKey = ROW_LABEL_TO_FEATURE[normalized] + || Object.keys(ROW_LABEL_TO_FEATURE).find((k) => normalized.includes(k)); + + if (featureKey === 'Colors') { + cell.textContent = ''; + cell.appendChild(createColorSwatchesForProduct(product)); + return; + } + + if (featureKey === 'Series') { + const p = document.createElement('p'); + p.textContent = product.name || '—'; + cell.textContent = ''; + cell.appendChild(p); + return; + } + + // Feature rows (from any table): use features-by-product when available + // Use for all tables so duplicated tables (e.g. sticky header clone) get correct data + if (featureKey && featuresByProduct?.data) { + const featuresRow = getFeaturesRowByPath(featuresByProduct, product.path); + const value = getFeatureDisplayFromRow(featuresRow, featureKey); + if (value === ':check:') { + const checkHtml = `

      ${t('Check')}

      `; + cell.innerHTML = checkHtml; + } else { + cell.textContent = value || '—'; + if (!cell.querySelector('p')) { + const p = document.createElement('p'); + p.textContent = cell.textContent; + cell.textContent = ''; + cell.appendChild(p); + } + } + return; + } + + // Rows without a feature key (e.g. compatibility): copy from series column in DOM + const copyFromSeries = sourceSeriesColumnIndex != null + && sourceSeriesColumnIndex !== columnIndex + && originalContent; + if (copyFromSeries) { + const rowSnapshot = originalContent[tableIndex]?.[rowIndex]; + const cloned = rowSnapshot?.[sourceSeriesColumnIndex]; + if (cloned != null) cell.innerHTML = cloned; + } + }); + }); +} + +/** + * Hide table columns whose index is not in the used set. + * Keeps row header column 0 and selected product columns. + * @param {HTMLElement} container - .widget-container + * @param {Set} usedColumnIndices - Column indices to keep (e.g. 0, 1, 2, 5) + */ +function hideUnusedColumns(container, usedColumnIndices) { + const tables = container.querySelectorAll('.table.comparison .table-comparison-scroll table'); + tables.forEach((table) => { + const colgroup = table.querySelector('colgroup'); + if (colgroup) { + [...colgroup.children].forEach((col, index) => { + if (usedColumnIndices.has(index)) { + col.classList.add('compare-products-column-visible'); + } else { + col.style.display = 'none'; + } + }); + } + [...table.querySelectorAll('thead tr'), ...table.querySelectorAll('tbody tr')].forEach((tr) => { + [...tr.children].forEach((cell, index) => { + if (usedColumnIndices.has(index)) { + cell.classList.add('compare-products-column-visible'); + } else { + cell.style.display = 'none'; + } + }); + }); + }); + debug('hideUnusedColumns: kept columns', [...usedColumnIndices].sort((a, b) => a - b)); +} + +/** + * Remove the widget wrapper on the next task so widget block can finish. + * @param {HTMLElement} widget - Widget root + */ +function removeWidgetWrapperLater(widget) { + setTimeout(() => { + const wrapper = widget.closest('.widget-wrapper'); + if (wrapper) wrapper.remove(); + }, 0); +} + +/** + * When compare-products=path[,path2,...] is set: inject each product into the column that + * matches its series (so the table’s existing feature values stay correct), hide other columns. + * @param {HTMLElement} widget - Widget root + * @returns {Promise} true if the param was set and handling was done + */ +async function handleCompareProductsParam(widget) { + const paths = getCompareProductsParamPaths(); + debug('handleCompareProductsParam: paths=', paths); + if (paths.length === 0) { + debug('handleCompareProductsParam: no paths, skip'); + return false; + } + + const container = widget.closest('.widget-container'); + debug( + 'handleCompareProductsParam: container=', + container ? 'found' : 'NOT FOUND (need .widget-container on page)', + ); + if (!container) return false; + + const [featuresByProduct, ...results] = await Promise.all([ + fetchFeaturesByProduct(), + ...paths.map((path) => fetchProduct(path)), + ]); + debug('handleCompareProductsParam: results=', results.map((r, i) => ({ path: paths[i], hasProduct: !!r.product, errorStatus: r.errorStatus }))); + + const originalContent = snapshotTableContent(container); + + const firstTable = container.querySelector('.table.comparison .table-comparison-scroll table'); + const firstRow = firstTable?.querySelector('thead tr, tbody tr'); + const maxColumns = firstRow ? firstRow.children.length : 0; + + const usedColumnIndices = new Set([0]); + let anyReplaced = false; + results.forEach(({ product }, i) => { + if (!product) { + debug('handleCompareProductsParam: skip path (no product)', paths[i]); + return; + } + const columnIndex = i + 1; + if (columnIndex >= maxColumns) { + debug('handleCompareProductsParam: skip path (no column left)', paths[i]); + return; + } + const featuresRow = getFeaturesRowByPath(featuresByProduct, product.path); + const series = featuresRow?.Series ?? ''; + const sourceSeriesColumnIndex = findColumnIndexForSeries(container, series); + usedColumnIndices.add(columnIndex); + debug( + 'handleCompareProductsParam: replace column', + 'path=', + paths[i], + 'series=', + series, + 'columnIndex=', + columnIndex, + 'sourceSeriesColumn=', + sourceSeriesColumnIndex, + ); + replaceColumnWithProduct( + container, + columnIndex, + product, + sourceSeriesColumnIndex, + originalContent, + featuresByProduct, + ); + anyReplaced = true; + }); + + if (anyReplaced) { + hideUnusedColumns(container, usedColumnIndices); + container.classList.add('compare-products-columns-filtered'); + container.dataset.compareProductsVisible = String(usedColumnIndices.size - 1); + } + + debug( + 'handleCompareProductsParam: done, anyReplaced=', + anyReplaced, + 'visibleColumns=', + usedColumnIndices.size - 1, + ); + + if (paths.length >= MAX_COMPARISON_PRODUCTS) { + removeWidgetWrapperLater(widget); + } + return true; +} + +/** + * Build an empty-state card when a product failed to load. + * @param {string} path - Product path (for link) + * @param {number} index - Index (for remove) + * @param {Function} onRemove - Callback when remove is clicked + * @param {number} [errorStatus] - HTTP status when failed (e.g. 404) + * @returns {HTMLElement} + */ +function buildPlaceholderCard(path, index, onRemove, errorStatus) { + const col = document.createElement('div'); + col.className = 'compare-products-product compare-products-product-placeholder'; + col.dataset.index = String(index); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'button button close compare-products-product-remove'; + removeBtn.setAttribute('aria-label', t('Remove from comparison')); + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', () => onRemove(index)); + + const msg = document.createElement('p'); + msg.className = 'compare-products-product-placeholder-msg'; + msg.textContent = errorStatus === 404 + ? t('Product not found (404).') + : t('Could not load this product.'); + + const link = document.createElement('a'); + link.href = path.startsWith('http') ? path : new URL(path, window.location.origin).href; + link.textContent = t('View Details'); + link.className = 'button link'; + + col.append(removeBtn, msg, link); + return col; +} + +/** + * Build one product card DOM node. + * @param {Object} product - Product JSON + * @param {number} index - Index in products array (for remove) + * @param {Function} onRemove - Callback when remove is clicked + * @returns {HTMLElement} + */ +function buildProductCard(product, index, onRemove) { + const col = document.createElement('div'); + col.className = 'compare-products-product'; + col.dataset.index = String(index); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'button button close compare-products-product-remove'; + removeBtn.setAttribute('aria-label', `${t('Remove')} ${product.name || t('product')} ${t('from comparison')}`); + removeBtn.textContent = '×'; + removeBtn.addEventListener('click', () => onRemove(index)); + + const imgWrap = document.createElement('div'); + imgWrap.className = 'compare-products-product-image-wrap'; + const img = document.createElement('img'); + img.src = getProductImageUrl(product, 0); + img.alt = ''; + img.loading = 'lazy'; + imgWrap.appendChild(img); + + const colorsWrap = document.createElement('div'); + colorsWrap.className = 'compare-products-product-colors'; + const options = product?.options?.find((o) => o.id === 'color'); + const variants = product?.variants || []; + if (options?.values?.length) { + options.values.forEach((opt, i) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = `compare-products-product-color ${i === 0 ? 'selected' : ''}`; + btn.setAttribute('aria-label', opt.value); + btn.title = opt.value; + const colorSlug = toClassName(opt.value); + btn.style.backgroundColor = colorSlug + ? `var(--color-${colorSlug}, var(--color-gray-300))` + : 'var(--color-gray-300)'; + btn.dataset.variantIndex = String(i); + btn.addEventListener('click', () => { + colorsWrap.querySelectorAll('.compare-products-product-color').forEach((c) => c.classList.remove('selected')); + btn.classList.add('selected'); + const idx = parseInt(btn.dataset.variantIndex, 10); + img.src = getProductImageUrl(product, idx); + }); + colorsWrap.appendChild(btn); + }); + } + + const nameEl = document.createElement('h3'); + nameEl.className = 'compare-products-product-name'; + nameEl.textContent = product.name || ''; + + const priceEl = document.createElement('div'); + priceEl.className = 'compare-products-product-price'; + const price = product?.price || variants[0]?.price; + const { now, save } = formatPrice(price); + priceEl.innerHTML = `${t('Now')} ${now}`; + if (save) { + const saveEl = document.createElement('span'); + saveEl.className = 'compare-products-product-price-save'; + saveEl.textContent = save; + priceEl.appendChild(saveEl); + } + + const cta = document.createElement('a'); + cta.className = 'button emphasis'; + const { path: productPath, url: productUrl } = product || {}; + cta.href = productPath || productUrl || '#'; + cta.textContent = t('View Details').toUpperCase(); + + col.append(removeBtn, imgWrap, colorsWrap, nameEl, priceEl, cta); + return col; +} + +/** + * Build features table body (feature rows with one cell per product/slot). + * Uses features-by-product.json by path; product still used for name/colors when needed. + * @param {{ path: string, product: Object|null }[]} slots - One slot per requested path + * @param {HTMLElement} tableEl - Table container + * @param {Object} [featuresByProduct] - Optional { data } from features-by-product.json + */ +function buildFeaturesTable(slots, tableEl, featuresByProduct) { + const columnCount = slots.length; + tableEl.style.setProperty('--compare-cols', String(columnCount)); + tableEl.innerHTML = ''; + + FEATURE_KEYS.forEach((key, rowIndex) => { + const row = document.createElement('div'); + row.className = `compare-products-features-row ${rowIndex % 2 ? 'row-odd' : 'row-even'}`; + const nameCell = document.createElement('div'); + nameCell.className = `compare-products-features-cell feature-name ${rowIndex % 2 ? 'row-odd' : 'row-even'}`; + nameCell.textContent = t(key); + row.appendChild(nameCell); + slots.forEach((slot) => { + const cell = document.createElement('div'); + cell.className = `compare-products-features-cell ${rowIndex % 2 ? 'row-odd' : 'row-even'}`; + let value = '—'; + if (slot.product && featuresByProduct?.data) { + const featuresRow = getFeaturesRowByPath(featuresByProduct, slot.path); + if (key === 'Series') { + value = slot.product.name || featuresRow?.Series || '—'; + } else { + value = getFeatureDisplayFromRow(featuresRow, key); + } + } else if (slot.product) { + value = '—'; + } + if (value === ':check:') { + const check = document.createElement('span'); + check.className = 'compare-products-features-cell-check'; + check.setAttribute('aria-hidden', 'true'); + check.textContent = '✓'; + cell.appendChild(check); + } else { + cell.textContent = value; + } + row.appendChild(cell); + }); + tableEl.appendChild(row); + }); +} + +/** + * Build a card for the "add a product" grid (product from index.json). + * @param {Object} product - Product from lookupProducts (title, image, price, url) + * @returns {HTMLElement} + */ +function buildAddProductCard(product) { + const col = document.createElement('div'); + col.className = 'compare-products-product compare-products-add-card'; + + const imgWrap = document.createElement('div'); + imgWrap.className = 'compare-products-product-image-wrap'; + const img = document.createElement('img'); + img.src = resolveImageUrlForDisplay(product.image) || ''; + img.alt = ''; + img.loading = 'lazy'; + imgWrap.appendChild(img); + + const nameEl = document.createElement('h3'); + nameEl.className = 'compare-products-product-name'; + nameEl.textContent = product.title || ''; + + const priceEl = document.createElement('div'); + priceEl.className = 'compare-products-product-price'; + const price = product.price != null + ? formatPriceValue(Number(product.price), getPricePlaceholders()) + : ''; + priceEl.innerHTML = price ? `${price}` : ''; + + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'button emphasis compare-products-add-to-comparison'; + addBtn.textContent = t('Add to comparison'); + addBtn.addEventListener('click', () => { + const current = getCompareProductsParamPaths(); + const next = [...current, product.url].filter(Boolean); + const url = new URL(window.location.href); + url.searchParams.set('compare-products', next.join(',')); + window.location.href = url.toString(); + }); + + col.append(imgWrap, addBtn, nameEl, priceEl); + return col; +} + +/** + * Fetch Full Size Blenders from index (fcors like PLP) and render grid to add a product. + * @param {HTMLElement} widget - Widget root + * @param {Object} [options] - Options + * @param {boolean} [options.inSection] - If true, render into .compare-products-add-section + * below features (1–2 products); else replace products area (0 products). + * @param {string[]} [options.excludePaths] - Paths already in comparison (omit from grid). + */ +async function renderAddProductsGrid(widget, options = {}) { + const { inSection = false, excludePaths = [] } = options; + const scroll = widget.querySelector('.compare-products-scroll'); + const productsContainer = widget.querySelector('.compare-products-products'); + const featuresSection = widget.querySelector('.compare-products-features'); + if (!scroll) return; + + let container; + if (inSection) { + let section = widget.querySelector('.compare-products-add-section'); + if (!section) { + section = document.createElement('div'); + section.className = 'compare-products-add-section'; + scroll.appendChild(section); + } + section.hidden = false; + section.innerHTML = ''; + container = section; + } else { + if (!productsContainer) return; + container = productsContainer; + container.innerHTML = ''; + if (featuresSection) featuresSection.hidden = true; + } + + const heading = document.createElement('h2'); + heading.className = 'compare-products-add-heading'; + heading.textContent = t('Add a product to compare'); + container.appendChild(heading); + + let products = []; + try { + products = await lookupProducts({ productType: ADD_TO_COMPARE_PRODUCT_TYPE }); + } catch (e) { + debug('renderAddProductsGrid: lookupProducts failed', e); + } + + const excludeSet = new Set(excludePaths.filter(Boolean)); + const filtered = products.filter((p) => p.url && !excludeSet.has(p.url)); + + if (filtered.length === 0) { + const empty = document.createElement('p'); + empty.className = 'compare-products-add-empty'; + empty.textContent = inSection && products.length > 0 + ? t('All selected. Remove one to add another.') + : t('No countertop blenders found.'); + container.appendChild(empty); + return; + } + + const grid = document.createElement('div'); + grid.className = 'compare-products-add-grid'; + filtered.forEach((product) => grid.appendChild(buildAddProductCard(product))); + container.appendChild(grid); +} + +/** + * Render the full compare view: product cards + features table. + * @param {HTMLElement} widget - Widget root + * @param {{ path: string, product: Object|null }[]} slots - One slot per requested path + * @param {Object} [featuresByProduct] - Optional { data } from features-by-product.json + */ +function render(widget, slots, featuresByProduct) { + const productsContainer = widget.querySelector('.compare-products-products'); + const featuresTable = widget.querySelector('.compare-products-features-table'); + if (!productsContainer || !featuresTable) return; + + widget.style.setProperty('--compare-cols', String(slots.length)); + productsContainer.innerHTML = ''; + + const removeProduct = (index) => { + const next = slots.filter((_, i) => i !== index); + if (next.length === 0) { + widget.dispatchEvent(new CustomEvent('compare-products-empty')); + return; + } + render(widget, next, featuresByProduct); + }; + + slots.forEach((slot, index) => { + const card = slot.product + ? buildProductCard(slot.product, index, removeProduct) + : buildPlaceholderCard(slot.path, index, removeProduct, slot.errorStatus); + productsContainer.appendChild(card); + }); + + buildFeaturesTable(slots, featuresTable, featuresByProduct); +} + +/** + * Initialize compare-products widget: read config, fetch products, render. + * If ?compare-products=path is set, inject that product into the matching series column. + * Otherwise use ?productComparison=path1,path2 to show the widget comparison grid. + * @param {HTMLElement} widget - Widget root + */ +export default async function decorate(widget) { + await loadComparisonTranslations(); + const paths = getCompareProductsParamPaths(); + debug('decorate: compare-products paths=', paths.length ? paths : 'none'); + const handled = await handleCompareProductsParam(widget); + debug('decorate: handled=', handled); + if (handled) { + const pathsAfter = getCompareProductsParamPaths(); + if (pathsAfter.length < MAX_COMPARISON_PRODUCTS) { + await renderAddProductsGrid(widget, { excludePaths: pathsAfter }); + } + return; + } + + if (getCompareProductsParam()) { + removeWidgetWrapperLater(widget); + return; + } + + const comparisonPaths = getProductComparisonPaths(); + if (comparisonPaths.length === 0) { + await renderAddProductsGrid(widget); + return; + } + + const [featuresByProduct, ...slotResults] = await Promise.all([ + fetchFeaturesByProduct(), + ...comparisonPaths.map((path) => fetchProduct(path)), + ]); + + const slots = slotResults.map(({ product, errorStatus }, i) => ({ + path: comparisonPaths[i], + product, + errorStatus, + })); + + const allFailed = slots.every((slot) => !slot.product); + if (allFailed) { + removeWidgetWrapperLater(widget); + return; + } + + render(widget, slots, featuresByProduct || undefined); + + if (slots.length < MAX_COMPARISON_PRODUCTS) { + await renderAddProductsGrid(widget, { + inSection: true, + excludePaths: comparisonPaths, + }); + } else { + const addSection = widget.querySelector('.compare-products-add-section'); + if (addSection) addSection.hidden = true; + } +} + +// Load CSS +const start = () => { + loadCSS('/widgets/compare-products/compare-products.css'); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); +} else { + start(); +} diff --git a/widgets/compare-products/compare-products.json b/widgets/compare-products/compare-products.json new file mode 100644 index 00000000..c5f29aa0 --- /dev/null +++ b/widgets/compare-products/compare-products.json @@ -0,0 +1,80 @@ +{ + "en": { + "normalize": { + "trailingSeries": "series", + "leadingSeries": "series", + "brandName": "Vitamix", + "warrantyHeading": "warranty" + }, + "View Details": "View Details", + "Add to comparison": "Add to comparison", + "Add a product to compare": "Add a product to compare", + "Remove": "Remove", + "from comparison": "from comparison", + "product": "product", + "Remove from comparison": "Remove from comparison", + "Product not found (404).": "Product not found (404).", + "Could not load this product.": "Could not load this product.", + "Yes": "Yes", + "Save": "Save", + "Was": "Was", + "Now": "Now", + "Starting at": "Starting at", + "En savoir plus": "Learn more", + "Check": "Check", + "All selected. Remove one to add another.": "All selected. Remove one to add another.", + "No countertop blenders found.": "No countertop blenders found." + }, + "fr": { + "normalize": { + "trailingSeries": "série", + "leadingSeries": "séries", + "brandName": "Vitamix", + "warrantyHeading": "garantie" + }, + "View Details": "Voir les détails", + "Add to comparison": "Ajouter à la comparaison", + "Add a product to compare": "Ajouter un produit à comparer", + "Remove": "Retirer", + "from comparison": "de la comparaison", + "product": "produit", + "Remove from comparison": "Retirer de la comparaison", + "Product not found (404).": "Produit introuvable (404).", + "Could not load this product.": "Impossible de charger ce produit.", + "Yes": "Oui", + "Save": "Économiser", + "Was": "Était", + "Now": "Maintenant", + "Starting at": "À partir de", + "En savoir plus": "En savoir plus", + "Check": "Coche", + "All selected. Remove one to add another.": "Tous sélectionnés. Retirez-en un pour en ajouter un autre.", + "No countertop blenders found.": "Aucun mélangeur de comptoir trouvé." + }, + "es": { + "normalize": { + "trailingSeries": "serie", + "leadingSeries": "serie", + "brandName": "Vitamix", + "warrantyHeading": "garantía" + }, + "View Details": "Ver detalles", + "Add to comparison": "Añadir a la comparación", + "Add a product to compare": "Añadir un producto para comparar", + "Remove": "Quitar", + "from comparison": "de la comparación", + "product": "producto", + "Remove from comparison": "Quitar de la comparación", + "Product not found (404).": "Producto no encontrado (404).", + "Could not load this product.": "No se pudo cargar este producto.", + "Yes": "Sí", + "Save": "Ahorrar", + "Was": "Era", + "Now": "Ahora", + "Starting at": "Desde", + "En savoir plus": "Saber más", + "Check": "Comprobar", + "All selected. Remove one to add another.": "Todos seleccionados. Quite uno para añadir otro.", + "No countertop blenders found.": "No se encontraron licuadoras de mostrador." + } +} diff --git a/widgets/compare-products/icon-check.svg b/widgets/compare-products/icon-check.svg new file mode 100644 index 00000000..99bdeed0 --- /dev/null +++ b/widgets/compare-products/icon-check.svg @@ -0,0 +1,4 @@ + + Check + + diff --git a/widgets/cookie-declaration/cookie-declaration.css b/widgets/cookie-declaration/cookie-declaration.css new file mode 100644 index 00000000..3a39ae0c --- /dev/null +++ b/widgets/cookie-declaration/cookie-declaration.css @@ -0,0 +1,5 @@ +.cookie-declaration { + background-color: #f0f0f0; + padding: 20px; + border-radius: 10px; +} \ No newline at end of file diff --git a/widgets/cookie-declaration/cookie-declaration.html b/widgets/cookie-declaration/cookie-declaration.html new file mode 100644 index 00000000..2e9d2ff7 --- /dev/null +++ b/widgets/cookie-declaration/cookie-declaration.html @@ -0,0 +1 @@ +
      \ No newline at end of file diff --git a/widgets/cookie-declaration/cookie-declaration.js b/widgets/cookie-declaration/cookie-declaration.js new file mode 100644 index 00000000..e5db9674 --- /dev/null +++ b/widgets/cookie-declaration/cookie-declaration.js @@ -0,0 +1,5 @@ +import { loadScript } from '../../scripts/aem.js'; + +export default async function decorate() { + await loadScript('https://consent.cookiebot.com/1d1d4c74-9c10-49e5-9577-f8eb4ba520fb/cd.js'); +} diff --git a/widgets/forms/contact-us.css b/widgets/forms/contact-us.css new file mode 100644 index 00000000..5c154b16 --- /dev/null +++ b/widgets/forms/contact-us.css @@ -0,0 +1,69 @@ +/* Contact us form – vertical stack, labels above, uses global form/input styles */ +.forms-contact-us { + background-color: var(--color-gray-100); + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-contact-us .contact-us-form { + display: flex; + flex-direction: column; + gap: var(--spacing-200); + max-width: 32rem; +} + +.forms-contact-us .contact-us-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-contact-us .contact-us-field label { + margin-bottom: 0; +} + +.forms-contact-us .contact-us-field .required { + color: var(--color-red); + margin-left: 0.15em; +} + +.forms-contact-us .contact-us-actions { + margin-top: var(--spacing-100); +} + +.forms-contact-us .contact-us-radio-group { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.forms-contact-us .contact-us-radio-group .radio-legend { + font-weight: var(--font-weight-medium, 500); + display: block; +} + +.forms-contact-us .radio-options { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-contact-us .radio-option { + display: flex; + align-items: center; + gap: 0.5em; + cursor: pointer; +} + +.forms-contact-us .contact-us-form button[type="submit"] { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-contact-us .contact-us-form button[type="submit"]:hover, +.forms-contact-us .contact-us-form button[type="submit"]:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} diff --git a/widgets/forms/contact-us.html b/widgets/forms/contact-us.html new file mode 100644 index 00000000..126847fe --- /dev/null +++ b/widgets/forms/contact-us.html @@ -0,0 +1,44 @@ +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + +
      + + +
      +
      +
      + + +
      +
      + +
      +
      diff --git a/widgets/forms/contact-us.js b/widgets/forms/contact-us.js new file mode 100644 index 00000000..5fd38682 --- /dev/null +++ b/widgets/forms/contact-us.js @@ -0,0 +1,114 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for contact-us form */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/contact-us'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Injects options into a select (keeps first empty option). + * @param {HTMLSelectElement} select - The select element + * @param {{ label: string, value: string }[]} options - Options array + */ +function setSelectOptions(select, options) { + if (!select || !options?.length) return; + while (select.options.length > 1) select.remove(1); + options.forEach((opt) => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + select.appendChild(option); + }); +} + +/** + * Decorates the contact-us widget: applies copy from JSON and configures form. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const form = widget.querySelector('.contact-us-form'); + if (!form) return; + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + const reasonOptions = copy.reasonOptions || []; + + form.querySelector('[for="contact-us-first-name"] .label-text').textContent = labels.firstName ?? 'First Name'; + form.querySelector('[for="contact-us-last-name"] .label-text').textContent = labels.lastName ?? 'Last Name'; + form.querySelector('[for="contact-us-email"] .label-text').textContent = labels.emailAddress ?? 'Email Address'; + + const radioLegend = form.querySelector('.contact-us-radio-group .radio-legend'); + if (radioLegend) radioLegend.textContent = labels.typeOfRequest ?? 'Type of request'; + const radioLabels = form.querySelectorAll('.contact-us-radio-group .radio-label'); + if (radioLabels[0]) radioLabels[0].textContent = labels.domestic ?? 'Domestic'; + if (radioLabels[1]) radioLabels[1].textContent = labels.commercial ?? 'Commercial'; + + form.querySelector('[for="contact-us-reason"] .label-text').textContent = labels.reasonForCommunication ?? 'Reason for communication'; + const reasonSelect = form.querySelector('#contact-us-reason'); + if (reasonSelect?.firstElementChild) { + reasonSelect.firstElementChild.textContent = labels.select ?? 'Select'; + } + setSelectOptions(reasonSelect, reasonOptions); + + const firstInput = form.querySelector('#contact-us-first-name'); + const lastInput = form.querySelector('#contact-us-last-name'); + const emailInput = form.querySelector('#contact-us-email'); + if (firstInput) firstInput.placeholder = inputHints.firstName ?? ''; + if (lastInput) lastInput.placeholder = inputHints.lastName ?? ''; + if (emailInput) emailInput.placeholder = inputHints.emailAddress ?? ''; + + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.textContent = labels.submit ?? 'Submit'; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + payload.formId = `${locale}/${language}/contact-us`; + + const submitButton = form.querySelector('button[type="submit"]'); + const buttonLabel = submitButton?.textContent; + [...form.elements].forEach((el) => { el.disabled = true; }); + if (submitButton) { + submitButton.dataset.originalLabel = buttonLabel; + submitButton.textContent = labels.sending ?? 'Sending...'; + } + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`Sheet logger responded with ${resp.status}`); + } + const thankYouPath = `/${locale}/${language}/contact-us-thankyou`; + window.location.href = thankYouPath; + } catch (err) { + // eslint-disable-next-line no-console + console.error('Contact us form submission failed', err); + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } + } + }); +} diff --git a/widgets/forms/contact-us.json b/widgets/forms/contact-us.json new file mode 100644 index 00000000..bd9511c1 --- /dev/null +++ b/widgets/forms/contact-us.json @@ -0,0 +1,116 @@ +{ + "en": { + "labels": { + "select": "Select", + "firstName": "First Name", + "lastName": "Last Name", + "emailAddress": "Email Address", + "typeOfRequest": "Type of request", + "domestic": "Domestic", + "commercial": "Commercial", + "reasonForCommunication": "Reason for communication", + "submit": "Submit", + "sending": "Sending..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "emailAddress": "" + }, + "reasonOptions": [ + { "label": "Check the status of my order", "value": "order-status" }, + { "label": "Where to get a Vitamix product", "value": "where-to-buy" }, + { "label": "Get help with warranty registration", "value": "warranty-registration" }, + { "label": "Get help choosing the Vitamix product that best suits my business", "value": "business-product-help" }, + { "label": "Request a brochure", "value": "request-brochure" }, + { "label": "Get help with recipes", "value": "recipe-help" }, + { "label": "Know when my order will be shipped", "value": "shipment-date" }, + { "label": "Request my order tracking number", "value": "tracking-number" }, + { "label": "Learn more about the exchange program", "value": "exchange-program" }, + { "label": "Ask a technical question", "value": "technical-question" }, + { "label": "Find a store or live demonstration", "value": "find-store-demo" }, + { "label": "Resolve a product-related issue", "value": "product-issue" }, + { "label": "Get help comparing Vitamix appliances and sets", "value": "compare-products" }, + { "label": "Become a retailer", "value": "become-retailer" }, + { "label": "Request help to buy directly from Vitamix", "value": "buy-direct" }, + { "label": "Product discount program", "value": "discount-program" }, + { "label": "General inquiry", "value": "general-inquiry" } + ] + }, + "fr": { + "labels": { + "select": "Sélectionner", + "firstName": "Prénom", + "lastName": "Nom de famille", + "emailAddress": "Adresse courriel", + "typeOfRequest": "Type de demande", + "domestic": "Domestique", + "commercial": "Commercial", + "reasonForCommunication": "Raison de la communication", + "submit": "Soumettre", + "sending": "Envoi en cours..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "emailAddress": "" + }, + "reasonOptions": [ + { "label": "Vérifier le statut de ma commande", "value": "order-status" }, + { "label": "Où acheter un produit Vitamix", "value": "where-to-buy" }, + { "label": "Aide pour l'enregistrement de la garantie", "value": "warranty-registration" }, + { "label": "Choisir le produit Vitamix qui convient à mon entreprise", "value": "business-product-help" }, + { "label": "Demander une brochure", "value": "request-brochure" }, + { "label": "Aide avec les recettes", "value": "recipe-help" }, + { "label": "Date d'expédition de ma commande", "value": "shipment-date" }, + { "label": "Numéro de suivi de ma commande", "value": "tracking-number" }, + { "label": "En savoir plus sur le programme d'échange", "value": "exchange-program" }, + { "label": "Question technique", "value": "technical-question" }, + { "label": "Trouver un magasin ou une démonstration", "value": "find-store-demo" }, + { "label": "Résoudre un problème lié au produit", "value": "product-issue" }, + { "label": "Comparer les appareils et ensembles Vitamix", "value": "compare-products" }, + { "label": "Devenir détaillant", "value": "become-retailer" }, + { "label": "Acheter directement chez Vitamix", "value": "buy-direct" }, + { "label": "Programme de rabais sur les produits", "value": "discount-program" }, + { "label": "Demande générale", "value": "general-inquiry" } + ] + }, + "es": { + "labels": { + "select": "Seleccionar", + "firstName": "Nombre", + "lastName": "Apellido", + "emailAddress": "Correo electrónico", + "typeOfRequest": "Tipo de solicitud", + "domestic": "Doméstico", + "commercial": "Comercial", + "reasonForCommunication": "Motivo de la comunicación", + "submit": "Enviar", + "sending": "Enviando..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "emailAddress": "" + }, + "reasonOptions": [ + { "label": "Consultar el estado de mi pedido", "value": "order-status" }, + { "label": "Dónde comprar un producto Vitamix", "value": "where-to-buy" }, + { "label": "Ayuda con el registro de garantía", "value": "warranty-registration" }, + { "label": "Elegir el producto Vitamix adecuado para mi negocio", "value": "business-product-help" }, + { "label": "Solicitar un folleto", "value": "request-brochure" }, + { "label": "Ayuda con recetas", "value": "recipe-help" }, + { "label": "Cuándo se enviará mi pedido", "value": "shipment-date" }, + { "label": "Número de seguimiento de mi pedido", "value": "tracking-number" }, + { "label": "Más información sobre el programa de intercambio", "value": "exchange-program" }, + { "label": "Pregunta técnica", "value": "technical-question" }, + { "label": "Buscar una tienda o demostración", "value": "find-store-demo" }, + { "label": "Resolver un problema con el producto", "value": "product-issue" }, + { "label": "Comparar aparatos y conjuntos Vitamix", "value": "compare-products" }, + { "label": "Ser minorista", "value": "become-retailer" }, + { "label": "Comprar directamente a Vitamix", "value": "buy-direct" }, + { "label": "Programa de descuento en productos", "value": "discount-program" }, + { "label": "Consulta general", "value": "general-inquiry" } + ] + } +} diff --git a/widgets/forms/create-account.css b/widgets/forms/create-account.css new file mode 100644 index 00000000..264e4f14 --- /dev/null +++ b/widgets/forms/create-account.css @@ -0,0 +1,96 @@ +/* Create account form – same pattern as contact-us / login */ +.forms-create-account { + background-color: var(--color-gray-100); + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-create-account .create-account-form { + display: flex; + flex-direction: column; + gap: var(--spacing-200); + max-width: 32rem; +} + +.forms-create-account .create-account-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-create-account .create-account-field label { + margin-bottom: 0; +} + +.forms-create-account .create-account-field .required { + color: var(--color-red); + margin-left: 0.15em; +} + +.forms-create-account .create-account-radio-group { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.forms-create-account .create-account-radio-group .radio-legend { + font-weight: var(--font-weight-medium, 500); + display: block; +} + +.forms-create-account .create-account-radio-group .radio-options { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-create-account .create-account-radio-group .radio-option { + display: flex; + align-items: center; + gap: 0.5em; + cursor: pointer; +} + +.forms-create-account .create-account-checkbox .checkbox-label { + display: flex; + align-items: flex-start; + gap: 0.5em; + cursor: pointer; +} + +.forms-create-account .create-account-checkbox input[type="checkbox"] { + margin-top: 0.25em; + flex-shrink: 0; +} + +.forms-create-account .create-account-checkbox .terms-link { + text-decoration: underline; +} + +.forms-create-account .create-account-required-legend { + margin: 0; + font-size: var(--font-size-20); + color: var(--color-gray-700); +} + +.forms-create-account .create-account-required-legend::before { + content: '* '; + color: var(--color-red); +} + +.forms-create-account .create-account-actions { + margin-top: var(--spacing-100); +} + +.forms-create-account .create-account-form button[type="submit"] { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-create-account .create-account-form button[type="submit"]:hover, +.forms-create-account .create-account-form button[type="submit"]:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} diff --git a/widgets/forms/create-account.html b/widgets/forms/create-account.html new file mode 100644 index 00000000..8d764389 --- /dev/null +++ b/widgets/forms/create-account.html @@ -0,0 +1,68 @@ + diff --git a/widgets/forms/create-account.js b/widgets/forms/create-account.js new file mode 100644 index 00000000..dbf980bf --- /dev/null +++ b/widgets/forms/create-account.js @@ -0,0 +1,122 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for create-account form */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/create-account'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Decorates the create-account widget: applies copy from JSON and configures form. + * No password fields; sign-in will use email confirmation. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const form = widget.querySelector('.create-account-form'); + if (!form) return; + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + + form.querySelector('[for="create-account-first-name"] .label-text').textContent = labels.firstName ?? 'First Name'; + form.querySelector('[for="create-account-last-name"] .label-text').textContent = labels.lastName ?? 'Last Name'; + form.querySelector('[for="create-account-email"] .label-text').textContent = labels.emailAddress ?? 'Email Address'; + form.querySelector('[for="create-account-confirm-email"] .label-text').textContent = labels.confirmEmailAddress ?? 'Confirm Email Address'; + form.querySelector('[for="create-account-postal-code"] .label-text').textContent = labels.postalCode ?? 'Postal code'; + + const useLegend = form.querySelector('#create-account-use-legend'); + if (useLegend) useLegend.textContent = labels.accountUseReasons ?? 'For what reasons will you use this account?'; + + const ownLegend = form.querySelector('#create-account-own-legend'); + if (ownLegend) ownLegend.textContent = labels.ownVitamix ?? 'Do you currently own a Vitamix?'; + + const allRadioLabels = form.querySelectorAll('.create-account-radio-group .radio-label'); + if (allRadioLabels[0]) allRadioLabels[0].textContent = labels.domesticProducts ?? 'For domestic products'; + if (allRadioLabels[1]) allRadioLabels[1].textContent = labels.commercialProducts ?? 'For commercial products'; + if (allRadioLabels[2]) allRadioLabels[2].textContent = labels.yes ?? 'Yes'; + if (allRadioLabels[3]) allRadioLabels[3].textContent = labels.no ?? 'No'; + + form.querySelector('.accept-prefix').textContent = labels.acceptTermsPrefix ?? 'I accept the '; + form.querySelector('.terms-link').textContent = labels.termsLinkText ?? 'terms and conditions of Vitamix'; + form.querySelector('.create-account-required-legend').textContent = labels.requiredFieldsLegend ?? '* Required fields'; + + const firstNameInput = form.querySelector('#create-account-first-name'); + const lastNameInput = form.querySelector('#create-account-last-name'); + const emailInput = form.querySelector('#create-account-email'); + const confirmEmailInput = form.querySelector('#create-account-confirm-email'); + const postalCodeInput = form.querySelector('#create-account-postal-code'); + if (firstNameInput) firstNameInput.placeholder = inputHints.firstName ?? ''; + if (lastNameInput) lastNameInput.placeholder = inputHints.lastName ?? ''; + if (emailInput) emailInput.placeholder = inputHints.emailAddress ?? ''; + if (confirmEmailInput) confirmEmailInput.placeholder = inputHints.confirmEmailAddress ?? ''; + if (postalCodeInput && inputHints.postalCode) { + postalCodeInput.placeholder = inputHints.postalCode; + } + + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.textContent = labels.createAccount ?? 'Create account'; + + confirmEmailInput?.addEventListener('input', () => { + confirmEmailInput.setCustomValidity(''); + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + + const email = emailInput?.value?.trim() || ''; + const confirmEmail = confirmEmailInput?.value?.trim() || ''; + if (email !== confirmEmail) { + confirmEmailInput.setCustomValidity(labels.emailsMustMatch ?? 'Emails must match'); + confirmEmailInput.reportValidity(); + return; + } + confirmEmailInput.setCustomValidity(''); + + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + + const submitButton = form.querySelector('button[type="submit"]'); + const buttonLabel = submitButton?.textContent; + [...form.elements].forEach((el) => { el.disabled = true; }); + if (submitButton) { + submitButton.dataset.originalLabel = buttonLabel; + submitButton.textContent = labels.sending ?? 'Sending...'; + } + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`Sheet logger responded with ${resp.status}`); + } + const thankYouPath = `/${locale}/${language}/create-account-thankyou`; + window.location.href = thankYouPath; + } catch (err) { + // eslint-disable-next-line no-console + console.error('Create account form submission failed', err); + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } + } + }); +} diff --git a/widgets/forms/create-account.json b/widgets/forms/create-account.json new file mode 100644 index 00000000..3a174159 --- /dev/null +++ b/widgets/forms/create-account.json @@ -0,0 +1,86 @@ +{ + "en": { + "labels": { + "firstName": "First Name", + "lastName": "Last Name", + "emailAddress": "Email Address", + "confirmEmailAddress": "Confirm Email Address", + "emailsMustMatch": "Emails must match", + "postalCode": "Postal code", + "accountUseReasons": "For what reasons will you use this account?", + "domesticProducts": "For domestic products", + "commercialProducts": "For commercial products", + "ownVitamix": "Do you currently own a Vitamix?", + "yes": "Yes", + "no": "No", + "acceptTermsPrefix": "I accept the ", + "termsLinkText": "terms and conditions of Vitamix", + "requiredFieldsLegend": "* Required fields", + "createAccount": "Create account", + "sending": "Sending..." + }, + "inputPlaceholders": { + "firstName": "e.g. Johnny", + "lastName": "e.g. Appleseed", + "emailAddress": "e.g. email@example.com", + "confirmEmailAddress": "Must be the same as the email above", + "postalCode": "" + } + }, + "fr": { + "labels": { + "firstName": "Prénom", + "lastName": "Nom de famille", + "emailAddress": "Adresse courriel", + "confirmEmailAddress": "Confirmer l'adresse courriel", + "emailsMustMatch": "Les adresses courriel doivent correspondre", + "postalCode": "Code postal", + "accountUseReasons": "Pour quelles raisons utiliserez-vous ce compte?", + "domesticProducts": "Pour les produits domestiques", + "commercialProducts": "Pour les produits commerciaux", + "ownVitamix": "Possédez-vous actuellement un Vitamix?", + "yes": "Oui", + "no": "Non", + "acceptTermsPrefix": "J'accepte les ", + "termsLinkText": "conditions générales de Vitamix", + "requiredFieldsLegend": "* Champs obligatoires", + "createAccount": "Créer un compte", + "sending": "Envoi en cours..." + }, + "inputPlaceholders": { + "firstName": "p. ex. Jean", + "lastName": "p. ex. Dupont", + "emailAddress": "p. ex. courriel@exemple.com", + "confirmEmailAddress": "Doit être identique à l'adresse ci-dessus", + "postalCode": "" + } + }, + "es": { + "labels": { + "firstName": "Nombre", + "lastName": "Apellido", + "emailAddress": "Correo electrónico", + "confirmEmailAddress": "Confirmar correo electrónico", + "emailsMustMatch": "Los correos deben coincidir", + "postalCode": "Código postal", + "accountUseReasons": "¿Para qué usará esta cuenta?", + "domesticProducts": "Para productos domésticos", + "commercialProducts": "Para productos comerciales", + "ownVitamix": "¿Tiene actualmente un Vitamix?", + "yes": "Sí", + "no": "No", + "acceptTermsPrefix": "Acepto los ", + "termsLinkText": "términos y condiciones de Vitamix", + "requiredFieldsLegend": "* Campos obligatorios", + "createAccount": "Crear cuenta", + "sending": "Enviando..." + }, + "inputPlaceholders": { + "firstName": "ej. Juan", + "lastName": "ej. García", + "emailAddress": "ej. correo@ejemplo.com", + "confirmEmailAddress": "Debe ser igual al correo anterior", + "postalCode": "" + } + } +} diff --git a/widgets/forms/edit-account.css b/widgets/forms/edit-account.css new file mode 100644 index 00000000..c639db91 --- /dev/null +++ b/widgets/forms/edit-account.css @@ -0,0 +1,105 @@ +/* Edit account form – same pattern as create-account, no password section */ +.forms-edit-account { + background-color: var(--color-gray-100); + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-edit-account .edit-account-header { + margin-bottom: var(--spacing-300); +} + +.forms-edit-account .edit-account-title { + margin: 0 0 var(--spacing-100); + font-size: var(--font-size-400, 1.25rem); + font-weight: var(--font-weight-bold, bold); + color: var(--color-charcoal, #333); +} + +.forms-edit-account .edit-account-instruction { + margin: 0; + font-size: var(--font-size-200); + color: var(--color-gray-700); +} + +.forms-edit-account .edit-account-form { + display: flex; + flex-direction: column; + gap: var(--spacing-200); + max-width: 32rem; +} + +.forms-edit-account .edit-account-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-edit-account .edit-account-field label { + margin-bottom: 0; +} + +.forms-edit-account .edit-account-field .required { + color: var(--color-red); + margin-left: 0.15em; +} + +.forms-edit-account .edit-account-radio-group { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.forms-edit-account .edit-account-radio-group .radio-legend { + font-weight: var(--font-weight-medium, 500); + display: block; +} + +.forms-edit-account .edit-account-radio-group .radio-options { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-edit-account .edit-account-radio-group .radio-option { + display: flex; + align-items: center; + gap: 0.5em; + cursor: pointer; +} + +.forms-edit-account .edit-account-section { + display: flex; + flex-direction: column; + gap: var(--spacing-200); +} + +.forms-edit-account .edit-account-section-title { + margin: 0; + font-size: var(--font-size-300, 1.125rem); + font-weight: var(--font-weight-bold, bold); + color: var(--color-charcoal, #333); +} + +.forms-edit-account .edit-account-consent { + margin: 0; + font-size: var(--font-size-20); + color: var(--color-gray-700); +} + +.forms-edit-account .edit-account-actions { + margin-top: var(--spacing-100); +} + +.forms-edit-account .edit-account-form button[type="submit"] { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-edit-account .edit-account-form button[type="submit"]:hover, +.forms-edit-account .edit-account-form button[type="submit"]:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} diff --git a/widgets/forms/edit-account.html b/widgets/forms/edit-account.html new file mode 100644 index 00000000..0db7bdb9 --- /dev/null +++ b/widgets/forms/edit-account.html @@ -0,0 +1,63 @@ + + diff --git a/widgets/forms/edit-account.js b/widgets/forms/edit-account.js new file mode 100644 index 00000000..1057730d --- /dev/null +++ b/widgets/forms/edit-account.js @@ -0,0 +1,116 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for edit-account form */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/edit-account'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Decorates the edit-account widget: applies copy from JSON and configures form. + * No password section; authentication is via email PIN. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const header = widget.querySelector('.edit-account-header'); + const form = widget.querySelector('.edit-account-form'); + if (!header || !form) return; + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + + header.querySelector('.edit-account-title').textContent = labels.accountInformation ?? 'Account Information'; + header.querySelector('.edit-account-instruction').textContent = labels.allFieldsMandatory ?? 'All fields are mandatory unless otherwise indicated (optional).'; + + form.querySelector('[for="edit-account-first-name"] .label-text').textContent = labels.firstName ?? 'First Name'; + form.querySelector('[for="edit-account-last-name"] .label-text').textContent = labels.lastName ?? 'Last Name'; + form.querySelector('[for="edit-account-email"] .label-text').textContent = labels.emailAddress ?? 'Email Address'; + form.querySelector('[for="edit-account-postal-code"] .label-text').textContent = labels.postalCodeOptional ?? 'Postal code (optional)'; + + const ownLegend = form.querySelector('#edit-account-own-legend'); + if (ownLegend) ownLegend.textContent = labels.ownVitamix ?? 'Do you own a Vitamix?'; + + const ownField = form.querySelector('#edit-account-own-legend')?.closest('.edit-account-field'); + const ownRadioLabels = ownField?.querySelectorAll('.radio-label') || []; + if (ownRadioLabels[0]) ownRadioLabels[0].textContent = labels.yes ?? 'Yes'; + if (ownRadioLabels[1]) ownRadioLabels[1].textContent = labels.no ?? 'No'; + + form.querySelector('.edit-account-section-title').textContent = labels.communications ?? 'Communications'; + + const newsletterLegend = form.querySelector('#edit-account-newsletter-legend'); + if (newsletterLegend) newsletterLegend.textContent = labels.newsletterQuestion ?? 'Would you like to receive periodic emails and newsletters from Vitamix?'; + + const newsletterField = form.querySelector('#edit-account-newsletter-legend')?.closest('.edit-account-field'); + const newsletterRadioLabels = newsletterField?.querySelectorAll('.radio-label') || []; + if (newsletterRadioLabels[0]) newsletterRadioLabels[0].textContent = labels.newsletterYes ?? 'Yes'; + if (newsletterRadioLabels[1]) newsletterRadioLabels[1].textContent = labels.newsletterNo ?? 'No, do not send me electronic mail'; + + const consentEl = form.querySelector('.edit-account-consent'); + if (consentEl && labels.emailConsentDisclaimer) { + consentEl.textContent = labels.emailConsentDisclaimer; + } + + const firstNameInput = form.querySelector('#edit-account-first-name'); + const lastNameInput = form.querySelector('#edit-account-last-name'); + const emailInput = form.querySelector('#edit-account-email'); + const postalCodeInput = form.querySelector('#edit-account-postal-code'); + if (firstNameInput && inputHints.firstName) firstNameInput.placeholder = inputHints.firstName; + if (lastNameInput && inputHints.lastName) lastNameInput.placeholder = inputHints.lastName; + if (emailInput && inputHints.emailAddress) emailInput.placeholder = inputHints.emailAddress; + if (postalCodeInput && inputHints.postalCode) { + postalCodeInput.placeholder = inputHints.postalCode; + } + + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.textContent = labels.saveChanges ?? 'Save changes'; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + + const submitButton = form.querySelector('button[type="submit"]'); + const buttonLabel = submitButton?.textContent; + [...form.elements].forEach((el) => { el.disabled = true; }); + if (submitButton) { + submitButton.dataset.originalLabel = buttonLabel; + submitButton.textContent = labels.sending ?? 'Sending...'; + } + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`Sheet logger responded with ${resp.status}`); + } + const thankYouPath = `/${locale}/${language}/edit-account-thankyou`; + window.location.href = thankYouPath; + } catch (err) { + // eslint-disable-next-line no-console + console.error('Edit account form submission failed', err); + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } + } + }); +} diff --git a/widgets/forms/edit-account.json b/widgets/forms/edit-account.json new file mode 100644 index 00000000..b943983a --- /dev/null +++ b/widgets/forms/edit-account.json @@ -0,0 +1,80 @@ +{ + "en": { + "labels": { + "accountInformation": "Account Information", + "allFieldsMandatory": "All fields are mandatory unless otherwise indicated (optional).", + "firstName": "First Name", + "lastName": "Last Name", + "emailAddress": "Email Address", + "postalCodeOptional": "Postal code (optional)", + "ownVitamix": "Do you own a Vitamix?", + "yes": "Yes", + "no": "No", + "communications": "Communications", + "newsletterQuestion": "Would you like to receive periodic emails and newsletters from Vitamix?", + "newsletterYes": "Yes", + "newsletterNo": "No, do not send me electronic mail", + "emailConsentDisclaimer": "", + "saveChanges": "Save changes", + "sending": "Sending..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "emailAddress": "", + "postalCode": "" + } + }, + "fr": { + "labels": { + "accountInformation": "Informations du compte", + "allFieldsMandatory": "Tous les champs sont obligatoires sauf indication contraire (optionnel).", + "firstName": "Prénom", + "lastName": "Nom de famille", + "emailAddress": "Adresse courriel", + "postalCodeOptional": "Code postal (optionnel)", + "ownVitamix": "Possédez-vous un Vitamix?", + "yes": "Oui", + "no": "Non", + "communications": "Communications", + "newsletterQuestion": "Souhaitez-vous recevoir des courriels et bulletins de Vitamix?", + "newsletterYes": "Oui", + "newsletterNo": "Non, ne pas m'envoyer de courriel", + "emailConsentDisclaimer": "", + "saveChanges": "Enregistrer les modifications", + "sending": "Envoi en cours..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "emailAddress": "", + "postalCode": "" + } + }, + "es": { + "labels": { + "accountInformation": "Información de la cuenta", + "allFieldsMandatory": "Todos los campos son obligatorios salvo que se indique lo contrario (opcional).", + "firstName": "Nombre", + "lastName": "Apellido", + "emailAddress": "Correo electrónico", + "postalCodeOptional": "Código postal (opcional)", + "ownVitamix": "¿Tiene un Vitamix?", + "yes": "Sí", + "no": "No", + "communications": "Comunicaciones", + "newsletterQuestion": "¿Desea recibir correos y boletines de Vitamix?", + "newsletterYes": "Sí", + "newsletterNo": "No, no deseo recibir correos electrónicos", + "emailConsentDisclaimer": "", + "saveChanges": "Guardar cambios", + "sending": "Enviando..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "emailAddress": "", + "postalCode": "" + } + } +} diff --git a/widgets/forms/login.css b/widgets/forms/login.css new file mode 100644 index 00000000..515f2a53 --- /dev/null +++ b/widgets/forms/login.css @@ -0,0 +1,121 @@ +/* Login form – single email field, same pattern as contact-us */ +.forms-login { + background-color: var(--color-gray-100); + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-login .login-form { + display: flex; + flex-direction: column; + gap: var(--spacing-200); + max-width: 32rem; +} + +/* Ensure form is fully hidden when replaced by verification step (overrides display: flex) */ +.forms-login .login-form[hidden], +.forms-login .login-form.is-hidden { + display: none !important; +} + +.forms-login .login-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-login .login-field label { + margin-bottom: 0; +} + +.forms-login .login-field .required { + color: var(--color-red); + margin-left: 0.15em; +} + +.forms-login .login-actions { + margin-top: var(--spacing-100); +} + +.forms-login .login-form button[type="submit"] { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-login .login-form button[type="submit"]:hover, +.forms-login .login-form button[type="submit"]:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} + +/* Verification step – title, instruction, 4 code inputs, resend */ +.forms-login .login-verify { + max-width: 32rem; + text-align: left; +} + +.forms-login .login-verify[hidden] { + display: none; +} + +.forms-login .login-verify-title { + margin: 0 0 var(--spacing-200); + font-weight: var(--font-weight-bold, bold); + font-size: var(--font-size-400, 1.25rem); + color: var(--color-charcoal, #333); +} + +.forms-login .login-verify-instruction { + margin: 0 0 var(--spacing-300); + font-size: var(--font-size-200); + color: var(--color-charcoal, #333); +} + +.forms-login .login-verify-inputs { + display: flex; + gap: var(--spacing-100); + justify-content: flex-start; + margin-bottom: var(--spacing-300); +} + +.forms-login .login-verify-input { + width: 3rem; + height: 3rem; + text-align: center; + font-size: var(--font-size-400, 1.25rem); + font-weight: var(--font-weight-bold, bold); + border: 1px solid var(--color-gray-300, #ccc); + border-radius: var(--rounding-m, 4px); + background-color: var(--color-white, #fff); +} + +.forms-login .login-verify-input:focus { + outline: 2px solid var(--color-link, #06c); + outline-offset: 2px; + border-color: var(--color-link, #06c); +} + +.forms-login .login-verify-didnt-receive { + margin: 0 0 var(--spacing-200); + font-size: var(--font-size-200); + color: var(--color-charcoal, #333); +} + +.forms-login .login-verify-resend { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-login .login-verify-resend:hover, +.forms-login .login-verify-resend:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} + +.forms-login .login-verify-resend:disabled { + opacity: 0.7; + cursor: not-allowed; +} diff --git a/widgets/forms/login.html b/widgets/forms/login.html new file mode 100644 index 00000000..37693d05 --- /dev/null +++ b/widgets/forms/login.html @@ -0,0 +1,23 @@ + + diff --git a/widgets/forms/login.js b/widgets/forms/login.js new file mode 100644 index 00000000..27a7beaa --- /dev/null +++ b/widgets/forms/login.js @@ -0,0 +1,139 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for login form */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/login'; + +const CODE_LENGTH = 4; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Decorates the login widget: applies copy from JSON and configures form. + * On success, hides form and shows verification UI (code inputs, resend). + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const form = widget.querySelector('.login-form'); + const verifyEl = widget.querySelector('.login-verify'); + if (!form || !verifyEl) return; + + const { language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + + form.querySelector('[for="login-email"] .label-text').textContent = labels.emailAddress ?? 'Email Address'; + + const emailInput = form.querySelector('#login-email'); + if (emailInput) emailInput.placeholder = inputHints.emailAddress ?? ''; + + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.textContent = labels.submit ?? 'Submit'; + + verifyEl.querySelector('.login-verify-title').textContent = labels.verifyYourEmail ?? 'Verify Your Email'; + verifyEl.querySelector('.login-verify-instruction').textContent = labels.enterVerificationCodeSent ?? 'Enter the verification code sent to your email:'; + verifyEl.querySelector('.login-verify-didnt-receive').textContent = labels.didntReceiveCode ?? "Didn't receive the code?"; + verifyEl.querySelector('.login-verify-resend').textContent = labels.resendCode ?? 'Resend code'; + + // Code inputs: single character each, auto-advance, paste support + const codeInputs = [...verifyEl.querySelectorAll('.login-verify-input')]; + codeInputs.forEach((input, i) => { + input.addEventListener('input', (e) => { + const val = (e.target.value || '').replace(/[^0-9A-Za-z]/g, '').slice(0, 1); + e.target.value = val; + if (val && i < codeInputs.length - 1) { + codeInputs[i + 1].focus(); + } + }); + input.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' && !e.target.value && i > 0) { + codeInputs[i - 1].focus(); + } + }); + input.addEventListener('paste', (e) => { + e.preventDefault(); + const pasted = (e.clipboardData?.getData('text') || '').replace(/[^0-9A-Za-z]/g, '').slice(0, CODE_LENGTH); + pasted.split('').forEach((ch, j) => { + if (codeInputs[j]) { + codeInputs[j].value = ch; + } + }); + const next = codeInputs[Math.min(pasted.length, codeInputs.length - 1)]; + if (next) next.focus(); + }); + }); + + const resendBtn = verifyEl.querySelector('.login-verify-resend'); + resendBtn.addEventListener('click', async () => { + const email = form.querySelector('#login-email').value; + if (!email) return; + resendBtn.disabled = true; + const originalText = resendBtn.textContent; + resendBtn.textContent = labels.sending ?? 'Sending...'; + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, pageUrl: window.location.href, resend: true }), + }); + if (!resp.ok) throw new Error(`Sheet logger responded with ${resp.status}`); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Login resend failed', err); + } + resendBtn.disabled = false; + resendBtn.textContent = originalText; + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + + const submitButton = form.querySelector('button[type="submit"]'); + const buttonLabel = submitButton?.textContent; + [...form.elements].forEach((el) => { el.disabled = true; }); + if (submitButton) { + submitButton.dataset.originalLabel = buttonLabel; + submitButton.textContent = labels.sending ?? 'Sending...'; + } + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`Sheet logger responded with ${resp.status}`); + } + form.setAttribute('hidden', ''); + form.classList.add('is-hidden'); + verifyEl.removeAttribute('hidden'); + verifyEl.hidden = false; + codeInputs[0].focus(); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Login form submission failed', err); + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } + } + }); +} diff --git a/widgets/forms/login.json b/widgets/forms/login.json new file mode 100644 index 00000000..b46ea800 --- /dev/null +++ b/widgets/forms/login.json @@ -0,0 +1,44 @@ +{ + "en": { + "labels": { + "emailAddress": "Email Address", + "submit": "Submit", + "sending": "Sending...", + "verifyYourEmail": "Verify Your Email", + "enterVerificationCodeSent": "Enter the verification code sent to your email:", + "didntReceiveCode": "Didn't receive the code?", + "resendCode": "Resend code" + }, + "inputPlaceholders": { + "emailAddress": "" + } + }, + "fr": { + "labels": { + "emailAddress": "Adresse courriel", + "submit": "Soumettre", + "sending": "Envoi en cours...", + "verifyYourEmail": "Vérifiez votre courriel", + "enterVerificationCodeSent": "Entrez le code de vérification envoyé à votre courriel :", + "didntReceiveCode": "Vous n'avez pas reçu le code?", + "resendCode": "Renvoyer le code" + }, + "inputPlaceholders": { + "emailAddress": "" + } + }, + "es": { + "labels": { + "emailAddress": "Correo electrónico", + "submit": "Enviar", + "sending": "Enviando...", + "verifyYourEmail": "Verifique su correo", + "enterVerificationCodeSent": "Ingrese el código de verificación enviado a su correo:", + "didntReceiveCode": "¿No recibió el código?", + "resendCode": "Reenviar código" + }, + "inputPlaceholders": { + "emailAddress": "" + } + } +} diff --git a/widgets/forms/manage-address.css b/widgets/forms/manage-address.css new file mode 100644 index 00000000..af00d26d --- /dev/null +++ b/widgets/forms/manage-address.css @@ -0,0 +1,121 @@ +/* Manage address form – two columns (Contact | Address), checkboxes, Save/Cancel */ +.forms-manage-address { + background-color: var(--color-gray-100); + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-manage-address .manage-address-header { + margin-bottom: var(--spacing-300); +} + +.forms-manage-address .manage-address-title { + margin: 0; + font-size: var(--font-size-400, 1.25rem); + font-weight: var(--font-weight-bold, bold); + color: var(--color-charcoal, #333); +} + +.forms-manage-address .manage-address-form { + display: flex; + flex-direction: column; + gap: var(--spacing-300); + max-width: 56rem; +} + +.forms-manage-address .manage-address-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-300); +} + +@media (width <= 699px) { + .forms-manage-address .manage-address-columns { + grid-template-columns: 1fr; + } +} + +.forms-manage-address .manage-address-column { + display: flex; + flex-direction: column; + gap: var(--spacing-200); +} + +.forms-manage-address .manage-address-section-title { + margin: 0 0 var(--spacing-100); + font-size: var(--font-size-300, 1.125rem); + font-weight: var(--font-weight-bold, bold); + color: var(--color-charcoal, #333); +} + +.forms-manage-address .manage-address-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-manage-address .manage-address-field label { + margin-bottom: 0; +} + +.forms-manage-address .manage-address-field .required { + color: var(--color-red); + margin-left: 0.15em; +} + +.forms-manage-address .manage-address-field-help { + margin: 0.25em 0 0; + font-size: var(--font-size-20); + color: var(--color-gray-700); +} + +.forms-manage-address .manage-address-checkboxes { + display: flex; + flex-direction: column; + gap: var(--spacing-100); +} + +.forms-manage-address .manage-address-checkbox .checkbox-label { + display: flex; + align-items: center; + gap: 0.5em; + cursor: pointer; +} + +.forms-manage-address .manage-address-checkbox input[type="checkbox"] { + flex-shrink: 0; +} + +.forms-manage-address .manage-address-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-200); +} + +.forms-manage-address .manage-address-form button[type="submit"] { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-manage-address .manage-address-form button[type="submit"]:hover, +.forms-manage-address .manage-address-form button[type="submit"]:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} + +.forms-manage-address .manage-address-cancel.link-style { + background: none; + border: none; + color: var(--color-link, #06c); + text-decoration: underline; + padding: 0; + font: inherit; +} + +.forms-manage-address .manage-address-cancel.link-style:hover, +.forms-manage-address .manage-address-cancel.link-style:focus { + color: var(--color-charcoal); +} diff --git a/widgets/forms/manage-address.html b/widgets/forms/manage-address.html new file mode 100644 index 00000000..484fde3c --- /dev/null +++ b/widgets/forms/manage-address.html @@ -0,0 +1,88 @@ +
      +

      +
      +
      +
      +
      +

      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +

      +
      +
      +
      +

      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      +
      + +
      +
      + +
      +
      +
      +
      +
      + + +
      +
      diff --git a/widgets/forms/manage-address.js b/widgets/forms/manage-address.js new file mode 100644 index 00000000..b4ea3349 --- /dev/null +++ b/widgets/forms/manage-address.js @@ -0,0 +1,147 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for manage-address form */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/manage-address'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Injects options into a select (keeps first empty option). + * @param {HTMLSelectElement} select - The select element + * @param {{ label: string, value: string }[]} options - Options array + */ +function setSelectOptions(select, options) { + if (!select || !options?.length) return; + while (select.options.length > 1) select.remove(1); + options.forEach((opt) => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + select.appendChild(option); + }); +} + +/** + * Decorates the manage-address widget: applies copy from JSON and configures form. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const header = widget.querySelector('.manage-address-header'); + const form = widget.querySelector('.manage-address-form'); + if (!header || !form) return; + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + const provinceOptions = copy.provinceOptions || []; + + header.querySelector('.manage-address-title').textContent = labels.manageAddress ?? 'Manage address'; + + form.querySelector('.manage-address-contact .contact-title').textContent = labels.contactInformation ?? 'Contact Information'; + form.querySelector('.manage-address-address .address-title').textContent = labels.address ?? 'Address'; + + form.querySelector('[for="manage-address-first-name"] .label-text').textContent = labels.firstName ?? 'First Name'; + form.querySelector('[for="manage-address-last-name"] .label-text').textContent = labels.lastName ?? 'Last Name'; + form.querySelector('[for="manage-address-company"] .label-text').textContent = labels.company ?? 'Company'; + form.querySelector('[for="manage-address-phone"] .label-text').textContent = labels.phoneNumber ?? 'Phone Number'; + + const phoneHelp = form.querySelector('#manage-address-phone-help'); + if (phoneHelp) phoneHelp.textContent = labels.phoneNumberHelp ?? 'Please enter a valid phone number. For example (207)973-7823, (207) 973-7823, 2079737823.'; + form.querySelector('#manage-address-phone')?.setAttribute('aria-describedby', 'manage-address-phone-help'); + + form.querySelector('[for="manage-address-address"] .label-text').textContent = labels.address ?? 'Address'; + form.querySelector('[for="manage-address-address-line-2"] .label-text').textContent = labels.addressLine2 ?? 'Address Line 2'; + form.querySelector('[for="manage-address-city"] .label-text').textContent = labels.city ?? 'City'; + form.querySelector('[for="manage-address-province"] .label-text').textContent = labels.province ?? 'Province'; + form.querySelector('[for="manage-address-postal-code"] .label-text').textContent = labels.postalCode ?? 'Postal code'; + + const provinceSelect = form.querySelector('#manage-address-province'); + if (provinceSelect?.firstElementChild) { + provinceSelect.firstElementChild.textContent = labels.provincePlaceholder ?? 'Please select a region, state or province'; + } + setSelectOptions(provinceSelect, provinceOptions); + + const billingCheckbox = form.querySelector('[name="defaultBilling"]')?.closest('.manage-address-checkbox'); + if (billingCheckbox) billingCheckbox.querySelector('.checkbox-text').textContent = labels.defaultBilling ?? 'Use as default billing address'; + + const shippingCheckbox = form.querySelector('[name="defaultShipping"]')?.closest('.manage-address-checkbox'); + if (shippingCheckbox) shippingCheckbox.querySelector('.checkbox-text').textContent = labels.defaultShipping ?? 'Use as default shipping address'; + + const inputs = { + firstName: form.querySelector('#manage-address-first-name'), + lastName: form.querySelector('#manage-address-last-name'), + company: form.querySelector('#manage-address-company'), + phone: form.querySelector('#manage-address-phone'), + address: form.querySelector('#manage-address-address'), + addressLine2: form.querySelector('#manage-address-address-line-2'), + city: form.querySelector('#manage-address-city'), + postalCode: form.querySelector('#manage-address-postal-code'), + }; + Object.entries(inputHints).forEach(([key, value]) => { + const el = inputs[key]; + if (el) el.placeholder = value ?? ''; + }); + + const submitBtn = form.querySelector('button[type="submit"]'); + const cancelBtn = form.querySelector('.manage-address-cancel'); + if (submitBtn) submitBtn.textContent = labels.saveAddress ?? 'Save address'; + if (cancelBtn) cancelBtn.textContent = labels.cancel ?? 'Cancel'; + + cancelBtn?.addEventListener('click', () => { + if (window.history.length > 1) { + window.history.back(); + } else { + form.reset(); + } + }); + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + + const submitButton = form.querySelector('button[type="submit"]'); + const buttonLabel = submitButton?.textContent; + [...form.elements].forEach((el) => { el.disabled = true; }); + if (submitButton) { + submitButton.dataset.originalLabel = buttonLabel; + submitButton.textContent = labels.sending ?? 'Sending...'; + } + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`Sheet logger responded with ${resp.status}`); + } + const thankYouPath = `/${locale}/${language}/manage-address-thankyou`; + window.location.href = thankYouPath; + } catch (err) { + // eslint-disable-next-line no-console + console.error('Manage address form submission failed', err); + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } + } + }); +} diff --git a/widgets/forms/manage-address.json b/widgets/forms/manage-address.json new file mode 100644 index 00000000..3c1fcf15 --- /dev/null +++ b/widgets/forms/manage-address.json @@ -0,0 +1,143 @@ +{ + "en": { + "labels": { + "manageAddress": "Manage address", + "contactInformation": "Contact Information", + "address": "Address", + "firstName": "First Name", + "lastName": "Last Name", + "company": "Company", + "phoneNumber": "Phone Number", + "phoneNumberHelp": "Please enter a valid phone number. For example (207)973-7823, (207) 973-7823, 2079737823.", + "addressLine2": "Address Line 2", + "city": "City", + "province": "Province", + "postalCode": "Postal code", + "provincePlaceholder": "Please select a region, state or province", + "defaultBilling": "Use as default billing address", + "defaultShipping": "Use as default shipping address", + "saveAddress": "Save address", + "cancel": "Cancel", + "sending": "Sending..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "company": "", + "phone": "", + "address": "", + "addressLine2": "", + "city": "", + "postalCode": "" + }, + "provinceOptions": [ + { "label": "Alberta", "value": "AB" }, + { "label": "British Columbia", "value": "BC" }, + { "label": "Manitoba", "value": "MB" }, + { "label": "New Brunswick", "value": "NB" }, + { "label": "Newfoundland and Labrador", "value": "NL" }, + { "label": "Northwest Territories", "value": "NT" }, + { "label": "Nova Scotia", "value": "NS" }, + { "label": "Nunavut", "value": "NU" }, + { "label": "Ontario", "value": "ON" }, + { "label": "Prince Edward Island", "value": "PE" }, + { "label": "Quebec", "value": "QC" }, + { "label": "Saskatchewan", "value": "SK" }, + { "label": "Yukon", "value": "YT" } + ] + }, + "fr": { + "labels": { + "manageAddress": "Gérer l'adresse", + "contactInformation": "Coordonnées", + "address": "Adresse", + "firstName": "Prénom", + "lastName": "Nom de famille", + "company": "Entreprise", + "phoneNumber": "Numéro de téléphone", + "phoneNumberHelp": "Veuillez entrer un numéro de téléphone valide. Par ex. (207)973-7823, (207) 973-7823, 2079737823.", + "addressLine2": "Ligne d'adresse 2", + "city": "Ville", + "province": "Province", + "postalCode": "Code postal", + "provincePlaceholder": "Veuillez sélectionner une région, un état ou une province", + "defaultBilling": "Utiliser comme adresse de facturation par défaut", + "defaultShipping": "Utiliser comme adresse de livraison par défaut", + "saveAddress": "Enregistrer l'adresse", + "cancel": "Annuler", + "sending": "Envoi en cours..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "company": "", + "phone": "", + "address": "", + "addressLine2": "", + "city": "", + "postalCode": "" + }, + "provinceOptions": [ + { "label": "Alberta", "value": "AB" }, + { "label": "Colombie-Britannique", "value": "BC" }, + { "label": "Manitoba", "value": "MB" }, + { "label": "Nouveau-Brunswick", "value": "NB" }, + { "label": "Terre-Neuve-et-Labrador", "value": "NL" }, + { "label": "Territoires du Nord-Ouest", "value": "NT" }, + { "label": "Nouvelle-Écosse", "value": "NS" }, + { "label": "Nunavut", "value": "NU" }, + { "label": "Ontario", "value": "ON" }, + { "label": "Île-du-Prince-Édouard", "value": "PE" }, + { "label": "Québec", "value": "QC" }, + { "label": "Saskatchewan", "value": "SK" }, + { "label": "Yukon", "value": "YT" } + ] + }, + "es": { + "labels": { + "manageAddress": "Gestionar dirección", + "contactInformation": "Información de contacto", + "address": "Dirección", + "firstName": "Nombre", + "lastName": "Apellido", + "company": "Empresa", + "phoneNumber": "Número de teléfono", + "phoneNumberHelp": "Ingrese un número de teléfono válido. Por ejemplo (207)973-7823, (207) 973-7823, 2079737823.", + "addressLine2": "Línea de dirección 2", + "city": "Ciudad", + "province": "Provincia", + "postalCode": "Código postal", + "provincePlaceholder": "Seleccione una región, estado o provincia", + "defaultBilling": "Usar como dirección de facturación predeterminada", + "defaultShipping": "Usar como dirección de envío predeterminada", + "saveAddress": "Guardar dirección", + "cancel": "Cancelar", + "sending": "Enviando..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "company": "", + "phone": "", + "address": "", + "addressLine2": "", + "city": "", + "postalCode": "" + }, + "provinceOptions": [ + { "label": "Alberta", "value": "AB" }, + { "label": "Columbia Británica", "value": "BC" }, + { "label": "Manitoba", "value": "MB" }, + { "label": "Nuevo Brunswick", "value": "NB" }, + { "label": "Terranova y Labrador", "value": "NL" }, + { "label": "Territorios del Noroeste", "value": "NT" }, + { "label": "Nueva Escocia", "value": "NS" }, + { "label": "Nunavut", "value": "NU" }, + { "label": "Ontario", "value": "ON" }, + { "label": "Isla del Príncipe Eduardo", "value": "PE" }, + { "label": "Quebec", "value": "QC" }, + { "label": "Saskatchewan", "value": "SK" }, + { "label": "Yukón", "value": "YT" } + ] + } +} diff --git a/widgets/forms/media-contact.css b/widgets/forms/media-contact.css new file mode 100644 index 00000000..569607e5 --- /dev/null +++ b/widgets/forms/media-contact.css @@ -0,0 +1,45 @@ +/* Media Contact form – vertical stack, labels above, uses global form/input styles */ +.forms-media-contact { + background-color: var(--color-gray-100); + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-media-contact .media-contact-form { + display: flex; + flex-direction: column; + gap: var(--spacing-200); + max-width: 32rem; +} + +.forms-media-contact .media-contact-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-media-contact .media-contact-field label { + margin-bottom: 0; +} + +.forms-media-contact .media-contact-field .required { + color: var(--color-red); + margin-left: 0.15em; +} + +.forms-media-contact .media-contact-actions { + margin-top: var(--spacing-100); +} + +.forms-media-contact .media-contact-form button[type="submit"] { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-media-contact .media-contact-form button[type="submit"]:hover, +.forms-media-contact .media-contact-form button[type="submit"]:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} diff --git a/widgets/forms/media-contact.html b/widgets/forms/media-contact.html new file mode 100644 index 00000000..419d138b --- /dev/null +++ b/widgets/forms/media-contact.html @@ -0,0 +1,57 @@ +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + +
      +
      diff --git a/widgets/forms/media-contact.js b/widgets/forms/media-contact.js new file mode 100644 index 00000000..7ec0a713 --- /dev/null +++ b/widgets/forms/media-contact.js @@ -0,0 +1,125 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for media contact form */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/media-contact'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Injects options into a select (keeps first empty option). + * @param {HTMLSelectElement} select - The select element + * @param {{ label: string, value: string }[]} options - Options array + */ +function setSelectOptions(select, options) { + if (!select || !options?.length) return; + while (select.options.length > 1) select.remove(1); + options.forEach((opt) => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + select.appendChild(option); + }); +} + +/** + * Decorates the media-contact widget: applies copy from JSON and configures form. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const form = widget.querySelector('.media-contact-form'); + if (!form) return; + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + const selectOption = copy.selectOption ?? 'Select an option'; + const businessLineOptions = copy.businessLineOptions || []; + const reasonForContactOptions = copy.reasonForContactOptions || []; + + form.querySelector('[for="media-contact-business-line"] .label-text').textContent = labels.businessLine ?? 'Business Line'; + form.querySelector('[for="media-contact-publication-company"] .label-text').textContent = labels.publicationCompanyOptional ?? 'Publication / Company (Optional)'; + form.querySelector('[for="media-contact-first-name"] .label-text').textContent = labels.firstName ?? 'First Name'; + form.querySelector('[for="media-contact-last-name"] .label-text').textContent = labels.lastName ?? 'Last Name'; + form.querySelector('[for="media-contact-email"] .label-text').textContent = labels.emailAddress ?? 'Email Address'; + form.querySelector('[for="media-contact-phone"] .label-text').textContent = labels.phoneNumber ?? 'Phone Number'; + form.querySelector('[for="media-contact-reason"] .label-text').textContent = labels.reasonForContact ?? 'Reason for Contact'; + form.querySelector('[for="media-contact-comments"] .label-text').textContent = labels.additionalCommentsOptional ?? 'Additional Comments (Optional)'; + + const businessLineSelect = form.querySelector('#media-contact-business-line'); + const reasonSelect = form.querySelector('#media-contact-reason'); + if (businessLineSelect?.firstElementChild) { + businessLineSelect.firstElementChild.textContent = selectOption; + } + if (reasonSelect?.firstElementChild) { + reasonSelect.firstElementChild.textContent = selectOption; + } + setSelectOptions(businessLineSelect, businessLineOptions); + setSelectOptions(reasonSelect, reasonForContactOptions); + + const pubInput = form.querySelector('#media-contact-publication-company'); + const firstInput = form.querySelector('#media-contact-first-name'); + const lastInput = form.querySelector('#media-contact-last-name'); + const emailInput = form.querySelector('#media-contact-email'); + const phoneInput = form.querySelector('#media-contact-phone'); + const commentsTextarea = form.querySelector('#media-contact-comments'); + if (pubInput) pubInput.placeholder = inputHints.publicationCompany ?? ''; + if (firstInput) firstInput.placeholder = inputHints.firstName ?? ''; + if (lastInput) lastInput.placeholder = inputHints.lastName ?? ''; + if (emailInput) emailInput.placeholder = inputHints.emailAddress ?? ''; + if (phoneInput) phoneInput.placeholder = inputHints.phoneNumber ?? ''; + if (commentsTextarea) commentsTextarea.placeholder = inputHints.additionalComments ?? ''; + + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.textContent = labels.submit ?? 'Submit'; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + payload.formId = `${locale}/${language}/media-contact`; + + const submitButton = form.querySelector('button[type="submit"]'); + const buttonLabel = submitButton?.textContent; + [...form.elements].forEach((el) => { el.disabled = true; }); + if (submitButton) { + submitButton.dataset.originalLabel = buttonLabel; + submitButton.textContent = labels.sending ?? 'Sending...'; + } + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`Sheet logger responded with ${resp.status}`); + } + const thankYouPath = `/${locale}/${language}/corporate-information/media-center/media-request-thankyou`; + window.location.href = thankYouPath; + } catch (err) { + // eslint-disable-next-line no-console + console.error('Media contact form submission failed', err); + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } + } + }); +} diff --git a/widgets/forms/media-contact.json b/widgets/forms/media-contact.json new file mode 100644 index 00000000..1d4eba2c --- /dev/null +++ b/widgets/forms/media-contact.json @@ -0,0 +1,101 @@ +{ + "en": { + "labels": { + "businessLine": "Business Line", + "publicationCompanyOptional": "Publication / Company (Optional)", + "firstName": "First Name", + "lastName": "Last Name", + "emailAddress": "Email Address", + "phoneNumber": "Phone Number", + "reasonForContact": "Reason for Contact", + "additionalCommentsOptional": "Additional Comments (Optional)", + "submit": "Submit", + "sending": "Sending..." + }, + "inputPlaceholders": { + "publicationCompany": "", + "firstName": "", + "lastName": "", + "emailAddress": "", + "phoneNumber": "", + "additionalComments": "" + }, + "selectOption": "Select an option", + "businessLineOptions": [ + { "label": "Domestic", "value": "domestic" }, + { "label": "Commercial", "value": "commercial" } + ], + "reasonForContactOptions": [ + { "label": "Product inquiry", "value": "product-inquiry" }, + { "label": "Media request", "value": "media-request" }, + { "label": "Partnership", "value": "partnership" }, + { "label": "Other", "value": "other" } + ] + }, + "fr": { + "labels": { + "businessLine": "Secteur d'activité", + "publicationCompanyOptional": "Publication / Entreprise (optionnel)", + "firstName": "Prénom", + "lastName": "Nom de famille", + "emailAddress": "Adresse courriel", + "phoneNumber": "Numéro de téléphone", + "reasonForContact": "Raison du contact", + "additionalCommentsOptional": "Commentaires additionnels (optionnel)", + "submit": "Soumettre", + "sending": "Envoi en cours..." + }, + "inputPlaceholders": { + "publicationCompany": "", + "firstName": "", + "lastName": "", + "emailAddress": "", + "phoneNumber": "", + "additionalComments": "" + }, + "selectOption": "Sélectionner une option", + "businessLineOptions": [ + { "label": "Domestique", "value": "domestic" }, + { "label": "Commercial", "value": "commercial" } + ], + "reasonForContactOptions": [ + { "label": "Demande produit", "value": "product-inquiry" }, + { "label": "Demande médias", "value": "media-request" }, + { "label": "Partenariat", "value": "partnership" }, + { "label": "Autre", "value": "other" } + ] + }, + "es": { + "labels": { + "businessLine": "Línea de negocio", + "publicationCompanyOptional": "Publicación / Empresa (opcional)", + "firstName": "Nombre", + "lastName": "Apellido", + "emailAddress": "Correo electrónico", + "phoneNumber": "Número de teléfono", + "reasonForContact": "Motivo del contacto", + "additionalCommentsOptional": "Comentarios adicionales (opcional)", + "submit": "Enviar", + "sending": "Enviando..." + }, + "inputPlaceholders": { + "publicationCompany": "", + "firstName": "", + "lastName": "", + "emailAddress": "", + "phoneNumber": "", + "additionalComments": "" + }, + "selectOption": "Seleccionar una opción", + "businessLineOptions": [ + { "label": "Doméstico", "value": "domestic" }, + { "label": "Comercial", "value": "commercial" } + ], + "reasonForContactOptions": [ + { "label": "Consulta sobre producto", "value": "product-inquiry" }, + { "label": "Solicitud de medios", "value": "media-request" }, + { "label": "Asociación", "value": "partnership" }, + { "label": "Otro", "value": "other" } + ] + } +} diff --git a/widgets/forms/order-status.css b/widgets/forms/order-status.css new file mode 100644 index 00000000..3e0f9c13 --- /dev/null +++ b/widgets/forms/order-status.css @@ -0,0 +1,59 @@ +/* Order status form – vertical stack, label above input, uses global form/input styles */ +.forms-order-status { + background-color: var(--color-gray-100); + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-order-status .order-status-form { + display: flex; + flex-direction: column; + gap: var(--spacing-200); + max-width: 32rem; +} + +.forms-order-status .order-status-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-order-status .order-status-field label { + margin-bottom: 0; +} + +.forms-order-status .order-status-actions { + margin-top: var(--spacing-100); +} + +.forms-order-status .order-status-form button[type="submit"] { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-order-status .order-status-form button[type="submit"]:hover, +.forms-order-status .order-status-form button[type="submit"]:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} + +/* Result JSON display below the form */ +.forms-order-status .order-status-result { + margin-top: var(--spacing-300); + padding: var(--spacing-200); + background-color: var(--color-white); + border: 1px solid var(--color-gray-300); + border-radius: var(--rounding-m); + font-family: var(--font-family-mono, monospace); + font-size: var(--font-size-20); + white-space: pre-wrap; + word-break: break-word; + max-height: 20rem; + overflow: auto; +} + +.forms-order-status .order-status-result:empty { + display: none; +} diff --git a/widgets/forms/order-status.html b/widgets/forms/order-status.html new file mode 100644 index 00000000..c190b13e --- /dev/null +++ b/widgets/forms/order-status.html @@ -0,0 +1,12 @@ +
      +
      + + +
      +
      + +
      +
      + diff --git a/widgets/forms/order-status.js b/widgets/forms/order-status.js new file mode 100644 index 00000000..1b2f85d5 --- /dev/null +++ b/widgets/forms/order-status.js @@ -0,0 +1,87 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for order status lookup */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/order-status'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Decorates the order-status widget: applies copy from JSON and configures form. + * Submits POST with JSON to sheet-logger and displays the response JSON below the form. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const form = widget.querySelector('.order-status-form'); + const resultEl = widget.querySelector('.order-status-result'); + if (!form || !resultEl) return; + + const { language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + + const submitBtn = form.querySelector('button[type="submit"]'); + + form.querySelector('[for="order-status-order-number"] .label-text').textContent = labels.orderNumber ?? 'Order Number'; + if (submitBtn) submitBtn.textContent = labels.searchOrder ?? 'Search Order'; + + const orderNumberInput = form.querySelector('#order-status-order-number'); + if (orderNumberInput) orderNumberInput.placeholder = inputHints.orderNumber ?? ''; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + + [...form.elements].forEach((el) => { el.disabled = true; }); + const originalSubmitText = submitBtn?.textContent; + if (submitBtn) { + submitBtn.dataset.originalLabel = originalSubmitText; + submitBtn.textContent = labels.searching ?? 'Searching...'; + } + resultEl.hidden = true; + resultEl.textContent = ''; + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + const text = await resp.text(); + let result; + try { + result = text ? JSON.parse(text) : { status: resp.status, ok: resp.ok }; + } catch { + result = { status: resp.status, ok: resp.ok, body: text }; + } + resultEl.hidden = false; + resultEl.textContent = JSON.stringify(result, null, 2); + resultEl.classList.add('order-status-result-visible'); + } catch (err) { + // eslint-disable-next-line no-console + console.error('Order status lookup failed', err); + resultEl.hidden = false; + resultEl.textContent = JSON.stringify({ error: err.message }, null, 2); + resultEl.classList.add('order-status-result-visible'); + } finally { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitBtn) submitBtn.textContent = submitBtn.dataset.originalLabel || originalSubmitText; + } + }); +} diff --git a/widgets/forms/order-status.json b/widgets/forms/order-status.json new file mode 100644 index 00000000..9e541464 --- /dev/null +++ b/widgets/forms/order-status.json @@ -0,0 +1,32 @@ +{ + "en": { + "labels": { + "orderNumber": "Order Number", + "searchOrder": "Search Order", + "searching": "Searching..." + }, + "inputPlaceholders": { + "orderNumber": "" + } + }, + "fr": { + "labels": { + "orderNumber": "Numéro de commande", + "searchOrder": "Rechercher la commande", + "searching": "Recherche en cours..." + }, + "inputPlaceholders": { + "orderNumber": "" + } + }, + "es": { + "labels": { + "orderNumber": "Número de pedido", + "searchOrder": "Buscar pedido", + "searching": "Buscando..." + }, + "inputPlaceholders": { + "orderNumber": "" + } + } +} diff --git a/widgets/forms/product-registration.css b/widgets/forms/product-registration.css new file mode 100644 index 00000000..f127b136 --- /dev/null +++ b/widgets/forms/product-registration.css @@ -0,0 +1,148 @@ +/* Product registration form – sections, labels above, uses global form/input styles */ +.forms-product-registration { + background-color: var(--color-gray-100); + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-product-registration .product-registration-form { + display: flex; + flex-direction: column; + gap: var(--spacing-200); + max-width: 32rem; +} + +.forms-product-registration .product-registration-section { + border: 0; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-200); +} + +.forms-product-registration .product-registration-section-legend { + font-weight: var(--font-weight-bold, bold); + margin-bottom: 0; + padding: 0; +} + +.forms-product-registration .product-registration-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-product-registration .product-registration-field label { + margin-bottom: 0; +} + +.forms-product-registration .product-registration-field .required { + color: var(--color-red); + margin-left: 0.15em; +} + +.forms-product-registration .field-hint { + font-size: var(--font-size-20); + color: var(--color-red); +} + +.forms-product-registration .product-registration-link { + font-size: var(--font-size-20); + margin-top: 0.25em; +} + +.forms-product-registration .product-registration-radio-group { + display: flex; + flex-direction: column; + gap: 0.5em; +} + +.forms-product-registration .product-registration-radio-group .radio-legend { + font-weight: var(--font-weight-medium, 500); +} + +.forms-product-registration .radio-options { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-product-registration .radio-option { + display: flex; + align-items: center; + gap: 0.5em; + cursor: pointer; +} + +.forms-product-registration .date-input-wrap { + display: block; +} + +.forms-product-registration .product-registration-consent { + display: flex; + flex-direction: column; + gap: var(--spacing-200); +} + +.forms-product-registration .product-registration-checkbox .checkbox-label { + display: flex; + align-items: flex-start; + gap: 0.5em; + cursor: pointer; +} + +.forms-product-registration .product-registration-checkbox input[type="checkbox"] { + margin-top: 0.25em; + flex-shrink: 0; +} + +.forms-product-registration .product-registration-disclaimer { + font-size: var(--font-size-20); + margin: 0; + color: var(--color-gray-700); +} + +.forms-product-registration .product-registration-legal-links { + font-size: var(--font-size-20); + margin: 0; +} + +.forms-product-registration .product-registration-legal-links a { + text-decoration: underline; +} + +.forms-product-registration .product-registration-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-200); + margin-top: var(--spacing-100); +} + +.forms-product-registration .product-registration-form button[type="submit"] { + background-color: var(--color-charcoal); + border-color: var(--color-charcoal); + color: var(--color-white); +} + +.forms-product-registration .product-registration-form button[type="submit"]:hover, +.forms-product-registration .product-registration-form button[type="submit"]:focus { + background-color: var(--color-gray-900); + border-color: var(--color-gray-900); +} + +.forms-product-registration .clear-form-btn.link-style { + background: none; + border: none; + color: var(--color-gray-700); + text-decoration: underline; + padding: 0; + font: inherit; +} + +.forms-product-registration .clear-form-btn.link-style:hover, +.forms-product-registration .clear-form-btn.link-style:focus { + color: var(--color-charcoal); +} diff --git a/widgets/forms/product-registration.html b/widgets/forms/product-registration.html new file mode 100644 index 00000000..d18b9d2d --- /dev/null +++ b/widgets/forms/product-registration.html @@ -0,0 +1,134 @@ +
      +
      + About your blender + +
      + + + + Find your serial number +
      + +
      + +
      + + +
      +
      + +
      + + +
      + +
      + +
      + +
      +
      +
      + +
      + About you + +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + + +
      + + +
      +
      diff --git a/widgets/forms/product-registration.js b/widgets/forms/product-registration.js new file mode 100644 index 00000000..affec1ea --- /dev/null +++ b/widgets/forms/product-registration.js @@ -0,0 +1,172 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for product registration form */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/product-registration'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Injects options into a select (keeps first empty option). + * @param {HTMLSelectElement} select - The select element + * @param {{ label: string, value: string }[]} options - Options array + */ +function setSelectOptions(select, options) { + if (!select || !options?.length) return; + while (select.options.length > 1) select.remove(1); + options.forEach((opt) => { + const option = document.createElement('option'); + option.value = opt.value; + option.textContent = opt.label; + select.appendChild(option); + }); +} + +/** + * Decorates the product-registration widget: applies copy from JSON and configures form. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const form = widget.querySelector('.product-registration-form'); + if (!form) return; + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + const purchasedFromOptions = copy.purchasedFromOptions || []; + const provinceOptions = copy.provinceOptions || []; + + const sectionLegends = form.querySelectorAll('.product-registration-section-legend .section-legend-text'); + if (sectionLegends[0]) sectionLegends[0].textContent = labels.aboutYourBlender ?? 'About your blender'; + if (sectionLegends[1]) sectionLegends[1].textContent = labels.aboutYou ?? 'About you'; + + const serialLabel = form.querySelector('[for="product-registration-serial-number"] .label-text'); + if (serialLabel) serialLabel.textContent = labels.serialNumber ?? 'Serial number'; + const hintEl = form.querySelector('#product-registration-serial-number-hint'); + if (hintEl) hintEl.textContent = labels.serialNumberHint ?? '(18 digits)'; + const serialInput = form.querySelector('#product-registration-serial-number'); + if (serialInput) serialInput.placeholder = inputHints.serialNumber ?? ''; + + const findLink = form.querySelector('.find-serial-link'); + if (findLink) findLink.textContent = labels.findYourSerialNumber ?? 'Find your serial number'; + + const radioLegend = form.querySelector('.product-registration-radio-group .radio-legend'); + if (radioLegend) radioLegend.textContent = labels.iPlanToUseIt ?? 'I plan to use it'; + const radioLabels = form.querySelectorAll('.product-registration-radio-group .radio-label'); + if (radioLabels[0]) radioLabels[0].textContent = labels.atHome ?? 'At home'; + if (radioLabels[1]) radioLabels[1].textContent = labels.inABusiness ?? 'In a business'; + + const purchasedFromLabel = form.querySelector('[for="product-registration-purchased-from"] .label-text'); + if (purchasedFromLabel) purchasedFromLabel.textContent = labels.purchasedFrom ?? 'Purchased from'; + const purchasedFromSelect = form.querySelector('#product-registration-purchased-from'); + if (purchasedFromSelect?.firstElementChild) { + purchasedFromSelect.firstElementChild.textContent = inputHints.selectOption ?? 'Select an option'; + } + setSelectOptions(purchasedFromSelect, purchasedFromOptions); + + const purchasedOnLabel = form.querySelector('[for="product-registration-purchased-on"] .label-text'); + if (purchasedOnLabel) purchasedOnLabel.textContent = labels.purchasedOn ?? 'Purchased on'; + + form.querySelector('[for="product-registration-first-name"] .label-text').textContent = labels.firstName ?? 'First Name'; + form.querySelector('[for="product-registration-last-name"] .label-text').textContent = labels.lastName ?? 'Last Name'; + form.querySelector('[for="product-registration-address"] .label-text').textContent = labels.address ?? 'Address'; + form.querySelector('[for="product-registration-address-line-2"] .label-text').textContent = labels.addressLine2 ?? 'Address Line 2'; + form.querySelector('[for="product-registration-city"] .label-text').textContent = labels.city ?? 'City'; + form.querySelector('[for="product-registration-province"] .label-text').textContent = labels.province ?? 'Province'; + form.querySelector('[for="product-registration-postal-code"] .label-text').textContent = labels.postalCode ?? 'Postal code'; + form.querySelector('[for="product-registration-phone"] .label-text').textContent = labels.phoneNumber ?? 'Phone Number'; + form.querySelector('[for="product-registration-email"] .label-text').textContent = labels.emailAddress ?? 'Email Address'; + + const provinceSelect = form.querySelector('#product-registration-province'); + if (provinceSelect?.firstElementChild) { + provinceSelect.firstElementChild.textContent = inputHints.province ?? 'Choose your province'; + } + setSelectOptions(provinceSelect, provinceOptions); + + const inputs = { + firstName: form.querySelector('#product-registration-first-name'), + lastName: form.querySelector('#product-registration-last-name'), + address: form.querySelector('#product-registration-address'), + addressLine2: form.querySelector('#product-registration-address-line-2'), + city: form.querySelector('#product-registration-city'), + postalCode: form.querySelector('#product-registration-postal-code'), + phone: form.querySelector('#product-registration-phone'), + email: form.querySelector('#product-registration-email'), + }; + if (inputs.firstName) inputs.firstName.placeholder = inputHints.firstName ?? ''; + if (inputs.lastName) inputs.lastName.placeholder = inputHints.lastName ?? ''; + if (inputs.address) inputs.address.placeholder = inputHints.address ?? ''; + if (inputs.addressLine2) inputs.addressLine2.placeholder = inputHints.addressLine2 ?? ''; + if (inputs.city) inputs.city.placeholder = inputHints.city ?? ''; + if (inputs.postalCode) inputs.postalCode.placeholder = inputHints.postalCode ?? ''; + if (inputs.phone) inputs.phone.placeholder = inputHints.phone ?? ''; + if (inputs.email) inputs.email.placeholder = inputHints.email ?? ''; + + const purchasedOnInput = form.querySelector('#product-registration-purchased-on'); + if (purchasedOnInput && inputHints.date) purchasedOnInput.setAttribute('placeholder', inputHints.date); + + const marketingCheckboxText = form.querySelector('.product-registration-consent .checkbox-text'); + if (marketingCheckboxText) marketingCheckboxText.textContent = labels.sendNewsPromotionsOptional ?? 'Please send the latest Vitamix news and promotions to my email address. (optional)'; + const termsCheckboxText = form.querySelector('.terms-text'); + if (termsCheckboxText) termsCheckboxText.textContent = labels.iAcceptTermsPrivacy ?? "I accept the terms & conditions and Vitamix's privacy policy."; + const emailConsentEl = form.querySelector('.email-consent-disclaimer'); + if (emailConsentEl) emailConsentEl.textContent = labels.emailConsentDisclaimer ?? ''; + form.querySelector('.click-here-prefix').textContent = labels.clickHereToConsult ?? 'Click here to consult our '; + form.querySelector('.privacy-policy-link').textContent = labels.privacyPolicyLinkText ?? 'privacy policy'; + form.querySelector('.and-our').textContent = labels.andOur ?? ' and our '; + form.querySelector('.terms-link').textContent = labels.termsOfUseLinkText ?? 'terms of use.'; + + const submitBtn = form.querySelector('button[type="submit"]'); + const clearBtn = form.querySelector('.clear-form-btn'); + if (submitBtn) submitBtn.textContent = labels.register ?? 'Register'; + if (clearBtn) clearBtn.textContent = labels.clearForm ?? 'Clear form'; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + + const submitButton = form.querySelector('button[type="submit"]'); + const buttonLabel = submitButton?.textContent; + [...form.elements].forEach((el) => { el.disabled = true; }); + if (submitButton) { + submitButton.dataset.originalLabel = buttonLabel; + submitButton.textContent = labels.sending ?? 'Sending...'; + } + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`Sheet logger responded with ${resp.status}`); + } + const thankYouPath = `/${locale}/${language}/product-registration-thankyou`; + window.location.href = thankYouPath; + } catch (err) { + // eslint-disable-next-line no-console + console.error('Product registration form submission failed', err); + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } + } + }); +} diff --git a/widgets/forms/product-registration.json b/widgets/forms/product-registration.json new file mode 100644 index 00000000..2aebe668 --- /dev/null +++ b/widgets/forms/product-registration.json @@ -0,0 +1,212 @@ +{ + "en": { + "labels": { + "aboutYourBlender": "About your blender", + "serialNumber": "Serial number", + "serialNumberHint": "(18 digits)", + "findYourSerialNumber": "Find your serial number", + "iPlanToUseIt": "I plan to use it", + "atHome": "At home", + "inABusiness": "In a business", + "purchasedFrom": "Purchased from", + "purchasedOn": "Purchased on", + "aboutYou": "About you", + "firstName": "First Name", + "lastName": "Last Name", + "address": "Address", + "addressLine2": "Address Line 2", + "city": "City", + "province": "Province", + "postalCode": "Postal code", + "phoneNumber": "Phone Number", + "emailAddress": "Email Address", + "sendNewsPromotionsOptional": "Please send the latest Vitamix news and promotions to my email address. (optional)", + "emailConsentDisclaimer": "", + "iAcceptTermsPrivacy": "I accept the terms & conditions and Vitamix's privacy policy.", + "clickHereToConsult": "Click here to consult our ", + "privacyPolicyLinkText": "privacy policy", + "andOur": " and our ", + "termsOfUseLinkText": "terms of use.", + "register": "Register", + "clearForm": "Clear form", + "sending": "Sending..." + }, + "inputPlaceholders": { + "selectOption": "Select an option", + "serialNumber": "", + "date": "mm/dd/yyyy", + "firstName": "", + "lastName": "", + "address": "", + "addressLine2": "", + "city": "", + "province": "Choose your province", + "postalCode": "", + "phone": "", + "email": "you@example.com" + }, + "purchasedFromOptions": [ + { "label": "Amazon", "value": "amazon" }, + { "label": "Best Buy", "value": "best-buy" }, + { "label": "Canadian Tire", "value": "canadian-tire" }, + { "label": "Costco", "value": "costco" }, + { "label": "Hudson's Bay", "value": "hudsons-bay" }, + { "label": "Other", "value": "other" } + ], + "provinceOptions": [ + { "label": "Alberta", "value": "AB" }, + { "label": "British Columbia", "value": "BC" }, + { "label": "Manitoba", "value": "MB" }, + { "label": "New Brunswick", "value": "NB" }, + { "label": "Newfoundland and Labrador", "value": "NL" }, + { "label": "Northwest Territories", "value": "NT" }, + { "label": "Nova Scotia", "value": "NS" }, + { "label": "Nunavut", "value": "NU" }, + { "label": "Ontario", "value": "ON" }, + { "label": "Prince Edward Island", "value": "PE" }, + { "label": "Quebec", "value": "QC" }, + { "label": "Saskatchewan", "value": "SK" }, + { "label": "Yukon", "value": "YT" } + ] + }, + "fr": { + "labels": { + "aboutYourBlender": "À propos de votre mélangeur", + "serialNumber": "Numéro de série", + "serialNumberHint": "(18 chiffres)", + "findYourSerialNumber": "Trouvez votre numéro de série", + "iPlanToUseIt": "Je prévois l'utiliser", + "atHome": "À la maison", + "inABusiness": "En entreprise", + "purchasedFrom": "Acheté chez", + "purchasedOn": "Acheté le", + "aboutYou": "À votre sujet", + "firstName": "Prénom", + "lastName": "Nom de famille", + "address": "Adresse", + "addressLine2": "Ligne d'adresse 2", + "city": "Ville", + "province": "Province", + "postalCode": "Code postal", + "phoneNumber": "Numéro de téléphone", + "emailAddress": "Adresse courriel", + "sendNewsPromotionsOptional": "Veuillez m'envoyer les dernières nouvelles et promotions Vitamix à mon courriel. (optionnel)", + "emailConsentDisclaimer": "", + "iAcceptTermsPrivacy": "J'accepte les conditions générales et la politique de confidentialité de Vitamix.", + "clickHereToConsult": "Cliquez ici pour consulter notre ", + "privacyPolicyLinkText": "politique de confidentialité", + "andOur": " et nos ", + "termsOfUseLinkText": "conditions d'utilisation.", + "register": "S'inscrire", + "clearForm": "Effacer le formulaire", + "sending": "Envoi en cours..." + }, + "inputPlaceholders": { + "selectOption": "Sélectionner une option", + "serialNumber": "", + "date": "aaaa-mm-jj", + "firstName": "", + "lastName": "", + "address": "", + "addressLine2": "", + "city": "", + "province": "Choisissez votre province", + "postalCode": "", + "phone": "", + "email": "courriel@exemple.com" + }, + "purchasedFromOptions": [ + { "label": "Amazon", "value": "amazon" }, + { "label": "Best Buy", "value": "best-buy" }, + { "label": "Canadian Tire", "value": "canadian-tire" }, + { "label": "Costco", "value": "costco" }, + { "label": "La Baie d'Hudson", "value": "hudsons-bay" }, + { "label": "Autre", "value": "other" } + ], + "provinceOptions": [ + { "label": "Alberta", "value": "AB" }, + { "label": "Colombie-Britannique", "value": "BC" }, + { "label": "Manitoba", "value": "MB" }, + { "label": "Nouveau-Brunswick", "value": "NB" }, + { "label": "Terre-Neuve-et-Labrador", "value": "NL" }, + { "label": "Territoires du Nord-Ouest", "value": "NT" }, + { "label": "Nouvelle-Écosse", "value": "NS" }, + { "label": "Nunavut", "value": "NU" }, + { "label": "Ontario", "value": "ON" }, + { "label": "Île-du-Prince-Édouard", "value": "PE" }, + { "label": "Québec", "value": "QC" }, + { "label": "Saskatchewan", "value": "SK" }, + { "label": "Yukon", "value": "YT" } + ] + }, + "es": { + "labels": { + "aboutYourBlender": "Acerca de su licuadora", + "serialNumber": "Número de serie", + "serialNumberHint": "(18 dígitos)", + "findYourSerialNumber": "Encuentre su número de serie", + "iPlanToUseIt": "Planeo usarlo", + "atHome": "En casa", + "inABusiness": "En un negocio", + "purchasedFrom": "Comprado en", + "purchasedOn": "Comprado el", + "aboutYou": "Acerca de usted", + "firstName": "Nombre", + "lastName": "Apellido", + "address": "Dirección", + "addressLine2": "Línea de dirección 2", + "city": "Ciudad", + "province": "Provincia", + "postalCode": "Código postal", + "phoneNumber": "Número de teléfono", + "emailAddress": "Correo electrónico", + "sendNewsPromotionsOptional": "Envíeme las últimas noticias y promociones de Vitamix a mi correo. (opcional)", + "emailConsentDisclaimer": "", + "iAcceptTermsPrivacy": "Acepto los términos y condiciones y la política de privacidad de Vitamix.", + "clickHereToConsult": "Haga clic aquí para consultar nuestra ", + "privacyPolicyLinkText": "política de privacidad", + "andOur": " y nuestros ", + "termsOfUseLinkText": "términos de uso.", + "register": "Registrar", + "clearForm": "Borrar formulario", + "sending": "Enviando..." + }, + "inputPlaceholders": { + "selectOption": "Seleccionar una opción", + "serialNumber": "", + "date": "aaaa-mm-dd", + "firstName": "", + "lastName": "", + "address": "", + "addressLine2": "", + "city": "", + "province": "Elija su provincia", + "postalCode": "", + "phone": "", + "email": "correo@ejemplo.com" + }, + "purchasedFromOptions": [ + { "label": "Amazon", "value": "amazon" }, + { "label": "Best Buy", "value": "best-buy" }, + { "label": "Canadian Tire", "value": "canadian-tire" }, + { "label": "Costco", "value": "costco" }, + { "label": "Hudson's Bay", "value": "hudsons-bay" }, + { "label": "Otro", "value": "other" } + ], + "provinceOptions": [ + { "label": "Alberta", "value": "AB" }, + { "label": "Columbia Británica", "value": "BC" }, + { "label": "Manitoba", "value": "MB" }, + { "label": "Nuevo Brunswick", "value": "NB" }, + { "label": "Terranova y Labrador", "value": "NL" }, + { "label": "Territorios del Noroeste", "value": "NT" }, + { "label": "Nueva Escocia", "value": "NS" }, + { "label": "Nunavut", "value": "NU" }, + { "label": "Ontario", "value": "ON" }, + { "label": "Isla del Príncipe Eduardo", "value": "PE" }, + { "label": "Quebec", "value": "QC" }, + { "label": "Saskatchewan", "value": "SK" }, + { "label": "Yukón", "value": "YT" } + ] + } +} diff --git a/widgets/forms/simple-search.css b/widgets/forms/simple-search.css new file mode 100644 index 00000000..4cd51a4e --- /dev/null +++ b/widgets/forms/simple-search.css @@ -0,0 +1,28 @@ +/* Simple Search widget – uses global form/button styles */ +.forms-simple-search .simple-search-form { + display: flex; + gap: var(--spacing-80, 0.5rem); + align-items: center; + max-width: 100%; +} + +.forms-simple-search .simple-search-form input { + flex: 1; + min-width: 0; +} + +.forms-simple-search .simple-search-form button { + flex-shrink: 0; +} + +.forms-simple-search .visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} diff --git a/widgets/forms/simple-search.html b/widgets/forms/simple-search.html new file mode 100644 index 00000000..fffb3974 --- /dev/null +++ b/widgets/forms/simple-search.html @@ -0,0 +1,5 @@ + diff --git a/widgets/forms/simple-search.js b/widgets/forms/simple-search.js new file mode 100644 index 00000000..2170d0cd --- /dev/null +++ b/widgets/forms/simple-search.js @@ -0,0 +1,45 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Decorates the simple-search widget: loads copy from JSON and configures form to submit as GET. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const form = widget.querySelector('.simple-search-form'); + const input = widget.querySelector('#simple-search-input'); + const submitBtn = widget.querySelector('button[type="submit"]'); + const label = widget.querySelector('label[for="simple-search-input"]'); + + if (!form || !input) return; + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + + const searchHint = labels.search ?? 'Search'; + const goLabel = labels.go ?? 'Go'; + + input.placeholder = searchHint; + input.setAttribute('aria-label', searchHint); + if (submitBtn) submitBtn.textContent = goLabel; + if (label) label.textContent = searchHint; + + form.action = `/${locale}/${language}/search-result`; + form.method = 'get'; +} diff --git a/widgets/forms/simple-search.json b/widgets/forms/simple-search.json new file mode 100644 index 00000000..679c25d7 --- /dev/null +++ b/widgets/forms/simple-search.json @@ -0,0 +1,20 @@ +{ + "en": { + "labels": { + "search": "Search", + "go": "Go" + } + }, + "fr": { + "labels": { + "search": "Rechercher", + "go": "Aller" + } + }, + "es": { + "labels": { + "search": "Buscar", + "go": "Ir" + } + } +} diff --git a/widgets/forms/wellness-program.css b/widgets/forms/wellness-program.css new file mode 100644 index 00000000..a97be6c4 --- /dev/null +++ b/widgets/forms/wellness-program.css @@ -0,0 +1,34 @@ +/* Wellness program form – vertical stack, labels above, centered */ +.forms-wellness-program { + padding: var(--spacing-300); + border-radius: var(--rounding-m); + max-width: 100%; +} + +.forms-wellness-program .wellness-program-form { + display: flex; + flex-direction: column; + gap: var(--spacing-200); + max-width: 32rem; + margin-left: auto; + margin-right: auto; +} + +.forms-wellness-program .wellness-program-field { + display: flex; + flex-direction: column; + gap: 0.35em; +} + +.forms-wellness-program .wellness-program-field label { + margin-bottom: 0; +} + +.forms-wellness-program .wellness-program-field .required { + color: var(--color-red); + margin-left: 0.15em; +} + +.forms-wellness-program .wellness-program-actions { + margin-top: var(--spacing-100); +} diff --git a/widgets/forms/wellness-program.html b/widgets/forms/wellness-program.html new file mode 100644 index 00000000..bbedf9fa --- /dev/null +++ b/widgets/forms/wellness-program.html @@ -0,0 +1,47 @@ +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + + +
      +
      + +
      +
      diff --git a/widgets/forms/wellness-program.js b/widgets/forms/wellness-program.js new file mode 100644 index 00000000..11f489fa --- /dev/null +++ b/widgets/forms/wellness-program.js @@ -0,0 +1,104 @@ +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** Sheet logger endpoint for wellness-program form */ +const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/wellness-program'; + +/** + * Loads form copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (en, fr, es) + * @returns {Promise} Form copy for that language + */ +async function loadFormCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * Decorates the wellness-program widget: applies copy from JSON and configures form. + * @param {HTMLElement} widget - The widget root element + */ +export default async function decorate(widget) { + const form = widget.querySelector('.wellness-program-form'); + if (!form) return; + + const { locale, language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadFormCopy(lang); + const labels = copy.labels || {}; + const inputHints = copy.inputPlaceholders || {}; + + const fieldLabels = [ + ['wellness-program-first-name', labels.firstName ?? 'First Name'], + ['wellness-program-last-name', labels.lastName ?? 'Last Name'], + ['wellness-program-company-name', labels.companyName ?? 'Company Name'], + ['wellness-program-job-title', labels.jobTitle ?? 'Job Title'], + ['wellness-program-phone', labels.phoneNumber ?? 'Phone Number'], + ['wellness-program-email', labels.emailAddress ?? 'Email Address'], + ['wellness-program-remarks', labels.otherRemarks ?? 'Other Remarks'], + ]; + fieldLabels.forEach(([id, text]) => { + const label = form.querySelector(`[for="${id}"] .label-text`); + if (label) label.textContent = text; + }); + + const inputs = { + firstName: form.querySelector('#wellness-program-first-name'), + lastName: form.querySelector('#wellness-program-last-name'), + companyName: form.querySelector('#wellness-program-company-name'), + jobTitle: form.querySelector('#wellness-program-job-title'), + phone: form.querySelector('#wellness-program-phone'), + email: form.querySelector('#wellness-program-email'), + remarks: form.querySelector('#wellness-program-remarks'), + }; + if (inputs.firstName) inputs.firstName.placeholder = inputHints.firstName ?? ''; + if (inputs.lastName) inputs.lastName.placeholder = inputHints.lastName ?? ''; + if (inputs.companyName) inputs.companyName.placeholder = inputHints.companyName ?? ''; + if (inputs.jobTitle) inputs.jobTitle.placeholder = inputHints.jobTitle ?? ''; + if (inputs.phone) inputs.phone.placeholder = inputHints.phoneNumber ?? ''; + if (inputs.email) inputs.email.placeholder = inputHints.emailAddress ?? ''; + if (inputs.remarks) inputs.remarks.placeholder = inputHints.otherRemarks ?? ''; + + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) submitBtn.textContent = labels.submit ?? 'Submit'; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const data = new FormData(form); + const payload = Object.fromEntries(data.entries()); + payload.pageUrl = window.location.href; + payload.formId = `${locale}/${language}/wellness-program`; + + const submitButton = form.querySelector('button[type="submit"]'); + const buttonLabel = submitButton?.textContent; + [...form.elements].forEach((el) => { el.disabled = true; }); + if (submitButton) { + submitButton.dataset.originalLabel = buttonLabel; + submitButton.textContent = labels.sending ?? 'Sending...'; + } + + try { + const resp = await fetch(SHEET_LOGGER_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + throw new Error(`Sheet logger responded with ${resp.status}`); + } + const thankYouPath = `/${locale}/${language}/wellness-program-thankyou`; + window.location.href = thankYouPath; + } catch (err) { + // eslint-disable-next-line no-console + console.error('Wellness program form submission failed', err); + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } + } + }); +} diff --git a/widgets/forms/wellness-program.json b/widgets/forms/wellness-program.json new file mode 100644 index 00000000..34c66186 --- /dev/null +++ b/widgets/forms/wellness-program.json @@ -0,0 +1,83 @@ +{ + "en": { + "labels": { + "title": "Let's get started", + "introText": "Are you a human resources representative? If not, ", + "introLink": "send this page", + "introSuffix": " to someone who is.", + "requiredLegend": "All fields are mandatory unless otherwise indicated (optional).", + "firstName": "First Name", + "lastName": "Last Name", + "companyName": "Company Name", + "jobTitle": "Job Title", + "phoneNumber": "Phone Number", + "emailAddress": "Email Address", + "otherRemarks": "Other Remarks", + "submit": "Submit", + "sending": "Sending..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "companyName": "", + "jobTitle": "", + "phoneNumber": "", + "emailAddress": "", + "otherRemarks": "" + } + }, + "fr": { + "labels": { + "title": "Commençons", + "introText": "Êtes-vous un représentant des ressources humaines? Si vous ne l'êtes pas, ", + "introLink": "envoyez cette page", + "introSuffix": " à quelqu'un qui l'est.", + "requiredLegend": "Tous les champs sont obligatoires sauf indication contraire (optionnel)", + "firstName": "Prénom", + "lastName": "Nom", + "companyName": "Nom de l'entreprise", + "jobTitle": "Titre de poste", + "phoneNumber": "Numéro de téléphone", + "emailAddress": "Adresse courriel", + "otherRemarks": "Autres remarques", + "submit": "Soumettre", + "sending": "Envoi en cours..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "companyName": "", + "jobTitle": "", + "phoneNumber": "", + "emailAddress": "", + "otherRemarks": "" + } + }, + "es": { + "labels": { + "title": "Comencemos", + "introText": "¿Es usted un representante de recursos humanos? Si no, ", + "introLink": "envíe esta página", + "introSuffix": " a alguien que lo sea.", + "requiredLegend": "Todos los campos son obligatorios salvo que se indique lo contrario (opcional).", + "firstName": "Nombre", + "lastName": "Apellido", + "companyName": "Nombre de la empresa", + "jobTitle": "Cargo", + "phoneNumber": "Número de teléfono", + "emailAddress": "Correo electrónico", + "otherRemarks": "Otros comentarios", + "submit": "Enviar", + "sending": "Enviando..." + }, + "inputPlaceholders": { + "firstName": "", + "lastName": "", + "companyName": "", + "jobTitle": "", + "phoneNumber": "", + "emailAddress": "", + "otherRemarks": "" + } + } +} diff --git a/widgets/locator/locator.css b/widgets/locator/locator.css new file mode 100644 index 00000000..4853a670 --- /dev/null +++ b/widgets/locator/locator.css @@ -0,0 +1,209 @@ +main > .locator-container.section { + margin-top: 0; + margin-bottom: 0; +} + +.locator-container .locator-wrapper { + margin: 0; + padding: 0; +} + +.locator .section.banner { + flex-direction: column; + gap: var(--spacing-300); + height: 415px; + padding: 0 var(--horizontal-spacing); + text-align: unset; +} + +.locator .section.banner > div { + width: 100%; + max-width: 1000px; +} + +.locator h1 { + text-align: center; +} + +.locator label { + color: var(--color-white); +} + +.locator form { + display: flex; + flex-wrap: wrap; + gap: 0 var(--spacing-100); +} + +.locator form > * { + flex: 1 1 100%; +} + +.locator form [data-name='productType'] { + flex: 1 1 55%; +} + +.locator form .button-wrapper { + flex: 1 1 100px; + align-self: flex-end; + margin-top: var(--spacing-100); +} + +.locator form .button-wrapper button { + width: 100%; + max-height: 48px; + padding: 0; + line-height: 48px; +} + +.locator a.button.ghost, +.locator button.button.ghost { + border-color: var(--color-white); + background-color: transparent; + color: var(--color-white); +} + +.locator select { + min-height: 48px; +} + +.locator form i.symbol.symbol-loading { + border-color: var(--color-gray-500); +} + +.locator form i.symbol.symbol-loading::after { + border-right-color: var(--color-white); +} + +@media (width >= 900px) { + .locator form [data-name='address'] { + flex: 1 1 45%; + } + + .locator form [data-name='productType'] { + flex: 1 1 25%; + } + + .locator form .form-field + .form-field { + margin-top: 0; + } +} + +/* stylelint-disable no-descending-specificity */ + +.locator-results { + background-color: var(--color-charcoal); +} + +.locator-results h3 { + margin-top: 0; + font-size: var(--body-size-xxl); + font-family: var(--font-family-body); + font-weight: var(--font-weight-medium); +} + +.locator-results ol { + counter-reset: locator-result-counter; + list-style: none; + margin: 0; +} + +.locator-results .locator-retailers { + --locator-accent-color: var(--color-rosewood); +} + +.locator-results .locator-distributors { + --locator-accent-color: var(--color-green); +} + +.locator-results .locator-online { + --locator-accent-color: var(--color-blue); +} + +.locator-results .locator-localrep { + --locator-accent-color: var(--color-blue); +} + +.locator-results .locator-events { + --locator-accent-color: var(--color-red); +} + + +.locator-results ol li { + counter-increment: locator-result-counter; + position: relative; + display: flex; + flex-direction: column; + gap: var(--spacing-60); + margin-bottom: var(--spacing-200); + padding: var(--spacing-200); + background-color: var(--color-white); +} + +.locator-results ol li::before { + content: counter(locator-result-counter); + color: var(--color-white); + font-size: 1.5rem; + font-weight: bold; + position: absolute; + + --size: 48px; + + left: calc(-1 * var(--size)); + line-height: var(--size); + width: var(--size); + height: var(--size); + top: var(--vertical-spacing); + background: var(--locator-accent-color); + border-radius: 50%; + text-align: center; +} + +.locator-results-tablist { + max-width: 1000px; + margin: 0 auto; + display: flex; + color: var(--color-white); + background-color: var(--color-charcoal); +} + +.locator-results-tablist button { + color: var(--color-white); + background-color: transparent; + border: none; + padding: var(--spacing-200) var(--spacing-300); + font-weight: var(--font-weight-normal); +} + +.locator-results-tablist button[aria-selected='true'] { + background-color: var(--color-white); + color: var(--color-charcoal); + border-top: 4px solid var(--locator-accent-color); +} + +.locator-tabpanels { + max-width: 1000px; + margin: 0 auto; + padding: var(--spacing-300); + background-color: var(--color-white); +} + +.locator-results .locator-website-label { + font-weight: var(--weight-medium); +} + +.locator-results .locator-phone-label { + font-weight: var(--weight-medium); +} + +.locator-results .locator-distance { + font-style: italic; +} + +.locator-results .locator-tabpanel[aria-hidden='true'] { + display: none; +} + +.locator .locator-results[aria-hidden='true'] { + display: none; +} diff --git a/widgets/locator/locator.html b/widgets/locator/locator.html new file mode 100644 index 00000000..59c262b9 --- /dev/null +++ b/widgets/locator/locator.html @@ -0,0 +1,73 @@ + +
      +
      + + + + + + + + + +
      +
      diff --git a/widgets/locator/locator.jpeg b/widgets/locator/locator.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..591992bae243047990ab93d24a61d2a3b239ff31 GIT binary patch literal 27322 zcmdSB2UrtZ*EYN-0YXPKC`c7h5)`Eb2tBA&DWTYqBULFP(xe1|Al66+3y2U(s3H~+ zP-!9rR63$kML?Q>bm`wmU%WyS~BSzWxnAN>n5ODEN#)T2@vj3i^Hw zA&`Lj^SX?*G!^>ej#R|YJ2FzK$e+g$Esg0%9r$M7x3zM>%?!~PsYpl^Ah;nUH?;Nw z2*cmb2z}drR)oKffsqNt%)-hBS18y35D*fHU_df5GQbUl0@fdgftzvTHW^K(O(r%d zQFpX#a8xq0*xm;fJf=-w#N})~&a<#?-onebb^8tp3|0~+ub_xmQr@>;OIt@*Pv6Y^ z;Gx4uj#}8+pE&8@=;Z9_b=KR**N+r(;o_ysSFTc`V`8u0xOposJ|#7cmY$KBmHqHh ze!=6yqT(mdD_>Mq(`#y9Hn+6CX?y$beS7c6zW#wvgG0k3lT*_(vvc!b7Z%ss1^3bS zX2GxDTlSZBal`FGFfbq)Q0wi25I*pZIn{D)~VLirMOCvVYgG^Z!>Z`@La*w5tQ`1Bh=M5&@4A5($qK1H3V!7}qxx zGwR#M{9|MNwy~{m9KUaC@J%529`J{m;C~Jl6wB}L{p*9ZE?Cqo*4hC(5`u*Z$qh8X z*S=b`pJ+x(NeEqJN=ryLYlzCM8l@90Nx~qw#7+7si4+j>y$bpNlNHL|zio?nl$UGK z?(~We2N{1J9o2y^Rdh(jc)19)| zN5t)9E`<+3L5N)#sy(UvClW^C>HF8JFMpIr?nR8j$iKG75ig9pWEg zMTB~Z@u~TLAbK_Xc1@G)RwgF1R_i>`K|w1Mw6-e&oWNK61m2>uU(7Gw%es-r+>L)=7od-h8J%LeG^oAp%E+<2m))!BcbO+B%C&HEx6MB|!#D8#cRsMv?wrxUJPy_n5 z38y(?mg;)^!$U#`7lXna@lYh`Bw2Dnr#n^QgRrd8PW{v!%C=6~+Ga7v#7DwOSOIIBAvJn3I%R-BCD zRBlZ3`v^8@Tpm4^Ph=9F^ICOy?YlU)@SvxYWXhpD7*ro1A<3<9Hph#|c;-=r zg?hc3a~&Ew_PGz%1rZjHw7{Vy{A?DDM&9>r4STb0qbKLzz_8TqdnUPcCzR z%pc*4A#DW>`3C9j#dD+_7lqo#1u7rOk}HW3kKSZs5P9#1meefZyZ!eUG}aowu7NM9 zUs5P@Tml-b8mt&9^4lr+fA<7IA+lsJcg~=}OCrngVN=de8O-qPXk&7Hb};thmfW2k zsGD`W0%ST|rM0wODtNnEJ#=d&ViHzy&?(!qG=9dtkC((SX=lw6e0awazhSg$Jk?IFmP!Nv(TW3yigZgPyC7{$2Ati}q0y zRsB{dO3P4OOE2?rJkZCakXirhomN3*NQrtUBCy3rT=YC7&K|8d#HcLGTYWF|;=5$C z6tSi-5U&MmWQ`~}~t|sr&EAx@J@wSU^P?YxD z`{8BjQ9MWDYV6~O@~sGKz<$I|X-Ic8{414%OU8-T(ng&!_B_x}0Wlt=rXo^npl<5t z!8Y>tPBq6a;>xYX=4&1*jSOiH@ux8cXEUscNbE|MBbA{YQ%?K4exXhhm;xjfV$N9D zY!Ba)nSlB0xZcZ|YS+Q+I4y8~4=3nerUOt`k$+{xX za@A*I!fc}>^y`PPyytQc>upU$;G9J9z@mTV? zMPlxuW^?N__YxmyjQ+Km<>N`{h`?Z^roaZ7*?uB&zleFVJHe$T6Qocu>pSGX^~xH! zYZ2=u&%FkYm9v?FpupPHQB#hKhgg<;v7 z3+VbFS?h?x3)6svMh3Lmdp=Ujw;TWb0+14mAeq-9)qv)KkVC8W#r>`483i*DH*anm zlGHpcGe&7-{op|3^;|ZG4t$O#QLG;7jYv+!(o2NQNR3RM$7)KlMMIS-ZSA8Iy4t=r z1U~ekn7v_dLx}Qu^M=4YCC%}UznL`2hsrP!J}UwnQ_+BtN9>MQ4otOXu^gV>k_$AY z?p~;pv`Lih!QvV46@#ewQysq<*-YQsO%J+mEO}8OyfdLKystanGn5AGj~%HU@Ht@OY>n;e^s3rD0xVz!vt>tUy#8N$ z_xmvZ-rMQAZs|!xeLptBChg!YxR0JX1=|7IL<^v6HQJ=q<0KZVsHi3_G%z_hZtKME zn&J9-@NHDbc}dDXrT4t&Bu`7QYik4gP>?ccOL^GNqG!jRq-gxinD0y;EGAo7J9Q+_ zAB`=0bw1D}YQAVN!i=yrcKz=Ec313j#+-T53ifnaV62|x!@|8T;Q#%OT5JA)FvA$D zv1uRgBs8DybcB#*m8>4m4UZgioq^2wR1aP#l&bx3u=VPpbSi6*y}o*NVu26b?|?SP zusVlXLIefggHid#qTh;K#(qnkf$>T(?#)rv3Ng|uIY=LdYdb4kA1{%WI!R03Jn(G# zIW&;YJ`>^DYo_6sQZz<4lXsb{4}umN5=5I27}xicFXvgjye~;g6Zph{BvLrfS%@#D<8`#EofJX;$X#h}^DP)=p*wnV#e zE!?fAjZBUg>+~O|&8E}+`J>s-&9V9jEekPm@nvyzifjgBytPA2?u*lejaT|2%H;9q zzHE_at=m*uH4veuVyCp|xzNhpjg{CL%GIk0ZnPMJ`%t2I@c(RdudygEsnqFaQiIlUgF0Too+ZPEB^d!8gd zH#kY&-Vs$N6SAl4)2+>ihMW(wm-tbcavVb1k__w{#By1?A}l}v+Wv=Z{FITumWN|j ztaEsd8`<+x+0ch*EY}#Z)n<$1?T)4yx`HP_k}*+bp{4A3Q#sF!{7GsWP}_ zG8Y`+y%oBVMeGZeW!*mPlx}}DaGj&RHzZp9pfG0m-L!=lk;$^H<*|Vald(bH*Q@mb zj}BSu7HrUN}X@<%&g{etj7}iOQ1)nLh z*iw=ypZQ`V626+LKUlJ(!IQ8F{y5QP9tZzJYCKHM%3Fy7K2(-0S;qbQ5@#6ZcaVkb zO$?`5hOoVajEhue88LseXU}Hk)te3wHuxPF*1XDPVw^%2k=7hujJGK~UO6idI}@1? zWNR!rI|ws^PK-~&ij1FzdidKfF1>k1QDlW35wCI+A;93K`(bX>Y)=j1LOBwV{(Lbo z-zGrA0$5+yF*)!lnYIx0RIh-`86fxMs)#MMR-#YF)M_@9#Zyw+b@ZiYASu2ot;r5JV*64u&iCloI8^RWQkD8uF(p1!Ap)EJM^GlDBT85}hey2mG&5s{_ z7wDYuF^+!2m&!yHH^25ae!qa}8sGugKAK<_e=d>+HY7#1gvsC;%!UApvKLD-A#gW` zPy!iuFakcoEN9tR7ZQl3M0^r->d1szP5rJ7>)a}hg>F2aIs>w_x{4wzHL5zOCMvziRh)iAi^yCam-o^Ju^!-{;vyyLkdiJtT2OuV27n*tG79nC|Nf zeJ?Re{Ym?hEDB2ku&NaaL?-u36YiCLQ4t0X;VYL56nJjz?^YZqmb*%*+%L^Y*PP3d z?b_uM!pGHU9rUyJ$Zbil!IcwxU=VY^UPuC9SuxLXl0z5_Fv9ogoWY1%y%gpG;$u^G8PFv3qOZg%v|7S9AO zGyS{wkk}fvDjOC2W)nPrSY>DvgTbtElM@<(|pp zxc-0_TRs*OF*rtGF1%l8nN`m$mQg&zhtIN2RC@GC)h?5MJhtT&w4E|#WGs;|-H_Rr zp&yM;y*AG7hB{tJ1A2eL7K$Wm!Ag#n#NwIcJBRtN@D0aMz}f#nc?hqy{x?b^qzXHP z%?J6XcO+hm&yON9u<*&B-BvAUR!uhByObbk4<|~i1q>m2!EC0{<`c+J%GErvy$37Zqjd!U|CxKFH#fp~$8Dl9 z(0e|*`vGFjQ+H8L&B4yCHlEwSo>vKp%dFs$mfSjG2|&M&mvw|@evWD!BBVK4LIMpw zR)sUF*}aq7Z@tGN1~yZjtkRN7+jYqfc!rC@X1ddryswIc-q((X$3nXqwL!PjM#nVU zD;1R4Dy6_BBJy#byWWEUY%3Azy@$3NMU(oADnFWZvUob^T~FaEp!;bgp@Y&Ff~p8x zy$*w6bq#lgV-PFm_Z#r4eWh|=7l}-zy@u1^V=D2*x%bOXOIl#J7XL-LmlmqD z`Q;(#KydTzo37^*(X3~IG^}3`H@m0=utW>?L=Y zL=&S#e7^Cux?5@7x8eATWce_xepeC&c<3lq`wtEx9~{EdV?H-dPR%fI8oL+1Z8V<| ziiHl2*@#_lxTiNL^lbWT3zdR@bl(n-iY9rD#>bVtiFdK`hSlj0r;STr^VJHrRj1xK z_dlSwjwIhEYka@JJd7c`GXuhHikK`F#itWKM7&jPyMu<}=CBHwu6Jv|YBHnpWN++^ zajxk#uoVWb*pg>=1r>M8>QBbh)^qxwq$+S8hE`oKM|())mzkGyj3^-G@dYD$yX$+N z^3n6A0Dhm?|D4=ZVBoBUxM1{U(L&=8kbszFHeMw5x9N`)6WruOO`6Y z&=>E}lhz*}!}|-2sXun}V8m;I8f`f`3K{C~wnVZkVW&&Y>1<|=7vT5uL z2eW{?Hp@Jfne9=Mrm18^E`Cq0s(=?7)@1PD@D3u;spGbn`JB%Jt*{IpDfvrUq6w35 zi*GfH-aljwis83ALhH}+w?3?pW11=}ki~xZV2+zioB{c8McI89 zs+gto-*XNkXutOo`^9HTGQHZ~V}UZ$?hP#{jWFH9N7l=!yd7`LR9B=mESg+n_vnMy zOD=a5F0Fy!z>v4O5*=xy1&3D|XFiY;PgzhvvTDoDvYSaI!mDa+sY@psG8PtsUbNkT zag31>vh)Jew{Fp31$(hrl%$1fnrOc>0R;ohf;cI$cXZEZlBJ34q%#cZzZys z->jXCT}21-SXW3m8;e?zKnTntXD!EJrtEPLBZgrn$LY+<$ipbfC<3~9v-}CE$=VzO z)Jy>cFnr)&wAe`z}1|rmgeefnCf5T zB$>Se1c^Lj(l=h^8+m&wU?9Wn*n|`eaQ;l$A@S)RhQg#oCJX?7qAIi*(L{#haDZb+ zQhw`gLw-Y`mESVlv3{UHk%`AZPn{I}n@uUeYG-%kzVMkv%Xr|(OZ%qn9*R6ER?*~t z2q_KTNsFph!V7p(Ku5~MLn8q%rJva&L(cnt2*ka4lH{Bq+=Z~T?hjWQ9)_K_gZT72 z3A-rL9-PpEFN4E$l_eDsQeI2roiG>(H~jwWDaNI5r1qBugaIAV4WEo-Yz}gi-8VG~ z5;L#LxnVA@z>2C2Vhvy6Qr+4V#~!6>cmCz!X&(J!h^Z|{nO`A85~V@CXR9}rIld#N znEtAqceq;4D4Jzc(}MUt^;Nx51Ai9dmuViHR)Todz=Cc0ViLZ;_4kFxDfK0hKzw&s zU(D-s9>GB(DuF@;-f>$$hnd}LL=6~!pDDMCH7mq$Bd0_QO$eNS)U)}s3AZQn=Y@$}vzf_7dWThl>KTs^uSiqSB!~i|sP8^FN6-^T<0qK!uiq8M zLBAu}8}a@2iwHjra(0qqlmL&7_&J1o!!8|Op`HlWd?-WfM3`Q~mprb~x7)Q*WjMCC zHuE|xWqAhY-2z~KU0*Wwl}JFjU@|hQoPL~BQ?-vrx5wb9G2o>7ELQ;+6XLO#Cn7xU zL?Yc=D2e}jkYQi*jmQ})^V{45CYXvWN61ah3Hy}Gf+#~^`x6fS_k0^_?d{jqSi2ID zg(v2tF2@o+!ZCT#=WBozie24yX!YeSkvs2sKc+2-yXZ+W4Rp{gK+YAjFnZ9vmeGh$ z&R&T^C2JtjeO2D>bL4%x(roQ)!!-I${^jMlYK`Y)K7s5(XKAJ3s-p<~TmdXEV7Iv{ z5Hq%%UBg7)G~b;cxYU-mj_AZz=5vKhXR~A-YpKk0HKYWSfkIE%k>wxA^E7(CZY_UT z-|;Ztb|cik5n*CPr|oDVuxRb(;KhO1-z)r4**tMaB*(3q28rcWyOKlxI<~qt(;JT< z2&#iU1&&YcE+mVM&p?S@^CDqgn*61in{R&JxG~T*LuoN&RX$e>m=#`4IfO!bZiYx8 zNbea-S-KNAC3yJGt+X4+%?_I~BFOwVXHxeJ zoAx)w5V+6SRAakOYv4MO1Xcl3i(tisp_it~mBvQI_Z2~AXV+M(z9x}J3YsA zWKz>Br-;a*G=b(u$j~)*zh)9&wker_T3$ZW^q57czZ%v^zpcV{)u43<#I5fs+wEL5~dOsv+?y zMfc9Jdf#P?{}m#+Ue-u?2IgAI0#@t3Rl7ZRg)_+anLj0-yi?_YYRd(i%eY~{bTfg7 zbkJQc$hh!ZD*C-&s99t`5xVc4%9|zYl=hL#ZR~tjo75894|i84tmgOC;I7R(wZ0gR z(EnYHusb%@?2 z#sy`JOl^*+w4_n&;rqTH0i)DUjlhSFgE4}+e{@(aVWWx3xW3|c*oW3B#&pI)+j)fg zQ$2_bv1W=6v~8VmyxC9WZ2C=fu$of^fyyv_dEQAerai4Zg)G`$9n7CNyPGhoeYl$} z+95J=V5!3Ng#T7cI2OBk0kz|1-5LEDTr8+ttqxmlUBp1a6?0ikk_!6Q{3TFPgv}mm zn3|c)3pwGA)28I@H8ZJV&8gF7%@Oiy7-*L7Tmq~1_96>XdVPr_L|8Yd(Qx~}MXc7b ziOSYf0h3?hYz!Ye!!xBC!UF_OK@8RFJDO*wI5EVZ*2O@e3T`x`;)C;hk3Nd7dXzN5 z&OMXtlFDBwCv(rr|~j*vs3jayL`su37D~#g*Pd> z=?mw{I}1|mCKNwuJS}WaRd6)!4thratL*URDCBPDzmf5h_81z01^QQ>rdb`4g^z%G-?&Cc{-vxka|Ocf&GLh+094`IGLEMcTJt)mJya2Q6F(LUIP}_CoVW2?Gw2qsVSz)zBpY4 zj;BO!(AFV+7b6=+C4I-Zh#nry$Mfh}D$@iybf)=P%TZ65yE{zlmfUXP-9+G&Bblkz za_PHicAAw_nS*@&Xv|$Bu%rAeN<8bq0*Y^$)A_-J&`>7Vz|z zhb%4-JlEjIh&nCyexUHRqO0oJb*F^tvO#?xcg65T{Qifczl{f)YlsNNHOMDx0n<}z zFKlW+mH>MwP+t0QupJ&)`=wWAKJx5Iet`utBaR4`0p0R$_ar)vMch}yt-RWAIPp5q zi=9}@1c69dSta2W=WLPSMKy!}(>J^>xk;t7>KM$@q@RDa!UxO=G6jB76_}-RD2JzI4k7fh%A8J~@c?_Rl@cHw z&`038XVPUZ((sx^sB)4oyLXQct2a3twHIIIY(n6BeN&8IR)0o3yyNoVd5v88z*dE7 zt4|`)_?Lab4NA4S${3`i?!APKeYrz3G-L^8O@Iv{&Qp*Q}2WVOCUn|{?UzZ?s%H7%TB#wuoJ?h9$Z3EtlK z?OU!&n`VTz6lJ)eLDy<*oE^-9G$N&)ycb(CsZ3+&)94Wx>T2<8DN}%JlXjC4Y?$*+ z@?q@+ze$A-e7Cq-xE$6q*2eew)XQGRyHd^a3ytj}8hA6F>1{LZ+vZ6o*)N&zZQk(I z&AlPWGUy_Z-?Pnk{=v@An)AzxW?^s2>=H{LC3>&k9NujU>^hfOP2!}|4v>p^>y=+1?SDP~d`%LP7$MoeKlt&+*PAlBl z%R?TnXA$?DrPF?chsps*HT_`P2s<}KxdV^M{94%Z^f*SKI+&+_<@J_4T<3$O)-O37 z@gPE0-QSGHCVtUu_-=A+4*Pj&oi;Ki_hZ6k zQ(kX@1ov=eRJ0pDqhy59Bm2EUNL0D28z+~Cq_HU1IM?en5Qev?Tm!0YWKqSCnxk_c zWazJbCx%$Yh7A=bMG#siTR$2?6(O$&-~$%pubY>@^Id1hPdwH+6J@M&>%@T>5~9>1+yG8 z(@iI3gQIl}#v1wv8=H>q?Vj);pUWTIrpr4o;+{lPN+}f=9E^NlQO@v*j2o6Se<8>F zc(W2`+C~pInH5iB$=$JHU$I@95^sIJHZooFN`E11oGZ^V;ssapJ*7ePi~x~|WFbiRd!8v-G-5uOS0*po&akr}9O;l$ z+0&l)Jvhd3Vi%S?_M!Ddt7~#51X(WUQ-x+ZHHzEF;uD0;>h5|m!zO`W&SojXnIxH1 z&*spr0F!a9G#35`?bx0@G~qoo*nh=PSvOkdd)^m6Z4yd~mYW@=)8v-+t;Vf^zMd85 z#&%j^q$1~KftF09ygW+n@C0|n?WEpOdYOeV(S2FND`B~FewbcpQT71yUJ#fa>uP3x z?(nHXq~A`&fo7H2%$o{CgG2k?CXu55M&(fxh>!`_?DZYJkIwGDEO8bX9+ZgXgtEjV zCHvb>Z5*ou+V5>BEd>lBOj+emlXx<(<$oj#{?{&!L!D}rt`0kuiEgB9H~lH~R9qx} zn;wBluc84YkOU^1$?}-&$eL!?&co-WQOSIQLC((?Ax|`{Y2SvaIG?AM`j@paMto zbkWV-v8QKgu&TON+Z;C?Q+KpH<204EmqW!V>nr+%BcfULxe?$yvuHSg);G zY+V<%G;z*+NZ#68LG*JeXV`MZK?oza5SJI@vq*1+P3 z#)IO3d+J1OD-C}qt23@UVP>!Pl%4{Q_(u0dgxby{Ug^@vc(>vI@X3+FE5EU<-n+a8 z4(|=h`Ie>w9Y16b#cr95Q#%x>Cnex~ z3)4pp5157J*ttVbOC(P;-@DE(UCuq^_yAHirDrzG_xE(gk>%D%LH5o36=DR7c1PkFWGmy!m!xGEOie|Uo$Tb)moao=oc8Xdg742F7 zQK=kEzU9refB~fRlT9Us4h#KrW7U$j#Th09_Nj*XI*A)WrOpO+22PV|$@z&cz}XYFPy8n1 zbBr~lWSurI;?EswvKmcB?=m_Ta-*S+emq3*YsThQKj4N(3A(vYgbw0B)Ao++yy7D`MYQiZQpPKDD_otF{J|!*%)M1cliz^ofNEaezaow9g(q3K=PlxXDH2 zS*X9-;d%Wx`X{zXP_tCJY_?j{F|WH+T-%o?7BW_)?cx!9aM4cH%};g3kIEqJ=UW-j zm5rY<<(kg4JW0DDcw4sT?7KvbXYYkG(3y%tj)((>*_ydbXX~~03Fu3n&LOZ*qi90b zGq`0|OIA>K>OI3HwN#O@2A2m5{xemWR!;M~4(z%@tGAX(vpHW;nd!GuB(`e@#zJ~| zN3Y*L$;8R&S!4O3MQf7J@ZufMqTq6|$>GDxnQP#Q#x|Yn!t*-)7{pyg4mtKrkfqdM zDwC*kHs=jz75g!y`-pp;;s2cAaWWv=(dKFzf4qT%H_FnhT8-p!3Jyy(Y>Bbw2#H1o zF3gST?l~zUc&!Fi7Es#t$O1=XI#P1;UZZ3 zS@nin5$dTpDDXd>Z#c+$;bSWMYj;gkx>6RCG7+huF`o$s-MX`+F|bB|_NcRY!FpDH zyN-C%3%PCO)j{mRD_Q`fh5pQ?e|b!L+`em?eR4Fy_+@P8UTL;)n4aiUT_iFTp{>oe zKW^)e$k!SrY%uyd9mx212P_zXqj%w3S~U$UV#+! z03-e}^n(3onl~^|S3a4wgk=alTSY@Oc1^ozvUd%;86GGAK3a=dH+w$ypiNXFTLXCq z`<{85>a&MG>wBPnxjElwTdMO`kg}(81Df-wjzogc8t89zAg(_Rp zTU{3^!PnP9Q+yS%lzMOc&PrfD^jn6zDtXT<-rD&-^=ExD zAx#?dwI;%1phh2oonovYBVjN&gU`b99MpaTpm#7f*V-n6&?^^+cH(KO?zgT?*R1;y z8o+V@89xtaeOo&|!I6E9M1)uWOy+`TLsx1Xr%qQEUVt+e+(MTq|KuF=WAh<&!k#`; z&g%kYK9gA}{l8>1$BCiCI?o=In4&_f11$5B866uE?as^v)X;_D7?By@RL4r5tvh{` zuw^J)_>9%<{xeZ}XIvbXu{|#EqKeZ~kAJe2=;PL6&~^%w$Tx&|G7~<}dDq75c}e_gUFEUXzLMi5frS9SBNT z1lE-G>S6{ABY(BHsW9vGO9b(c<7y{qtRu`xVKZ=$NY!qw!`rjc%A7$WCHLwRieu4P zytO0Zb%K;6*d*R6g>ff#7VEB>5qM~?>aOR=>_1@NbXs^){x{eP{|=UZ$*|w5rHIeM zypIRg&fZ!$`mkXoE4V>@1HqcCl}}hz2z+*3~eZ{^xj~TjuFEVH zpcAC!clcI)^CZ6YZR(lX*lCkOSt3GZqj#?El}#~iQCrTSa@ENK7I7`{SYsTV3Ja5T z!UX}Cc35;6%4smR19sXOqb9vEo#t>H-FdZOhf`?WupuAJ^IAqY%c^C=*7CE-r=SP0 zo;K(#`AS2z!~>V?;}V*0VuO?1kFEjRSaqK^nij_TDVfUN^H5T~{DTakgbT&H9ARcb z)V95%ZRDPqIEYIv&?E5dZnM2&Q$G;UNdmshq=OnW<)6Qvxivo9&WRWL;4sOPcX0Q! zi3NMzm5v#P>1vttN;6c}BTw7KPSZIO@A#=X3cI>(-rKXg*>?yJCl!~xiy0UM-lON0 z(xzRl215rOB{Bl2QG|_KO81|zgrVZ2p^_lw8%igZjAQBeUCeYXU_Ci%a_OlKv7^{~ zZ1t_x%X~Mv1P@2Z@yF<3ov@AwY~aK{s}c&Z3FA7*M4b|~9WiJBAixfz8k$9B%R~VX zGPmkH&ZNwFDMC`JC1NY%zofuBIoZ;Dy{yT~R;$In9e6xwM9A$ga zP5OwUR7}F`>RG?lsQ_o~2?F0<{@$WX{nnw0itP5nmLF*wj&o2Iks|hsb_|RCTy41- z7I9A+ne3ywW&8L9&MTh9kIu}kNOrdHJ}B_n6jRFzc*g?^)O5u4_H;L7pag_cC#lSg z{oCT~RrDVS*1Px6G{)dKt3Tf`%rNA&MdeDpzb0!R6H_~3bjtAvIGkZ*OckynGFZ7< zE(J&$7wo%pw?p@{reI+H#6vB^rCmp$3eSdJ9)2ImyI8|u9hSz!?*T`n|D?%M{y~xb zybgS5&znu{IvbJ31u{2rH9C*~)n_tw=w@F0V>uwUKrbK0N@|_?qq)qBb5jJj2r76CqBgnB& z2~?(m+qtUjaFPf&jozcWk<}M?80<16N>MXBQfO#9 z%ouVv{RnyeN<{s799FYvn(-Q|L(`c*vK~f-(*S`R)2-KAa^?IByE3vP7M$A1T+bxM zUZl$7`)JzQ!=CUg{)d~I+TrrQbrMoPr0o_l;>r3$iuAiZPvhhQ&5>Ldp;VZK9MBxD zl6Z~Bb>7G0zG%XM3^?bO(-8r!KDleo`=U5&=&z;W_S*DnHfgzw1gNsDp5d#=>D(X(Q{FDVxsKU11y5&e+k zbu*&p#TKPS$)r(z*9_CcUoZ70C5tT*89%*DXnpB(5Ry^^y6x(XyS%OHZE!UguggB7W{#oeRiBT6)O+;@tg&1%E1a{oq`92K_)g(^j#| z^76j~W&en|{uO(ypKk-R>HQf+8BGMXR(n%3rF~H~8KKfO|6!52*4h?4nL@W-+qfQn zKCm(ZuSSff1lKo|E}LR|=7q>RcBN*ePc(F;Tl;*vvx0&2H!sn@WH2CZK88_p`Aiy} zCIzP$gHCxkX!)PR4NKm?lk3#ny>Qk6Y2|^(FJ`TiwRsjE8@mT0*h;Lb0G~sda8N z-qu6TC~y-Fl&c54*=Zdgu73C|-U9=LhGc$d-gSAr%*x_M>RBeTcs-WX%38(AA7#d= z{B2$$!V5MmefpC`!V|>cPN}~8hl9s%s5l~g(|D`&j=Jmm*yh8F#@5#}{T;OX_1ZR# zbFFW8SN3yRhLq;wRfxB zS@_jG0l^ixu(DJ8&v0u!y!Y?+$Vg>4-`Zf8Kar=wi~>x{O*gAuG(S+3;1JbvkIORg zMPG)}u+bKxffBjbTzaOlk@1P-_C&>iD?V;U zIz}=*lUa`I$`R7BxDqgJ`fV`(cT4tD2Gi8APF(sxl(!CpTYr#rW^XleA-gznuhWZD^&%JHoN|yLS zYL#}+&JbfYdM;29B2s*ZO`hwBCSty2{m9%5tDG$fSL)mJxbkl&P{0%yHMQ?BT} z&nCigQ&=@$?vclgnsAjmBJ_Vq`G%Qn9}Vw3@9gP&{Z+ucP|Gf%EsCFz?Y6fm(fQMQ z&&&SsB>sXzYEngG*t9p3w}^&P45WB1fF;FL+o{OHbPEpJvRuG}6_Z~tw!tx_eADIc zEPm|o2R}W~|E;{E1gmbqIf7*#$7*)b_x(=72*bGoKUtCgMRom+|AhE<4adZ{UY;qgU&4O8k+k?^*B=y``{Wl z!Q+wa$bIX9t&0u@QNjp1o~oz30o=?`_L$+Y`XUedTsuuaAT!v63Lu} z)NK|2)s;{o0wpZkH!_9Krjpu7UB%YwrffW;9eBYf&200MEMZ3GG8Ge{5zooE!d(7~ zvRgJ*KeXm}11ENxCy8Wjd+8Z@$Fn0ty@2IZ3NsjVTdL&c;~jphI=K94*1_eLzDyZ^ zu&wWif9QwFVFK%k((0ipnVJQE^0UD7TMfRPN*_K5mIR-9%bU8gJ-8>*ug`HjHKLx@ zaW=tv{G3g*ZPVvC7(Q!xS0Cp441r3ek4-dt*RwVE(_ z5P*3H$AQ)li-StEt7cMd7l}|wfMa)QKAeNA0EdS7Z?@%F){36Sc;{`u!XM70VlUuX zFIoR)Ce61mhX2hmL0O2(ddf$lt{7r_)-T{j6@H83Z!=LKp0k zRtxl{Y&Yd!aNRAfi4^o~;rlpzdh83`WW3?!e?FOPcZ6< zBP%2%c!l4}YvXsb2dCAtg>KXlkyf1w%wM@FZuhC*zCU!lVmPD)Pi2$6?082tfoSYRmXC1 zw%@a5p_zFcxT%khtLP1OB+O6D&=lsix(d%xqf3m5B)h24X06jI#n&HFzd&|R`S_OM zXiTCb^ultpxQ1DTW=K?2gLOdJ)&g#~kk=xG9vUkL8DxweinwxXbJZor3Flc&QyO7v z(}x1RI+>Aayx2QKWuoOHg?GD`*GhzPhx=R4(3Ws`Br0MqVzfbC4Gp2*C$CP7IC0!% z27zeJ_^dbVl#l#=A$kk;wM3tmH{+3e9%I2S7D z3^&%QXb^}PAZ!hi?iXk=c#RT;X=aCFVZWfti+n=Mf%#?DF-o24C3Nu*{zS$KyjoVb z$3fp*h58dxjX|2L=!W~BS%fB@JPk5|3D7h3`d0D*lGzrju0?JHB_&~_I|0gXKC^=0 zcQSni4lB1GuYsaE(b=WfMnT4-7Vmgaok6*PxS`B)e4=&vj#^2WM2v*tQbg;LXG?p5 z-DrH&MPSe7=fjm$sG4XjrUo+=`nNw%`$erzm#HS|sCv!wc%?fj5E;^%5@k&Mg2tK# z)<6M)t-yJYvW&zHJybYdU}li_Q`)-#=N(tA+?8HogbnP^!>dtQo_#g%bNfV<+namm zW6sL;E$O`r{=&mL?@bnUE2ri)+Q@tS9@u%$R!l^#`V$|%pgTW__D4vDwC`>ck0}}` zO^aD^_U%Os*h|bivvFmCAbr?s!eC8T+sR38n83LZ;8^V}miRnIc+8e85L9$s;J#|q zL*)5RhmNvg<6%=Glg-1Mcpb=C=mPtAgMb5$AGI@I8ih>shf}E3*7^8Ufjt5f+r&<> z1N?BPdrR=4b9r;^@UKCLPL*{ytA0NgYwt^S7$r=re1IKPNK0p_W(~Bb*2ls|UKuv0 z*SwPS<)pv?Ev-1m*ZGhXc9i(Z!t_KP5T1Hxekpn3S;eUSxz~*hx8`wnovlY=mY%~d zK`&urUuRQ ztUP2u|4fZ!9!6LR2Sg5MFtzG>3dk1L_oqI6j7P0RbVBDXuA% z;bi~h_iVavwmWn#CDnn>R!c=aW#pSM0nHvO*q65kMl3&(w}SsjjnEOEG`}toME}Fp zxLnwlYCM}alf`u=S37C+I9d2X0ORf^IGZ2rK+$+%(D5e^^6xxMedT7;@O)@1{Obc6 z`fkI9R&IMS9_4QYBD?p zCUdxrdgCpw1|DiBZN2f&{z@LcCWoyp*{0Nhh)f-jGz($OWFjoEvM?z73GM%>%zSpmJwnQTINv;<$mq^4mUe1^ z)os5Zn(H}D3|Aiu!C4ajg;?eoBlSIU3o$hwEWHm}qCyTiQrUZL8oF2a7@wQ2LsSZk z4+W^TE-oDkLqis9Hjpi+52K*WytW4Xvr1^NXoIItnk14Mfu1W$-;G$Jlm^1@V9Uc> zL4cEE)pmIK>K_pQTlSmIyw$tb)La=lN^pHi@SY!8wfj0oO)?hW{t#E*Muz;q&h>vf zPtDzq86|M%EmqECaK^rY7j_56_BN9@$4PV<=fXd`0lxxW<*o1v*yi759(I}0Ndb~3 zZ7DGmG@$E9Q*-*m2}qOVfYrfk>0y2TwmaH#Fe0yyjRn!(&XFG2-1GTSGgW_C+X^f2 zAM*#X`FTwD?F}QJQX_jGlirXO?qLvofrr>7EeGRIaa0cLUKWG>{l#Bo3HDh?`aSx$ z&cbH9n#$7JZ!W>=G%iVvh--tmf{pDAEb{euog&>k+U$8bULGqxMzV(f|LN_@!=c>!_cJpwWUWR~7$>Qr5?RMO5));e(IQ1COC@`n zvZYd!EFmS$Oc-k_9a|}>qby~KX_2iO8rz^4>)`zor8?)j&ii|>>-Wbz%NUo(^L)1N z{kcE)+!^LTclZ6}CFyS!Y4KJScJze5+Qx(QzEaN{LB~dg`KRLbn_`1naz=8gFSxGo z48jNMl6?&>Es5OZCWRW;;pwI@4c8@^xE$vf&1C5xZk?Pk_Qeekev zeNR<17-fHzoVmB<+8!r`pia4B7YNKt+7^Fb7yU+|#2annbzRs){%=-GAoN2>O5F3R z2u4_BT!e<;N+=Sm@r5ADW1O*qt~vM{Q)Ko@8*~}%DvF79&ul8z*GR*b>i~g(O$O$- z(jQ}JY@}xY=>TN9UP+!nrWG}4aYrsi`5fX7XvSq55y|Zp6Y3F&LK#-B9_6)1$YQeI zb~Ncq^2Jd=W&6!n7W$6Z{(Y?kc8!0M(`_%}(!Ci2*SDW;US4Biz8S}@9ij=5*2}Ur zlJr{3Bcva1LUK~ryB_MbvcQQ3vI+d{J4P;nY0OnII?rkstrrY^8+n3-=pq=%z0g|= ziCpze4XlUSDL`(+7r2>{-`XTC0-h8IA?e1`Q1_D|H96(`ks)Y?2pvWCPW}h9s_7Ny>oq`HlIc1X#WehSh5 zsWUoTE08hO&{`u2)QJmSCZA>4IN@6jVpV?@TH3F#7{fVQ*jw33yOXRQ>0%ISdM&wj zD(olS#wwvnQ`l#wRFBq3{-*eDV=s!&s7)In`45VM1)7yvi!l=?45)U^vGtcIdC&?V zuhDM~feG8Fm*ws{PdeRqELOt6C=Np@FLj|$RIqlFsg$gL_51z%^?s_74@k$WsYcWx zj03X_B018{K8YSI8b68P*!et>Up8@f$w$4RpJL=fVDh))!+z<6RXLERFMW2--~jp$ z)VM(nkSRnrqq-eEsh9M=7Z3l!T4$^lv9~XAYZp1tIB6LUvC){{kiAo$?h5S`s=r|~ zKx`*wH8LN8>TNE}T&<2o+8$U(>oLt*(eFn?Cb##g?Nel)8o>3EI84do!`XX~p*BWI zhS9sF%h1I`G~{HOVp)M#-LkjRtwVun=>DMawx)Dc!(q{y`du=@+D_;wcfIQ6T9gk< zeL@Vxr9s<^@(EM>GWd+r2(mu;^5^A8!Y5KeP#nm#<-00M5)&ALXXWSI-47ePF!h*; zTegv5bbis7w_$H%qMXW26SdE$B0Nq&Lpm;PX@OY})jLBArC#<*M41~@Fng@&w=h*7 zlCs#6!_$!dS!nlM(E-DAGNQ&CMu{oo4E&@U7XvrX1I{6#>ZD321xrm+r$k(tr~5PG zDh!O=sHUi^5O>?D%i^>(kmbr<;EwZAVBsDlUlDHpv8QQL?&vZ1wzEI^@4^o*Jcdiw zhb7AMm9GW`;{iu)OGyd?8VBW)_m8{{E*%zC3Tl8xt;GRdvk3zY`goWYtp6q1N})n* zf9;Wz1ltMM4(tb}?)h4}TW{)nKqU3Pwd{qu2`&YSJn5}Z-~D17L@VBd(@?Ro{X`xZ zkfCCS>ClL3H=x?%gWR@+z6XgGSU+f#BjB+!k_doTw4mZ!Sa<%LV|h;%6LZj2*YPsD z*f*s*#i%Ih2jps?YJ^wy!SMpW_Q9+EBji3GI>PB{CF2cIriUjvb1GvXro8Sp(z+Vp z;5PLA*Hdzg*0q_&`DLU|bp5>m$y^9T#AvsUQ*%?S(si1yr|72!sez**~GhAjNOx6s?Z5)@2k(j_l!&cH}R14 zxkMMKKM4**XY`Re#Hlu%n??m`BVjOIrUi0BIGURWve|hw+BUCC9E#SUrPG2X|x#iD70j z<6}?3`((S7K)$kSd@Al>AbQV8KiTuINd=}fOVHp&)sl2_d%%N`IM?f@ zv4la4vqTso%+unL@1s4WNHc?cfJUSv_Svv-+93U_h46*bTeBh!s)7+aU$iW9eY(X^(c_aF#=C?%-Ey@KpXb=LgLh zDhR~Rbc4;58rESXy@6!EYss0syg3 zI?sW`*pmQYj0!D45t;WWD`Lx2zE=e&FQ;*NugB9R> zak^eTVC~7U)$&u4(%LRSSRsLlTe*&|HZ}gnE0RNav9Byz?`T`^S1h(8vs$V&X33s{ z)6G|UgC&!KK$Hfz-A!y*&%d&Y^3YF$8&b)wg z)7(DA+~A{f^C*F+>3$ zgs(>lEfUKaCvpsX`9p4=w`-r^ly^{p0_xZRi-+OKhZWZ)1rcLvQI$oKaR0$_y?DNR zSY$xA{u%!@4SJ($Vut#mkH+in&ckQfc-6rFlmWpTlUf<>4E69}SCqwDO zqa@6so;rW2skpX+KUhdEi>pv^T6FOlaqMJ(=1@vSR0DSqj7u)tZ!9|qr;tQ$2S26q zRGIsS7{ZcP9*U}c*``pgVr zMLaRy8yc;@)KlK=vhh8|=k3mtfx@n^Y#l|@?1;=KQo^KQEl0c3KC6A+d{TJq5STe8 zA5#=RZNaHl-E;c`f+7Uj4t{uks;rc$eRez1hbocsib0kJElcKScDx-BgpSDEAhMIha5H4X6 z=p5^*+G*(U+c1g>^-D@Gn!WB=Tqd?DYJeuBuzth4f|i@*6Ys_zI7yLg&VQ8uBRD}^ z#=+q$37w{dfp>tlia=4l2MH(?`_%WT#)Ydw>6ARt^n8`$IhvAn@DuK?U*EpvsP@j( zjU~3zP^=YO+6kin7e_6>{~Hk%w*2QSc1u8s{3x&&UJh*ohf z$-5dyjB~C-C~wLaPk0~SE1cW_%m#YB+t zs@yK_*3h z69J%unu1LROM;oU`v0VP`#mnr$EP2A9w|3@ysas8IIv+$;1k2ine8_LiL}b6hi;g~ z_F2LsPhUxVm@!Vs_%&hube>^9=^H$-se?e=H1$A#bj4R1f5x2O$X{$GblpJr{Lscp zqjD~q&unnQpi4|c(52j>OqR4Ph|japMo|UF|0d?T!&qjH!X1Iiw4;YQI$^oRF68J0 zRb!=hnI>Rh38w4jPxeDWPQR{2=Ff$y*lB;TEiz*JKh0$3*%S=g@!?apg&#o)G+V8H z|G|QPi3bu}8Q4nphYoQs05mfH)+YNTGo#Y z4locDY{>+TxVh*&uLnyqm{V;=y zUj150A1k%g13J0UDwGy4^6^GTS*So$#ziIwIsO?K^8eoR{$aR3lI^$mz8mta3IA-! zBl3(!Af3cNVx*Nw_5Huwv4Z}XyMieIeHTFaiNS9+YRzmQtxzL)h-k!X-$>^QK_wBu zss9Mz)`i!JP|#wmsbC>O&6_B;bR+@=TUiEJn(1|*RK}0Ir|yWFbOK_Fb2j?7`;51f z2TiQ*ECp%-bG1CC2QWBa?BpsOcmtFRujGtc{w=zSyzRcJ7^9c}M@I9h(z-<L$Kc}qk7_?rFpd0V5dEM{zjFu4e_8KbVF}^kYE&b`IwSvhF$18o)fpi<$fkr*1YE0zkR#%se}S zj+%XJDLIDV3pw@RpbPF0^m0KR!_^ z93V?MtjC-@MeQ#f%vn1PSyDND(@>==T>U|uyv&eb@Y)01vld6SuklTNiJnT%&~Mi7 zdpvdS^`WLo*J-GL`qGm3k&(~TZF} Copy for that language (e.g. { labels: { ... } }) + */ +async function loadWidgetCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key] || {}; +} + +const MAX_DISTANCE = 100; + +const hhRetailersResults = document.querySelector('#locator-hh-retailers-tabpanel'); +const hhDistributorsResults = document.querySelector('#locator-hh-distributors-tabpanel'); +const hhOnlineResults = document.querySelector('#locator-hh-online-tabpanel'); + +const commDistributorsResults = document.querySelector('#locator-comm-distributors-tabpanel'); +const commLocalrepResults = document.querySelector('#locator-comm-localrep-tabpanel'); + +const eventsHHResults = document.querySelector('#locator-events-hh-tabpanel'); +const eventsCommResults = document.querySelector('#locator-events-comm-tabpanel'); + +async function fetchData(form) { + const fetchSheet = async (src) => { + const resp = await fetch(`https://little-forest-58aa.david8603.workers.dev/?url=${encodeURIComponent(src)}`); + const { data } = await resp.json(); + data.forEach((item) => { + item.lat = +item.LAT; + item.lng = +item.LONG; + }); + return data; + }; + + const loaded = form.dataset.status; + if (loaded) return window.locatorData; + + form.dataset.status = 'loading'; + window.locatorData = {}; + window.locatorData.HH = await fetchSheet('https://main--thinktanked--davidnuescheler.aem.live/vitamix/storelocations-hh.json?limit=10000'); + window.locatorData.COMM = await fetchSheet('https://main--thinktanked--davidnuescheler.aem.live/vitamix/storelocations-comm.json?limit=2000'); + window.locatorData.EVENTS = await fetchSheet('https://main--thinktanked--davidnuescheler.aem.live/vitamix/storelocations-events.json'); + form.dataset.status = 'loaded'; + return window.locatorData; +} + +const haversineDistance = (lat1, lon1, lat2, lon2) => { + const toRadians = (deg) => (deg * Math.PI) / 180; + const R = 6371; + const dLat = toRadians(lat2 - lat1); + const dLon = toRadians(lon2 - lon1); + const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) + + Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) + * Math.sin(dLon / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + const distanceInKm = R * c; + // Convert kilometers to miles + const distanceInMiles = distanceInKm * 0.621371; + return distanceInMiles; +}; + +async function geoCode(address) { + const resp = await fetch(`https://helix-geocode.adobeaem.workers.dev/?address=${encodeURIComponent(address)}`); + const json = await resp.json(); + const { results } = json; + const r0 = results?.[0]; + + const comps = r0?.address_components || []; + const findType = (t) => comps.find((c) => Array.isArray(c.types) && c.types.includes(t)); + const countryComp = findType('country'); + const admin1Comp = findType('administrative_area_level_1'); + return { + location: r0?.geometry?.location || null, + country: countryComp ? { + short: countryComp.short_name, + long: countryComp.long_name, + type: 'country', + } : null, + region: admin1Comp ? { + short: admin1Comp.short_name, + long: admin1Comp.long_name, + type: 'administrative_area_level_1', + } : null, + }; +} + +function findEventsResults(data, location) { + // Household Events + const filteredHHEvents = data.filter((item) => item.PRODUCT_TYPE === 'HH'); + const hhEvents = filteredHHEvents.sort( + (a, b) => haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng), + ); + + // Commercial Events + const filteredCommEvents = data.filter((item) => item.PRODUCT_TYPE === 'COMM'); + const commEvents = filteredCommEvents.sort( + (a, b) => haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng), + ); + + return { hhEvents, commEvents }; +} + +function findCommResults(data, location, countryShort, regionShort) { + // Distributors + const filteredDistributors = data.filter( + (item) => item.TYPE === 'DEALER/DISTRIBUTOR' && haversineDistance(location.lat, location.lng, item.lat, item.lng) < MAX_DISTANCE, + ); + const distributors = filteredDistributors.sort( + (a, b) => haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng), + ); + + // Local Representatives (country-based) + const wantCountry = (countryShort || '').toUpperCase(); + const wantRegion = (regionShort || '').toUpperCase(); + + // Local Representatives (country + region match) + const localRep = data.filter((item) => { + if (item.TYPE !== 'LOCAL REP') return false; + const itemCountry = String(item.COUNTRY || '').toUpperCase(); + const itemRegion = String(item.STATE_PROVINCE || item.STATE || item.PROVINCE || '').toUpperCase(); + return itemCountry === wantCountry && itemRegion === wantRegion; + }); + + return { distributors, localRep }; +} + +function findHHResults(data, location, country) { + // Retailers + const filteredRetailers = data.filter( + (item) => item.TYPE === 'RETAILERS' && haversineDistance(location.lat, location.lng, item.lat, item.lng) < MAX_DISTANCE, + ); + const retailers = filteredRetailers.sort( + (a, b) => haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng), + ); + + // Distributors + const filteredDistributors = data.filter((item) => item.TYPE === 'DEALER/DISTRIBUTOR'); + const distributors = filteredDistributors.sort( + (a, b) => haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng), + ); + + // Online + const online = data.filter((item) => item.TYPE === 'ONLINE' && item.COUNTRY === country); + + return { retailers, distributors, online }; +} + +function displayCommResults(results, location, labels = {}) { + const { distributors, localRep } = results; + + const createDistributorResult = (result) => { + const li = document.createElement('li'); + const title = document.createElement('h3'); + title.textContent = result.NAME; + li.append(title); + + const distance = document.createElement('span'); + const milesAway = (labels.milesAway ?? 'miles away'); + distance.textContent = `${haversineDistance(location.lat, location.lng, result.lat, result.lng).toFixed(1)} ${milesAway}`; + distance.classList.add('locator-distance'); + li.append(distance); + + const address = document.createElement('a'); + const addressQuery = `${result.NAME} ${result.ADDRESS_1}, ${result.CITY}, ${result.STATE_PROVINCE} ${result.POSTAL_CODE}`; + address.href = `https://maps.google.com/?q=${encodeURIComponent(addressQuery)}`; + address.target = '_blank'; + address.rel = 'noopener noreferrer'; + address.textContent = addressQuery; + address.classList.add('locator-address'); + li.append(address); + + // Phone number + if (result.PHONE_NUMBER) { + const phoneWrapper = document.createElement('span'); + phoneWrapper.classList.add('locator-phone'); + const phoneLink = document.createElement('a'); + phoneLink.href = `tel:${result.PHONE_NUMBER}`; + phoneLink.textContent = result.PHONE_NUMBER; + phoneWrapper.append(phoneLink); + li.append(phoneWrapper); + } + // Web address + if (result.WEB_ADDRESS) { + const webWrapper = document.createElement('span'); + webWrapper.classList.add('locator-web'); + const webLink = document.createElement('a'); + + const webAddress = result.WEB_ADDRESS.startsWith('http') + ? result.WEB_ADDRESS + : `https://${result.WEB_ADDRESS}`; + + webLink.href = webAddress; + webLink.target = '_blank'; + webLink.textContent = result.WEB_ADDRESS_LINK_TEXT || result.WEB_ADDRESS; + webWrapper.append(webLink); + li.append(webWrapper); + } + return li; + }; + + const createLocalRepResult = (result) => { + const li = document.createElement('li'); + const title = document.createElement('h3'); + title.textContent = result.NAME; + li.append(title); + + const distance = document.createElement('span'); + const milesAway = (labels.milesAway ?? 'miles away'); + distance.textContent = `${haversineDistance(location.lat, location.lng, result.lat, result.lng).toFixed(1)} ${milesAway}`; + distance.classList.add('locator-distance'); + li.append(distance); + + // Phone number + if (result.PHONE_NUMBER) { + const phoneWrapper = document.createElement('span'); + phoneWrapper.classList.add('locator-phone'); + const phoneLabel = document.createElement('strong'); + phoneLabel.textContent = labels.phone ?? 'Phone: '; + phoneWrapper.append(phoneLabel); + + const phoneLink = document.createElement('a'); + phoneLink.href = `tel:${result.PHONE_NUMBER}`; + phoneLink.textContent = result.PHONE_NUMBER; + phoneWrapper.append(phoneLink); + + li.append(phoneWrapper); + } + + // Web address + if (result.WEB_ADDRESS) { + const webWrapper = document.createElement('span'); + webWrapper.classList.add('locator-web'); + + const webLabel = document.createElement('strong'); + webLabel.textContent = labels.website ?? 'Website: '; + webWrapper.append(webLabel); + + const webLink = document.createElement('a'); + const webAddress = result.WEB_ADDRESS.startsWith('http') + ? result.WEB_ADDRESS + : `https://${result.WEB_ADDRESS}`; + + webLink.href = webAddress; + webLink.target = '_blank'; + webLink.textContent = result.WEB_ADDRESS_LINK_TEXT || result.WEB_ADDRESS; + webWrapper.append(webLink); + + li.append(webWrapper); + } + + return li; + }; + + if (distributors && distributors.length > 0) { + const distributorList = document.createElement('ol'); + distributors.forEach((distributor) => { + distributorList.appendChild(createDistributorResult(distributor)); + }); + commDistributorsResults.textContent = ''; + commDistributorsResults.appendChild(distributorList); + } else { + commDistributorsResults.innerHTML = `

      ${labels.noDistributorsFound ?? 'No distributors found'}

      `; + } + + if (localRep && localRep.length > 0) { + const localRepList = document.createElement('ol'); + localRep.forEach((lr) => { + localRepList.appendChild(createLocalRepResult(lr)); + }); + commLocalrepResults.textContent = ''; + commLocalrepResults.appendChild(localRepList); + } else { + commLocalrepResults.innerHTML = `

      ${labels.noLocalRepFound ?? 'No local representatives found'}

      `; + } +} + +function displayEventsResults(results, location, labels = {}) { + const { hhEvents, commEvents } = results; + const formatDate = (excelDate) => { + // Excel dates are the number of days since January 1, 1900 + // JavaScript dates are milliseconds since January 1, 1970 + // Excel has a bug where it treats 1900 as a leap year, so we adjust for that + const excelEpoch = new Date(1900, 0, 1); // January 1, 1900 + const millisecondsPerDay = 24 * 60 * 60 * 1000; + // Adjust for Excel's leap year bug + const adjustedDays = excelDate > 59 ? excelDate - 1 : excelDate; + const date = new Date(excelEpoch.getTime() + (adjustedDays - 1) * millisecondsPerDay); + return date.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); + }; + + const createHHEventResult = (result) => { + const li = document.createElement('li'); + const title = document.createElement('h3'); + title.textContent = result.NAME; + li.append(title); + + const date = document.createElement('span'); + date.textContent = `${formatDate(result.START_DATE)} - ${formatDate(result.END_DATE)}`; + date.classList.add('locator-date'); + li.append(date); + + const distance = document.createElement('span'); + const milesAway = (labels.milesAway ?? 'miles away'); + distance.textContent = `${haversineDistance(location.lat, location.lng, result.lat, result.lng).toFixed(1)} ${milesAway}`; + distance.classList.add('locator-distance'); + li.append(distance); + + const address = document.createElement('a'); + const addressQuery = `${result.NAME} ${result.ADDRESS_1}, ${result.CITY}, ${result.STATE_PROVINCE} ${result.POSTAL_CODE}`; + address.href = `https://maps.google.com/?q=${encodeURIComponent(addressQuery)}`; + address.target = '_blank'; + address.rel = 'noopener noreferrer'; + address.textContent = addressQuery; + address.classList.add('locator-address'); + li.append(address); + + return li; + }; + + const createCommEventResult = (result) => { + const li = document.createElement('li'); + const title = document.createElement('h3'); + title.textContent = result.NAME; + li.append(title); + + const date = document.createElement('span'); + date.textContent = `${formatDate(result.START_DATE)} - ${formatDate(result.END_DATE)}`; + date.classList.add('locator-date'); + li.append(date); + + const distance = document.createElement('span'); + const milesAway = (labels.milesAway ?? 'miles away'); + distance.textContent = `${haversineDistance(location.lat, location.lng, result.lat, result.lng).toFixed(1)} ${milesAway}`; + distance.classList.add('locator-distance'); + li.append(distance); + + const address = document.createElement('a'); + const addressQuery = `${result.NAME} ${result.ADDRESS_1}, ${result.CITY}, ${result.STATE_PROVINCE} ${result.POSTAL_CODE}`; + address.href = `https://maps.google.com/?q=${encodeURIComponent(addressQuery)}`; + address.target = '_blank'; + address.rel = 'noopener noreferrer'; + address.textContent = addressQuery; + address.classList.add('locator-address'); + li.append(address); + + return li; + }; + + if (hhEvents && hhEvents.length > 0) { + const hhEventList = document.createElement('ol'); + hhEvents.forEach((event) => { + hhEventList.appendChild(createHHEventResult(event)); + }); + eventsHHResults.textContent = ''; + eventsHHResults.appendChild(hhEventList); + } else { + eventsHHResults.innerHTML = `

      ${labels.noHouseholdEventsFound ?? 'No household events found'}

      `; + } + + if (commEvents && commEvents.length > 0) { + const commEventList = document.createElement('ol'); + commEvents.forEach((event) => { + commEventList.appendChild(createCommEventResult(event)); + }); + eventsCommResults.textContent = ''; + eventsCommResults.appendChild(commEventList); + } else { + eventsCommResults.innerHTML = `

      ${labels.noCommercialEventsFound ?? 'No commercial events found'}

      `; + } +} + +function displayHHResults(results, location, labels = {}) { + const { retailers, distributors, online } = results; + + const createOnlineResult = (result) => { + const li = document.createElement('li'); + const title = document.createElement('h3'); + title.textContent = result.NAME; + li.append(title); + if (result.WEB_ADDRESS) { + const label = document.createElement('span'); + label.textContent = labels.website ?? 'Website: '; + label.classList.add('locator-website-label'); + + const website = document.createElement('a'); + website.href = result.WEB_ADDRESS.startsWith('https://') ? result.WEB_ADDRESS : `https://${result.WEB_ADDRESS}`; + website.textContent = new URL(website.href).hostname; + website.target = '_blank'; + website.rel = 'noopener noreferrer'; + website.classList.add('locator-website'); + label.append(website); + li.append(label); + } + return li; + }; + + const createDistributorResult = (result) => { + const li = document.createElement('li'); + const title = document.createElement('h3'); + title.textContent = result.NAME; + li.append(title); + + const distance = document.createElement('span'); + const milesAway = (labels.milesAway ?? 'miles away'); + distance.textContent = `${haversineDistance(location.lat, location.lng, result.lat, result.lng).toFixed(1)} ${milesAway}`; + distance.classList.add('locator-distance'); + li.append(distance); + + const address = document.createElement('a'); + const addressQuery = `${result.NAME} ${result.ADDRESS_1}, ${result.CITY}, ${result.STATE_PROVINCE} ${result.POSTAL_CODE}`; + address.href = `https://maps.google.com/?q=${encodeURIComponent(addressQuery)}`; + address.target = '_blank'; + address.rel = 'noopener noreferrer'; + address.textContent = addressQuery; + address.classList.add('locator-address'); + li.append(address); + + return li; + }; + + const createRetailerResult = (result) => { + const li = document.createElement('li'); + const title = document.createElement('h3'); + title.textContent = result.NAME; + li.append(title); + + const distance = document.createElement('span'); + const milesAway = (labels.milesAway ?? 'miles away'); + distance.textContent = `${haversineDistance(location.lat, location.lng, result.lat, result.lng).toFixed(1)} ${milesAway}`; + distance.classList.add('locator-distance'); + li.append(distance); + + const address = document.createElement('a'); + const addressQuery = `${result.NAME} ${result.ADDRESS_1}, ${result.CITY}, ${result.STATE_PROVINCE} ${result.POSTAL_CODE}`; + address.href = `https://maps.google.com/?q=${encodeURIComponent(addressQuery)}`; + address.target = '_blank'; + address.rel = 'noopener noreferrer'; + address.textContent = addressQuery; + address.classList.add('locator-address'); + li.append(address); + + if (result.WEB_ADDRESS) { + const label = document.createElement('span'); + label.textContent = labels.website ?? 'Website: '; + label.classList.add('locator-website-label'); + + const website = document.createElement('a'); + website.href = result.WEB_ADDRESS.startsWith('https://') ? result.WEB_ADDRESS : `https://${result.WEB_ADDRESS}`; + website.textContent = new URL(website.href).hostname; + website.target = '_blank'; + website.rel = 'noopener noreferrer'; + website.classList.add('locator-website'); + label.append(website); + li.append(label); + } + + if (result.PHONE_NUMBER) { + const label = document.createElement('span'); + label.textContent = labels.phone ?? 'Phone: '; + label.classList.add('locator-phone-label'); + + const phone = document.createElement('a'); + phone.textContent = result.PHONE_NUMBER; + phone.href = `tel:${result.PHONE_NUMBER}`; + phone.target = '_blank'; + phone.rel = 'noopener noreferrer'; + phone.classList.add('locator-phone'); + label.append(phone); + li.append(label); + } + return li; + }; + + if (retailers && retailers.length > 0) { + const retailerList = document.createElement('ol'); + retailers.forEach((retailer) => { + retailerList.appendChild(createRetailerResult(retailer)); + }); + hhRetailersResults.textContent = ''; + hhRetailersResults.appendChild(retailerList); + } else { + hhRetailersResults.innerHTML = `

      ${labels.noRetailersFound ?? 'No retailers found'}

      `; + } + + if (distributors && distributors.length > 0) { + const distributorList = document.createElement('ol'); + distributors.forEach((distributor) => { + distributorList.appendChild(createDistributorResult(distributor)); + }); + hhDistributorsResults.textContent = ''; + hhDistributorsResults.appendChild(distributorList); + } else { + hhDistributorsResults.innerHTML = `

      ${labels.noDistributorsFound ?? 'No distributors found'}

      `; + } + + if (online && online.length > 0) { + const onlineList = document.createElement('ol'); + online.forEach((item) => { + onlineList.appendChild(createOnlineResult(item)); + }); + hhOnlineResults.textContent = ''; + hhOnlineResults.appendChild(onlineList); + } else { + hhOnlineResults.innerHTML = `

      ${labels.noOnlineFound ?? 'No online retailers found'}

      `; + } +} + +export default async function decorate(widget) { + widget.style.visibility = 'hidden'; + loadCSS('/blocks/form/form.css').then(() => widget.removeAttribute('style')); + + const { language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadWidgetCopy(lang); + const labels = copy.labels || {}; + + const form = widget.querySelector('form'); + + // Apply copy to static form and headings + const h1 = widget.querySelector('#find-locally'); + if (h1) h1.textContent = labels.findLocally ?? 'Find Locally'; + const locationLabel = widget.querySelector('label[for="location"]'); + if (locationLabel) locationLabel.textContent = labels.yourLocation ?? 'Your Location'; + const addressInput = widget.querySelector('#address'); + if (addressInput) addressInput.placeholder = labels.addressHint ?? 'Address, City, or Zipcode'; + const productTypeLabel = widget.querySelector('label[for="productType"]'); + if (productTypeLabel) productTypeLabel.textContent = labels.whatAreYouLookingFor ?? 'What are you looking for?'; + const productTypeSelect = widget.querySelector('#productType'); + if (productTypeSelect) { + const opts = productTypeSelect.querySelectorAll('option'); + if (opts[0]) opts[0].textContent = labels.householdProducts ?? 'Household Products'; + if (opts[1]) opts[1].textContent = labels.commercialProducts ?? 'Commercial Products'; + if (opts[2]) opts[2].textContent = labels.demonstrations ?? 'Demonstrations'; + } + const submitBtn = widget.querySelector('form button[type="submit"]'); + if (submitBtn) submitBtn.textContent = labels.search ?? 'Search'; + + // Tab labels: HH = Retailers, Online Retailers, Distributors; COMM = Distributors, Local Rep; + // Events = Household Events, Commercial Events + const hhTabs = widget.querySelectorAll('.locator-hh-results .locator-results-tablist button'); + if (hhTabs[0]) hhTabs[0].textContent = labels.retailers ?? 'Retailers'; + if (hhTabs[1]) hhTabs[1].textContent = labels.onlineRetailers ?? 'Online Retailers'; + if (hhTabs[2]) hhTabs[2].textContent = labels.distributors ?? 'Distributors'; + const commTabs = widget.querySelectorAll('.locator-comm-results .locator-results-tablist button'); + if (commTabs[0]) commTabs[0].textContent = labels.distributors ?? 'Distributors'; + if (commTabs[1]) commTabs[1].textContent = labels.localRepresentatives ?? 'Local Representatives'; + const eventsTabs = widget.querySelectorAll('.locator-events-results .locator-results-tablist button'); + if (eventsTabs[0]) eventsTabs[0].textContent = labels.householdEvents ?? 'Household Events'; + if (eventsTabs[1]) eventsTabs[1].textContent = labels.commercialEvents ?? 'Commercial Events'; + + // set initial values from query params + const queryParams = Object.fromEntries(new URLSearchParams(window.location.search)); + Object.entries(queryParams).forEach(([key, value]) => { + const input = form.querySelector(`[name="${key}"]`); + if (input) input.value = value; + }); + + // load results data + setTimeout(() => fetchData(form), 300); + + const tablistButtons = widget.querySelectorAll('.locator-results-tablist button'); + const showTab = (tabButton) => { + tablistButtons.forEach((b) => b.removeAttribute('aria-selected')); + widget.querySelectorAll('.locator-tabpanels .locator-tabpanel').forEach((panel) => panel.setAttribute('aria-hidden', true)); + tabButton.setAttribute('aria-selected', 'true'); + const tabpanel = document.getElementById(tabButton.getAttribute('aria-controls')); + if (tabpanel) tabpanel.setAttribute('aria-hidden', false); + }; + + const showType = (type) => { + widget.querySelectorAll('.locator-results').forEach((result) => { + result.setAttribute('aria-hidden', true); + }); + widget.querySelector(`.locator-results.locator-${type}-results`).setAttribute('aria-hidden', false); + showTab(widget.querySelector(`.locator-results.locator-${type}-results button`)); + }; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const formData = new FormData(form); + const data = Object.fromEntries(formData); + const { location, country, region } = await geoCode(data.address); + + if (data.productType === 'HH') { + if (location) { + const results = findHHResults(window.locatorData.HH, location, country?.short); + displayHHResults(results, location, labels); + } else { + displayHHResults({}, null, labels); + } + showType('hh'); + } + + if (data.productType === 'COMM') { + if (location) { + const results = findCommResults( + window.locatorData.COMM, + location, + country?.short, + region?.short, + ); + displayCommResults(results, location, labels); + } else { + displayCommResults({}, null, labels); + } + showType('comm'); + } + + if (data.productType === 'EVENTS') { + if (location) { + const results = findEventsResults(window.locatorData.EVENTS, location); + displayEventsResults(results, location, labels); + } else { + displayEventsResults({}, null, labels); + } + showType('events'); + } + }); + + tablistButtons.forEach((button) => { + button.addEventListener('click', (e) => { + e.preventDefault(); + showTab(button); + }); + }); + + widget.querySelector('#productType').addEventListener('change', () => { + if (widget.querySelector('#address').value) { + form.requestSubmit(); + } + }); +} diff --git a/widgets/locator/locator.json b/widgets/locator/locator.json new file mode 100644 index 00000000..5ce37a22 --- /dev/null +++ b/widgets/locator/locator.json @@ -0,0 +1,83 @@ +{ + "en": { + "labels": { + "findLocally": "Find Locally", + "yourLocation": "Your Location", + "addressHint": "Address, City, or Zipcode", + "whatAreYouLookingFor": "What are you looking for?", + "householdProducts": "Household Products", + "commercialProducts": "Commercial Products", + "demonstrations": "Demonstrations", + "search": "Search", + "retailers": "Retailers", + "onlineRetailers": "Online Retailers", + "distributors": "Distributors", + "localRepresentatives": "Local Representatives", + "householdEvents": "Household Events", + "commercialEvents": "Commercial Events", + "milesAway": "miles away", + "phone": "Phone: ", + "website": "Website: ", + "noDistributorsFound": "No distributors found", + "noLocalRepFound": "No local representatives found", + "noHouseholdEventsFound": "No household events found", + "noCommercialEventsFound": "No commercial events found", + "noRetailersFound": "No retailers found", + "noOnlineFound": "No online retailers found" + } + }, + "fr": { + "labels": { + "findLocally": "Trouver près de chez vous", + "yourLocation": "Votre emplacement", + "addressHint": "Adresse, ville ou code postal", + "whatAreYouLookingFor": "Que recherchez-vous?", + "householdProducts": "Produits grand public", + "commercialProducts": "Produits commerciaux", + "demonstrations": "Démonstrations", + "search": "Rechercher", + "retailers": "Détaillants", + "onlineRetailers": "Détaillants en ligne", + "distributors": "Distributeurs", + "localRepresentatives": "Représentants locaux", + "householdEvents": "Événements grand public", + "commercialEvents": "Événements commerciaux", + "milesAway": "miles", + "phone": "Téléphone : ", + "website": "Site Web : ", + "noDistributorsFound": "Aucun distributeur trouvé", + "noLocalRepFound": "Aucun représentant local trouvé", + "noHouseholdEventsFound": "Aucun événement grand public trouvé", + "noCommercialEventsFound": "Aucun événement commercial trouvé", + "noRetailersFound": "Aucun détaillant trouvé", + "noOnlineFound": "Aucun détaillant en ligne trouvé" + } + }, + "es": { + "labels": { + "findLocally": "Encuéntralo cerca", + "yourLocation": "Su ubicación", + "addressHint": "Dirección, ciudad o código postal", + "whatAreYouLookingFor": "¿Qué está buscando?", + "householdProducts": "Productos para el hogar", + "commercialProducts": "Productos comerciales", + "demonstrations": "Demostraciones", + "search": "Buscar", + "retailers": "Minoristas", + "onlineRetailers": "Minoristas en línea", + "distributors": "Distribuidores", + "localRepresentatives": "Representantes locales", + "householdEvents": "Eventos para el hogar", + "commercialEvents": "Eventos comerciales", + "milesAway": "millas", + "phone": "Teléfono: ", + "website": "Sitio web: ", + "noDistributorsFound": "No se encontraron distribuidores", + "noLocalRepFound": "No se encontraron representantes locales", + "noHouseholdEventsFound": "No se encontraron eventos para el hogar", + "noCommercialEventsFound": "No se encontraron eventos comerciales", + "noRetailersFound": "No se encontraron minoristas", + "noOnlineFound": "No se encontraron minoristas en línea" + } + } +} diff --git a/widgets/recipe-center/recipe-center.css b/widgets/recipe-center/recipe-center.css new file mode 100644 index 00000000..7eb8f9de --- /dev/null +++ b/widgets/recipe-center/recipe-center.css @@ -0,0 +1,520 @@ +/* Recipe Center Widget */ +.recipe-center { + max-width: var(--site-width); + margin: 0 auto; + margin-top: calc(-1 * var(--spacing-700)); +} + +@media (width >= 800px) { + .recipe-center { + position: relative; + margin-top: -110px; + } +} + +@media (width >= 900px) { + .recipe-center { + display: grid; + grid-template-areas: + 'title title' + 'controls controls' + 'info info' + 'facets results' + 'facets pagination'; + grid-template-columns: 280px 1fr; + margin-top: -140px; + } +} + +/* Controls */ +.recipe-center .title { + display: inline-block; + margin: 0 var(--spacing-600); + border-radius: var(--rounding-m) var(--rounding-m) 0 0; + padding: var(--spacing-60) var(--spacing-300) 0; + background-color: var(--color-charcoal); + color: var(--color-white); +} + +.recipe-center .controls { + display: flex; + flex-direction: column; + gap: var(--spacing-100); + padding: var(--spacing-200) var(--spacing-600); + background-color: var(--color-charcoal); +} + +.recipe-center .filters { + display: flex; + flex-direction: column; + align-items: stretch; + gap: var(--spacing-80); + border: none; + margin: 0; + padding: 0; +} + +.recipe-center .filters legend { + padding: 0; + color: var(--color-white); + float: left; + font-size: var(--body-size-m); + line-height: 42px; + text-align: center; + white-space: nowrap; +} + +.recipe-center .filters select { + appearance: none; + width: 100%; + font-weight: var(--weight-regular); + padding-right: var(--spacing-500); + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M6 9L1 4h10z'/%3E%3C/svg%3E"); + background-position: right var(--spacing-80) center; + background-repeat: no-repeat; + background-size: 12px; +} + +.recipe-center .filters select:hover, +.recipe-center .filters select:focus-visible { + background-color: var(--color-gray-100); +} + +.recipe-center .go { + display: none; /* not needed: dropdowns run search on change */ + width: 100%; + white-space: nowrap; +} + +.recipe-center .search { + display: flex; + position: relative; +} + +.recipe-center .search input { + flex: 1; + font-weight: var(--weight-regular); + padding-right: var(--spacing-600); +} + +.recipe-center .search-btn { + display: flex; + align-items: center; + justify-content: center; + position: absolute; + top: 50%; + right: var(--spacing-60); + border: none; + padding: var(--spacing-60); + background: none; + color: var(--color-gray-700); + cursor: pointer; + transform: translateY(-50%); +} + +.recipe-center .search-btn:hover { + color: var(--color-charcoal); +} + +@media (width >= 800px) { + .recipe-center .title { + padding: 16px 40px 0; + font-size: 45px; + } + + .recipe-center .filters { + flex-flow: row wrap; + align-items: center; + } + + .recipe-center .filters select { + flex: 1; + width: auto; + min-width: 150px; + } +} + +@media (width >= 900px) { + .recipe-center .title { + grid-area: title; + width: max-content; + } + + .recipe-center .controls { + padding-top: var(--spacing-800); + padding-bottom: var(--spacing-800); + grid-area: controls; + } +} + +/* Info Bar */ +.recipe-center .info { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1ch; + border-bottom: var(--border-s) solid var(--color-gray-300); + padding: var(--spacing-200) var(--spacing-600); + background-color: var(--color-gray-100); + color: var(--color-charcoal); + font-size: var(--body-size-m); +} + +.recipe-center .sort { + position: relative; + font-size: var(--body-size-s); +} + +.recipe-center .sort summary { + list-style: none; + cursor: pointer; +} + +.recipe-center .sort summary::-webkit-details-marker { + display: none; +} + +.recipe-center .sort menu { + list-style: none; + position: absolute; + top: 100%; + right: 0; + z-index: 100; + border: var(--border-s) solid var(--color-gray-300); + border-radius: var(--rounding-s); + margin: var(--spacing-40) 0 0; + padding: 0; + background-color: var(--color-background); + box-shadow: var(--shadow-hover); + font-size: var(--body-size-m); + min-width: 190px; +} + +.recipe-center .sort li { + border-bottom: var(--border-s) solid var(--color-gray-300); +} + +.recipe-center .sort li:last-child { + border-bottom: none; +} + +.recipe-center .sort button { + display: block; + width: 100%; + border: none; + padding: var(--spacing-60) var(--spacing-80); + background: none; + font-size: inherit; + text-align: left; + cursor: pointer; +} + +.recipe-center .sort button:hover { + background-color: var(--color-gray-300); +} + +.recipe-center .sort button[aria-pressed="true"] { + background: url('/blocks/plp/checkmark.svg') center right 10px no-repeat; +} + +@media (width >= 900px) { + .recipe-center .info { + grid-area: info; + } + + .recipe-center .sort summary { + display: inline-block; + border: var(--border-m) solid var(--color-gray-800); + border-radius: var(--rounding-m); + padding: var(--spacing-60) 45px var(--spacing-60) 15px; + background: url('/blocks/plp/chevron-down.svg') center right 10px no-repeat; + font-size: var(--body-size-m); + } +} + +/* Facets Panel */ +.recipe-center .facets { + display: none; + position: fixed; + inset: 0; + z-index: 1; + padding: var(--spacing-400); + background-color: var(--shadow-300); + overflow-y: auto; +} + +.recipe-center .facets.visible { + display: block; +} + +.recipe-center .facets h2 { + margin: 0 0 var(--spacing-400); + color: var(--color-charcoal); + font-family: var(--heading-font-family); + font-size: var(--font-size-600); + font-weight: var(--weight-regular); +} + +.recipe-center .facets .selected { + margin-bottom: var(--spacing-200); +} + +.recipe-center .facets .tag { + display: inline-block; + border: none; + border-radius: var(--rounding-l); + margin-right: var(--spacing-60); + margin-bottom: var(--spacing-60); + padding: 10px var(--spacing-80) 10px 38px; + background-color: var(--color-charcoal); + background-image: url('/blocks/plp/close-white.svg'); + background-position: 8px center; + background-repeat: no-repeat; + background-size: var(--icon-m); + color: var(--color-white); + font-size: var(--body-size-m); + cursor: pointer; +} + +.recipe-center .facets .clear { + border: var(--border-m) solid var(--color-charcoal); + border-radius: var(--rounding-m); + padding: var(--spacing-60) var(--spacing-100); + background-color: var(--color-white); + color: var(--color-charcoal); + font-size: var(--body-size-s); + cursor: pointer; +} + +.recipe-center .facets .apply { + width: 100%; + margin-top: var(--spacing-400); +} + +.recipe-center .facet { + border-bottom: var(--border-s) solid var(--color-gray-300); +} + +.recipe-center .facet summary { + list-style: none; + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--spacing-100) 0; + color: var(--color-charcoal); + font-family: var(--body-font-family); + font-size: var(--body-size-m); + font-weight: var(--weight-medium); + cursor: pointer; +} + +.recipe-center .facet summary::-webkit-details-marker { + display: none; +} + +.recipe-center .facet summary::after { + content: '+'; + color: var(--color-red); + font-size: var(--font-size-500); + font-weight: var(--weight-light); +} + +.recipe-center .facet[open] summary::after { + content: '−'; +} + +.recipe-center .facet input[type="checkbox"] { + position: absolute; + width: 20px; + height: 20px; + margin: 0; + opacity: 0; + cursor: pointer; +} + +.recipe-center .facet label { + display: block; + margin: var(--spacing-100) 0; + padding-left: 30px; + background-image: url('/blocks/plp/checkbox.svg'); + background-position: left center; + background-repeat: no-repeat; + background-size: 20px; + cursor: pointer; +} + +.recipe-center .facet :checked + label { + background-image: url('/blocks/plp/checked.svg'); +} + +@media (width >= 900px) { + .recipe-center .facets { + display: block; + position: static; + z-index: auto; /* only need z-index when fixed overlay on small screens */ + grid-area: facets; + padding: var(--spacing-600) 30px var(--spacing-600) var(--spacing-600); + background: none; + overflow: visible; + } + + .recipe-center .facets .apply { + display: none; + } +} + +/* Results Grid */ +.recipe-center .results { + list-style: none; + display: grid; + grid-template-columns: 1fr; + gap: var(--spacing-400); + margin: var(--spacing-400) var(--spacing-600); + padding: 0; +} + +.recipe-center .results .highlight { + padding: 0 var(--spacing-20); + background-color: var(--color-yellow); +} + +.recipe-center .card { + background-color: var(--color-white); +} + +.recipe-center .card a { + display: block; + color: inherit; + text-decoration: none; +} + +.recipe-center .card img { + display: block; + width: 100%; + height: 280px; + object-fit: cover; + transition: opacity 0.2s ease; +} + +.recipe-center .card:hover img { + opacity: 0.9; +} + +.recipe-center .card .placeholder { + height: 280px; + background-color: var(--color-gray-200); +} + +.recipe-center .card h3 { + margin: var(--spacing-100) var(--spacing-100) var(--spacing-60); + color: var(--color-charcoal); + font-family: var(--heading-font-family); + font-size: var(--font-size-300); + font-weight: var(--weight-regular); + line-height: var(--line-height-m); +} + +.recipe-center .card .rating { + display: flex; + gap: var(--spacing-40); + margin: 0 var(--spacing-100) var(--spacing-40); + color: var(--color-gray-400); + font-size: var(--font-size-200); +} + +.recipe-center .card .meta { + margin: 0 var(--spacing-100) var(--spacing-100); + color: var(--color-gray-700); + font-size: var(--body-size-xs); +} + +@media (width >= 600px) { + .recipe-center .results { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width >= 900px) { + .recipe-center .results { + grid-area: results; + gap: var(--spacing-400); + margin: 0; + padding: var(--spacing-600) var(--spacing-600) var(--spacing-600) var(--spacing-400); + } +} + +@media (width >= 1400px) { + .recipe-center .results { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (width >= 1600px) { + .recipe-center .results { + grid-template-columns: repeat(4, 1fr); + } +} + +/* Pagination */ +.recipe-center .pagination { + display: flex; + align-items: center; + justify-content: center; + gap: var(--spacing-60); + padding: var(--spacing-600); +} + +/* stylelint-disable-next-line no-descending-specificity */ +.recipe-center .pagination button { + border: var(--border-s) solid var(--color-gray-400); + border-radius: var(--rounding-m); + padding: var(--spacing-60) var(--spacing-100); + background-color: var(--color-white); + color: var(--color-charcoal); + font-size: var(--body-size-m); + cursor: pointer; + transition: all 0.2s ease; + min-width: 40px; +} + +.recipe-center .pagination button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.recipe-center .pagination button:hover:not(:disabled) { + border-color: var(--color-charcoal); + background-color: var(--color-gray-100); +} + +.recipe-center .pagination button[aria-current="page"] { + border-color: var(--color-red); + background-color: var(--color-red); + color: var(--color-white); +} + +.recipe-center .pagination .pages { + display: none; + align-items: center; + gap: var(--spacing-40); +} + +@media (width >= 600px) { + .recipe-center .pagination .pages { + display: flex; + } +} + +@media (width >= 900px) { + .recipe-center .pagination { + grid-area: pagination; + } +} + +/* Suppress results when user has not applied filters or search */ +.recipe-center.suppress-results .info, +.recipe-center.suppress-results .facets, +.recipe-center.suppress-results .results, +.recipe-center.suppress-results .pagination { + display: none; +} diff --git a/widgets/recipe-center/recipe-center.html b/widgets/recipe-center/recipe-center.html new file mode 100644 index 00000000..458cbd5e --- /dev/null +++ b/widgets/recipe-center/recipe-center.html @@ -0,0 +1,47 @@ + +
      + +
      +

      Items 1 - 12 of 0

      +
      + Sort By: Featured + +
    • +
    • +
    • +
    • +
    • +
      +
      +
      + +
        + +
        diff --git a/widgets/recipe-center/recipe-center.js b/widgets/recipe-center/recipe-center.js new file mode 100644 index 00000000..f44470d3 --- /dev/null +++ b/widgets/recipe-center/recipe-center.js @@ -0,0 +1,1122 @@ +/* eslint-disable max-len */ + +import { loadCSS } from '../../scripts/aem.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** + * Load widget copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (e.g. en, fr) + * @returns {Promise} Copy for that language (flat key-value) + */ +async function loadWidgetCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key] || {}; +} + +/** + * Parses raw recipe data from index and transforms values. + * @param {Object} data - Raw recipe data object from the recipe index + * @returns {Object} Parsed recipe object with transformed values + */ +function parseRecipeData(data) { + const parsed = {}; + Object.entries(data).forEach(([key, value]) => { + switch (key) { + case 'compatible-containers': + case 'dietary-interests': + case 'course': + case 'recipe-type': + // split comma-separated values into trimmed arrays + parsed[key] = value ? value.split(',').map((s) => s.trim()) : []; + break; + default: + parsed[key] = typeof value === 'string' ? value.trim() : value; + break; + } + }); + return parsed; +} + +/** + * Capitalizes the first letter of a string. + * @param {string} str - String to capitalize + * @returns {string} String with first letter capitalized + */ +function capitalizeFirstLetter(str) { + if (!str) return str; + return str.charAt(0).toUpperCase() + str.slice(1).toLowerCase(); +} + +/** + * Fetches and filters recipes from the recipe index. + * @param {Object} config - Object with filter criteria + * @param {Object} facets - Optional object to populate with facet counts for UI filters. + * @returns {Promise>} Array of filtered recipe objects + */ +async function lookupRecipes(config = {}, facets = {}) { + const { locale, language } = getLocaleAndLanguage(); + if (!window.recipeIndex) { + // fetch the main recipe index + const resp = await fetch(`/${locale}/${language}/recipes/query-index.json`); + if (!resp.ok) { + window.recipeIndex = { data: [] }; + return []; + } + + const { data } = await resp.json(); + + // parse and filter recipes - only include Updated or New status, exclude Deleted + const recipes = data + .map((d) => parseRecipeData(d)) + .filter((recipe) => { + const status = recipe.status ? recipe.status.toLowerCase() : ''; + return status === 'updated' || status === 'new'; + }); + + window.recipeIndex = { + data: recipes, + }; + } + + // extract all facet keys from the facets object for dynamic filter UI + const facetKeys = Object.keys(facets); + + // extract all filter criteria keys from the config object + // exclude fulltext if it's empty or just whitespace, and exclude 'page' and 'sort' + const filterKeys = Object.keys(config).filter((key) => { + // Exclude pagination and sorting keys from filtering + if (key === 'page' || key === 'sort') { + return false; + } + if (key === 'fulltext') { + return config[key] && config[key].trim().length > 0; + } + return config[key]; // exclude any other empty values + }); + + // Track which recipe titles have been counted for each facet value to avoid duplicates + const facetTitleTracking = {}; + // Track the canonical case for each facet value (first occurrence wins) + const facetCanonicalCase = {}; + facetKeys.forEach((facetKey) => { + facetTitleTracking[facetKey] = {}; + facetCanonicalCase[facetKey] = {}; + }); + + // parse comma-separated filter values into trimmed token arrays for matching + const tokens = {}; + filterKeys.forEach((key) => { + if (config[key]) { + if (key === 'fulltext') { + // fulltext is a single search term, not comma-separated + tokens[key] = config[key].trim(); + } else { + tokens[key] = config[key].split(',').map((t) => t.trim()); + } + } + }); + + // filter recipes based on all configured criteria (must match ALL filters) + const results = window.recipeIndex.data.filter((recipe) => { + // Exclude recipes without a difficulty rating + if (!recipe.difficulty || recipe.difficulty.trim() === '') { + return false; + } + + // track which individual filters matched for this recipe + const filterMatches = {}; + + // check if this recipe matches ALL the filter criteria + // IMPORTANT: Use forEach instead of every() to avoid short-circuiting + // We need to evaluate ALL filters and store them in filterMatches for facet counting + filterKeys.forEach((filterKey) => { + let matched = false; + + // special case: full-text search on recipe title + if (filterKey === 'fulltext') { + // Only apply fulltext filter if it has actual content + if (config.fulltext && config.fulltext.trim().length > 0) { + const titleLower = recipe.title.toLowerCase(); + const searchTerm = config.fulltext.toLowerCase().trim(); + matched = titleLower.includes(searchTerm); + } else { + // Empty fulltext matches everything + matched = true; + } + } else if (recipe[filterKey]) { + // array-based filter matching (dietary-interests, compatible-containers, etc.) + if (Array.isArray(recipe[filterKey])) { + // recipe matches if ANY of its values match ANY of the filter tokens (case-insensitive) + matched = tokens[filterKey].some((t) => recipe[filterKey].some((rv) => rv.toLowerCase() === t.toLowerCase())); + } else { + // for non-array fields, check if the value matches any token (case-insensitive) + matched = tokens[filterKey].some((t) => recipe[filterKey].toLowerCase() === t.toLowerCase()); + } + } + + // ALWAYS store whether this filter matched (for facet counting) + filterMatches[filterKey] = matched; + }); + + // Now check if ALL filters matched + const matchedAll = filterKeys.every((filterKey) => filterMatches[filterKey] || !config[filterKey]); + + // NOW calculate facet counts AFTER all filterMatches have been collected + // This ensures we have the complete picture of which filters matched + facetKeys.forEach((facetKey) => { + // intelligent facet counting: include recipes that match ALL OTHER filters + let includeInFacet = true; + Object.keys(filterMatches).forEach((filterKey) => { + // exclude this recipe from facet count if any OTHER filter didn't match + if (filterKey !== facetKey && !filterMatches[filterKey]) includeInFacet = false; + }); + + // if this recipe qualifies for inclusion in the facet counts + if (includeInFacet) { + // check if the recipe has any values for this facet field + if (recipe[facetKey]) { + const values = Array.isArray(recipe[facetKey]) ? recipe[facetKey] : [recipe[facetKey]]; + values.forEach((val) => { + if (val) { + const valLower = val.toLowerCase(); + + // Store the canonical case (first occurrence, capitalized for display) + if (!facetCanonicalCase[facetKey][valLower]) { + facetCanonicalCase[facetKey][valLower] = capitalizeFirstLetter(val); + } + const canonicalVal = facetCanonicalCase[facetKey][valLower]; + + // Track by recipe title to avoid counting duplicate recipes with same name + if (!facetTitleTracking[facetKey][valLower]) { + facetTitleTracking[facetKey][valLower] = new Set(); + } + + // Only count this recipe if we haven't seen this title for this facet value yet + if (!facetTitleTracking[facetKey][valLower].has(recipe.title)) { + facetTitleTracking[facetKey][valLower].add(recipe.title); + + if (facets[facetKey][canonicalVal]) { + // increment existing count + facets[facetKey][canonicalVal] += 1; + } else { + // initialize count for a new facet value + facets[facetKey][canonicalVal] = 1; + } + } + } + }); + } + } + }); + + return matchedAll; + }); + + return results; +} + +/** + * Formats time string from HH:MM:SS to readable format. + * @param {string} timeString - Time in HH:MM:SS format + * @returns {string} Formatted time string + */ +function formatTime(timeString) { + if (!timeString) return ''; + + // Parse HH:MM:SS format + const parts = timeString.split(':'); + if (parts.length !== 3) return timeString; + + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + const seconds = parseInt(parts[2], 10); + + // Round seconds up to next minute if > 0 + let totalMinutes = hours * 60 + minutes; + if (seconds > 0) { + totalMinutes += 1; + } + + // Convert back to hours and minutes + const finalHours = Math.floor(totalMinutes / 60); + const finalMinutes = totalMinutes % 60; + + // Build readable string + const parts2 = []; + if (finalHours > 0) { + parts2.push(`${finalHours}h`); + } + if (finalMinutes > 0) { + parts2.push(`${finalMinutes}m`); + } + + return parts2.length > 0 ? parts2.join(' ') : '0m'; +} + +/** + * Formats servings/yield string to be human-readable without decimals. + * @param {string} yieldString - Yield string like "8.00 servings" or "2.5 cups" + * @returns {string} Formatted yield string + */ +function formatYield(yieldString) { + if (!yieldString) return ''; + + // Extract number from string like "8.00 servings" + const match = yieldString.match(/^([\d.]+)\s*(.*)$/); + if (!match) return yieldString; + + const number = parseFloat(match[1]); + const unit = match[2]; + + // Round to nearest whole number + const roundedNumber = Math.round(number); + + return unit ? `${roundedNumber} ${unit}` : `${roundedNumber}`; +} + +/** + * Extracts the numeric value from a yield string. + * @param {string} yieldString - Yield string like "8.00 servings" + * @returns {number} Numeric yield value + */ +function parseYieldNumber(yieldString) { + if (!yieldString) return 0; + const match = yieldString.match(/^([\d.]+)/); + return match ? parseFloat(match[1]) : 0; +} + +/** + * Collapses recipes with the same title, aggregating yields and compatible-containers. + * @param {Array} recipes - Array of recipe objects + * @returns {Array} Array of collapsed recipe objects + */ +function collapseRecipesByTitle(recipes) { + const recipesByTitle = {}; + + recipes.forEach((recipe) => { + const { title } = recipe; + if (!title) return; + + if (!recipesByTitle[title]) { + // First occurrence - create the base recipe + recipesByTitle[title] = { + ...recipe, + yields: [recipe.yield], + allContainers: recipe['compatible-containers'] ? [...recipe['compatible-containers']] : [], + }; + } else { + // Duplicate - aggregate data + const existing = recipesByTitle[title]; + + // Add yield to the list + if (recipe.yield) { + existing.yields.push(recipe.yield); + } + + // Add compatible containers + if (recipe['compatible-containers']) { + recipe['compatible-containers'].forEach((container) => { + if (!existing.allContainers.includes(container)) { + existing.allContainers.push(container); + } + }); + } + } + }); + + // Process the collapsed recipes to create yield ranges + return Object.values(recipesByTitle).map((recipe) => { + if (recipe.yields && recipe.yields.length > 1) { + // Find min and max yields + const yieldNumbers = recipe.yields + .map(parseYieldNumber) + .filter((n) => n > 0); + + if (yieldNumbers.length > 0) { + const minYield = Math.min(...yieldNumbers); + const maxYield = Math.max(...yieldNumbers); + + // Extract unit from first yield (same pattern as formatYield) + const match = recipe.yields[0].match(/^([\d.]+)\s*(.*)$/); + const unit = match ? match[2].trim() : ''; + + // Create range string + recipe.yieldRange = minYield !== maxYield + ? `${Math.round(minYield)} - ${Math.round(maxYield)} ${unit}` + : formatYield(recipe.yields[0]); + } + } else { + recipe.yieldRange = recipe.yield ? formatYield(recipe.yield) : ''; + } + + // Update compatible-containers with aggregated list + recipe['compatible-containers'] = recipe.allContainers; + + return recipe; + }); +} + +/** + * Creates a recipe card DOM element for display in the recipe listing. + * @param {Object} recipe - Recipe data object with title, image, time, etc. + * @returns {HTMLElement} Recipe card element + */ +function createRecipeCard(recipe) { + const li = document.createElement('li'); + li.className = 'card'; + + const link = document.createElement('a'); + link.href = recipe.path || '#'; + + // Check if image is the default-meta-image and use fallback instead + const imagePath = new URL(recipe.image).pathname || ''; + const isDefaultImage = imagePath.includes('default-meta-image'); + + let image; + if (isDefaultImage) { + image = document.createElement('div'); + image.className = 'img placeholder'; + } else { + image = document.createElement('img'); + image.src = imagePath; + image.alt = ''; + image.loading = 'lazy'; + } + + const title = document.createElement('h3'); + title.textContent = recipe.title || ''; + + // Star rating + const rating = document.createElement('div'); + rating.className = 'rating'; + rating.setAttribute('aria-label', '0 of 5 stars'); + for (let i = 0; i < 5; i += 1) { + const star = document.createElement('span'); + star.textContent = '☆'; + rating.appendChild(star); + } + + const meta = document.createElement('p'); + meta.className = 'meta'; + + const metaParts = []; + if (recipe['total-time']) metaParts.push(formatTime(recipe['total-time'])); + if (recipe.difficulty) metaParts.push(recipe.difficulty); + if (recipe.yieldRange || recipe.yield) metaParts.push(recipe.yieldRange || formatYield(recipe.yield)); + + meta.textContent = metaParts.join(' • '); + + link.append(image, title, rating, meta); + li.appendChild(link); + + return li; +} + +/** + * Reads query parameters from URL and returns as config object. + * @returns {Object} Configuration object from URL params + */ +function getConfigFromURL() { + const params = new URLSearchParams(window.location.search); + const config = {}; + + // Read all URL parameters into config + params.forEach((value, key) => { + config[key] = value; + }); + + return config; +} + +/** + * Updates URL query parameters to reflect current filter state. + * @param {Object} filterConfig - Current filter configuration + * @returns {void} + */ +function updateURL(filterConfig) { + const params = new URLSearchParams(); + + // Add all non-empty config values to URL params + Object.keys(filterConfig).forEach((key) => { + // Skip page if it's 1 (default) + if (key === 'page' && filterConfig[key] === 1) { + return; + } + + if (filterConfig[key] && filterConfig[key].trim && filterConfig[key].trim()) { + params.set(key, filterConfig[key]); + } else if (filterConfig[key] && !filterConfig[key].trim) { + // Handle non-string values (but still skip page=1) + if (key !== 'page' || filterConfig[key] !== 1) { + params.set(key, filterConfig[key]); + } + } + }); + + // Update URL without reloading page + const newURL = params.toString() ? `${window.location.pathname}?${params.toString()}` : window.location.pathname; + window.history.pushState({ filterConfig }, '', newURL); +} + +/** + * Returns true if the user has applied any filters or entered a fulltext query. + * @param {Object} filterConfig - Current filter configuration + * @returns {boolean} + */ +function hasActiveFilters(filterConfig) { + return Object.keys(filterConfig).some((key) => { + if (key === 'page' || key === 'sort') return false; + if (key === 'fulltext') return filterConfig[key] && filterConfig[key].trim().length > 0; + return !!filterConfig[key]; + }); +} + +/** + * Builds complete recipe listing with filtering, sorting, and search functionality. + * @param {HTMLElement} container - Container element to transform into a recipe listing + * @param {Object} config - Initial filter configuration + * @param {Object} copy - Widget copy (i18n labels) + * @returns {void} + */ +function buildRecipeFiltering(container, config = {}, copy = {}) { + const ITEMS_PER_PAGE = 12; + let currentPage = 1; + + const facetLabels = { + difficulty: copy.difficulty || 'Difficulty', + 'compatible-containers': copy.compatibleContainers || 'Compatible Containers', + 'dietary-interests': copy.dietaryInterests || 'Dietary Interests', + course: copy.course || 'Course', + 'recipe-type': copy.recipeType || 'Recipe Type', + }; + + // Reference existing DOM elements from static HTML + const resultsElement = container.querySelector('.results'); + const facetsElement = container.querySelector('.facets'); + const paginationElement = container.querySelector('.pagination'); + const resultsCountElement = container.querySelector('#results-count'); + const resultsStartElement = container.querySelector('#results-start'); + const resultsEndElement = container.querySelector('#results-end'); + + // Set up facet panel event listeners + const applyButton = facetsElement.querySelector('.apply'); + if (applyButton) { + applyButton.addEventListener('click', () => { + facetsElement.classList.remove('visible'); + }); + } + + facetsElement.addEventListener('click', (event) => { + if (event.currentTarget === event.target) { + facetsElement.classList.remove('visible'); + } + }); + + // Get dropdown elements + const dietarySelect = container.querySelector('#dietary-interests'); + const courseSelect = container.querySelector('#course'); + const recipeTypeSelect = container.querySelector('#recipe-type'); + const goButton = container.querySelector('.go'); + const fulltextElement = container.querySelector('#fulltext'); + const form = container.querySelector('form.controls'); + + // Sort dropdown uses native
        element + const sortDetails = container.querySelector('.sort'); + const sortMenu = container.querySelector('.sort menu'); + const sortLabel = container.querySelector('#sortby'); + + const selectSort = (btn) => { + sortMenu.querySelectorAll('button').forEach((b) => b.removeAttribute('aria-pressed')); + btn.setAttribute('aria-pressed', true); + sortLabel.textContent = btn.textContent; + sortLabel.dataset.sort = btn.dataset.sort; + sortDetails.open = false; + // eslint-disable-next-line no-use-before-define + const filterConfig = createFilterConfig(); + filterConfig.sort = btn.dataset.sort; + // eslint-disable-next-line no-use-before-define + runSearch(filterConfig); + }; + + sortMenu.addEventListener('click', (event) => { + const btn = event.target.closest('button[data-sort]'); + if (btn) selectSort(btn); + }); + + // highlights search terms in recipe titles by wrapping matches in a span. + const highlightResults = (res) => { + const fulltext = fulltextElement.value; + if (fulltext) { + res.querySelectorAll('h3').forEach((title) => { + const content = title.textContent; + const offset = content.toLowerCase().indexOf(fulltext.toLowerCase()); + if (offset >= 0) { + title.innerHTML = `${content.substring(0, offset)}${content.substring(offset, offset + fulltext.length)}${content.substring(offset + fulltext.length)}`; + } + }); + } + }; + + // renders recipe cards to the results area and highlights search terms + const displayResults = async (results, page = 1) => { + resultsElement.innerHTML = ''; + + // Calculate pagination + const startIndex = (page - 1) * ITEMS_PER_PAGE; + const endIndex = startIndex + ITEMS_PER_PAGE; + const paginatedResults = results.slice(startIndex, endIndex); + + paginatedResults.forEach((recipe) => { + resultsElement.append(createRecipeCard(recipe)); + }); + highlightResults(resultsElement); + + // Scroll to top of results + if (page > 1) { + container.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + }; + + // renders pagination controls + const displayPagination = (totalResults, page = 1) => { + if (!paginationElement) return; + + const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE); + paginationElement.innerHTML = ''; + + if (totalPages <= 1) return; + + const prevBtn = document.createElement('button'); + prevBtn.textContent = copy.previous || 'Previous'; + prevBtn.disabled = page <= 1; + if (page > 1) prevBtn.dataset.page = page - 1; + paginationElement.appendChild(prevBtn); + + const pages = document.createElement('span'); + pages.className = 'pages'; + + const ellipsis = () => { + const span = document.createElement('span'); + span.textContent = '…'; + span.setAttribute('aria-hidden', 'true'); + return span; + }; + + // First page + if (page > 3) { + const btn = document.createElement('button'); + btn.textContent = '1'; + btn.dataset.page = '1'; + pages.appendChild(btn); + if (page > 4) pages.appendChild(ellipsis()); + } + + // Pages around current + for (let i = Math.max(1, page - 2); i <= Math.min(totalPages, page + 2); i += 1) { + const btn = document.createElement('button'); + btn.textContent = i; + btn.dataset.page = i; + if (i === page) btn.setAttribute('aria-current', 'page'); + pages.appendChild(btn); + } + + // Last page + if (page < totalPages - 2) { + if (page < totalPages - 3) pages.appendChild(ellipsis()); + const btn = document.createElement('button'); + btn.textContent = totalPages; + btn.dataset.page = totalPages; + pages.appendChild(btn); + } + + paginationElement.appendChild(pages); + + const nextBtn = document.createElement('button'); + nextBtn.textContent = copy.next || 'Next'; + nextBtn.disabled = page >= totalPages; + if (page < totalPages) nextBtn.dataset.page = page + 1; + paginationElement.appendChild(nextBtn); + + // Add event listeners + paginationElement.querySelectorAll('button[data-page]').forEach((btn) => { + btn.addEventListener('click', () => { + const newPage = parseInt(btn.dataset.page, 10); + currentPage = newPage; + // eslint-disable-next-line no-use-before-define + const filterConfig = createFilterConfig(false); + filterConfig.page = newPage; + // eslint-disable-next-line no-use-before-define + runSearch(filterConfig); + }); + }); + }; + + // gets all currently selected filter checkboxes + const getSelectedFilters = () => [...container.querySelectorAll('input[type="checkbox"]:checked')]; + + // creates a filter configuration object from selected filters and search input + const createFilterConfig = (resetPage = false) => { + const filterConfig = { ...config }; + + // Add dropdown selections + if (dietarySelect.value) { + filterConfig['dietary-interests'] = dietarySelect.value; + } + if (courseSelect.value) { + filterConfig.course = courseSelect.value; + } + if (recipeTypeSelect.value) { + filterConfig['recipe-type'] = recipeTypeSelect.value; + } + + // Add sidebar checkbox filters + getSelectedFilters().forEach((checked) => { + const facetKey = checked.name; + const facetValue = checked.value; + if (filterConfig[facetKey]) filterConfig[facetKey] += `, ${facetValue}`; + else filterConfig[facetKey] = facetValue; + }); + + filterConfig.fulltext = fulltextElement.value; + + // Reset to page 1 when filters change, unless explicitly maintaining page + if (resetPage) { + currentPage = 1; + filterConfig.page = 1; + } else { + filterConfig.page = currentPage; + } + + return filterConfig; + }; + + // renders the filter facets UI with checkboxes, selected filter tags, and counts + const displayFacets = (facets, filters) => { + const selected = getSelectedFilters().map((check) => check.value); + + // Clear dynamic content areas + const selectedFilters = container.querySelector('.selected'); + const facetsList = container.querySelector('.list'); + + selectedFilters.innerHTML = ''; + facetsList.innerHTML = ''; + + selected.forEach((tag) => { + const span = document.createElement('span'); + span.className = 'tag'; + span.textContent = tag; + span.addEventListener('click', () => { + const checkbox = container.querySelector(`[id="filter-${tag}"]`); + if (checkbox) checkbox.checked = false; + const filterConfig = createFilterConfig(true); + // eslint-disable-next-line no-use-before-define + runSearch(filterConfig); + }); + selectedFilters.append(span); + }); + + if (selected.length > 0) { + const clearButton = document.createElement('button'); + clearButton.className = 'clear'; + clearButton.textContent = copy.clearAll || 'Clear All'; + clearButton.addEventListener('click', () => { + selected.forEach((tag) => { + const checkbox = container.querySelector(`[id="filter-${tag}"]`); + if (checkbox) checkbox.checked = false; + }); + const filterConfig = createFilterConfig(true); + // eslint-disable-next-line no-use-before-define + runSearch(filterConfig); + }); + selectedFilters.appendChild(clearButton); + } + + // build facet filter lists with accordion (excluding the top dropdown facets) + const excludedFacets = ['dietary-interests', 'course', 'recipe-type']; + const facetKeys = Object.keys(facets).filter((key) => !excludedFacets.includes(key)); + facetKeys.forEach((facetKey, index) => { + const filter = filters[facetKey]; + const filterValues = filter ? filter.split(',').map((t) => t.trim()) : []; + const filterValuesLower = filterValues.map((v) => v.toLowerCase()); + + const details = document.createElement('details'); + details.className = 'facet'; + // First facet is expanded by default + if (index === 0 || filterValues.length > 0) { + details.open = true; + } + + const summary = document.createElement('summary'); + summary.textContent = facetLabels[facetKey] || facetKey; + details.append(summary); + + const facetValues = Object.keys(facets[facetKey]).sort((a, b) => a.localeCompare(b)); + facetValues.forEach((facetValue) => { + const input = document.createElement('input'); + input.type = 'checkbox'; + input.value = facetValue; + input.checked = filterValuesLower.includes(facetValue.toLowerCase()); + input.id = `filter-${facetValue}`; + input.name = facetKey; + const label = document.createElement('label'); + label.setAttribute('for', input.id); + label.textContent = `${facetValue} (${facets[facetKey][facetValue]})`; + details.append(input, label); + input.addEventListener('change', () => { + const filterConfig = createFilterConfig(true); + // eslint-disable-next-line no-use-before-define + runSearch(filterConfig); + }); + }); + facetsList.append(details); + }); + }; + + // converts a time string to minutes for sorting + const getTimeInMinutes = (timeString) => { + if (!timeString) return 0; + const parts = timeString.split(':'); + if (parts.length !== 3) return 0; + const hours = parseInt(parts[0], 10); + const minutes = parseInt(parts[1], 10); + return hours * 60 + minutes; + }; + + // Populate dropdown options with counts + const populateDropdown = (select, facetData, facetKey, filterConfig) => { + const currentValue = select.value; + const filterValues = filterConfig[facetKey] ? filterConfig[facetKey].split(',').map((t) => t.trim()) : []; + const filterValuesLower = filterValues.map((v) => v.toLowerCase()); + + // Keep the first option (empty/default) + const firstOption = select.options[0]; + select.innerHTML = ''; + select.appendChild(firstOption); + + // Add options sorted alphabetically + const sortedValues = Object.keys(facetData).sort((a, b) => a.localeCompare(b)); + sortedValues.forEach((value) => { + const option = document.createElement('option'); + option.value = value; + option.textContent = `${value} (${facetData[value]})`; + if (filterValuesLower.includes(value.toLowerCase())) { + option.selected = true; + } + select.appendChild(option); + }); + + // Restore previous selection if it still exists + if (currentValue && sortedValues.includes(currentValue)) { + select.value = currentValue; + } + }; + + // main search function that filters, sorts, and displays recipes + const runSearch = async (filterConfig = config, updateURLState = true) => { + const facets = { + difficulty: {}, + 'compatible-containers': {}, + 'dietary-interests': {}, + course: {}, + 'recipe-type': {}, + }; + + const sorts = { + 'name-asc': (a, b) => a.title.localeCompare(b.title), + 'name-desc': (a, b) => b.title.localeCompare(a.title), + 'time-asc': (a, b) => getTimeInMinutes(a['total-time']) - getTimeInMinutes(b['total-time']), + 'time-desc': (a, b) => getTimeInMinutes(b['total-time']) - getTimeInMinutes(a['total-time']), + featured: (a, b) => { + // Check if recipes have default images + const aImagePath = new URL(a.image).pathname || ''; + const bImagePath = new URL(b.image).pathname || ''; + const aIsDefault = aImagePath.includes('default-meta-image'); + const bIsDefault = bImagePath.includes('default-meta-image'); + + // If both have or don't have default images, sort alphabetically + if (aIsDefault === bIsDefault) { + return a.title.localeCompare(b.title); + } + + // Push recipes with default images to the end + return aIsDefault ? 1 : -1; + }, + }; + + let results = await lookupRecipes(filterConfig, facets); + + // Collapse recipes with the same title + results = collapseRecipesByTitle(results); + + // Check for sort in filterConfig first, then fall back to UI element + let sortBy = filterConfig.sort || 'featured'; + if (!filterConfig.sort && sortLabel) { + sortBy = sortLabel.dataset.sort; + } + results.sort(sorts[sortBy]); + + // Get page number from filterConfig or default to 1 + const page = parseInt(filterConfig.page, 10) || 1; + currentPage = page; + + // Update results count and pagination info + const totalResults = results.length; + const startNum = totalResults > 0 ? ((page - 1) * ITEMS_PER_PAGE) + 1 : 0; + const endNum = Math.min(page * ITEMS_PER_PAGE, totalResults); + + if (resultsCountElement) resultsCountElement.textContent = totalResults; + if (resultsStartElement) resultsStartElement.textContent = startNum; + if (resultsEndElement) resultsEndElement.textContent = endNum; + + // Populate top dropdowns + populateDropdown(dietarySelect, facets['dietary-interests'], 'dietary-interests', filterConfig); + populateDropdown(courseSelect, facets.course, 'course', filterConfig); + populateDropdown(recipeTypeSelect, facets['recipe-type'], 'recipe-type', filterConfig); + + const showResults = hasActiveFilters(filterConfig); + if (showResults) { + container.classList.remove('suppress-results'); + displayResults(results, page); + displayPagination(totalResults, page); + displayFacets(facets, filterConfig); + } else { + container.classList.add('suppress-results'); + resultsElement.innerHTML = ''; + if (paginationElement) paginationElement.innerHTML = ''; + displayFacets(facets, filterConfig); + } + + // Update URL with current filter state + if (updateURLState) { + updateURL(filterConfig); + } + }; + + fulltextElement.addEventListener('input', () => { + runSearch(createFilterConfig(true)); // Reset to page 1 on search + }); + + // Add event listeners for dropdowns + goButton.addEventListener('click', () => { + runSearch(createFilterConfig(true)); // Reset to page 1 on filter change + }); + + dietarySelect.addEventListener('change', () => { + runSearch(createFilterConfig(true)); // Reset to page 1 on filter change + }); + + courseSelect.addEventListener('change', () => { + runSearch(createFilterConfig(true)); // Reset to page 1 on filter change + }); + + recipeTypeSelect.addEventListener('change', () => { + runSearch(createFilterConfig(true)); // Reset to page 1 on filter change + }); + + // Search button click (form submit) + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + runSearch(createFilterConfig(true)); + }); + } + + // Also trigger search on Enter key + fulltextElement.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + runSearch(createFilterConfig(true)); + } + }); + + // Read initial config from URL params + const urlConfig = getConfigFromURL(); + const initialConfig = { ...config, ...urlConfig }; + + // Set initial page from URL + if (urlConfig.page) { + currentPage = parseInt(urlConfig.page, 10); + } + + // Apply URL params to UI elements + if (urlConfig.fulltext) { + fulltextElement.value = urlConfig.fulltext; + } + + if (urlConfig.sort) { + if (sortLabel) { + sortLabel.dataset.sort = urlConfig.sort; + } + } + + runSearch(initialConfig); + + // Handle browser back/forward buttons (after runSearch is defined) + window.addEventListener('popstate', (event) => { + if (event.state && event.state.filterConfig) { + // Restore filter state from history + const savedConfig = event.state.filterConfig; + + // Update dropdowns + if (savedConfig['dietary-interests']) { + dietarySelect.value = savedConfig['dietary-interests']; + } else { + dietarySelect.selectedIndex = 0; + } + + if (savedConfig.course) { + courseSelect.value = savedConfig.course; + } else { + courseSelect.selectedIndex = 0; + } + + if (savedConfig['recipe-type']) { + recipeTypeSelect.value = savedConfig['recipe-type']; + } else { + recipeTypeSelect.selectedIndex = 0; + } + + // Update search input + if (savedConfig.fulltext) { + fulltextElement.value = savedConfig.fulltext; + } else { + fulltextElement.value = ''; + } + + // Update sort + if (savedConfig.sort) { + if (sortLabel) { + sortLabel.dataset.sort = savedConfig.sort; + const sortBtn = sortMenu.querySelector(`button[data-sort="${savedConfig.sort}"]`); + if (sortBtn) { + sortLabel.textContent = sortBtn.textContent; + } + } + } + + // Restore page number + if (savedConfig.page) { + currentPage = parseInt(savedConfig.page, 10); + } else { + currentPage = 1; + } + + // Run search without updating URL (to avoid duplicate history entries) + runSearch(savedConfig, false); + } + }); +} + +// Initialize the recipe center +async function init() { + const recipeCenter = document.querySelector('.recipe-center'); + if (recipeCenter) { + const { language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadWidgetCopy(lang); + + // Move existing H1 into recipe-center if it exists + const existingH1 = document.querySelector('main h1'); + if (existingH1 && !recipeCenter.contains(existingH1)) { + existingH1.classList.add('title'); + recipeCenter.insertBefore(existingH1, recipeCenter.firstChild); + } + + // Apply copy to HTML + const searchInput = recipeCenter.querySelector('#fulltext'); + if (searchInput) { + searchInput.placeholder = copy.searchRecipes || 'Type to search recipes'; + } + + const goButton = recipeCenter.querySelector('.go'); + if (goButton) { + goButton.textContent = copy.go || 'Go'; + } + + const itemsLabel = recipeCenter.querySelector('.info > p'); + if (itemsLabel) { + // Keep the structure but update the text nodes + itemsLabel.childNodes[0].textContent = `${copy.items || 'Items'} `; + itemsLabel.childNodes[2].textContent = ' - '; + itemsLabel.childNodes[4].textContent = ` ${copy.of || 'of'} `; + } + + const sortLabel = recipeCenter.querySelector('#sortby'); + if (sortLabel) { + sortLabel.textContent = copy.featured || 'Featured'; + } + + const sortSummary = recipeCenter.querySelector('.sort summary'); + if (sortSummary) { + sortSummary.childNodes[0].textContent = `${copy.sortBy || 'Sort By'}: `; + } + + // Populate sort options + const sortButtons = recipeCenter.querySelectorAll('.sort menu button'); + sortButtons.forEach((btn) => { + const sortType = btn.dataset.sort; + if (sortType === 'featured') { + btn.textContent = copy.featured || 'Featured'; + } else if (sortType === 'name-asc') { + btn.textContent = copy.nameAZ || 'Name (A-Z)'; + } else if (sortType === 'name-desc') { + btn.textContent = copy.nameZA || 'Name (Z-A)'; + } else if (sortType === 'time-asc') { + btn.textContent = copy.timeLowToHigh || 'Time (Low to High)'; + } else if (sortType === 'time-desc') { + btn.textContent = copy.timeHighToLow || 'Time (High to Low)'; + } + }); + + const refineHeading = recipeCenter.querySelector('.facets h2'); + if (refineHeading) { + refineHeading.textContent = copy.refineYourRecipe || 'Refine Your Recipe'; + } + + const applyButton = recipeCenter.querySelector('.facets .apply'); + if (applyButton) { + applyButton.textContent = copy.seeResults || 'See Results'; + } + + const legend = recipeCenter.querySelector('legend'); + if (legend) { + legend.textContent = copy.refineYourSearch || 'Refine Your Search'; + } + + // Update select dropdown labels + const dietarySelect = recipeCenter.querySelector('#dietary-interests option[value=""]'); + if (dietarySelect) { + dietarySelect.textContent = copy.dietary || 'Dietary'; + } + + const courseOption = recipeCenter.querySelector('#course option[value=""]'); + if (courseOption) { + courseOption.textContent = copy.course || 'Course'; + } + + const recipeTypeOption = recipeCenter.querySelector('#recipe-type option[value=""]'); + if (recipeTypeOption) { + recipeTypeOption.textContent = copy.recipeType || 'Recipe Type'; + } + + buildRecipeFiltering(recipeCenter, {}, copy); + } +} + +// Wait for DOM to be ready +const start = () => { + loadCSS('/widgets/recipe-center/recipe-center.css'); + init(); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); +} else { + start(); +} + +export default { lookupRecipes }; diff --git a/widgets/recipe-center/recipe-center.json b/widgets/recipe-center/recipe-center.json new file mode 100644 index 00000000..fa87935c --- /dev/null +++ b/widgets/recipe-center/recipe-center.json @@ -0,0 +1,74 @@ +{ + "en": { + "searchRecipes": "Type to search recipes", + "go": "Go", + "items": "Items", + "of": "of", + "featured": "Featured", + "sortBy": "Sort By", + "nameAZ": "Name (A-Z)", + "nameZA": "Name (Z-A)", + "timeLowToHigh": "Time (Low to High)", + "timeHighToLow": "Time (High to Low)", + "refineYourRecipe": "Refine Your Recipe", + "seeResults": "See Results", + "refineYourSearch": "Refine Your Search", + "dietary": "Dietary", + "course": "Course", + "recipeType": "Recipe Type", + "difficulty": "Difficulty", + "compatibleContainers": "Compatible Containers", + "dietaryInterests": "Dietary Interests", + "previous": "Previous", + "next": "Next", + "clearAll": "Clear All" + }, + "fr": { + "searchRecipes": "Rechercher des recettes", + "go": "Aller", + "items": "Éléments", + "of": "sur", + "featured": "En vedette", + "sortBy": "Trier par", + "nameAZ": "Nom (A-Z)", + "nameZA": "Nom (Z-A)", + "timeLowToHigh": "Temps (croissant)", + "timeHighToLow": "Temps (décroissant)", + "refineYourRecipe": "Affiner votre recette", + "seeResults": "Voir les résultats", + "refineYourSearch": "Affiner votre recherche", + "dietary": "Régime", + "course": "Type de plat", + "recipeType": "Type de recette", + "difficulty": "Difficulté", + "compatibleContainers": "Contenants compatibles", + "dietaryInterests": "Régimes alimentaires", + "previous": "Précédent", + "next": "Suivant", + "clearAll": "Tout effacer" + }, + "es": { + "searchRecipes": "Buscar recetas", + "go": "Ir", + "items": "Elementos", + "of": "de", + "featured": "Destacados", + "sortBy": "Ordenar por", + "nameAZ": "Nombre (A-Z)", + "nameZA": "Nombre (Z-A)", + "timeLowToHigh": "Tiempo (menor a mayor)", + "timeHighToLow": "Tiempo (mayor a menor)", + "refineYourRecipe": "Refinar su receta", + "seeResults": "Ver resultados", + "refineYourSearch": "Refinar su búsqueda", + "dietary": "Dieta", + "course": "Tipo de plato", + "recipeType": "Tipo de receta", + "difficulty": "Dificultad", + "compatibleContainers": "Contenedores compatibles", + "dietaryInterests": "Preferencias dietéticas", + "previous": "Anterior", + "next": "Siguiente", + "clearAll": "Borrar todo" + } +} diff --git a/widgets/search-results/search-results.css b/widgets/search-results/search-results.css new file mode 100644 index 00000000..5b3d23ae --- /dev/null +++ b/widgets/search-results/search-results.css @@ -0,0 +1,275 @@ +/* Search Results Widget – combines articles, recipes, and query index */ + +.search-results { + max-width: 1200px; + margin: 0 auto; + padding: var(--spacing-400) var(--spacing-200); +} + +.search-results .title { + font-family: var(--heading-font-family); + font-size: var(--font-size-700); + font-weight: var(--weight-regular); + text-align: center; + margin: 0 0 var(--spacing-400); + color: var(--color-charcoal); +} + +.search-results .controls { + display: flex; + justify-content: center; + margin-bottom: var(--spacing-400); +} + +.search-results .search { + display: flex; + position: relative; + width: 100%; + max-width: 600px; +} + +.search-results .search input { + flex: 1; + padding: 14px 50px 14px 20px; + border: var(--border-s) solid var(--color-gray-400); + border-radius: 0; + font-size: var(--body-size-m); + background-color: var(--color-white); +} + +.search-results .search input:focus { + outline: none; + border-color: var(--color-charcoal); +} + +.search-results .search-btn { + position: absolute; + right: 0; + top: 0; + bottom: 0; + background: var(--color-charcoal); + border: none; + padding: 0 var(--spacing-100); + cursor: pointer; + color: var(--color-white); + display: flex; + align-items: center; + justify-content: center; +} + +.search-results .search-btn:hover { + background-color: var(--color-gray-800); +} + +.search-results .type-filters { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--spacing-80); + margin-bottom: var(--spacing-400); +} + +.search-results .type-filter { + padding: 8px 16px; + font-size: var(--body-size-s); + color: var(--color-gray-700); + background-color: var(--color-gray-100); + border: 1px solid var(--color-gray-300); + border-radius: var(--rounding-s, 4px); + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; +} + +.search-results .type-filter:hover { + background-color: var(--color-gray-200); + color: var(--color-charcoal); +} + +.search-results .type-filter:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +.search-results .type-filter.active, +.search-results .type-filter[aria-pressed="true"] { + background-color: var(--color-charcoal); + color: var(--color-white); + border-color: var(--color-charcoal); +} + +.search-results .type-filter.active:hover, +.search-results .type-filter[aria-pressed="true"]:hover { + background-color: var(--color-gray-800); + color: var(--color-white); +} + +.search-results .info { + display: flex; + flex-direction: column; + gap: var(--spacing-100); + align-items: flex-start; + padding: var(--spacing-100) 0; + margin-bottom: var(--spacing-200); + font-size: var(--body-size-s); + color: var(--color-gray-900); +} + +.search-results .info p { + margin: 0; +} + +.search-results .pagination { + display: flex; + justify-content: center; + align-items: center; + gap: var(--spacing-60); + padding: var(--spacing-600) 0; +} + +.search-results .pagination button { + padding: var(--spacing-60) var(--spacing-100); + background-color: var(--color-white); + color: var(--color-charcoal); + border: var(--border-s) solid var(--color-gray-400); + border-radius: var(--rounding-m); + cursor: pointer; + font-size: var(--body-size-m); + transition: all 0.2s ease; + min-width: 40px; +} + +.search-results .pagination button:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.search-results .pagination button:hover:not(:disabled) { + background-color: var(--color-gray-100); + border-color: var(--color-charcoal); +} + +.search-results .pagination button[aria-current="page"] { + background-color: var(--color-charcoal); + color: var(--color-white); + border-color: var(--color-charcoal); +} + +.search-results .pagination .pages { + display: flex; + gap: var(--spacing-40); + align-items: center; +} + +.search-results .results { + display: flex; + flex-direction: column; + gap: var(--spacing-400); + margin: 0; + padding: 0; + list-style: none; +} + +.search-results .card { + border-bottom: var(--border-s) solid var(--color-gray-200); + padding-bottom: var(--spacing-400); +} + +.search-results .card:last-child { + border-bottom: none; +} + +.search-results .card a { + text-decoration: none; + color: inherit; + display: flex; + flex-direction: column; + gap: var(--spacing-300); + align-items: flex-start; +} + +.search-results .card img { + width: 100%; + aspect-ratio: 1; + height: auto; + object-fit: cover; + flex-shrink: 0; + background-color: var(--color-gray-200); +} + +.search-results .card .placeholder { + width: 100%; + aspect-ratio: 1; + height: auto; + flex-shrink: 0; + background-color: var(--color-gray-200); + display: flex; + align-items: center; + justify-content: center; +} + +.search-results .card .content { + flex: 1; + min-width: 0; +} + +.search-results .card .type-badge { + display: inline-block; + font-size: var(--body-size-xs); + text-transform: uppercase; + letter-spacing: var(--letter-spacing-s); + color: var(--color-gray-700); + margin-bottom: var(--spacing-60); +} + +.search-results .card h3 { + font-family: var(--heading-font-family); + font-size: var(--font-size-400); + font-weight: var(--weight-medium); + margin: 0 0 var(--spacing-60); + color: var(--color-blue-700, #1a5dab); + line-height: var(--line-height-m); +} + +.search-results .card a:hover h3 { + text-decoration: underline; +} + +.search-results .card .description { + margin: 0; + font-size: var(--body-size-m); + color: var(--color-charcoal); + line-height: var(--line-height-l); +} + +.search-results .results .highlight { + background-color: var(--color-yellow, #fff3cd); + padding: 0 2px; +} + +@media (width >= 600px) { + .search-results { + padding: var(--spacing-600) var(--spacing-400); + } + + .search-results .title { + font-size: var(--font-size-900); + } + + .search-results .card a { + flex-direction: row; + } + + .search-results .card img, + .search-results .card .placeholder { + width: 140px; + height: 140px; + aspect-ratio: auto; + } + + .search-results .info { + flex-direction: row; + justify-content: space-between; + align-items: center; + } +} diff --git a/widgets/search-results/search-results.html b/widgets/search-results/search-results.html new file mode 100644 index 00000000..ca41923d --- /dev/null +++ b/widgets/search-results/search-results.html @@ -0,0 +1,19 @@ + +
        +
        + +
        +
        +
        +

        1-12 0

        +
        +
          + +
          diff --git a/widgets/search-results/search-results.js b/widgets/search-results/search-results.js new file mode 100644 index 00000000..a44bd9e5 --- /dev/null +++ b/widgets/search-results/search-results.js @@ -0,0 +1,701 @@ +import { loadCSS } from '../../scripts/aem.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** + * Load widget copy from the widget's local JSON (same name as the script). + * @param {string} lang - Language key (e.g. en, fr) + * @returns {Promise} Copy for that language (flat key-value) + */ +async function loadWidgetCopy(lang) { + const scriptPath = new URL(import.meta.url).pathname; + const jsonPath = scriptPath.replace(/\.js$/, '.json'); + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key] || {}; +} + +/** + * Normalize a single article from the articles query index. + * @param {Object} row - Raw row from index + * @returns {Object} Normalized item with type, path, title, description, image + */ +function normalizeArticle(row) { + return { + type: 'article', + path: row.path || '', + title: (row.title || '').trim(), + description: (row.description || '').trim(), + image: row.image || '', + 'publication-date': row['publication-date'] || '', + author: row.author || '', + tags: typeof row.tags === 'string' ? row.tags.split(',').map((t) => t.trim()).filter(Boolean) : (row.tags || []), + }; +} + +/** + * Normalize a single recipe from the recipes query index. + * @param {Object} row - Raw row from index + * @param {string} locale - Locale (e.g. us) + * @param {string} language - Language (e.g. en_us) + * @returns {Object} Normalized item with type, path, title, description, image + */ +function normalizeRecipe(row, locale, language) { + const path = row.path || (row.title ? `/${locale}/${language}/recipes/${encodeURIComponent(String(row.title).trim().toLowerCase().replace(/\s+/g, '-'))}` : ''); + return { + type: 'recipe', + path, + title: (row.title || '').trim(), + description: (row.description || '').trim(), + image: row.image || '', + }; +} + +/** + * Normalize a single item from the locale query index. + * @param {Object} row - Raw row from index + * @returns {Object} Normalized item with type, path, title, description, image + */ +function normalizeQueryItem(row) { + return { + type: 'query', + path: row.path || row.url || '', + title: (row.title || '').trim(), + description: (row.description || '').trim(), + image: row.image || '', + }; +} + +/** + * Whether to use fcors proxy for product index (localhost, .aem.page, .aem.live). + * @returns {boolean} + */ +function useFcors() { + const { hostname } = window.location; + return hostname === 'localhost' || hostname.includes('.aem.page') || hostname.includes('.aem.live'); +} + +/** + * Fetch URL via fcors proxy for non-prod origins. + * @param {string} url - Path (e.g. /us/en_us/products/index.json) + * @returns {Promise} + */ +function corsProxyFetch(url) { + const corsProxy = 'https://fcors.org/?url='; + const corsKey = '&key=Mg23N96GgR8O3NjU'; + const fullUrl = `https://main--vitamix--aemsites.aem.network${url}`; + return fetch(`${corsProxy}${encodeURIComponent(fullUrl)}${corsKey}`); +} + +/** + * Normalize a parent product from the products index for search. + * @param {Object} row - Raw product row (parent only: no parentSku) + * @param {string} locale - Locale (e.g. us) + * @param {string} language - Language (e.g. en_us) + * @returns {Object} Normalized item with type, path, title, description, image + */ +function normalizeProduct(row, locale, language) { + const urlKey = (row.urlKey || '').trim(); + const path = urlKey ? `/${locale}/${language}/products/${urlKey}` : ''; + const title = (row.title || row.name || '').trim(); + const description = (row.description || row.shortDescription || '').trim(); + let image = row.image || ''; + if (image && image.startsWith('./')) { + image = `/${locale}/${language}/products/${image.substring(2)}`; + } + return { + type: 'product', + path, + title, + description, + image, + }; +} + +/** + * Fetch and merge all search indices (articles, recipes, locale query-index, products). + * Uses fcors for products on localhost, .aem.page, .aem.live. + * @returns {Promise>} Combined normalized items + */ +async function loadSearchIndex() { + if (window.searchResultsIndex) { + return window.searchResultsIndex; + } + + const { locale, language } = getLocaleAndLanguage(); + + const articlesUrl = `/${locale}/${language}/articles/query-index.json`; + const recipesUrl = `/${locale}/${language}/recipes/query-index.json`; + const queryUrl = `/${locale}/${language}/query-index.json`; + const productsUrl = `/${locale}/${language}/products/index.json?include=all`; + + const fetchJson = (url) => fetch(url).then((r) => (r.ok ? r.json() : { data: [] })); + const productsFetch = useFcors() + ? () => corsProxyFetch(productsUrl).then((r) => (r.ok ? r.json() : { data: [] })) + : () => fetchJson(productsUrl); + + const [articlesRes, recipesRes, queryRes, productsRes] = await Promise.allSettled([ + fetchJson(articlesUrl), + fetchJson(recipesUrl), + fetchJson(queryUrl), + productsFetch(), + ]); + + const combined = []; + + if (articlesRes.status === 'fulfilled' && Array.isArray(articlesRes.value?.data)) { + articlesRes.value.data.forEach((row) => combined.push(normalizeArticle(row))); + } + + if (recipesRes.status === 'fulfilled' && Array.isArray(recipesRes.value?.data)) { + const recipes = recipesRes.value.data.filter((r) => { + const status = (r.status || '').toLowerCase(); + return status === 'updated' || status === 'new'; + }); + const seenRecipeTitles = new Set(); + recipes.forEach((row) => { + const key = (row.title || '').trim().toLowerCase(); + if (key && seenRecipeTitles.has(key)) return; + if (key) seenRecipeTitles.add(key); + combined.push(normalizeRecipe(row, locale, language)); + }); + } + + if (queryRes.status === 'fulfilled' && Array.isArray(queryRes.value?.data)) { + queryRes.value.data.forEach((row) => combined.push(normalizeQueryItem(row))); + } + + if (productsRes.status === 'fulfilled' && Array.isArray(productsRes.value?.data)) { + const parents = productsRes.value.data.filter((row) => !(row.parentSku || '').trim()); + parents.forEach((row) => combined.push(normalizeProduct(row, locale, language))); + } + + window.searchResultsIndex = combined; + return combined; +} + +/** + * Remove diacritical marks for accent-insensitive matching (e.g. "mélangeur" → "melangeur"). + * @param {string} str + * @returns {string} + */ +function removeAccents(str) { + if (!str) return ''; + return str.normalize('NFD').replace(/\p{Diacritic}/gu, ''); +} + +/** + * Normalize string for search: lowercase and remove accents. + * @param {string} str + * @returns {string} + */ +function normalizeForSearch(str) { + return removeAccents((str || '').toLowerCase()); +} + +/** + * Filter combined index by search term (accent-insensitive). + * @param {Array} index - Normalized items + * @param {string} searchTerm - Search string + * @returns {Array} Filtered items with match info + */ +function filterBySearch(index, searchTerm) { + if (!searchTerm || !searchTerm.trim()) { + return index.map((item) => ({ ...item, searchTerm: '' })); + } + + const term = searchTerm.toLowerCase().trim(); + const termNorm = normalizeForSearch(term); + return index.filter((item) => { + const titleMatch = normalizeForSearch(item.title || '').includes(termNorm); + const descMatch = normalizeForSearch(item.description || '').includes(termNorm); + const authorMatch = normalizeForSearch(item.author || '').includes(termNorm); + const tags = item.tags || []; + const tagsMatch = tags.some((tag) => normalizeForSearch(String(tag)).includes(termNorm)); + return titleMatch || descMatch || authorMatch || tagsMatch; + }).map((item) => ({ ...item, searchTerm: term })); +} + +/** Type order for tie-break: product > recipe > article > query (lower = higher priority). */ +const TYPE_ORDER = { + product: 0, + recipe: 1, + article: 2, + query: 3, +}; + +/** + * Sort by relevance: first occurrence of term in title then description; then by type. + * @param {Array} results - Filtered results (with searchTerm set) + * @param {string} searchTerm - Normalized search term (lowercase) + * @returns {void} Sorts in place + */ +function sortByRelevance(results, searchTerm) { + if (!searchTerm || !searchTerm.trim()) { + results.sort((a, b) => (TYPE_ORDER[a.type] ?? 4) - (TYPE_ORDER[b.type] ?? 4)); + return; + } + + const term = searchTerm.toLowerCase().trim(); + const termNorm = normalizeForSearch(term); + + function getSortKey(item) { + const titleNorm = normalizeForSearch(item.title || ''); + const descNorm = normalizeForSearch(item.description || ''); + const titleIdx = titleNorm.indexOf(termNorm); + const descIdx = descNorm.indexOf(termNorm); + const inTitle = titleIdx !== -1; + const inDesc = descIdx !== -1; + + // Rank 0 = title match (earlier offset = better), 1 = description only, 2 = other (author/tags) + const typeOrder = TYPE_ORDER[item.type] ?? 4; + if (inTitle) return [0, titleIdx, typeOrder]; + if (inDesc) return [1, descIdx, typeOrder]; + return [2, Number.MAX_SAFE_INTEGER, typeOrder]; + } + + results.sort((a, b) => { + const [rankA, offsetA, typeA] = getSortKey(a); + const [rankB, offsetB, typeB] = getSortKey(b); + if (rankA !== rankB) return rankA - rankB; + if (offsetA !== offsetB) return offsetA - offsetB; + return typeA - typeB; + }); +} + +const AEM_NETWORK_ORIGIN = 'https://main--vitamix--aemsites.aem.network'; + +const IMAGE_QUERY_PARAMS = 'width=750&format=webply&optimize=medium'; + +/** + * Append image optimization query params to a URL. + * @param {string} url - Image URL (path or full URL) + * @returns {string} + */ +function addImageParams(url) { + if (!url) return ''; + const sep = url.includes('?') ? '&' : '?'; + return `${url}${sep}${IMAGE_QUERY_PARAMS}`; +} + +/** + * Get relative image path from URL. + * @param {string} imageUrl - Absolute or relative URL + * @returns {string} + */ +function getRelativeImagePath(imageUrl) { + if (!imageUrl) return ''; + try { + const url = new URL(imageUrl, window.location.origin); + return url.pathname + url.search; + } catch { + return imageUrl; + } +} + +/** + * Whether the URL is usable as a result card image (rejects data: and invalid URLs). + * @param {string} url + * @returns {boolean} + */ +function isUsableImageUrl(url) { + if (!url || typeof url !== 'string' || !url.trim()) return false; + const s = url.trim().toLowerCase(); + if (s.startsWith('data:')) return false; + try { + const parsed = new URL(url, window.location.origin); + return Boolean(parsed); + } catch { + return false; + } +} + +/** + * Get image src for a result card. Product images use .aem.network on preview origins. + * Query index: only https:// URLs; others use fallback. Rest: reject data:/invalid. + * @param {Object} item - Normalized search item + * @returns {string} + */ +function getResultImageSrc(item) { + if (!item?.image || !isUsableImageUrl(item.image)) return ''; + if (item.type === 'query' && !item.image.trim().startsWith('https://')) return ''; + if (item.type === 'product' && useFcors()) { + const path = item.image.startsWith('http') + ? new URL(item.image).pathname + : item.image; + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return addImageParams(`${AEM_NETWORK_ORIGIN}${normalizedPath}`); + } + return addImageParams(getRelativeImagePath(item.image)); +} + +/** + * Build a map from normalized (accent-stripped) index to original string index. + * @param {string} original + * @returns {number[]} normalizedIndex → originalIndex + */ +function getNormalizedToOriginalMap(original) { + const map = []; + for (let i = 0; i < original.length; i += 1) { + const norm = removeAccents(original[i]); + for (let j = 0; j < norm.length; j += 1) map.push(i); + } + return map; +} + +/** + * Highlight matching substring in text (accent-insensitive). + * @param {string} text - Full text + * @param {string} searchTerm - Term to highlight + * @returns {string} HTML with highlight span + */ +function highlightMatch(text, searchTerm) { + if (!text || !searchTerm) return text; + const textNorm = normalizeForSearch(text); + const termNorm = normalizeForSearch(searchTerm); + const normStart = textNorm.indexOf(termNorm); + if (normStart === -1) return text; + const normEnd = normStart + termNorm.length; + const map = getNormalizedToOriginalMap(text); + const origStart = map[normStart]; + const origEnd = normEnd > 0 && normEnd <= map.length ? map[normEnd - 1] + 1 : text.length; + const before = text.substring(0, origStart); + const match = text.substring(origStart, origEnd); + const after = text.substring(origEnd); + return `${before}${match}${after}`; +} + +/** + * Create a result card DOM element. + * @param {Object} item - Normalized search item + * @param {Object} copy - Widget copy (i18n labels) + * @returns {HTMLElement} + */ +function createResultCard(item, copy = {}) { + const li = document.createElement('li'); + li.className = 'card'; + + const link = document.createElement('a'); + link.href = item.path || '#'; + + let imageEl; + const imageSrc = getResultImageSrc(item); + if (imageSrc && !imageSrc.includes('default-meta-image')) { + imageEl = document.createElement('img'); + imageEl.src = imageSrc; + imageEl.alt = ''; + imageEl.loading = 'lazy'; + } else { + imageEl = document.createElement('div'); + imageEl.className = 'img placeholder'; + } + + const content = document.createElement('div'); + content.className = 'content'; + + // Type badges: use sheet keys Item, Recipe, Page, Product (e.g. Product => Produit for FR). + const typeLabels = { + article: copy.typeArticle || copy.item || 'Article', + recipe: copy.typeRecipe || copy.recipe || 'Recipe', + query: copy.typeQuery || copy.page || 'Page', + product: copy.typeProduct || copy.product || 'Product', + }; + const typeBadge = document.createElement('span'); + typeBadge.className = 'type-badge'; + typeBadge.textContent = typeLabels[item.type] || item.type; + + const title = document.createElement('h3'); + const titleText = item.title || ''; + title.innerHTML = item.searchTerm ? highlightMatch(titleText, item.searchTerm) : titleText; + + const description = document.createElement('p'); + description.className = 'description'; + const descText = item.description || ''; + description.innerHTML = item.searchTerm ? highlightMatch(descText, item.searchTerm) : descText; + + content.append(typeBadge, title, description); + link.append(imageEl, content); + li.appendChild(link); + return li; +} + +/** + * Read filter config from URL query params. + * @returns {Object} + */ +function getConfigFromURL() { + const params = new URLSearchParams(window.location.search); + const config = {}; + params.forEach((value, key) => { config[key] = value; }); + return config; +} + +/** + * Update URL with current filter state. + * @param {Object} filterConfig + */ +function updateURL(filterConfig) { + const params = new URLSearchParams(); + Object.keys(filterConfig).forEach((key) => { + if (key === 'page' && filterConfig[key] === 1) return; + const val = filterConfig[key]; + if (val && (typeof val !== 'string' || val.trim())) { + if (key !== 'page' || val !== 1) params.set(key, val); + } + }); + const newURL = params.toString() + ? `${window.location.pathname}?${params.toString()}` + : window.location.pathname; + window.history.pushState({ filterConfig }, '', newURL); +} + +/** Type filter options (value '' = all). Uses same copy keys as card badges. */ +const FILTER_TYPES = [ + { + value: '', labelKey: 'filterAll', fallbackKey: null, defaultLabel: 'All', + }, + { + value: 'product', labelKey: 'typeProduct', fallbackKey: 'product', defaultLabel: 'Product', + }, + { + value: 'recipe', labelKey: 'typeRecipe', fallbackKey: 'recipe', defaultLabel: 'Recipe', + }, + { + value: 'article', labelKey: 'typeArticle', fallbackKey: 'item', defaultLabel: 'Article', + }, + { + value: 'query', labelKey: 'typeQuery', fallbackKey: 'page', defaultLabel: 'Page', + }, +]; + +/** + * Build search UI and wire search/pagination. + * @param {HTMLElement} container - .search-results root + * @param {Object} config - Initial config + * @param {Object} copy - Widget copy (i18n labels) + */ +function buildSearchFiltering(container, config = {}, copy = {}) { + const ITEMS_PER_PAGE = 12; + let currentPage = 1; + let currentTypeFilter = ''; + + const resultsElement = container.querySelector('.results'); + const typeFiltersEl = container.querySelector('.type-filters'); + + const createFilterConfig = (resetPage = true) => { + const filterConfig = { ...config }; + filterConfig.search = document.getElementById('fulltext').value; + filterConfig.type = currentTypeFilter || ''; + filterConfig.page = resetPage ? 1 : currentPage; + if (resetPage) currentPage = 1; + return filterConfig; + }; + + const displayResults = (results, page = 1) => { + resultsElement.innerHTML = ''; + const start = (page - 1) * ITEMS_PER_PAGE; + const end = start + ITEMS_PER_PAGE; + const pageResults = results.slice(start, end); + pageResults.forEach((item) => resultsElement.append(createResultCard(item, copy))); + if (page > 1) container.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + const displayPagination = (totalResults, page, onPageChange) => { + const pageNum = parseInt(page, 10) || 1; + const paginationElement = container.querySelector('.pagination'); + if (!paginationElement) return; + const totalPages = Math.ceil(totalResults / ITEMS_PER_PAGE); + paginationElement.innerHTML = ''; + if (totalPages <= 1) return; + + const prevBtn = document.createElement('button'); + prevBtn.textContent = copy.previous || 'Previous'; + prevBtn.disabled = pageNum <= 1; + if (pageNum > 1) prevBtn.dataset.page = pageNum - 1; + paginationElement.appendChild(prevBtn); + + const pages = document.createElement('span'); + pages.className = 'pages'; + const ellipsis = () => { + const span = document.createElement('span'); + span.textContent = '…'; + span.setAttribute('aria-hidden', 'true'); + return span; + }; + if (pageNum > 3) { + const btn = document.createElement('button'); + btn.textContent = '1'; + btn.dataset.page = '1'; + pages.appendChild(btn); + if (pageNum > 4) pages.appendChild(ellipsis()); + } + for (let i = Math.max(1, pageNum - 2); i <= Math.min(totalPages, pageNum + 2); i += 1) { + const btn = document.createElement('button'); + btn.textContent = i; + btn.dataset.page = i; + if (i === pageNum) btn.setAttribute('aria-current', 'page'); + pages.appendChild(btn); + } + if (pageNum < totalPages - 2) { + if (pageNum < totalPages - 3) pages.appendChild(ellipsis()); + const btn = document.createElement('button'); + btn.textContent = totalPages; + btn.dataset.page = totalPages; + pages.appendChild(btn); + } + paginationElement.appendChild(pages); + + const nextBtn = document.createElement('button'); + nextBtn.textContent = copy.next || 'Next'; + nextBtn.disabled = pageNum >= totalPages; + if (pageNum < totalPages) nextBtn.dataset.page = pageNum + 1; + paginationElement.appendChild(nextBtn); + + if (onPageChange) { + paginationElement.querySelectorAll('button[data-page]').forEach((btn) => { + btn.addEventListener('click', () => onPageChange(parseInt(btn.dataset.page, 10))); + }); + } + }; + + const updateTypeFilterButtons = (typeCounts = {}) => { + if (!typeFiltersEl) return; + typeFiltersEl.querySelectorAll('.type-filter').forEach((btn) => { + const typeVal = btn.dataset.type; + btn.disabled = typeVal !== '' && (typeCounts[typeVal] || 0) === 0; + }); + }; + + const runSearch = async (filterConfig = config, updateURLState = true) => { + const index = await loadSearchIndex(); + let results = filterBySearch(index, filterConfig.search || ''); + const typeCounts = {}; + results.forEach((item) => { + typeCounts[item.type] = (typeCounts[item.type] || 0) + 1; + }); + if (filterConfig.type) { + results = results.filter((item) => item.type === filterConfig.type); + } + sortByRelevance(results, filterConfig.search || ''); + updateTypeFilterButtons(typeCounts); + + const page = parseInt(filterConfig.page, 10) || 1; + currentPage = page; + + const totalResults = results.length; + const startNum = totalResults > 0 ? (page - 1) * ITEMS_PER_PAGE + 1 : 0; + const endNum = Math.min(page * ITEMS_PER_PAGE, totalResults); + + container.querySelector('#results-count').textContent = totalResults; + container.querySelector('#results-start').textContent = startNum; + container.querySelector('#results-end').textContent = endNum; + + displayResults(results, page); + displayPagination(totalResults, page, (pageNum) => { + currentPage = pageNum; + runSearch(createFilterConfig(false)); + }); + + if (updateURLState) updateURL(filterConfig); + }; + + const renderTypeFilters = () => { + if (!typeFiltersEl) return; + typeFiltersEl.innerHTML = ''; + FILTER_TYPES.forEach(({ + value, labelKey, fallbackKey, defaultLabel, + }) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'type-filter'; + btn.dataset.type = value; + const label = copy[labelKey] + || (fallbackKey ? copy[fallbackKey] : null) + || defaultLabel; + btn.textContent = label; + btn.setAttribute('aria-pressed', currentTypeFilter === value ? 'true' : 'false'); + if (currentTypeFilter === value) btn.classList.add('active'); + btn.addEventListener('click', () => { + currentTypeFilter = value; + typeFiltersEl.querySelectorAll('.type-filter').forEach((b) => { + b.setAttribute('aria-pressed', b.dataset.type === value ? 'true' : 'false'); + b.classList.toggle('active', b.dataset.type === value); + }); + runSearch(createFilterConfig(true)); + }); + typeFiltersEl.appendChild(btn); + }); + }; + + const searchElement = container.querySelector('#fulltext'); + searchElement.addEventListener('input', () => runSearch(createFilterConfig(true))); + searchElement.addEventListener('keypress', (e) => { + if (e.key === 'Enter') runSearch(createFilterConfig(true)); + }); + + const form = container.querySelector('form.controls'); + if (form) { + form.addEventListener('submit', (e) => { + e.preventDefault(); + runSearch(createFilterConfig(true)); + }); + } + + const urlConfig = getConfigFromURL(); + const initialConfig = { ...config, ...urlConfig }; + if (urlConfig.page) currentPage = parseInt(urlConfig.page, 10); + if (urlConfig.search) searchElement.value = urlConfig.search; + if (urlConfig.type && FILTER_TYPES.some((t) => t.value === urlConfig.type)) { + currentTypeFilter = urlConfig.type; + } + + renderTypeFilters(); + runSearch(initialConfig); + + window.addEventListener('popstate', (event) => { + if (event.state?.filterConfig) { + const saved = event.state.filterConfig; + if (saved.search !== undefined) searchElement.value = saved.search || ''; + if (saved.page) currentPage = parseInt(saved.page, 10); + if (saved.type !== undefined) { + currentTypeFilter = saved.type || ''; + renderTypeFilters(); + } + runSearch(saved, false); + } + }); +} + +async function init() { + const container = document.querySelector('.search-results'); + if (!container) return; + + const { language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadWidgetCopy(lang); + + const existingH1 = document.querySelector('main h1'); + if (existingH1 && !container.contains(existingH1)) { + existingH1.classList.add('title'); + container.insertBefore(existingH1, container.firstChild); + } + + const searchInput = container.querySelector('#fulltext'); + if (searchInput) searchInput.placeholder = copy.search || 'Search'; + const showingLabel = container.querySelector('.showing-label'); + if (showingLabel) showingLabel.textContent = copy.showing || 'Showing'; + const ofLabel = container.querySelector('.of-label'); + if (ofLabel) ofLabel.textContent = copy.of || 'of'; + + buildSearchFiltering(container, {}, copy); +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + loadCSS('/widgets/search-results/search-results.css'); + init(); +} + +export default { loadSearchIndex, filterBySearch }; diff --git a/widgets/search-results/search-results.json b/widgets/search-results/search-results.json new file mode 100644 index 00000000..17bd7222 --- /dev/null +++ b/widgets/search-results/search-results.json @@ -0,0 +1,50 @@ +{ + "en": { + "search": "Search", + "showing": "Showing", + "of": "of", + "previous": "Previous", + "next": "Next", + "filterAll": "All", + "typeProduct": "Product", + "product": "Product", + "typeRecipe": "Recipe", + "recipe": "Recipe", + "typeArticle": "Article", + "item": "Article", + "typeQuery": "Page", + "page": "Page" + }, + "fr": { + "search": "Rechercher", + "showing": "Affichage", + "of": "sur", + "previous": "Précédent", + "next": "Suivant", + "filterAll": "Tout", + "typeProduct": "Produit", + "product": "Produit", + "typeRecipe": "Recette", + "recipe": "Recette", + "typeArticle": "Article", + "item": "Article", + "typeQuery": "Page", + "page": "Page" + }, + "es": { + "search": "Buscar", + "showing": "Mostrando", + "of": "de", + "previous": "Anterior", + "next": "Siguiente", + "filterAll": "Todo", + "typeProduct": "Producto", + "product": "Producto", + "typeRecipe": "Receta", + "recipe": "Receta", + "typeArticle": "Artículo", + "item": "Artículo", + "typeQuery": "Página", + "page": "Página" + } +} From 63f44e3c5a359f8e7682e3eec9ed06795feb277f Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Sat, 7 Mar 2026 12:16:46 -0700 Subject: [PATCH 14/89] chore: more inclusive branch name --- scripts/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index f791933c..c7ba6b11 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -21,7 +21,7 @@ const { hostname } = window.location; export const ORDERS_API_ORIGIN = 'https://vitamix-api.adobeaem.workers.dev'; -window.cartMode = hostname.includes('localhost') || hostname.includes('edge-orders--') ? 'edge' : 'legacy'; +window.cartMode = hostname.includes('localhost') || hostname.startsWith('edge-orders-') ? 'edge' : 'legacy'; if (['edge', 'legacy'].includes(localStorage.getItem('cartMode'))) { window.cartMode = localStorage.getItem('cartMode'); } From 018edbe3ed314e55b20f9492398ddfb5cf702d29 Mon Sep 17 00:00:00 2001 From: Max Edell Date: Mon, 9 Mar 2026 08:23:19 -0700 Subject: [PATCH 15/89] fix: remove storecode, viewcode from order --- scripts/cart.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/cart.js b/scripts/cart.js index 2a827dce..a64c3590 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -192,8 +192,6 @@ export class Cart { Object.entries(shippingAddr).filter(([_, value]) => value !== ''), ); const order = { - storeCode: 'main', - storeViewCode: 'default', customer: { firstName, lastName, From 2e39dc361729c6d5ab9858c8c7376527bb2b1921 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 27 Mar 2026 13:25:10 -0400 Subject: [PATCH 16/89] fix: updates --- blocks/cart-summary/cart-summary.js | 35 +- blocks/checkout/checkout.css | 171 +++++++++- blocks/checkout/checkout.js | 472 +++++++++++++++++++++----- blocks/form/form.js | 52 +-- blocks/order-summary/order-summary.js | 192 +++++++++-- blocks/pdp/add-to-cart.js | 2 + scripts/cart.js | 58 +++- scripts/commerce-api.js | 69 ++++ scripts/scripts.js | 13 +- 9 files changed, 891 insertions(+), 173 deletions(-) create mode 100644 scripts/commerce-api.js diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js index 7a434932..05ef0aa5 100644 --- a/blocks/cart-summary/cart-summary.js +++ b/blocks/cart-summary/cart-summary.js @@ -28,7 +28,7 @@ const template = /* html */`
          Estimated taxes - $1.00 +
          Total @@ -66,6 +66,7 @@ export default function decorate(block) { const itemsList = block.querySelector('.cart-summary-items'); const subtotalEl = block.querySelector('.cart-summary-subtotal'); const shippingEl = block.querySelector('.cart-summary-shipping'); + const taxesEl = block.querySelector('.cart-summary-taxes'); const grandTotalEl = block.querySelector('.cart-summary-grand-total'); const headerTotalEl = block.querySelector('.cart-summary-total'); @@ -88,7 +89,7 @@ export default function decorate(block) { }; window.addEventListener('resize', handleResize); - handleResize(); // Initial check + handleResize(); const renderItems = () => { itemsList.innerHTML = ''; @@ -108,7 +109,6 @@ export default function decorate(block) { name.textContent = item.name; const variant = itemEl.querySelector('.cart-summary-item-variant'); - // Extract variant from item if available if (item.variant) { variant.textContent = item.variant; } else { @@ -126,14 +126,12 @@ export default function decorate(block) { }; const updateTotals = () => { - const { subtotal, shipping } = cart; - const taxes = 1.00; // Placeholder - const total = subtotal + shipping + taxes; - - subtotalEl.textContent = `$${subtotal.toFixed(2)}`; - shippingEl.textContent = shipping === 0 ? 'Free' : `$${shipping.toFixed(2)}`; - grandTotalEl.textContent = `$${total.toFixed(2)}`; - headerTotalEl.textContent = `$${total.toFixed(2)}`; + subtotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; + // show placeholder until real estimates arrive + shippingEl.textContent = '--'; + taxesEl.textContent = '--'; + grandTotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; + headerTotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; }; // Initial render @@ -145,4 +143,19 @@ export default function decorate(block) { renderItems(); updateTotals(); }); + + // Listen for real estimates from checkout preview + document.addEventListener('checkout:preview', (e) => { + const { preview } = e.detail; + const subtotal = parseFloat(preview.subtotal) || cart.subtotal; + const taxAmount = parseFloat(preview.taxAmount) || 0; + const shippingRate = preview.shippingMethod?.rate ?? 0; + const total = parseFloat(preview.total) || (subtotal + taxAmount + shippingRate); + + subtotalEl.textContent = `$${subtotal.toFixed(2)}`; + shippingEl.textContent = shippingRate === 0 ? 'Free' : `$${parseFloat(shippingRate).toFixed(2)}`; + taxesEl.textContent = `$${taxAmount.toFixed(2)}`; + grandTotalEl.textContent = `$${total.toFixed(2)}`; + headerTotalEl.textContent = `$${total.toFixed(2)}`; + }); } diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index f0fb6e1f..643b6112 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -4,7 +4,7 @@ gap: 24px; max-width: 1440px; margin: 0 auto; - padding: 0; + padding: 32px 0 0; } /* Mobile: cart summary on top, form below */ @@ -63,6 +63,8 @@ font-weight: normal; font-family: var(--sans-serif-font-family); font-size: 16px; + box-sizing: border-box; + height: 48px; } .checkout-form-column .button-wrapper { @@ -85,6 +87,85 @@ display: none; } +/* Shipping & billing address grid layout — 6-col grid for 2-col and 3-col rows */ +.checkout-form-column .section-shipping, +.checkout-form-column .section-billing { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 12px; + align-items: end; +} + +.checkout-form-column .section-shipping h3, +.checkout-form-column .section-billing h3 { + grid-column: 1 / -1; +} + +/* Default: all fields full width */ +.checkout-form-column .section-shipping .form-field, +.checkout-form-column .section-billing .form-field { + grid-column: 1 / -1; +} + +/* Company — full width, after lastname */ +.checkout-form-column .section-shipping .form-field[data-name="company"], +.checkout-form-column .section-billing .form-field[data-name="company"] { order: 3; } + +/* Reorder fields: firstname(1) lastname(2) street-0(3) street-1(4) city(5) state(6) zip(7) phone(8) */ +.checkout-form-column .section-shipping .form-field[data-name="firstname"], +.checkout-form-column .section-billing .form-field[data-name="firstname"] { order: 1; } + +.checkout-form-column .section-shipping .form-field[data-name="lastname"], +.checkout-form-column .section-billing .form-field[data-name="lastname"] { order: 2; } + +.checkout-form-column .section-shipping .form-field[data-name="street-0"], +.checkout-form-column .section-billing .form-field[data-name="street-0"] { order: 4; } + +.checkout-form-column .section-shipping .form-field[data-name="street-1"], +.checkout-form-column .section-billing .form-field[data-name="street-1"] { order: 5; } + +.checkout-form-column .section-shipping .form-field[data-name="city"], +.checkout-form-column .section-billing .form-field[data-name="city"] { order: 6; } + +.checkout-form-column .section-shipping .form-field[data-name="state"], +.checkout-form-column .section-billing .form-field[data-name="state"] { order: 7; } + +.checkout-form-column .section-shipping .form-field[data-name="zip"], +.checkout-form-column .section-billing .form-field[data-name="zip"] { order: 8; } + +.checkout-form-column .section-shipping .form-field[data-name="telephone"], +.checkout-form-column .section-billing .form-field[data-name="telephone"] { order: 9; } + +/* Desktop layout — data-name uses original field names (without shipping-/billing- prefix) */ +@media (width >= 480px) { + /* First name / Last name — half width each */ + .checkout-form-column .section-shipping .form-field[data-name="firstname"], + .checkout-form-column .section-billing .form-field[data-name="firstname"] { + grid-column: 1 / 4; + } + + .checkout-form-column .section-shipping .form-field[data-name="lastname"], + .checkout-form-column .section-billing .form-field[data-name="lastname"] { + grid-column: 4 / 7; + } + + /* City / Province / Postal code — thirds */ + .checkout-form-column .section-shipping .form-field[data-name="city"], + .checkout-form-column .section-billing .form-field[data-name="city"] { + grid-column: 1 / 3; + } + + .checkout-form-column .section-shipping .form-field[data-name="state"], + .checkout-form-column .section-billing .form-field[data-name="state"] { + grid-column: 3 / 5; + } + + .checkout-form-column .section-shipping .form-field[data-name="zip"], + .checkout-form-column .section-billing .form-field[data-name="zip"] { + grid-column: 5 / 7; + } +} + .checkout-form-column .form form .form-field[data-name="street-1"] { margin-top: -2px; } @@ -113,3 +194,91 @@ span.payment-button.paypal .button img { height: 24px; width: 70px; } + +/* Form section ordering */ +.checkout-form-column form .section-shipping { order: 1; } +.checkout-form-column form > .form-field[data-name="billingEqualsShipping"] { order: 2; } +.checkout-form-column form .section-billing { order: 3; } +.checkout-form-column form .shipping-methods { order: 4; } +.checkout-form-column form .section-payment { order: 5; } +.checkout-form-column form > .form-field[data-name="email"] { order: 0; } +.checkout-form-column form > .form-field[data-name="optIn"] { order: 6; } +.checkout-form-column form .button-wrapper { order: 7; } + +/* Shipping methods */ +.shipping-methods { + border: none; + padding: 0; + width: 100%; +} + +.shipping-methods h3 { + margin-bottom: var(--spacing-40); +} + +.shipping-methods.loading { + opacity: 0.5; + pointer-events: none; +} + +.shipping-method-option { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + border: 1px solid #ddd; + border-radius: 6px; + margin-bottom: 8px; + cursor: pointer; + transition: border-color 0.15s; + width: 100%; + box-sizing: border-box; +} + +.shipping-method-option:hover { + border-color: #333; +} + +.shipping-method-option:has(input:checked) { + border-color: #333; + background: #fafafa; +} + +.shipping-method-option input[type="radio"] { + margin: 0; + width: 18px; + height: 18px; +} + +.shipping-method-label { + flex: 1; +} + +.shipping-method-price { + font-weight: 600; +} + +.shipping-methods-empty { + color: #666; + font-style: italic; +} + +/* Checkout error */ +.checkout-error { + background: #fee; + border: 1px solid #c00; + color: #900; + padding: 12px 16px; + border-radius: 6px; + margin-bottom: var(--spacing-40); +} + +.checkout-error[aria-hidden="true"] { + display: none; +} + +/* Loading state for pay button */ +.checkout-form-column .button.loading { + opacity: 0.7; + pointer-events: none; +} diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 1880be65..1e0f2d83 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -4,10 +4,208 @@ import { loadBlock, loadScript, } from '../../scripts/aem.js'; -import { ORDERS_API_ORIGIN } from '../../scripts/scripts.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { + estimateShipping, + previewOrder, + createOrder, + initiatePayment, +} from '../../scripts/commerce-api.js'; const ADDRESS_FORM = 'https://main--vitamix--aemsites.aem.page/drafts/maxed/checkout/address-form.json'; +let currentEstimateToken = null; +let currentPreview = null; +let selectedShippingMethodId = null; + +/** + * Derive country code from the current locale. + * TODO: Remove drafts fallback before merging + */ +function getCountry() { + const { locale } = getLocaleAndLanguage(); + if (locale === 'drafts') return 'ca'; + return locale; +} + +/** + * Get the locale string for the API. + * TODO: Remove drafts fallback before merging + */ +function getLocale() { + const { locale, language } = getLocaleAndLanguage(); + if (locale === 'drafts') return 'ca/fr_ca'; + return `${locale}/${language}`; +} + +/** + * Collect address fields from the form by prefix (shipping- or billing-). + */ +function collectAddress(form, formData, prefix, email) { + const firstName = formData[`${prefix}firstname`] || ''; + const lastName = formData[`${prefix}lastname`] || ''; + // Use the state/province code (e.g., "QC") — the API matches against codes, not names + const stateValue = formData[`${prefix}state`] || ''; + + return { + name: `${firstName} ${lastName}`.trim(), + company: formData[`${prefix}company`] || '', + address1: formData[`${prefix}street-0`] || '', + address2: formData[`${prefix}street-1`] || '', + city: formData[`${prefix}city`] || '', + state: stateValue, + zip: formData[`${prefix}zip`] || '', + country: getCountry(), + phone: formData[`${prefix}telephone`] || '', + email, + }; +} + +/** + * Show an error message in the checkout form. + */ +function showError(formColumn, message) { + let errorEl = formColumn.querySelector('.checkout-error'); + if (!errorEl) { + errorEl = document.createElement('div'); + errorEl.className = 'checkout-error'; + formColumn.querySelector('form')?.prepend(errorEl); + } + errorEl.textContent = message; + errorEl.removeAttribute('aria-hidden'); +} + +function clearError(formColumn) { + const errorEl = formColumn.querySelector('.checkout-error'); + if (errorEl) { + errorEl.setAttribute('aria-hidden', 'true'); + } +} + +/** + * Render shipping method radio buttons from API rates. + */ +function renderShippingMethods(container, rates) { + container.innerHTML = ''; + const heading = document.createElement('h3'); + heading.textContent = 'Shipping Method'; + container.appendChild(heading); + + if (!rates || rates.length === 0) { + const noRates = document.createElement('p'); + noRates.textContent = 'No shipping methods available for this address.'; + noRates.className = 'shipping-methods-empty'; + container.appendChild(noRates); + return; + } + + rates.forEach((rate, index) => { + const label = document.createElement('label'); + label.className = 'shipping-method-option'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'shippingMethod'; + radio.value = rate.id; + radio.required = true; + if (index === 0) radio.checked = true; + + const text = document.createElement('span'); + text.className = 'shipping-method-label'; + text.textContent = rate.label; + + const price = document.createElement('span'); + price.className = 'shipping-method-price'; + price.textContent = rate.rate === 0 ? 'Free' : `$${parseFloat(rate.rate).toFixed(2)}`; + + label.append(radio, text, price); + container.appendChild(label); + }); +} + +/** + * Fetch shipping rates and preview the order, dispatching an event with the results. + */ +async function fetchAndPreview(form, formData, shippingMethodsContainer) { + const country = getCountry(); + // Use the select value (province code like "QC") for API calls, + // not the display text ("Quebec") — the shipping sheet matches on codes + const stateValue = formData['shipping-state'] || ''; + + if (!stateValue) return; + + const { default: cart } = await import('../../scripts/cart.js'); + const items = cart.getItemsForAPI(); + if (items.length === 0) return; + + // fetch shipping rates + shippingMethodsContainer.classList.add('loading'); + try { + const { rates } = await estimateShipping(country, stateValue, items); + renderShippingMethods(shippingMethodsContainer, rates); + + // auto-select first rate and preview + const firstRate = shippingMethodsContainer.querySelector('input[name="shippingMethod"]:checked'); + if (firstRate) { + selectedShippingMethodId = firstRate.value; + await updatePreview(form, formData, cart); + } + + // listen for rate changes + shippingMethodsContainer.addEventListener('change', async (e) => { + if (e.target.name === 'shippingMethod') { + selectedShippingMethodId = e.target.value; + const currentFormData = Object.fromEntries(new FormData(form).entries()); + await updatePreview(form, currentFormData, cart); + } + }); + } catch (err) { + console.error('Failed to fetch shipping rates', err); + renderShippingMethods(shippingMethodsContainer, []); + } finally { + shippingMethodsContainer.classList.remove('loading'); + } +} + +/** + * Call the order preview API to lock in estimates. + */ +async function updatePreview(form, formData, cart) { + if (!selectedShippingMethodId) return; + + const email = formData.email || ''; + const shipping = collectAddress(form, formData, 'shipping-', email); + + const previewBody = { + customer: { + firstName: formData['shipping-firstname'] || '', + lastName: formData['shipping-lastname'] || '', + email, + }, + shipping, + items: cart.getItemsForAPI(), + shippingMethod: { id: selectedShippingMethodId }, + country: getCountry(), + locale: getLocale(), + }; + + try { + console.debug('updatePreview: calling previewOrder with', previewBody); + const preview = await previewOrder(previewBody); + console.debug('updatePreview: got preview', preview); + currentPreview = preview; + currentEstimateToken = preview.estimateToken; + + document.dispatchEvent(new CustomEvent('checkout:preview', { + detail: { preview }, + })); + } catch (err) { + console.error('Failed to preview order', err); + currentPreview = null; + currentEstimateToken = null; + } +} + /** * Creates and decorates the checkout page with form and cart summary * @param {HTMLElement} block @@ -49,11 +247,29 @@ export default async function decorate(block) { loadBlock(summaryBlock), ]); + // The form JSON has two sections both named "shipping": + // 1. Shipping Address (address fields) + // 2. Payment Method (billingEquals checkbox + payment radios) + // Fix: identify them by heading, rename the payment one, and restructure + const allShippingSections = formColumn.querySelectorAll('fieldset.form-section.section-shipping'); + const shippingAddressSection = allShippingSections[0]; // "Shipping Address" + const paymentSection = allShippingSections[1]; // "Payment Method" (misnamed section-shipping) + + // Rename payment section to have its own class + if (paymentSection) { + paymentSection.classList.remove('section-shipping'); + paymentSection.classList.add('section-payment'); + } + + // Extract billingEqualsShipping checkbox from payment section → make it a form-level element const sameShipBillCheckbox = formColumn.querySelector('fieldset[data-name="billingEqualsShipping"] input[type="checkbox"]'); + const billingEqualsField = sameShipBillCheckbox?.closest('.form-field'); + if (billingEqualsField) { + // Move it out of the payment section, right after shipping address + shippingAddressSection.after(billingEqualsField); + } // duplicate the shipping address form section to create billing address form - // will be hidden by default, only show when sameShipBill is unchecked - const shippingAddressSection = formColumn.querySelector('fieldset.form-section.section-shipping'); const billingAddressSection = shippingAddressSection.cloneNode(true); billingAddressSection.classList.add('form-section', 'section-billing'); billingAddressSection.querySelector('h3').textContent = 'Billing Address'; @@ -63,20 +279,20 @@ export default async function decorate(block) { shippingAddressSection.querySelectorAll('input, select').forEach((input) => { input.id = `shipping-${input.id}`; input.name = `shipping-${input.name}`; - if (input.previousElementSibling.tagName === 'LABEL') { + if (input.previousElementSibling?.tagName === 'LABEL') { input.previousElementSibling.setAttribute('for', input.id); } }); billingAddressSection.querySelectorAll('input, select').forEach((input) => { input.id = `billing-${input.id}`; input.name = `billing-${input.name}`; - if (input.previousElementSibling.tagName === 'LABEL') { + if (input.previousElementSibling?.tagName === 'LABEL') { input.previousElementSibling.setAttribute('for', input.id); } }); - // insert below sameShipBill checkbox's section - sameShipBillCheckbox.closest('fieldset.form-section').after(billingAddressSection); + // Insert billing address after the checkbox + billingEqualsField.after(billingAddressSection); // hide billing address section by default billingAddressSection.setAttribute('aria-hidden', true); @@ -88,35 +304,112 @@ export default async function decorate(block) { billingAddressSection.setAttribute('disabled', sameShipBillCheckbox.checked); }); - // initialize pay buttons + // -- Shipping methods section -- + const shippingMethodsContainer = document.createElement('fieldset'); + shippingMethodsContainer.className = 'form-section shipping-methods'; + // insert before the payment method section wrapper + // The payment checkboxes are inside a section like fieldset.section-paymentMethod + const paymentMethodInner = formColumn.querySelector('fieldset[data-name="paymentMethod"]'); + const paymentMethodSection = paymentMethodInner?.closest('fieldset.form-section') || paymentMethodInner; + if (paymentMethodSection) { + paymentMethodSection.before(shippingMethodsContainer); + } else { + billingAddressSection.after(shippingMethodsContainer); + } + + // Invalidate estimates when address fields change + const form = formColumn.querySelector('form'); + const shippingInputs = shippingAddressSection.querySelectorAll('input, select'); + shippingInputs.forEach((input) => { + input.addEventListener('change', () => { + currentEstimateToken = null; + currentPreview = null; + }); + }); + + // Override state dropdown with Canadian provinces for CA locale + const stateSelect = form.querySelector('select#shipping-state'); + if (stateSelect && getCountry() === 'ca') { + const provinces = [ + ['', 'Select province...'], + ['AB', 'Alberta'], ['BC', 'British Columbia'], ['MB', 'Manitoba'], + ['NB', 'New Brunswick'], ['NL', 'Newfoundland and Labrador'], + ['NS', 'Nova Scotia'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut'], + ['ON', 'Ontario'], ['PE', 'Prince Edward Island'], ['QC', 'Quebec'], + ['SK', 'Saskatchewan'], ['YT', 'Yukon'], + ]; + stateSelect.innerHTML = ''; + provinces.forEach(([value, label]) => { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + if (!value) opt.disabled = true; + stateSelect.appendChild(opt); + }); + // Update label + const stateLabel = stateSelect.previousElementSibling; + if (stateLabel?.tagName === 'LABEL') { + stateLabel.textContent = 'Province'; + } + } + + // Fetch shipping rates when state is selected + if (stateSelect) { + stateSelect.addEventListener('change', () => { + console.debug('checkout: state changed to', stateSelect.value); + const formData = Object.fromEntries(new FormData(form).entries()); + fetchAndPreview(form, formData, shippingMethodsContainer); + }); + } else { + console.warn('checkout: select#shipping-state not found in form'); + } + + // TODO: Remove test prefill before merging + const prefill = { + email: 'test@example.com', + 'shipping-firstname': 'Jean', + 'shipping-lastname': 'Tremblay', + 'shipping-telephone': '514-555-1234', + 'shipping-street-0': '1234 Rue Sainte-Catherine', + 'shipping-street-1': '', + 'shipping-city': 'Montréal', + 'shipping-zip': 'H3B 1A7', + 'shipping-company': '', + }; + Object.entries(prefill).forEach(([name, value]) => { + const input = form.querySelector(`[name="${name}"]`); + if (input) input.value = value; + }); + // Prefill province — options are already loaded synchronously for CA + if (stateSelect) { + stateSelect.value = 'QC'; + stateSelect.dispatchEvent(new Event('change', { bubbles: true })); + } + + // -- Pay buttons -- const submitButtons = [...formColumn.querySelectorAll('form .button-wrapper button[type="submit"]')]; submitButtons.forEach((button) => { let paymentMethod = 'Credit Card'; if (button.textContent.toLowerCase().includes('paypal')) { paymentMethod = 'PayPal'; - // replace with icon svg button.textContent = button.textContent.replace('PayPal', ''); const icon = document.createElement('img'); icon.src = `${window.hlx.codeBasePath}/icons/paypal.svg`; - icon.classList.add('icon'); - icon.classList.add('icon-paypal'); + icon.classList.add('icon', 'icon-paypal'); button.appendChild(icon); } else if (button.textContent.toLowerCase().includes('apple')) { paymentMethod = 'Apple Pay'; } - // update button styling, wrap each in a span const span = document.createElement('span'); span.classList.add('payment-button'); span.dataset.paymentMethod = paymentMethod; button.replaceWith(span); if (paymentMethod === 'PayPal') { - // TODO: add paypal's script & styles span.classList.add('paypal'); span.appendChild(button); } else if (paymentMethod === 'Apple Pay') { - // TODO: conditionally disable this payment type if apple pay is not supported span.classList.add('apple-pay'); loadScript('https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'); span.innerHTML = ''; @@ -127,16 +420,21 @@ export default async function decorate(block) { ev.preventDefault(); ev.stopPropagation(); - // validate form(s) - /** @type {HTMLFormElement} */ - const form = button.closest('form'); + // validate form const isValid = form.checkValidity(); if (!isValid) { form.reportValidity(); return; } - // set loading state, create order, simulate payment processing, redirect to complete page + // must have a shipping method selected + if (!selectedShippingMethodId) { + showError(formColumn, 'Please select a shipping method.'); + return; + } + + clearError(formColumn); + button.classList.add('loading'); button.textContent = 'Processing...'; button.disabled = true; @@ -147,63 +445,78 @@ export default async function decorate(block) { button.disabled = false; }; - // get form data - const formData = Object.fromEntries(new FormData(form).entries()); - const { - email, - 'shipping-firstname': firstName, - 'shipping-lastname': lastName, - 'shipping-telephone': phone, - } = formData; - // use state text instead of index value - const state = form.querySelector(`select#shipping-state option[value="${formData['shipping-state']}"]`).textContent; - const shipping = { - name: `${firstName} ${lastName}`, - company: formData['shipping-company'], - address1: formData['shipping-street-0'], - address2: formData['shipping-street-1'], - city: formData['shipping-city'], - state, - zip: formData['shipping-zip'], - country: 'US', - phone, - email, - }; - - const { default: cart } = await import('../../scripts/cart.js'); - const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping); - console.debug('order', order); - - // create the order - const resp = await fetch(`${ORDERS_API_ORIGIN}/orders`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(order), - }); - if (!resp.ok) { - console.error('Failed to create order', resp); - // TODO: show validation errors etc. + try { + const formData = Object.fromEntries(new FormData(form).entries()); + const { + email, + 'shipping-firstname': firstName, + 'shipping-lastname': lastName, + 'shipping-telephone': phone, + } = formData; + + const shipping = collectAddress(form, formData, 'shipping-', email); + const country = getCountry(); + + // collect billing address if different from shipping + let billingAddr; + if (!sameShipBillCheckbox.checked) { + billingAddr = collectAddress(form, formData, 'billing-', email); + } + + // if we don't have a fresh preview, try to get one now + if (!currentEstimateToken) { + const { default: cartForPreview } = await import('../../scripts/cart.js'); + await updatePreview(form, formData, cartForPreview); + } + + // block checkout if preview/estimates failed + if (!currentEstimateToken || !currentPreview) { + showError(formColumn, 'Unable to calculate shipping and taxes. Please try again.'); + reenableButton(); + return; + } + + const { default: cart } = await import('../../scripts/cart.js'); + const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { + billingAddr, + shippingMethod: selectedShippingMethodId, + estimateToken: currentEstimateToken, + locale: getLocale(), + country, + }); + + console.debug('creating order', order); + + // save checkout context for confirmation page + sessionStorage.setItem('checkout_email', email); + sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); + if (currentPreview) { + sessionStorage.setItem('checkout_preview', JSON.stringify(currentPreview)); + } + + // create order + const { order: createdOrder } = await createOrder(order); + console.debug('order created', createdOrder); + sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); + + // initiate payment + const idempotencyKey = crypto.randomUUID(); + const payment = await initiatePayment(createdOrder.id, idempotencyKey); + console.debug('payment initiated', payment); + + if (payment.action === 'redirect' && payment.redirectUrl) { + // redirect to Chase hosted payment page + window.location.href = payment.redirectUrl; + } else { + showError(formColumn, 'Unexpected payment response. Please try again.'); + reenableButton(); + } + } catch (error) { + console.error('Checkout failed', error); + const message = error.body?.message || error.message || 'Something went wrong. Please try again.'; + showError(formColumn, message); reenableButton(); - return; } - const data = await resp.json(); - console.debug('completed order', data); - - // simulate payment processing for a bit.. - await new Promise((resolve) => { - setTimeout(resolve, 2000); - }); - - // clear cart on successful payment - cart.clear(); - - // redirect to complete page - window.location.href = `/drafts/maxed/checkout/complete?id=${data.order.id}&email=${email}`; - - // just in case - reenableButton(); }); } @@ -219,22 +532,19 @@ export default async function decorate(block) { paymentMethodGroup.addEventListener('change', () => { const paymentMethod = paymentMethodGroup.querySelector('input:checked').value; - // only show billing address section if payment method is credit card if (sameShipBillCheckbox.checked || paymentMethod !== 'Credit Card') { billingAddressSection.setAttribute('aria-hidden', true); } else { billingAddressSection.removeAttribute('aria-hidden'); } - // toggle which submit button is visible - payButtons.forEach((button) => { - if (paymentMethod !== button.dataset.paymentMethod) { - button.setAttribute('aria-hidden', true); + payButtons.forEach((btn) => { + if (paymentMethod !== btn.dataset.paymentMethod) { + btn.setAttribute('aria-hidden', true); } else { - button.removeAttribute('aria-hidden'); - // NOTE: temp force apply pay button to show up, even on insecure hosts + btn.removeAttribute('aria-hidden'); if (paymentMethod === 'Apple Pay') { - const applePayButton = button.querySelector('apple-pay-button'); + const applePayButton = btn.querySelector('apple-pay-button'); applePayButton.removeAttribute('hidden'); applePayButton.removeAttribute('aria-hidden'); } diff --git a/blocks/form/form.js b/blocks/form/form.js index b3945820..949ca0ae 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -140,41 +140,6 @@ async function appendSelectOptions(select, url) { } } -function buildSelect(field) { - const { - field: fieldName, required, default: defaultValue, placeholder, options, - } = field; - const select = createElement('select'); - select.id = generateId(fieldName); - select.name = select.id; - select.required = required === 'true'; - if (typeof options !== 'string') { - return select; - } - - if (/^https?:\/\//.test(options)) { - if (placeholder) { - const optionEl = createElement('option'); - if (defaultValue != null) { - optionEl.value = defaultValue; - } - optionEl.textContent = placeholder; - optionEl.setAttribute('disabled', 'true'); - select.append(optionEl); - } - appendSelectOptions(select, options); // async - } else { - options.split(';').forEach((o) => { - const option = o.trim(); - const optionEl = createElement('option'); - optionEl.value = option; - optionEl.textContent = option; - select.append(optionEl); - }); - } - return select; -} - /** * Decodes option string into text and value pair * @param {string} option - Option string @@ -191,7 +156,7 @@ function decodeOption(option) { */ function buildSelect(field) { const { - field: fieldName, required, default: defaultValue, options, + field: fieldName, required, default: defaultValue, placeholder, options, } = field; const select = createElement('select'); @@ -199,7 +164,20 @@ function buildSelect(field) { select.name = select.id; select.required = required === 'true'; - if (options) { + if (options && /^https?:\/\//.test(options)) { + // URL-based options: fetch from JSON sheet + if (placeholder) { + const optionEl = createElement('option'); + if (defaultValue != null) { + optionEl.value = defaultValue; + } + optionEl.textContent = placeholder; + optionEl.setAttribute('disabled', 'true'); + select.append(optionEl); + } + appendSelectOptions(select, options); + } else if (options) { + // Inline comma-separated options options.split(',').forEach((o) => { const [text, value] = decodeOption(o); const option = createElement('option'); diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js index 9ab1e3bd..41aad346 100644 --- a/blocks/order-summary/order-summary.js +++ b/blocks/order-summary/order-summary.js @@ -1,36 +1,174 @@ -import { ORDERS_API_ORIGIN } from '../../scripts/scripts.js'; - /** - * @param {HTMLDivElement} block - * @returns {Promise} + * Order confirmation / cancellation page. + * + * Success: Chase → API → redirect here with ?orderId=... + * Cancel: Chase → API → redirect here with ?orderId=...&reason=... + * + * Order display data comes from sessionStorage (saved before Chase redirect). */ export default async function decorate(block) { - // get orderId, email from url params const params = Object.fromEntries(new URLSearchParams(window.location.search).entries()); - if (!params.id || !params.email) { - // redirect to home page - window.location.href = '/us/en_us/'; - } - - // fetch order info - const resp = await fetch(`${ORDERS_API_ORIGIN}/customers/${params.email}/orders/${params.id}`); - if (!resp.ok) { - console.error(`Failed to fetch order info: ${resp.status} ${resp.statusText}`); - if (resp.status !== 404) { - // redirect after 10 seconds - setTimeout(() => { - window.location.href = '/us/en_us/'; - }, 10000); - } else { - // redirect to home page - window.location.href = '/us/en_us/'; - } + // cancelled or failed payment + if (params.reason) { + block.innerHTML = ` +
          +

          Payment not completed

          +

          ${params.reason === 'customer_cancelled' ? 'You cancelled the payment.' : `Payment could not be processed (${params.reason}).`}

          +

          Return to checkout

          +
          + `; + return; + } + + // success flow — read from sessionStorage + const orderId = params.orderId || params.id; + const email = params.email || sessionStorage.getItem('checkout_email'); + const orderData = sessionStorage.getItem('checkout_order'); + const previewData = sessionStorage.getItem('checkout_preview'); + const cartItemsData = sessionStorage.getItem('checkout_cart_items'); + + if (!orderId) { + window.location.href = '/'; return; } - const data = await resp.json(); - // just dump it into a code block for now - block.innerHTML = '
          '; - block.querySelector('code').innerText = JSON.stringify(data, null, 2); + let order; + let preview; + let cartItems; + try { + order = orderData ? JSON.parse(orderData) : null; + preview = previewData ? JSON.parse(previewData) : null; + cartItems = cartItemsData ? JSON.parse(cartItemsData) : null; + } catch { + order = null; + preview = null; + cartItems = null; + } + + // clear checkout session data + sessionStorage.removeItem('checkout_email'); + sessionStorage.removeItem('checkout_order'); + sessionStorage.removeItem('checkout_preview'); + sessionStorage.removeItem('checkout_cart_items'); + + // clear the cart + try { + const { default: cart } = await import('../../scripts/cart.js'); + cart.clear(); + } catch { + // cart may not be available + } + + // build confirmation display + const container = document.createElement('div'); + container.className = 'order-result order-confirmed'; + + const heading = document.createElement('h2'); + heading.textContent = 'Thank you for your order!'; + container.appendChild(heading); + + const orderIdEl = document.createElement('p'); + orderIdEl.className = 'order-id'; + orderIdEl.innerHTML = `Order ID: ${orderId}`; + container.appendChild(orderIdEl); + + if (email) { + const emailEl = document.createElement('p'); + emailEl.textContent = `A confirmation will be sent to ${email}.`; + container.appendChild(emailEl); + } + + // show order items — prefer cart items (has variant/image) over order items + const displayItems = cartItems || order?.items; + if (displayItems?.length) { + const itemsSection = document.createElement('div'); + itemsSection.className = 'order-items'; + + const itemsHeading = document.createElement('h3'); + itemsHeading.textContent = 'Items ordered'; + itemsSection.appendChild(itemsHeading); + + displayItems.forEach((item) => { + const itemEl = document.createElement('div'); + itemEl.className = 'order-item'; + + if (item.image) { + const img = document.createElement('img'); + img.src = item.image; + img.alt = item.name || ''; + img.className = 'order-item-image'; + itemEl.appendChild(img); + } + + const details = document.createElement('div'); + details.className = 'order-item-details'; + + const name = document.createElement('p'); + name.className = 'order-item-name'; + name.textContent = item.name || item.sku; + details.appendChild(name); + + if (item.variant) { + const variant = document.createElement('p'); + variant.className = 'order-item-variant'; + variant.textContent = `Color: ${item.variant}`; + details.appendChild(variant); + } + + const qty = document.createElement('p'); + qty.className = 'order-item-qty'; + const price = item.price?.final || item.price; + qty.textContent = `Qty: ${item.quantity} — $${price}`; + details.appendChild(qty); + + itemEl.appendChild(details); + itemsSection.appendChild(itemEl); + }); + container.appendChild(itemsSection); + } + + // show totals from preview if available + if (preview) { + const totalsSection = document.createElement('div'); + totalsSection.className = 'order-totals'; + + const rows = [ + ['Subtotal', `$${parseFloat(preview.subtotal).toFixed(2)}`], + ['Shipping', preview.shippingMethod?.rate === 0 ? 'Free' : `$${parseFloat(preview.shippingMethod?.rate || 0).toFixed(2)}`], + ['Tax', `$${parseFloat(preview.taxAmount).toFixed(2)}`], + ['Total', `$${parseFloat(preview.total).toFixed(2)}`], + ]; + + rows.forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = `order-totals-row${label === 'Total' ? ' order-totals-total' : ''}`; + row.innerHTML = `${label}${value}`; + totalsSection.appendChild(row); + }); + + container.appendChild(totalsSection); + } + + // shipping address + if (order?.shipping) { + const addrSection = document.createElement('div'); + addrSection.className = 'order-shipping-address'; + const addrHeading = document.createElement('h3'); + addrHeading.textContent = 'Shipping address'; + addrSection.appendChild(addrHeading); + + const addr = order.shipping; + const addrLines = [addr.name, addr.address1, addr.address2, `${addr.city}, ${addr.state} ${addr.zip}`, addr.country].filter(Boolean); + const addrEl = document.createElement('p'); + addrEl.innerHTML = addrLines.join('
          '); + addrSection.appendChild(addrEl); + container.appendChild(addrSection); + } + + const continueLink = document.createElement('p'); + continueLink.innerHTML = 'Continue shopping'; + container.appendChild(continueLink); + + block.replaceChildren(container); } diff --git a/blocks/pdp/add-to-cart.js b/blocks/pdp/add-to-cart.js index dca047b0..1bdae301 100644 --- a/blocks/pdp/add-to-cart.js +++ b/blocks/pdp/add-to-cart.js @@ -225,7 +225,9 @@ export default function renderAddToCart(ph, block, parent) { price, name, url: selectedVariant.url, + path: new URL(selectedVariant.url).pathname, image: selectedVariant.image[0], + variant: window.selectedVariant?.options?.color || '', selectedOptions, }; await cartApi.addItem(item); diff --git a/scripts/cart.js b/scripts/cart.js index a64c3590..0f0baa35 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -185,12 +185,32 @@ export class Cart { * }} shipping * @returns {Object} */ - getOrderJSON(email, firstName, lastName, phone, shippingAddr) { - // remove empty string values - const shipping = Object.fromEntries( + /** + * Returns cart items in API-compatible format. + * @returns {Array<{sku: string, path: string, quantity: number, name: string, price: {final: string, currency: string}}>} + */ + getItemsForAPI() { + return this.items.map((item) => ({ + sku: item.sku, + path: item.path || new URL(item.url, window.location.origin).pathname, + quantity: item.quantity, + name: item.name, + price: { + final: String(item.price), + currency: 'USD', + }, + })); + } + + getOrderJSON(email, firstName, lastName, phone, shippingAddr, { + billingAddr, shippingMethod, estimateToken, locale, country, + } = {}) { + // remove empty string values from addresses + const cleanAddr = (addr) => Object.fromEntries( // eslint-disable-next-line no-unused-vars - Object.entries(shippingAddr).filter(([_, value]) => value !== ''), + Object.entries(addr).filter(([_, value]) => value !== ''), ); + const order = { customer: { firstName, @@ -198,18 +218,26 @@ export class Cart { email, phone, }, - shipping, - items: this.items.map((item) => ({ - sku: item.sku, - urlKey: (item.url || '').split('/').pop() || '', - name: item.name, - quantity: item.quantity, - price: { - currency: 'USD', - final: item.price, - }, - })), + shipping: cleanAddr(shippingAddr), + items: this.getItemsForAPI(), }; + + if (billingAddr) { + order.billing = cleanAddr(billingAddr); + } + if (shippingMethod) { + order.shippingMethod = { id: shippingMethod }; + } + if (estimateToken) { + order.estimateToken = estimateToken; + } + if (locale) { + order.locale = locale; + } + if (country) { + order.country = country; + } + return order; } diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js new file mode 100644 index 00000000..893d7752 --- /dev/null +++ b/scripts/commerce-api.js @@ -0,0 +1,69 @@ +import { ORDERS_API_ORIGIN } from './scripts.js'; + +class CommerceApiError extends Error { + constructor(status, body) { + super(body?.message || `API error ${status}`); + this.status = status; + this.body = body; + } +} + +async function post(path, body) { + const resp = await fetch(`${ORDERS_API_ORIGIN}${path}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await resp.json(); + if (!resp.ok) { + throw new CommerceApiError(resp.status, data); + } + return data; +} + +/** + * Fetch available shipping rates for a given address and items. + * @param {string} country + * @param {string} state + * @param {Array} items - Items in API format (sku, path, quantity, price) + * @returns {Promise<{rates: Array}>} + */ +export async function estimateShipping(country, state, items) { + return post('/estimate/shipping', { + country, + shipping: { country, state }, + items, + }); +} + +/** + * Preview an order to lock in estimates and get an estimateToken. + * @param {Object} orderBody - Full order body including shippingMethod.id + * @returns {Promise<{subtotal, taxAmount, taxRate, shippingMethod, discounts, total, estimateToken}>} + */ +export async function previewOrder(orderBody) { + return post('/orders/preview', orderBody); +} + +/** + * Create an order. + * @param {Object} orderBody - Full order body including estimateToken + * @returns {Promise<{order: Object}>} + */ +export async function createOrder(orderBody) { + return post('/orders', orderBody); +} + +/** + * Initiate payment for an order. + * @param {string} orderId + * @param {string} idempotencyKey + * @returns {Promise<{orderId, paymentAttemptId, status, action, redirectUrl}>} + */ +export async function initiatePayment(orderId, idempotencyKey) { + return post(`/orders/${orderId}/payments`, { + provider: 'chase', + paymentMethod: 'card', + idempotencyKey, + }); +} diff --git a/scripts/scripts.js b/scripts/scripts.js index 0342a04e..8af2b855 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -17,7 +17,18 @@ import { getMetadata, } from './aem.js'; -const isProdHost = window.location.hostname.includes('vitamix.com'); +const { hostname } = window.location; + +export const ORDERS_API_ORIGIN = 'https://api-stage.adobecommerce.live/aemsites/sites/vitamix'; + +const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders--') || hostname.includes('uat.vitamix.com'); +const { locale } = getLocaleAndLanguage(); +window.cartMode = (isEdgeHost && locale === 'ca') ? 'edge' : 'legacy'; +if (['edge', 'legacy'].includes(localStorage.getItem('cartMode'))) { + window.cartMode = localStorage.getItem('cartMode'); +} + +const isProdHost = hostname.includes('vitamix.com'); export const FORMS_ENDPOINT = isProdHost ? 'https://main--vitamix--aemsites.aem.network' // TODO: make empty string when Akamai ready : 'https://main--vitamix--aemsites.aem.network'; From 05ce459d669b228950c34f7c1bc6de143e1ea99f Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 27 Mar 2026 13:30:00 -0400 Subject: [PATCH 17/89] fix: isEdgeHost --- scripts/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index 8af2b855..2ada5e55 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -21,7 +21,7 @@ const { hostname } = window.location; export const ORDERS_API_ORIGIN = 'https://api-stage.adobecommerce.live/aemsites/sites/vitamix'; -const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders--') || hostname.includes('uat.vitamix.com'); +const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('uat.vitamix.com'); const { locale } = getLocaleAndLanguage(); window.cartMode = (isEdgeHost && locale === 'ca') ? 'edge' : 'legacy'; if (['edge', 'legacy'].includes(localStorage.getItem('cartMode'))) { From 3187e3b3fa5281443e4df75c70a7af034a258d90 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 27 Mar 2026 17:25:40 -0400 Subject: [PATCH 18/89] fix: mini cart ui tweaks --- blocks/cart/cart.css | 227 +++++++++++++++++---------------------- blocks/cart/cart.js | 110 ++++++++++++++----- blocks/header/header.css | 22 ++-- blocks/header/header.js | 18 +++- 4 files changed, 213 insertions(+), 164 deletions(-) diff --git a/blocks/cart/cart.css b/blocks/cart/cart.css index 875c3327..cf3da71a 100644 --- a/blocks/cart/cart.css +++ b/blocks/cart/cart.css @@ -12,42 +12,14 @@ div.section.cart-section .cart-wrapper { } .cart-items-header { - display: grid; - grid-template-columns: 3fr 2fr 1fr; -} - -.cart-items-header h6.title-quantity { - justify-self: right; - margin-left: calc(100% - 180px); - width: 180px; - opacity: 0; -} - -@media (width >= 600px) { - .cart-items-header h6.title-quantity { - opacity: 1; - } - - .minicart .cart-items-header h6.title-quantity { - opacity: 0; - } -} - -@media (width >= 900px) { - .cart-items-header h6.title-quantity { - margin-left: calc(100% - 200px); - width: 200px; - } - - .minicart .cart-items-header h6.title-quantity { - opacity: 1; - margin-left: calc(100% - 120px); - width: 120px; - } -} - -.cart-items-header h6.title-total { - justify-self: right; + display: flex; + justify-content: space-between; + font-size: 11px; + letter-spacing: 0.05em; + color: #555; + padding-bottom: 12px; + border-bottom: 1px solid #0000002b; + margin-bottom: 16px; } .cart-items-list { @@ -59,12 +31,15 @@ div.section.cart-section .cart-wrapper { .minicart .cart-items-list { min-height: calc(100vh - var(--header-height) - 16em); max-height: calc(100vh - var(--header-height) - 16em); - overflow-y: scroll; + overflow-y: auto; } +/* Cart item layout: image | details | total */ .cart-item { display: grid; - grid-template-columns: 3fr 2fr 1fr; + grid-template-columns: 80px 1fr auto; + gap: 16px; + align-items: start; } .cart-items-list > span:not(:last-child) { @@ -72,93 +47,74 @@ div.section.cart-section .cart-wrapper { padding-bottom: 1em; } -.cart-item .cart-item-product { - display: grid; - gap: 0.5em; - grid-template: 'image image' - 'price price' - 'link link' / fit-content(150px) 1fr; -} - -@media (width >= 600px) { - .cart-item .cart-item-product { - grid-template: 'image link' - 'image price' / fit-content(150px) 1fr; - } - - .minicart .cart-item .cart-item-product { - grid-template: 'image image' - 'price price' - 'link link' / fit-content(150px) 1fr; - } +/* Image */ +.cart-item .cart-item-image { + width: 80px; + height: 80px; + display: flex; + align-items: center; + justify-content: center; } -@media (width >= 1440px) { - .minicart .cart-item .cart-item-product { - gap: 1em; - grid-template: 'image link' - 'image price' / fit-content(150px) 1fr; - } +.cart-item .cart-item-image img { + max-width: 80px; + max-height: 80px; + width: auto; + height: auto; + object-fit: contain; } -.cart-item .cart-item-product span.image { - grid-area: image; - width: 150px; - min-height: 150px; +/* Details column */ +.cart-item .cart-item-details { display: flex; + flex-direction: column; + gap: 4px; } -.cart-item .cart-item-product span.image img { - max-width: 150px; - max-height: 150px; - width: auto; - margin: 1em auto; +.cart-item .cart-item-name a { + font-weight: 600; + font-size: 14px; + line-height: 1.4; + text-decoration: none; + color: var(--color-text); } -.cart-item .cart-item-product a { - grid-area: link; - align-content: end; - font-weight: 600; - max-width: 350px; +.cart-item .cart-item-price { + font-size: 14px; + color: var(--color-gray-800); } -.cart-item .cart-item-product span.price { - grid-area: price; +.cart-item .cart-item-variant { + font-size: 13px; + color: #555; } -.cart-item .cart-item-quantity { +/* Actions row: quantity picker + trash icon */ +.cart-item .cart-item-actions { display: flex; - flex-direction: column; align-items: center; - gap: 1em; - align-self: center; -} - -@media (width >= 600px) { - .cart-item .cart-item-quantity { - flex-direction: row; - align-items: center; - place-self: center right; - } + gap: 12px; + margin-top: 8px; } -@media (width >= 900px) { - .cart-item .cart-item-quantity { - gap: 2em; - } +.cart-item .cart-item-quantity { + display: flex; } .cart-item .cart-item-quantity .quantity-control { display: flex; flex-direction: row; - gap: 0.5em; + align-items: center; + border: 1px solid var(--color-gray-400); + border-radius: 4px; + overflow: hidden; } .quantity-control button { - width: 30px; - height: 30px; - border: 1px solid var(--color-gray-400); - border-radius: 50%; + width: 36px; + height: 36px; + border: none; + border-radius: 0; background-color: var(--color-white); color: var(--color-text); font-size: 16px; @@ -166,61 +122,76 @@ div.section.cart-section .cart-wrapper { cursor: pointer; } +.quantity-control button:hover { + background-color: var(--color-gray-100); +} + .quantity-control input { width: 40px; - height: 30px; - border: 1px solid var(--color-gray-400); + height: 36px; + border: none; + border-left: 1px solid var(--color-gray-400); + border-right: 1px solid var(--color-gray-400); border-radius: 0; text-align: center; - font-size: 16px; + font-size: 14px; font-weight: 600; - cursor: pointer; padding-inline: 0; + -moz-appearance: textfield; } -.cart-item .cart-item-quantity .remove-button { - color: var(--color-red); - font-size: 14px; - display: none; +.quantity-control input::-webkit-inner-spin-button, +.quantity-control input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; } -@media (width >= 600px) { - .cart-item .cart-item-quantity .remove-button { - display: block; - } +/* Remove (trash) button */ +.cart-item .cart-item-remove { + background: none; + border: none; + cursor: pointer; + color: #555; + padding: 4px; + display: flex; + align-items: center; +} - .minicart .cart-item .cart-item-quantity .remove-button { - display: none; - } +.cart-item .cart-item-remove:hover { + color: var(--color-text); } +/* Total */ .cart-item .cart-item-total { - align-content: center; - justify-self: end; + font-weight: 600; + font-size: 14px; + text-align: right; } .cart-footer-subtotal { display: flex; flex-direction: row; align-items: center; - gap: 1em; - justify-content: flex-end; + justify-content: space-between; margin-top: 2em; border-top: 1px solid #0000002b; padding-top: 1em; + font-weight: 600; + font-size: 16px; } -.cart-footer-subtotal h4 { - margin: 0; -} - -.cart-footer-subtotal p { - margin: 0; +.cart-footer-note { + margin: 4px 0 0; + font-size: 13px; + color: #555; } .cart-controls { - display: flex; - flex-direction: row; - justify-content: flex-end; margin: 2em 0 0; +} + +.cart-controls .cart-checkout { + width: 100%; + text-align: center; + box-sizing: border-box; } \ No newline at end of file diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index e28ff6c1..8716792d 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -3,8 +3,20 @@ import cart from '../../scripts/cart.js'; const itemTemplate = /* html */`
          -
          -
          +
          +
          +
          +
          +
          +
          +
          + +
          +
          `; @@ -12,17 +24,17 @@ const template = /* html */`
          -
          Product
          -
          Quantity
          -
          Total
          + PRODUCT + TOTAL
          @@ -122,35 +134,85 @@ export default async function decorate(block, parent) { const cartItem = itemElement.querySelector('.cart-item'); cartItem.classList.add(`cart-item-${item.sku}`); - // product element - const productEl = itemElement.querySelector('.cart-item-product'); - const productPictureEl = createOptimizedPicture(item.image, '', true); - const productImgWrapper = document.createElement('span'); - productImgWrapper.appendChild(productPictureEl); - productImgWrapper.classList.add('image'); + // image + const imageEl = itemElement.querySelector('.cart-item-image'); + const pictureEl = createOptimizedPicture(item.image, item.name || '', true); + imageEl.appendChild(pictureEl); - const productLinkEl = document.createElement('a'); - productLinkEl.textContent = item.name; + // name as link + const nameEl = itemElement.querySelector('.cart-item-name'); + const linkEl = document.createElement('a'); + linkEl.textContent = item.name; let path; try { path = new URL(item.url).pathname; } catch (error) { path = item.url; } - productLinkEl.setAttribute('href', path); - - const productPriceEl = document.createElement('span'); - productPriceEl.classList.add('price'); - productPriceEl.textContent = `$${item.price}`; - productPriceEl.setAttribute('data-price', item.price); - productEl.append(productImgWrapper, productLinkEl, productPriceEl); + linkEl.setAttribute('href', path); + nameEl.appendChild(linkEl); + + // price + const priceEl = itemElement.querySelector('.cart-item-price'); + priceEl.textContent = `$${item.price}`; + + // variant (e.g., color) + const variantEl = itemElement.querySelector('.cart-item-variant'); + if (item.variant) { + variantEl.textContent = `Color: ${item.variant}`; + } else { + variantEl.remove(); + } - // total (qty*unit price) + // total (qty * unit price) const totalElement = itemElement.querySelector('.cart-item-total'); + totalElement.textContent = `$${(item.price * item.quantity).toFixed(2)}`; // quantity picker const qtyElement = itemElement.querySelector('.cart-item-quantity'); - renderQuantityPicker(item, qtyElement, totalElement); + const qtyControlEl = document.createElement('div'); + qtyControlEl.classList.add('quantity-control'); + + const qtyInput = document.createElement('input'); + qtyInput.type = 'number'; + qtyInput.id = `qty-input-${item.sku}`; + qtyInput.value = item.quantity; + qtyInput.classList.add('quantity-input'); + + const decrementButton = document.createElement('button'); + decrementButton.textContent = '\u2013'; + decrementButton.classList.add('quantity-button'); + + const incrementButton = document.createElement('button'); + incrementButton.textContent = '+'; + incrementButton.classList.add('quantity-button'); + + qtyControlEl.append(decrementButton, qtyInput, incrementButton); + qtyElement.appendChild(qtyControlEl); + + // remove button + const removeBtn = itemElement.querySelector('.cart-item-remove'); + + // event handlers + const updateQty = (newQty) => { + if (newQty < 1) { + cart.removeItem(item.sku); + itemElement.remove(); + return; + } + cart.updateItem(item.sku, newQty); + qtyInput.value = newQty; + totalElement.textContent = `$${(item.price * newQty).toFixed(2)}`; + }; + + decrementButton.addEventListener('click', () => updateQty(+qtyInput.value - 1)); + incrementButton.addEventListener('click', () => updateQty(+qtyInput.value + 1)); + qtyInput.addEventListener('change', (e) => updateQty(+e.target.value)); + removeBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + cart.removeItem(item.sku); + itemElement.remove(); + }); }); }; diff --git a/blocks/header/header.css b/blocks/header/header.css index 67a3f14f..1749ff4f 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -1,4 +1,4 @@ -/* stylelint-disable no-descending-specificity */ +/* stylelint-disable no-descending-specificity 12345 */ header { max-width: var(--site-width); margin: 0 auto; @@ -574,14 +574,15 @@ header .nav-cart [data-cart-items]::after { /* minicart */ header .minicart { - width: clamp(400px, 50vw, min(800px, 100vw)); + width: min(400px, 100vw); min-height: 100vh; background: var(--layer-elevated); - padding: 2em; + padding: 1em 2em 2em; box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); margin: 0 0 0 100%; border: none; transition: transform 0.3s ease-in-out; + overflow: hidden auto; } header .minicart[aria-expanded="false"] { @@ -593,20 +594,25 @@ header .minicart[aria-expanded="true"] { } header .minicart button.close { - position: absolute; - right: 0; - top: 0; - padding: 0.5em; + padding: 0; cursor: pointer; font-weight: 100; transform: scaleX(1.3); font-size: 1.3em; - margin: 0.5em 1em 0.5em 0; + line-height: 1; + margin-left: auto; } header .minicart h2 { margin: 0; font-size: var(--font-size-600); + line-height: 1; +} + +header .minicart .minicart-header { + display: flex; + align-items: center; + margin-bottom: 1em; } header .minicart > div { diff --git a/blocks/header/header.js b/blocks/header/header.js index 8341e294..f8dda1fd 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -454,8 +454,14 @@ export default async function decorate(block) { // update cart qty bubble on change document.addEventListener('cart:change', (e) => { - cartLink.dataset.cartItems = e.detail.cart.itemCount; - cartLink.lastChild.textContent = `Cart (${e.detail.cart.itemCount})`; + const { itemCount } = e.detail.cart; + if (itemCount > 0) { + cartLink.dataset.cartItems = itemCount; + cartLink.lastChild.textContent = `Cart (${itemCount})`; + } else { + delete cartLink.dataset.cartItems; + cartLink.lastChild.textContent = 'Cart'; + } }); // change to edge cart link @@ -502,9 +508,12 @@ export default async function decorate(block) { minicart.setAttribute('aria-expanded', false); block.append(minicart); + const headerRow = document.createElement('div'); + headerRow.className = 'minicart-header'; + const cartTitle = document.createElement('h2'); cartTitle.textContent = 'Cart'; - minicart.append(cartTitle); + headerRow.append(cartTitle); const cartClose = document.createElement('button'); cartClose.className = 'close'; @@ -512,7 +521,8 @@ export default async function decorate(block) { cartClose.addEventListener('click', () => { minicart.closeModal(); }); - minicart.append(cartClose); + headerRow.append(cartClose); + minicart.append(headerRow); const cartBlock = document.createElement('div'); minicart.append(cartBlock); From 93ca9b19094073e9f925a8f77b7c0323c20e41cc Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 27 Mar 2026 18:51:48 -0400 Subject: [PATCH 19/89] fix: checkout tweaks --- blocks/cart-summary/cart-summary.css | 80 ++++++++++++++++++++++------ blocks/cart-summary/cart-summary.js | 48 ++++++++++++++--- blocks/cart/cart.css | 2 +- blocks/checkout/checkout.css | 15 ++++++ blocks/checkout/checkout.js | 37 +++++++++++++ blocks/form/form.js | 1 + scripts/cart.js | 2 +- 7 files changed, 162 insertions(+), 23 deletions(-) diff --git a/blocks/cart-summary/cart-summary.css b/blocks/cart-summary/cart-summary.css index 47e08e1a..d3e36fa3 100644 --- a/blocks/cart-summary/cart-summary.css +++ b/blocks/cart-summary/cart-summary.css @@ -111,25 +111,75 @@ border-radius: 8px; } -.cart-summary-item-quantity { - position: absolute; - top: -8px; - right: -8px; - background: var(--color-black, #333); - color: white; - border-radius: 50%; - width: 24px; - height: 24px; +.cart-summary-item-details { + flex: 1; + min-width: 0; +} + +/* Quantity controls */ +.cart-summary-item-actions { display: flex; align-items: center; - justify-content: center; - font-size: 12px; + gap: 10px; + margin-top: 8px; +} + +.cart-summary .cart-summary-qty-control { + display: flex; + align-items: center; + border: 1px solid var(--color-gray-400, #ccc); + border-radius: 0; + overflow: hidden; +} + +.cart-summary .cart-summary-qty-control button { + width: 30px; + height: 30px; + border: none; + border-radius: 0; + background: white; + font-size: 14px; font-weight: 600; + cursor: pointer; + color: var(--color-text); } -.cart-summary-item-details { - flex: 1; - min-width: 0; +.cart-summary-qty-control button:hover { + background: var(--color-gray-100, #f5f5f5); +} + +.cart-summary .cart-summary-qty-control .qty-input { + width: 32px; + height: 30px; + border: none; + border-left: 1px solid var(--color-gray-400, #ccc); + border-right: 1px solid var(--color-gray-400, #ccc); + border-radius: 0; + text-align: center; + font-size: 13px; + font-weight: 600; + padding: 0; + -moz-appearance: textfield; +} + +.cart-summary-qty-control .qty-input::-webkit-inner-spin-button, +.cart-summary-qty-control .qty-input::-webkit-outer-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.cart-summary-item-remove { + background: none; + border: none; + cursor: pointer; + color: #555; + padding: 4px; + display: flex; + align-items: center; +} + +.cart-summary-item-remove:hover { + color: var(--color-text); } .cart-summary-item-name { @@ -142,7 +192,7 @@ .cart-summary-item-variant { margin: 0; font-size: 13px; - color: var(--color-gray-600, #666); + color: #555; } .cart-summary-item-price { diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js index 05ef0aa5..e213edf9 100644 --- a/blocks/cart-summary/cart-summary.js +++ b/blocks/cart-summary/cart-summary.js @@ -1,4 +1,10 @@ import cart from '../../scripts/cart.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +function getCurrency() { + const { locale } = getLocaleAndLanguage(); + return (locale === 'ca' || locale === 'drafts') ? 'CAD' : 'USD'; +} const template = /* html */`
          @@ -33,7 +39,7 @@ const template = /* html */`
          Total
          - USD +
          @@ -46,11 +52,22 @@ const itemTemplate = /* html */`
          -

          +
          +
          + + + +
          + +
          @@ -69,6 +86,8 @@ export default function decorate(block) { const taxesEl = block.querySelector('.cart-summary-taxes'); const grandTotalEl = block.querySelector('.cart-summary-grand-total'); const headerTotalEl = block.querySelector('.cart-summary-total'); + const currencyEl = block.querySelector('.currency'); + currencyEl.textContent = getCurrency(); // Toggle expansion on mobile header.addEventListener('click', () => { @@ -102,9 +121,6 @@ export default function decorate(block) { img.src = item.image; img.alt = item.name; - const quantity = itemEl.querySelector('.cart-summary-item-quantity'); - quantity.textContent = item.quantity; - const name = itemEl.querySelector('.cart-summary-item-name'); name.textContent = item.name; @@ -118,9 +134,29 @@ export default function decorate(block) { const price = itemEl.querySelector('.cart-summary-item-price'); const itemPrice = typeof item.price === 'string' ? parseFloat(item.price) - : item.price / 100; + : item.price; price.textContent = `$${(itemPrice * item.quantity).toFixed(2)}`; + // quantity controls + const qtyInput = itemEl.querySelector('.qty-input'); + qtyInput.value = item.quantity; + + const updateQty = (newQty) => { + if (newQty < 1) { + cart.removeItem(item.sku); + return; + } + cart.updateItem(item.sku, newQty); + }; + + itemEl.querySelector('.qty-dec').addEventListener('click', () => updateQty(+qtyInput.value - 1)); + itemEl.querySelector('.qty-inc').addEventListener('click', () => updateQty(+qtyInput.value + 1)); + qtyInput.addEventListener('change', (e) => updateQty(+e.target.value)); + + itemEl.querySelector('.cart-summary-item-remove').addEventListener('click', () => { + cart.removeItem(item.sku); + }); + itemsList.appendChild(itemEl.firstElementChild); }); }; diff --git a/blocks/cart/cart.css b/blocks/cart/cart.css index cf3da71a..46fd28c4 100644 --- a/blocks/cart/cart.css +++ b/blocks/cart/cart.css @@ -106,7 +106,7 @@ div.section.cart-section .cart-wrapper { flex-direction: row; align-items: center; border: 1px solid var(--color-gray-400); - border-radius: 4px; + border-radius: 0; overflow: hidden; } diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 643b6112..cdc1289c 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -277,6 +277,21 @@ span.payment-button.paypal .button img { display: none; } +/* Empty cart */ +.checkout-empty { + text-align: center; + padding: 80px 24px; +} + +.checkout-empty h2 { + margin: 0 0 8px; +} + +.checkout-empty p { + margin: 0 0 24px; + color: #555; +} + /* Loading state for pay button */ .checkout-form-column .button.loading { opacity: 0.7; diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 1e0f2d83..9babd9a7 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -210,7 +210,26 @@ async function updatePreview(form, formData, cart) { * Creates and decorates the checkout page with form and cart summary * @param {HTMLElement} block */ +function showEmptyCart(block) { + block.innerHTML = ''; + const empty = document.createElement('div'); + empty.className = 'checkout-empty'; + empty.innerHTML = ` +

          Your cart is empty

          +

          Add some products to your cart to continue.

          + Continue shopping + `; + block.appendChild(empty); +} + export default async function decorate(block) { + // Check if cart has items + const { default: cartCheck } = await import('../../scripts/cart.js'); + if (cartCheck.itemCount === 0) { + showEmptyCart(block); + return; + } + // Create the checkout layout const checkoutContainer = document.createElement('div'); checkoutContainer.className = 'checkout-container'; @@ -339,6 +358,7 @@ export default async function decorate(block) { ['SK', 'Saskatchewan'], ['YT', 'Yukon'], ]; stateSelect.innerHTML = ''; + stateSelect.dataset.optionsOverridden = 'true'; provinces.forEach(([value, label]) => { const opt = document.createElement('option'); opt.value = value; @@ -364,6 +384,23 @@ export default async function decorate(block) { console.warn('checkout: select#shipping-state not found in form'); } + // Re-preview when cart items change (quantity update or item removed) + document.addEventListener('cart:change', async (e) => { + if (['update', 'remove'].includes(e.detail?.action)) { + // Show empty cart if all items removed + if (e.detail.cart.itemCount === 0) { + showEmptyCart(block); + return; + } + currentEstimateToken = null; + currentPreview = null; + if (selectedShippingMethodId) { + const formData = Object.fromEntries(new FormData(form).entries()); + fetchAndPreview(form, formData, shippingMethodsContainer); + } + } + }); + // TODO: Remove test prefill before merging const prefill = { email: 'test@example.com', diff --git a/blocks/form/form.js b/blocks/form/form.js index 949ca0ae..85f793d5 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -127,6 +127,7 @@ async function appendSelectOptions(select, url) { return select; } + if (select.dataset.optionsOverridden) return select; data.forEach((option) => { const optionEl = createElement('option'); optionEl.value = option.Value || option.value; diff --git a/scripts/cart.js b/scripts/cart.js index 0f0baa35..64d7145c 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -197,7 +197,7 @@ export class Cart { name: item.name, price: { final: String(item.price), - currency: 'USD', + currency: window.location.pathname.startsWith('/ca/') ? 'CAD' : 'USD', }, })); } From a7637c699620f1a4d4f10e4e70a9eceef0b593b5 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 31 Mar 2026 21:15:27 -0400 Subject: [PATCH 20/89] fix: various bugs --- blocks/cart-summary/cart-summary.css | 25 ++++ blocks/cart-summary/cart-summary.js | 11 +- blocks/cart/cart.js | 65 ---------- blocks/checkout/checkout.css | 15 ++- blocks/checkout/checkout.js | 168 +++++++++++++----------- blocks/header/header.css | 2 +- blocks/order-summary/order-summary.css | 172 +++++++++++++++++++++++++ blocks/order-summary/order-summary.js | 168 +++++++++++++++++++----- scripts/cart.js | 24 +--- 9 files changed, 456 insertions(+), 194 deletions(-) create mode 100644 blocks/order-summary/order-summary.css diff --git a/blocks/cart-summary/cart-summary.css b/blocks/cart-summary/cart-summary.css index d3e36fa3..3b3722a7 100644 --- a/blocks/cart-summary/cart-summary.css +++ b/blocks/cart-summary/cart-summary.css @@ -276,3 +276,28 @@ font-size: 20px; } +/* Loading state — fade content and show spinner */ +.cart-summary-content.loading { + position: relative; + opacity: 0.4; + pointer-events: none; +} + +.cart-summary-content.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 32px; + height: 32px; + margin: -16px 0 0 -16px; + border: 3px solid #ddd; + border-top-color: #333; + border-radius: 50%; + animation: cart-summary-spin 0.7s linear infinite; +} + +@keyframes cart-summary-spin { + to { transform: rotate(360deg); } +} + diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js index e213edf9..1e1bae5b 100644 --- a/blocks/cart-summary/cart-summary.js +++ b/blocks/cart-summary/cart-summary.js @@ -180,9 +180,18 @@ export default function decorate(block) { updateTotals(); }); + // Show loading state while preview is in flight + const summaryContent = block.querySelector('.cart-summary-content'); + document.addEventListener('checkout:preview-loading', () => { + summaryContent?.classList.add('loading'); + }); + // Listen for real estimates from checkout preview document.addEventListener('checkout:preview', (e) => { - const { preview } = e.detail; + summaryContent?.classList.remove('loading'); + const { preview } = e.detail || {}; + if (!preview) return; + const subtotal = parseFloat(preview.subtotal) || cart.subtotal; const taxAmount = parseFloat(preview.taxAmount) || 0; const shippingRate = preview.shippingMethod?.rate ?? 0; diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index 8716792d..f9998794 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -42,71 +42,6 @@ const template = /* html */`
          `; -/** - * @param {typeof cart.items[0]} item - * @param {HTMLElement} container - * @param {HTMLElement} totalEl - */ -function renderQuantityPicker(item, container, totalEl) { - // initialize the total - totalEl.textContent = `$${(item.price * item.quantity).toFixed(2)}`; - totalEl.setAttribute('data-total', item.price * item.quantity); - - // remove button, to remove the entire line item - const removeButton = document.createElement('button'); - removeButton.textContent = 'Remove'; - removeButton.classList.add('remove-button'); - removeButton.addEventListener('click', (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - cart.removeItem(item.sku); - container.closest('span .cart-item').remove(); - }); - - // decrement, input, increment are grouped into a single visual element - // [ - ] [ 1 ] [ + ] - const qtyControlEl = document.createElement('div'); - qtyControlEl.classList.add('quantity-control'); - - // quantity input - const qtyInput = document.createElement('input'); - qtyInput.type = 'number'; - qtyInput.id = `qty-input-${item.sku}`; - qtyInput.value = item.quantity; - qtyInput.classList.add('quantity-input'); - qtyInput.addEventListener('change', (e) => { - const newQty = e instanceof CustomEvent ? e.detail : +e.target.value; - if (newQty < 1) { - removeButton.click(); - return; - } - cart.updateItem(item.sku, newQty); - qtyInput.value = newQty; - totalEl.textContent = `$${(item.price * newQty).toFixed(2)}`; - totalEl.setAttribute('data-total', item.price * newQty); - }); - // decrement - const decrementButton = document.createElement('button'); - decrementButton.textContent = '-'; - decrementButton.classList.add('quantity-button'); - decrementButton.addEventListener('click', () => { - const newQty = +qtyInput.value - 1; - qtyInput.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: true, detail: newQty })); - }); - // increment - const incrementButton = document.createElement('button'); - incrementButton.textContent = '+'; - incrementButton.classList.add('quantity-button'); - incrementButton.addEventListener('click', () => { - const newQty = +qtyInput.value + 1; - qtyInput.dispatchEvent(new CustomEvent('change', { bubbles: true, cancelable: true, detail: newQty })); - }); - - // add the controls to the group - qtyControlEl.append(decrementButton, qtyInput, incrementButton); - container.append(qtyControlEl, removeButton); -} - /** * Cart page or minicart popover * @param {HTMLElement} block diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index cdc1289c..be2c91f5 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -202,7 +202,7 @@ span.payment-button.paypal .button img { .checkout-form-column form .shipping-methods { order: 4; } .checkout-form-column form .section-payment { order: 5; } .checkout-form-column form > .form-field[data-name="email"] { order: 0; } -.checkout-form-column form > .form-field[data-name="optIn"] { order: 6; } +.checkout-form-column form > .form-field[data-name="optIn"] { order: 0; } .checkout-form-column form .button-wrapper { order: 7; } /* Shipping methods */ @@ -263,6 +263,19 @@ span.payment-button.paypal .button img { font-style: italic; } +/* Required field indicator — label comes before input in the DOM */ +.checkout-form-column form .form-field:has(input[required]) > label::after, +.checkout-form-column form .form-field:has(select[required]) > label::after { + content: ' *'; + color: #c00; +} + +/* Required field error state */ +.checkout-form-column form input.field-required, +.checkout-form-column form select.field-required { + border-color: #c00; +} + /* Checkout error */ .checkout-error { background: #fee; diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 9babd9a7..53b93172 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -150,15 +150,6 @@ async function fetchAndPreview(form, formData, shippingMethodsContainer) { selectedShippingMethodId = firstRate.value; await updatePreview(form, formData, cart); } - - // listen for rate changes - shippingMethodsContainer.addEventListener('change', async (e) => { - if (e.target.name === 'shippingMethod') { - selectedShippingMethodId = e.target.value; - const currentFormData = Object.fromEntries(new FormData(form).entries()); - await updatePreview(form, currentFormData, cart); - } - }); } catch (err) { console.error('Failed to fetch shipping rates', err); renderShippingMethods(shippingMethodsContainer, []); @@ -174,12 +165,30 @@ async function updatePreview(form, formData, cart) { if (!selectedShippingMethodId) return; const email = formData.email || ''; + const firstName = formData['shipping-firstname'] || ''; + const lastName = formData['shipping-lastname'] || ''; + + // Skip preview if required fields are missing — highlight them + if (!firstName || !lastName || !email) { + ['shipping-firstname', 'shipping-lastname', 'email'].forEach((name) => { + const input = form.querySelector(`[name="${name}"]`); + if (input) { + if (!input.value) { + input.classList.add('field-required'); + } else { + input.classList.remove('field-required'); + } + } + }); + return; + } + const shipping = collectAddress(form, formData, 'shipping-', email); const previewBody = { customer: { - firstName: formData['shipping-firstname'] || '', - lastName: formData['shipping-lastname'] || '', + firstName, + lastName, email, }, shipping, @@ -190,9 +199,8 @@ async function updatePreview(form, formData, cart) { }; try { - console.debug('updatePreview: calling previewOrder with', previewBody); + document.dispatchEvent(new CustomEvent('checkout:preview-loading')); const preview = await previewOrder(previewBody); - console.debug('updatePreview: got preview', preview); currentPreview = preview; currentEstimateToken = preview.estimateToken; @@ -203,6 +211,7 @@ async function updatePreview(form, formData, cart) { console.error('Failed to preview order', err); currentPreview = null; currentEstimateToken = null; + document.dispatchEvent(new CustomEvent('checkout:preview')); } } @@ -319,8 +328,13 @@ export default async function decorate(block) { // show billing address section when sameShipBill is unchecked sameShipBillCheckbox.addEventListener('change', () => { - billingAddressSection.setAttribute('aria-hidden', sameShipBillCheckbox.checked); - billingAddressSection.setAttribute('disabled', sameShipBillCheckbox.checked); + if (sameShipBillCheckbox.checked) { + billingAddressSection.setAttribute('aria-hidden', true); + billingAddressSection.setAttribute('disabled', ''); + } else { + billingAddressSection.removeAttribute('aria-hidden'); + billingAddressSection.removeAttribute('disabled'); + } }); // -- Shipping methods section -- @@ -336,6 +350,29 @@ export default async function decorate(block) { billingAddressSection.after(shippingMethodsContainer); } + // Listen for shipping method rate changes (registered once, not inside fetchAndPreview) + shippingMethodsContainer.addEventListener('change', async (e) => { + if (e.target.name === 'shippingMethod') { + selectedShippingMethodId = e.target.value; + const { default: cart } = await import('../../scripts/cart.js'); + const currentFormData = Object.fromEntries(new FormData(formColumn.querySelector('form')).entries()); + await updatePreview(formColumn.querySelector('form'), currentFormData, cart); + } + }); + + // Reorder DOM to match visual layout so tab order is correct + // The form JSON has fields in a different order than the CSS grid displays them + const fieldOrder = ['firstname', 'lastname', 'company', 'street-0', 'street-1', 'city', 'state', 'zip', 'telephone']; + [shippingAddressSection, billingAddressSection].forEach((section) => { + const h3 = section.querySelector('h3'); + fieldOrder.forEach((name) => { + const field = section.querySelector(`.form-field[data-name="${name}"]`); + if (field) section.appendChild(field); + }); + // Keep h3 heading first + if (h3) section.prepend(h3); + }); + // Invalidate estimates when address fields change const form = formColumn.querySelector('form'); const shippingInputs = shippingAddressSection.querySelectorAll('input, select'); @@ -346,44 +383,59 @@ export default async function decorate(block) { }); }); - // Override state dropdown with Canadian provinces for CA locale + // Override state dropdowns with Canadian provinces for CA locale const stateSelect = form.querySelector('select#shipping-state'); - if (stateSelect && getCountry() === 'ca') { - const provinces = [ - ['', 'Select province...'], - ['AB', 'Alberta'], ['BC', 'British Columbia'], ['MB', 'Manitoba'], - ['NB', 'New Brunswick'], ['NL', 'Newfoundland and Labrador'], - ['NS', 'Nova Scotia'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut'], - ['ON', 'Ontario'], ['PE', 'Prince Edward Island'], ['QC', 'Quebec'], - ['SK', 'Saskatchewan'], ['YT', 'Yukon'], - ]; - stateSelect.innerHTML = ''; - stateSelect.dataset.optionsOverridden = 'true'; - provinces.forEach(([value, label]) => { - const opt = document.createElement('option'); - opt.value = value; - opt.textContent = label; - if (!value) opt.disabled = true; - stateSelect.appendChild(opt); + if (getCountry() === 'ca') { + [stateSelect, form.querySelector('select#billing-state')].forEach((sel) => { + if (!sel) return; + const provinces = [ + ['', 'Select province...'], + ['AB', 'Alberta'], ['BC', 'British Columbia'], ['MB', 'Manitoba'], + ['NB', 'New Brunswick'], ['NL', 'Newfoundland and Labrador'], + ['NS', 'Nova Scotia'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut'], + ['ON', 'Ontario'], ['PE', 'Prince Edward Island'], ['QC', 'Quebec'], + ['SK', 'Saskatchewan'], ['YT', 'Yukon'], + ]; + sel.innerHTML = ''; + sel.dataset.optionsOverridden = 'true'; + provinces.forEach(([value, label]) => { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + if (!value) opt.disabled = true; + sel.appendChild(opt); + }); + const lbl = sel.previousElementSibling; + if (lbl?.tagName === 'LABEL') lbl.textContent = 'Province'; }); - // Update label - const stateLabel = stateSelect.previousElementSibling; - if (stateLabel?.tagName === 'LABEL') { - stateLabel.textContent = 'Province'; - } } // Fetch shipping rates when state is selected if (stateSelect) { stateSelect.addEventListener('change', () => { - console.debug('checkout: state changed to', stateSelect.value); const formData = Object.fromEntries(new FormData(form).entries()); fetchAndPreview(form, formData, shippingMethodsContainer); }); - } else { - console.warn('checkout: select#shipping-state not found in form'); } + // Retry preview when customer fields are filled, clear error styling + ['email', 'shipping-firstname', 'shipping-lastname'].forEach((name) => { + const input = form.querySelector(`[name="${name}"]`); + if (input) { + input.addEventListener('input', () => { + if (input.value) input.classList.remove('field-required'); + }); + input.addEventListener('blur', async () => { + if (input.value) input.classList.remove('field-required'); + if (selectedShippingMethodId && !currentPreview) { + const { default: cart } = await import('../../scripts/cart.js'); + const formData = Object.fromEntries(new FormData(form).entries()); + await updatePreview(form, formData, cart); + } + }); + } + }); + // Re-preview when cart items change (quantity update or item removed) document.addEventListener('cart:change', async (e) => { if (['update', 'remove'].includes(e.detail?.action)) { @@ -401,28 +453,6 @@ export default async function decorate(block) { } }); - // TODO: Remove test prefill before merging - const prefill = { - email: 'test@example.com', - 'shipping-firstname': 'Jean', - 'shipping-lastname': 'Tremblay', - 'shipping-telephone': '514-555-1234', - 'shipping-street-0': '1234 Rue Sainte-Catherine', - 'shipping-street-1': '', - 'shipping-city': 'Montréal', - 'shipping-zip': 'H3B 1A7', - 'shipping-company': '', - }; - Object.entries(prefill).forEach(([name, value]) => { - const input = form.querySelector(`[name="${name}"]`); - if (input) input.value = value; - }); - // Prefill province — options are already loaded synchronously for CA - if (stateSelect) { - stateSelect.value = 'QC'; - stateSelect.dispatchEvent(new Event('change', { bubbles: true })); - } - // -- Pay buttons -- const submitButtons = [...formColumn.querySelectorAll('form .button-wrapper button[type="submit"]')]; submitButtons.forEach((button) => { @@ -500,10 +530,11 @@ export default async function decorate(block) { billingAddr = collectAddress(form, formData, 'billing-', email); } + const { default: cart } = await import('../../scripts/cart.js'); + // if we don't have a fresh preview, try to get one now if (!currentEstimateToken) { - const { default: cartForPreview } = await import('../../scripts/cart.js'); - await updatePreview(form, formData, cartForPreview); + await updatePreview(form, formData, cart); } // block checkout if preview/estimates failed @@ -512,8 +543,6 @@ export default async function decorate(block) { reenableButton(); return; } - - const { default: cart } = await import('../../scripts/cart.js'); const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { billingAddr, shippingMethod: selectedShippingMethodId, @@ -522,8 +551,6 @@ export default async function decorate(block) { country, }); - console.debug('creating order', order); - // save checkout context for confirmation page sessionStorage.setItem('checkout_email', email); sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); @@ -533,14 +560,11 @@ export default async function decorate(block) { // create order const { order: createdOrder } = await createOrder(order); - console.debug('order created', createdOrder); sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); // initiate payment const idempotencyKey = crypto.randomUUID(); const payment = await initiatePayment(createdOrder.id, idempotencyKey); - console.debug('payment initiated', payment); - if (payment.action === 'redirect' && payment.redirectUrl) { // redirect to Chase hosted payment page window.location.href = payment.redirectUrl; diff --git a/blocks/header/header.css b/blocks/header/header.css index 1749ff4f..90809dc5 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -1,4 +1,4 @@ -/* stylelint-disable no-descending-specificity 12345 */ +/* stylelint-disable no-descending-specificity */ header { max-width: var(--site-width); margin: 0 auto; diff --git a/blocks/order-summary/order-summary.css b/blocks/order-summary/order-summary.css new file mode 100644 index 00000000..31fdb314 --- /dev/null +++ b/blocks/order-summary/order-summary.css @@ -0,0 +1,172 @@ +/* Order confirmation page */ +.order-result { + max-width: 900px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +/* Header */ +.order-header { + text-align: center; + padding-bottom: 32px; + border-bottom: 1px solid #e0e0e0; + margin-bottom: 32px; +} + +.order-checkmark { + margin-bottom: 16px; +} + +.order-header h2 { + margin: 0 0 8px; + font-size: 28px; +} + +.order-id { + margin: 0 0 4px; + font-size: 14px; + color: #555; +} + +.order-email { + margin: 0; + font-size: 14px; + color: #555; +} + +/* Details grid: items+totals left, address right */ +.order-details { + display: grid; + grid-template-columns: 1fr; + gap: 32px; +} + +@media (width >= 768px) { + .order-details { + grid-template-columns: 3fr 2fr; + } +} + +.order-details h3 { + font-size: 16px; + margin: 0 0 16px; + padding-bottom: 8px; + border-bottom: 1px solid #e0e0e0; +} + +/* Items */ +.order-items { + margin-bottom: 24px; +} + +.order-item { + display: grid; + grid-template-columns: 64px 1fr auto; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid #f0f0f0; + align-items: start; +} + +.order-item:last-child { + border-bottom: none; +} + +.order-item-image { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + background: #f9f9f9; + border-radius: 4px; +} + +.order-item-image img { + max-width: 56px; + max-height: 56px; + object-fit: contain; +} + +.order-item-name { + margin: 0; + font-weight: 600; + font-size: 14px; + line-height: 1.4; +} + +.order-item-variant { + margin: 2px 0 0; + font-size: 13px; + color: #555; +} + +.order-item-qty { + margin: 2px 0 0; + font-size: 13px; + color: #555; +} + +.order-item-price { + font-weight: 600; + font-size: 14px; + text-align: right; + white-space: nowrap; +} + +/* Totals */ +.order-totals { + padding-top: 12px; + border-top: 1px solid #e0e0e0; +} + +.order-totals-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + font-size: 14px; +} + +.order-totals-total { + padding-top: 12px; + margin-top: 8px; + border-top: 1px solid #e0e0e0; + font-size: 16px; +} + +/* Shipping address & contact */ +.order-shipping-address, +.order-contact { + margin-bottom: 24px; +} + +.order-shipping-address p, +.order-contact p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: #333; +} + +/* Actions */ +.order-actions { + text-align: center; + margin-top: 40px; + padding-top: 32px; + border-top: 1px solid #e0e0e0; +} + +/* Cancelled state */ +.order-cancelled { + text-align: center; + padding: 80px 24px; +} + +.order-cancelled h2 { + margin: 0 0 8px; +} + +.order-cancelled p { + margin: 0 0 24px; + color: #555; +} diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js index 41aad346..e746c69d 100644 --- a/blocks/order-summary/order-summary.js +++ b/blocks/order-summary/order-summary.js @@ -11,13 +11,24 @@ export default async function decorate(block) { // cancelled or failed payment if (params.reason) { - block.innerHTML = ` -
          -

          Payment not completed

          -

          ${params.reason === 'customer_cancelled' ? 'You cancelled the payment.' : `Payment could not be processed (${params.reason}).`}

          -

          Return to checkout

          -
          - `; + const container = document.createElement('div'); + container.className = 'order-result order-cancelled'; + + const heading = document.createElement('h2'); + heading.textContent = 'Payment not completed'; + container.appendChild(heading); + + const msg = document.createElement('p'); + msg.textContent = params.reason === 'customer_cancelled' + ? 'You cancelled the payment.' + : 'Payment could not be processed. Please try again.'; + container.appendChild(msg); + + const link = document.createElement('p'); + link.innerHTML = 'Return to checkout'; + container.appendChild(link); + + block.replaceChildren(container); return; } @@ -60,26 +71,50 @@ export default async function decorate(block) { // cart may not be available } - // build confirmation display + // build confirmation page const container = document.createElement('div'); container.className = 'order-result order-confirmed'; + // header section + const headerSection = document.createElement('div'); + headerSection.className = 'order-header'; + + const checkmark = document.createElement('div'); + checkmark.className = 'order-checkmark'; + checkmark.innerHTML = ''; + headerSection.appendChild(checkmark); + const heading = document.createElement('h2'); heading.textContent = 'Thank you for your order!'; - container.appendChild(heading); + headerSection.appendChild(heading); const orderIdEl = document.createElement('p'); orderIdEl.className = 'order-id'; - orderIdEl.innerHTML = `Order ID: ${orderId}`; - container.appendChild(orderIdEl); + const orderIdLabel = document.createElement('span'); + orderIdLabel.textContent = 'Order ID: '; + const orderIdValue = document.createElement('strong'); + orderIdValue.textContent = orderId; + orderIdEl.append(orderIdLabel, orderIdValue); + headerSection.appendChild(orderIdEl); if (email) { const emailEl = document.createElement('p'); + emailEl.className = 'order-email'; emailEl.textContent = `A confirmation will be sent to ${email}.`; - container.appendChild(emailEl); + headerSection.appendChild(emailEl); } - // show order items — prefer cart items (has variant/image) over order items + container.appendChild(headerSection); + + // two-column layout: items + totals on left, shipping on right + const detailsGrid = document.createElement('div'); + detailsGrid.className = 'order-details'; + + // left column: items + totals + const leftCol = document.createElement('div'); + leftCol.className = 'order-details-left'; + + // items const displayItems = cartItems || order?.items; if (displayItems?.length) { const itemsSection = document.createElement('div'); @@ -94,11 +129,13 @@ export default async function decorate(block) { itemEl.className = 'order-item'; if (item.image) { + const imgWrapper = document.createElement('div'); + imgWrapper.className = 'order-item-image'; const img = document.createElement('img'); img.src = item.image; img.alt = item.name || ''; - img.className = 'order-item-image'; - itemEl.appendChild(img); + imgWrapper.appendChild(img); + itemEl.appendChild(imgWrapper); } const details = document.createElement('div'); @@ -112,23 +149,29 @@ export default async function decorate(block) { if (item.variant) { const variant = document.createElement('p'); variant.className = 'order-item-variant'; - variant.textContent = `Color: ${item.variant}`; + variant.textContent = item.variant; details.appendChild(variant); } const qty = document.createElement('p'); qty.className = 'order-item-qty'; - const price = item.price?.final || item.price; - qty.textContent = `Qty: ${item.quantity} — $${price}`; + qty.textContent = `Qty: ${item.quantity}`; details.appendChild(qty); itemEl.appendChild(details); + + const price = document.createElement('div'); + price.className = 'order-item-price'; + const unitPrice = parseFloat(item.price?.final || item.price) || 0; + price.textContent = `$${(unitPrice * item.quantity).toFixed(2)}`; + itemEl.appendChild(price); + itemsSection.appendChild(itemEl); }); - container.appendChild(itemsSection); + leftCol.appendChild(itemsSection); } - // show totals from preview if available + // totals if (preview) { const totalsSection = document.createElement('div'); totalsSection.className = 'order-totals'; @@ -137,38 +180,97 @@ export default async function decorate(block) { ['Subtotal', `$${parseFloat(preview.subtotal).toFixed(2)}`], ['Shipping', preview.shippingMethod?.rate === 0 ? 'Free' : `$${parseFloat(preview.shippingMethod?.rate || 0).toFixed(2)}`], ['Tax', `$${parseFloat(preview.taxAmount).toFixed(2)}`], - ['Total', `$${parseFloat(preview.total).toFixed(2)}`], ]; rows.forEach(([label, value]) => { const row = document.createElement('div'); - row.className = `order-totals-row${label === 'Total' ? ' order-totals-total' : ''}`; - row.innerHTML = `${label}${value}`; + row.className = 'order-totals-row'; + const labelEl = document.createElement('span'); + labelEl.textContent = label; + const valueEl = document.createElement('span'); + valueEl.textContent = value; + row.append(labelEl, valueEl); totalsSection.appendChild(row); }); - container.appendChild(totalsSection); + const totalRow = document.createElement('div'); + totalRow.className = 'order-totals-row order-totals-total'; + const totalLabel = document.createElement('strong'); + totalLabel.textContent = 'Total'; + const totalValue = document.createElement('strong'); + totalValue.textContent = `$${parseFloat(preview.total).toFixed(2)}`; + totalRow.append(totalLabel, totalValue); + totalsSection.appendChild(totalRow); + + leftCol.appendChild(totalsSection); } - // shipping address + detailsGrid.appendChild(leftCol); + + // right column: shipping address + const rightCol = document.createElement('div'); + rightCol.className = 'order-details-right'; + if (order?.shipping) { const addrSection = document.createElement('div'); addrSection.className = 'order-shipping-address'; + const addrHeading = document.createElement('h3'); addrHeading.textContent = 'Shipping address'; addrSection.appendChild(addrHeading); const addr = order.shipping; - const addrLines = [addr.name, addr.address1, addr.address2, `${addr.city}, ${addr.state} ${addr.zip}`, addr.country].filter(Boolean); - const addrEl = document.createElement('p'); - addrEl.innerHTML = addrLines.join('
          '); - addrSection.appendChild(addrEl); - container.appendChild(addrSection); + const lines = [ + addr.name, + addr.company, + addr.address1, + addr.address2, + `${addr.city}, ${addr.state} ${addr.zip}`, + addr.country?.toUpperCase(), + ].filter(Boolean); + + lines.forEach((line) => { + const p = document.createElement('p'); + p.textContent = line; + addrSection.appendChild(p); + }); + + rightCol.appendChild(addrSection); + } + + if (order?.customer) { + const contactSection = document.createElement('div'); + contactSection.className = 'order-contact'; + + const contactHeading = document.createElement('h3'); + contactHeading.textContent = 'Contact'; + contactSection.appendChild(contactHeading); + + const contactEmail = document.createElement('p'); + contactEmail.textContent = order.customer.email; + contactSection.appendChild(contactEmail); + + if (order.customer.phone) { + const contactPhone = document.createElement('p'); + contactPhone.textContent = order.customer.phone; + contactSection.appendChild(contactPhone); + } + + rightCol.appendChild(contactSection); } - const continueLink = document.createElement('p'); - continueLink.innerHTML = 'Continue shopping'; - container.appendChild(continueLink); + detailsGrid.appendChild(rightCol); + container.appendChild(detailsGrid); + + // continue shopping + const actions = document.createElement('div'); + actions.className = 'order-actions'; + const continueLink = document.createElement('a'); + continueLink.href = '/'; + continueLink.className = 'button emphasis'; + continueLink.textContent = 'Continue shopping'; + actions.appendChild(continueLink); + container.appendChild(actions); block.replaceChildren(container); } diff --git a/scripts/cart.js b/scripts/cart.js index 64d7145c..bdae81d6 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -2,7 +2,7 @@ const debounce = (func, wait) => { let timeout; return (...args) => { clearTimeout(timeout); - timeout = setTimeout(() => func.apply(this, args), wait); + timeout = setTimeout(() => func(...args), wait); }; }; @@ -152,12 +152,13 @@ export class Cart { * @param {string} sku */ removeItem(sku) { + const item = this.#items[sku]; delete this.#items[sku]; document.dispatchEvent( new CustomEvent('cart:change', { detail: { cart: this, - item: this.#items[sku], + item, action: 'remove', }, }), @@ -166,25 +167,6 @@ export class Cart { this.#persist(); } - /** - * @param {string} firstName - * @param {string} lastName - * @param {string} email - * @param {string} phone - * @param {{ - * name: string; - * company: string; - * address1: string; - * address2: string; - * city: string; - * state: string; - * zip: string; - * country: string; - * phone: string; - * email: string; - * }} shipping - * @returns {Object} - */ /** * Returns cart items in API-compatible format. * @returns {Array<{sku: string, path: string, quantity: number, name: string, price: {final: string, currency: string}}>} From b5e4ec4be4e4f25f9ec6810f031248a4abf95b95 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 1 Apr 2026 13:41:55 -0400 Subject: [PATCH 21/89] fix: login and checkout updates --- blocks/cart-summary/cart-summary.css | 4 +- blocks/cart/cart.css | 4 +- blocks/checkout/checkout.css | 22 +-- blocks/checkout/checkout.js | 71 ++++---- blocks/form/form.js | 3 + blocks/header/auth-panel.js | 235 +++++++++++++++++++++++++++ blocks/header/header.css | 185 +++++++++++++++++++++ blocks/header/header.js | 45 ++++- package.json | 1 + scripts/auth-api.js | 156 ++++++++++++++++++ scripts/cart.js | 8 +- scripts/commerce-api.js | 72 ++++++-- scripts/scripts.js | 26 +-- scripts/slide-panel.js | 58 +++++++ 14 files changed, 806 insertions(+), 84 deletions(-) create mode 100644 blocks/header/auth-panel.js create mode 100644 scripts/auth-api.js create mode 100644 scripts/slide-panel.js diff --git a/blocks/cart-summary/cart-summary.css b/blocks/cart-summary/cart-summary.css index 3b3722a7..5f3e7982 100644 --- a/blocks/cart-summary/cart-summary.css +++ b/blocks/cart-summary/cart-summary.css @@ -159,12 +159,12 @@ font-size: 13px; font-weight: 600; padding: 0; - -moz-appearance: textfield; + appearance: textfield; } .cart-summary-qty-control .qty-input::-webkit-inner-spin-button, .cart-summary-qty-control .qty-input::-webkit-outer-spin-button { - -webkit-appearance: none; + appearance: none; margin: 0; } diff --git a/blocks/cart/cart.css b/blocks/cart/cart.css index 46fd28c4..e519dd68 100644 --- a/blocks/cart/cart.css +++ b/blocks/cart/cart.css @@ -137,12 +137,12 @@ div.section.cart-section .cart-wrapper { font-size: 14px; font-weight: 600; padding-inline: 0; - -moz-appearance: textfield; + appearance: textfield; } .quantity-control input::-webkit-inner-spin-button, .quantity-control input::-webkit-outer-spin-button { - -webkit-appearance: none; + appearance: none; margin: 0; } diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index be2c91f5..1239be82 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -96,6 +96,17 @@ align-items: end; } +/* Shipping methods */ +.shipping-methods { + border: none; + padding: 0; + width: 100%; +} + +.shipping-methods h3 { + margin-bottom: var(--spacing-40); +} + .checkout-form-column .section-shipping h3, .checkout-form-column .section-billing h3 { grid-column: 1 / -1; @@ -205,17 +216,6 @@ span.payment-button.paypal .button img { .checkout-form-column form > .form-field[data-name="optIn"] { order: 0; } .checkout-form-column form .button-wrapper { order: 7; } -/* Shipping methods */ -.shipping-methods { - border: none; - padding: 0; - width: 100%; -} - -.shipping-methods h3 { - margin-bottom: var(--spacing-40); -} - .shipping-methods.loading { opacity: 0.5; pointer-events: none; diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 53b93172..823773cd 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -123,41 +123,6 @@ function renderShippingMethods(container, rates) { }); } -/** - * Fetch shipping rates and preview the order, dispatching an event with the results. - */ -async function fetchAndPreview(form, formData, shippingMethodsContainer) { - const country = getCountry(); - // Use the select value (province code like "QC") for API calls, - // not the display text ("Quebec") — the shipping sheet matches on codes - const stateValue = formData['shipping-state'] || ''; - - if (!stateValue) return; - - const { default: cart } = await import('../../scripts/cart.js'); - const items = cart.getItemsForAPI(); - if (items.length === 0) return; - - // fetch shipping rates - shippingMethodsContainer.classList.add('loading'); - try { - const { rates } = await estimateShipping(country, stateValue, items); - renderShippingMethods(shippingMethodsContainer, rates); - - // auto-select first rate and preview - const firstRate = shippingMethodsContainer.querySelector('input[name="shippingMethod"]:checked'); - if (firstRate) { - selectedShippingMethodId = firstRate.value; - await updatePreview(form, formData, cart); - } - } catch (err) { - console.error('Failed to fetch shipping rates', err); - renderShippingMethods(shippingMethodsContainer, []); - } finally { - shippingMethodsContainer.classList.remove('loading'); - } -} - /** * Call the order preview API to lock in estimates. */ @@ -208,13 +173,46 @@ async function updatePreview(form, formData, cart) { detail: { preview }, })); } catch (err) { - console.error('Failed to preview order', err); currentPreview = null; currentEstimateToken = null; document.dispatchEvent(new CustomEvent('checkout:preview')); } } +/** + * Fetch shipping rates and preview the order, dispatching an event with the results. + */ +async function fetchAndPreview(form, formData, shippingMethodsContainer) { + const country = getCountry(); + // Use the select value (province code like "QC") for API calls, + // not the display text ("Quebec") — the shipping sheet matches on codes + const stateValue = formData['shipping-state'] || ''; + + if (!stateValue) return; + + const { default: cart } = await import('../../scripts/cart.js'); + const items = cart.getItemsForAPI(); + if (items.length === 0) return; + + // fetch shipping rates + shippingMethodsContainer.classList.add('loading'); + try { + const { rates } = await estimateShipping(country, stateValue, items); + renderShippingMethods(shippingMethodsContainer, rates); + + // auto-select first rate and preview + const firstRate = shippingMethodsContainer.querySelector('input[name="shippingMethod"]:checked'); + if (firstRate) { + selectedShippingMethodId = firstRate.value; + await updatePreview(form, formData, cart); + } + } catch (err) { + renderShippingMethods(shippingMethodsContainer, []); + } finally { + shippingMethodsContainer.classList.remove('loading'); + } +} + /** * Creates and decorates the checkout page with form and cart summary * @param {HTMLElement} block @@ -573,7 +571,6 @@ export default async function decorate(block) { reenableButton(); } } catch (error) { - console.error('Checkout failed', error); const message = error.body?.message || error.message || 'Something went wrong. Please try again.'; showError(formColumn, message); reenableButton(); diff --git a/blocks/form/form.js b/blocks/form/form.js index 85f793d5..baa96c6b 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -118,11 +118,13 @@ async function appendSelectOptions(select, url) { const { pathname } = new URL(url); const resp = await fetch(pathname); if (!resp.ok) { + // eslint-disable-next-line no-console console.error('Failed to fetch select options', resp.status); return select; } const { data } = await resp.json(); if (!data || !Array.isArray(data)) { + // eslint-disable-next-line no-console console.error('Invalid select options JSON', data); return select; } @@ -136,6 +138,7 @@ async function appendSelectOptions(select, url) { }); return select; } catch (error) { + // eslint-disable-next-line no-console console.error('Failed to parse select options', error); return select; } diff --git a/blocks/header/auth-panel.js b/blocks/header/auth-panel.js new file mode 100644 index 00000000..f990406e --- /dev/null +++ b/blocks/header/auth-panel.js @@ -0,0 +1,235 @@ +import createSlidePanel from '../../scripts/slide-panel.js'; +import { login, verifyCode } from '../../scripts/auth-api.js'; + +/** + * Builds the first step of the auth flow: an email input form. + * The form has no submit logic attached — that is wired in `showEmailStep` + * so it has access to the panel's closure state. + * + * @returns {HTMLElement} + */ +function buildEmailStep() { + const step = document.createElement('div'); + step.className = 'auth-step auth-step-email'; + step.innerHTML = ` +

          Sign in

          +

          Enter your email to receive a one-time code.

          +
          + + +

          +
          + `; + return step; +} + +/** + * Attaches keyboard and paste behaviour to the 6 individual digit input boxes + * inside a code step element: + * - Restricts each box to a single numeric digit + * - Auto-advances focus to the next box after a digit is entered + * - Moves focus back on Backspace when the current box is empty + * - Distributes a pasted string across the boxes and focuses the last filled box + * + * @param {HTMLElement} container - The code step element containing `.auth-code-box` inputs + */ +function wireCodeBoxes(container) { + const boxes = container.querySelectorAll('.auth-code-box'); + boxes.forEach((box, i) => { + box.addEventListener('input', () => { + box.value = box.value.replace(/[^0-9]/g, '').slice(0, 1); + if (box.value && i < boxes.length - 1) boxes[i + 1].focus(); + }); + box.addEventListener('keydown', (e) => { + if (e.key === 'Backspace' && !box.value && i > 0) { + boxes[i - 1].focus(); + boxes[i - 1].value = ''; + } + }); + box.addEventListener('paste', (e) => { + e.preventDefault(); + const paste = (e.clipboardData.getData('text') || '').replace(/\D/g, '').slice(0, 6); + paste.split('').forEach((ch, j) => { + if (boxes[j]) boxes[j].value = ch; + }); + const next = Math.min(paste.length, boxes.length - 1); + boxes[next].focus(); + }); + }); +} + +/** + * Reads the current value from all 6 digit boxes and concatenates them + * into a single string. May be shorter than 6 characters if not all boxes + * are filled — callers should validate length before submitting. + * + * @param {HTMLElement} container - The code step element containing `.auth-code-box` inputs + * @returns {string} The concatenated digit string (0–6 characters) + */ +function getCodeValue(container) { + return Array.from(container.querySelectorAll('.auth-code-box')) + .map((b) => b.value) + .join(''); +} + +/** + * Builds the second step of the auth flow: the 6-digit OTP entry form. + * Renders the user's email via `textContent` (not innerHTML) to prevent XSS. + * Code box interaction logic is attached via `wireCodeBoxes`. + * Submit logic is wired in `showCodeStep` so it has access to closure state. + * + * @param {string} email - The email address the OTP was sent to + * @returns {HTMLElement} + */ +function buildCodeStep(email) { + const step = document.createElement('div'); + step.className = 'auth-step auth-step-code'; + + const boxesHtml = Array.from({ length: 6 }, (_, i) => ``).join(''); + + step.innerHTML = ` +

          Check your email

          +

          We sent a 6-digit code to

          +
          +
          ${boxesHtml}
          + +

          +
          + + `; + step.querySelector('.auth-step-desc strong').textContent = email; + + wireCodeBoxes(step); + return step; +} + +/** + * Builds the final step shown after successful OTP verification. + * Renders the user's email via `textContent` (not innerHTML) to prevent XSS. + * The panel auto-closes 1.5s after this step is shown. + * + * @param {string} email - The authenticated user's email address + * @returns {HTMLElement} + */ +function buildSuccessStep(email) { + const step = document.createElement('div'); + step.className = 'auth-step auth-step-success'; + step.innerHTML = ` +
          +

          Welcome

          +

          + `; + step.querySelector('.auth-step-desc').textContent = email; + return step; +} + +/** + * Creates the slide-out authentication panel and returns controls for it. + * + * The panel manages a two-step passwordless OTP flow: + * 1. Email step — user enters their email, triggering an OTP email + * 2. Code step — user enters the 6-digit code; on success the JWT is stored + * and the panel auto-closes after showing a brief success message + * + * OTP state (`hash` and `exp` returned by the login API) is held in a closure + * variable scoped to this panel instance, so it is never accessible outside + * and is cleared on successful verification. + * + * @returns {{ dialog: HTMLDialogElement, open: Function, close: Function, + * showEmailStep: Function }} + */ +export default function createAuthPanel() { + let otpState = null; + + const { + dialog, content, open, close, + } = createSlidePanel('auth-panel', 'Account', 'auth-panel'); + + /** + * Replaces the panel's current content with the given step element and + * moves focus to the first input within it. + * + * @param {HTMLElement} stepEl + */ + function showStep(stepEl) { + content.innerHTML = ''; + content.append(stepEl); + const firstInput = stepEl.querySelector('input'); + if (firstInput) setTimeout(() => firstInput.focus(), 100); + } + + /** + * Renders the email step and wires its submit handler. + * On submit, calls the login API and transitions to the code step. + * Exported so the header can reset the panel to this step on each open. + */ + function showEmailStep() { + const step = buildEmailStep(); + step.querySelector('form').addEventListener('submit', async (e) => { + e.preventDefault(); + const email = step.querySelector('[name="email"]').value.trim(); + const btn = step.querySelector('.auth-submit'); + const errEl = step.querySelector('.auth-error'); + errEl.textContent = ''; + btn.disabled = true; + btn.textContent = 'Sending code\u2026'; + + try { + otpState = await login(email); + // eslint-disable-next-line no-use-before-define + showCodeStep(email); + } catch (err) { + errEl.textContent = err.message || 'Failed to send code'; + btn.disabled = false; + btn.textContent = 'Continue'; + } + }); + showStep(step); + } + + /** + * Renders the code entry step and wires its submit handler. + * Validates that all 6 digits are filled before calling the API to avoid + * consuming one of the 3 server-side attempts with an incomplete code. + * On success, clears OTP state, shows the success step, and auto-closes. + * + * @param {string} email - The email address the OTP was sent to + */ + function showCodeStep(email) { + const step = buildCodeStep(email); + step.querySelector('form').addEventListener('submit', async (e) => { + e.preventDefault(); + const code = getCodeValue(step); + const btn = step.querySelector('.auth-submit'); + const errEl = step.querySelector('.auth-error'); + errEl.textContent = ''; + + if (code.length < 6) { + errEl.textContent = 'Please enter all 6 digits.'; + return; + } + + btn.disabled = true; + btn.textContent = 'Verifying\u2026'; + + try { + await verifyCode(email, code, otpState.hash, otpState.exp); + otpState = null; + showStep(buildSuccessStep(email)); + setTimeout(close, 1500); + } catch (err) { + errEl.textContent = err.message || 'Invalid code'; + btn.disabled = false; + btn.textContent = 'Verify'; + } + }); + + step.querySelector('.auth-back').addEventListener('click', showEmailStep); + showStep(step); + } + + return { + dialog, open, close, showEmailStep, + }; +} diff --git a/blocks/header/header.css b/blocks/header/header.css index 90809dc5..29cedc0a 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -746,3 +746,188 @@ header.header-commercial .nav-sections .submenu-wrapper ul > li.submenu-wrapper border-color: var(--color-gray-700); color: inherit; } + +/* slide panel (shared by minicart + auth panel) */ + +.slide-panel-header { + display: flex; + align-items: center; + margin-bottom: 1em; +} + +.slide-panel-header h2 { + margin: 0; + font-size: var(--font-size-600); + line-height: 1; +} + +.slide-panel-close { + padding: 0; + cursor: pointer; + font-weight: 100; + transform: scaleX(1.3); + font-size: 1.3em; + line-height: 1; + margin-left: auto; + border: none; + background: none; + color: var(--color-text); +} + +/* auth panel */ + +header .auth-panel { + width: min(400px, 100vw); + min-height: 100vh; + background: var(--layer-elevated); + padding: 1em 2em 2em; + box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); + margin: 0 0 0 100%; + border: none; + transition: transform 0.3s ease-in-out; + overflow: hidden auto; +} + +header .auth-panel[aria-expanded="false"] { + transform: translateX(0); +} + +header .auth-panel[aria-expanded="true"] { + transform: translateX(-100%); +} + +header .auth-panel::backdrop { + background: rgb(0 0 0 / 50%); +} + +.auth-step { + display: flex; + flex-direction: column; +} + +.auth-step h3 { + margin: 0 0 4px; + font-size: 1.125rem; + font-weight: 600; +} + +.auth-step-desc { + color: var(--color-gray-500); + font-size: 0.875rem; + margin: 0 0 1em; +} + +.auth-form { + display: flex; + flex-direction: column; + gap: 8px; +} + +.auth-input { + width: 100%; + padding: 0.625rem 0.75rem; + border: 1px solid var(--color-gray-700); + border-radius: 4px; + font-family: inherit; + font-size: 0.875rem; + color: var(--color-text); + background: var(--color-white); + box-sizing: border-box; + transition: border-color 0.2s; +} + +.auth-input:focus { + outline: none; + border-color: var(--color-charcoal); +} + +.auth-code-boxes { + display: flex; + gap: 8px; + justify-content: center; +} + +.auth-code-box { + width: 44px; + height: 52px; + border: 1px solid var(--color-gray-700); + border-radius: 4px; + text-align: center; + font-size: 1.25rem; + font-weight: 600; + font-family: inherit; + color: var(--color-text); + background: var(--color-white); + box-sizing: border-box; + transition: border-color 0.2s, box-shadow 0.2s; + padding: 0; +} + +.auth-code-box:focus { + outline: none; + border-color: var(--color-charcoal); + box-shadow: 0 0 0 3px rgb(0 0 0 / 10%); +} + +.auth-submit { + width: 100%; + padding: 0.75rem; + margin-top: 8px; + background: var(--color-charcoal); + color: var(--color-white); + border: none; + border-radius: 4px; + font-family: inherit; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.auth-submit:hover { + background: var(--color-dark-charcoal); +} + +.auth-submit:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.auth-error { + color: var(--color-red); + font-size: 0.8125rem; + min-height: 1.2em; + margin: 0; +} + +.auth-back { + border: none; + background: none; + color: var(--color-link); + font-size: 0.8125rem; + cursor: pointer; + padding: 0; + margin-top: 12px; + text-align: left; +} + +.auth-back:hover { + text-decoration: underline; +} + +.auth-step-success { + text-align: center; + padding: 2em 0; +} + +.auth-success-icon { + width: 48px; + height: 48px; + margin: 0 auto 12px; + border-radius: 50%; + background: var(--color-asparagus); + color: var(--color-white); + font-size: 1.5rem; + line-height: 48px; + text-align: center; +} diff --git a/blocks/header/header.js b/blocks/header/header.js index f8dda1fd..fb9c9b10 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -1,6 +1,7 @@ import { getMetadata, toClassName } from '../../scripts/aem.js'; import { swapIcons, getCookies } from '../../scripts/scripts.js'; import { loadFragment } from '../fragment/fragment.js'; +import { AUTH_TOKEN_KEY } from '../../scripts/auth-api.js'; const EDGE_CART_PATH = '/drafts/maxed/checkout/cart'; // const EDGE_CART_PATH = '/checkout/cart'; @@ -438,10 +439,49 @@ export default async function decorate(block) { } } + const accountLink = block.querySelector('.icon-account').parentElement; + const customer = cookies.vitamix_customer; if (customer) { - const account = block.querySelector('.icon-account').parentElement; - account.lastChild.textContent = `${customer}'s Account`; + accountLink.lastChild.textContent = `${customer}'s Account`; + } + + // --- Auth state display (all pages) --- + + const updateAuthUI = (loggedIn) => { + accountLink.classList.toggle('is-logged-in', !!loggedIn); + }; + + document.addEventListener('commerce:auth-state-changed', (ev) => { + updateAuthUI(ev.detail.loggedIn); + }); + + // restore auth UI on page load + try { + const hasToken = !!sessionStorage.getItem(AUTH_TOKEN_KEY); + if (hasToken) updateAuthUI(true); + } catch { /* ignore */ } + + // --- Auth panel (edge cart mode only) --- + if (window.cartMode === 'edge') { + let authPanel = null; + const ensureAuthPanel = async () => { + if (authPanel) return authPanel; + const { default: createAuthPanel } = await import('./auth-panel.js'); + authPanel = createAuthPanel(); + block.append(authPanel.dialog); + return authPanel; + }; + + accountLink.addEventListener('click', async (e) => { + const hasToken = !!sessionStorage.getItem(AUTH_TOKEN_KEY); + if (!hasToken) { + e.preventDefault(); + const panel = await ensureAuthPanel(); + panel.showEmailStep(); + panel.open(); + } + }); } const cartItems = cookies.cart_items_count; @@ -567,7 +607,6 @@ export default async function decorate(block) { // scroll item into view scrollToItem(); } catch (error) { - console.error('Error importing cart:', error); setTimeout(() => { // redirect to cart page window.location.href = EDGE_CART_PATH; diff --git a/package.json b/package.json index 1faf161b..4c6c07d7 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "lint:js": "eslint .", "lint:css": "stylelint blocks/**/*.css styles/*.css widgets/**/*.css", "lint": "npm run lint:js && npm run lint:css", + "lint:fix": "npm run lint:js -- --fix && npm run lint:css -- --fix", "build:json": "npm run build:json:models && npm run build:json:definitions && npm run build:json:filters", "build:json:models": "merge-json-cli -i \"ue/models/component-models.json\" -o \"component-models.json\"", "build:json:definitions": "merge-json-cli -i \"ue/models/component-definition.json\" -o \"component-definition.json\"", diff --git a/scripts/auth-api.js b/scripts/auth-api.js new file mode 100644 index 00000000..2c7256c1 --- /dev/null +++ b/scripts/auth-api.js @@ -0,0 +1,156 @@ +import { ORDERS_API_ORIGIN } from './scripts.js'; + +/** sessionStorage key for the JWT issued after OTP verification */ +export const AUTH_TOKEN_KEY = 'auth_token'; + +/** localStorage key for the serialised user object { email, roles } */ +export const AUTH_USER_KEY = 'auth_user'; + +/** CustomEvent name dispatched on document when auth state changes */ +export const AUTH_EVENT = 'commerce:auth-state-changed'; + +/** + * Dispatches a `commerce:auth-state-changed` CustomEvent on document. + * @param {boolean} loggedIn - Whether the user is now authenticated + * @param {string|null} email - The user's email, or null when logged out + */ +function dispatchAuthEvent(loggedIn, email) { + document.dispatchEvent(new CustomEvent(AUTH_EVENT, { detail: { loggedIn, email } })); +} + +/** + * Initiates the OTP login flow for the given email address. + * Sends a POST to /auth/login, which triggers the API to email a 6-digit + * one-time code to the user. The returned `hash` and `exp` values must be + * passed back to `verifyCode` to complete authentication. + * + * @param {string} email - The user's email address + * @returns {Promise<{ email: string, hash: string, exp: number }>} + * @throws {Error} If the request fails or the API returns an error + */ +export async function login(email) { + const resp = await fetch(`${ORDERS_API_ORIGIN}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || `Login failed: ${resp.status}`); + } + return resp.json(); +} + +/** + * Completes the OTP login flow by verifying the code the user received. + * Sends a POST to /auth/callback with the code and the `hash`/`exp` values + * returned by `login`. On success, stores the JWT in sessionStorage (cleared + * when the browser tab closes) and the user object in localStorage (persists + * across sessions for UI restoration), then dispatches an auth state event. + * + * @param {string} email - The user's email address + * @param {string} code - The 6-digit OTP code from the user's email + * @param {string} hash - The HMAC hash returned by `login` + * @param {number} exp - The expiry timestamp returned by `login` + * @returns {Promise<{ success: boolean, token: string, email: string, roles: string[] }>} + * @throws {Error} If the code is invalid, expired, or the request fails + */ +export async function verifyCode(email, code, hash, exp) { + const resp = await fetch(`${ORDERS_API_ORIGIN}/auth/callback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, code, hash, exp, + }), + }); + if (!resp.ok) { + const err = await resp.json().catch(() => ({})); + throw new Error(err.message || `Verification failed: ${resp.status}`); + } + const data = await resp.json(); + if (data.token) { + sessionStorage.setItem(AUTH_TOKEN_KEY, data.token); + } + localStorage.setItem(AUTH_USER_KEY, JSON.stringify({ + email: data.email, + roles: data.roles, + })); + dispatchAuthEvent(true, data.email); + return data; +} + +/** + * Logs the current user out. + * Makes a best-effort POST to /auth/logout to invalidate the token server-side, + * then unconditionally clears the JWT from sessionStorage and the user object + * from localStorage regardless of whether the server call succeeds. + * Dispatches an auth state event on completion. + * + * @returns {Promise} + */ +export async function logout() { + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + try { + await fetch(`${ORDERS_API_ORIGIN}/auth/logout`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + } catch { /* best-effort */ } + sessionStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_USER_KEY); + dispatchAuthEvent(false, null); +} + +/** + * Returns whether the current user has an active session. + * Checks for the presence of a JWT in sessionStorage. Note that this does not + * validate the token with the server — it only checks local storage state. + * + * @returns {boolean} + */ +export function isLoggedIn() { + return !!sessionStorage.getItem(AUTH_TOKEN_KEY); +} + +/** + * Returns the stored user object for the current session, or null if not + * logged in. The object is written to localStorage by `verifyCode` and + * persists across browser sessions for UI restoration purposes, but a missing + * JWT in sessionStorage means the user is effectively logged out. + * + * @returns {{ email: string, roles: string[] }|null} + */ +export function getUser() { + const raw = localStorage.getItem(AUTH_USER_KEY); + if (!raw) return null; + try { return JSON.parse(raw); } catch { return null; } +} + +/** + * Wrapper around `fetch` that attaches the current user's Bearer token to the + * Authorization header. If the server responds with 401, the local session is + * cleared and an auth state event is dispatched so the UI can react. + * + * @param {string} url - The URL to fetch + * @param {RequestInit} [options={}] - Standard fetch options (method, body, etc.) + * @returns {Promise} + * @throws {Error} If the user is not authenticated (no token in sessionStorage) + */ +export async function authFetch(url, options = {}) { + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + if (!token) throw new Error('Not authenticated'); + + const headers = new Headers(options.headers || {}); + headers.set('Authorization', `Bearer ${token}`); + + const resp = await fetch(url, { ...options, headers }); + if (resp.status === 401) { + sessionStorage.removeItem(AUTH_TOKEN_KEY); + localStorage.removeItem(AUTH_USER_KEY); + dispatchAuthEvent(false, null); + } + return resp; +} diff --git a/scripts/cart.js b/scripts/cart.js index bdae81d6..0b9a016a 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -7,7 +7,10 @@ const debounce = (func, wait) => { }; export class Cart { - static STORAGE_KEY = 'cart'; + static get STORAGE_KEY() { + const locale = window.location.pathname.split('/')[1] || 'default'; + return `cart:${locale === 'drafts' ? 'ca' : locale}`; + } static STORAGE_VERSION = 1; @@ -169,7 +172,8 @@ export class Cart { /** * Returns cart items in API-compatible format. - * @returns {Array<{sku: string, path: string, quantity: number, name: string, price: {final: string, currency: string}}>} + * @returns {Array<{sku: string, path: string, quantity: number, name: string, + * price: {final: string, currency: string}}>} */ getItemsForAPI() { return this.items.map((item) => ({ diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index 893d7752..c169fc30 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -1,5 +1,10 @@ import { ORDERS_API_ORIGIN } from './scripts.js'; +/** + * Error thrown when the Commerce API returns a non-2xx response. + * Carries the HTTP status code and the parsed response body so callers + * can inspect the error detail without re-parsing the response. + */ class CommerceApiError extends Error { constructor(status, body) { super(body?.message || `API error ${status}`); @@ -8,10 +13,24 @@ class CommerceApiError extends Error { } } +/** + * Makes an authenticated POST request to the Commerce API. + * Attaches a Bearer token from sessionStorage if one is present, so orders + * created while a user is logged in are associated with their account. + * + * @param {string} path - API path relative to ORDERS_API_ORIGIN (e.g. '/orders') + * @param {Object} body - Request payload, serialised as JSON + * @returns {Promise} Parsed JSON response body + * @throws {CommerceApiError} If the response status is not 2xx + */ async function post(path, body) { + const headers = { 'Content-Type': 'application/json' }; + const token = sessionStorage.getItem('auth_token'); + if (token) headers.Authorization = `Bearer ${token}`; + const resp = await fetch(`${ORDERS_API_ORIGIN}${path}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify(body), }); const data = await resp.json(); @@ -22,11 +41,16 @@ async function post(path, body) { } /** - * Fetch available shipping rates for a given address and items. - * @param {string} country - * @param {string} state - * @param {Array} items - Items in API format (sku, path, quantity, price) - * @returns {Promise<{rates: Array}>} + * Fetches available shipping rates for a given destination and cart contents. + * Used to populate the shipping method selector on the checkout form before + * the order is previewed or placed. + * + * @param {string} country - ISO 3166-1 alpha-2 country code (e.g. 'ca', 'us') + * @param {string} state - State or province code (e.g. 'QC', 'CA') + * @param {Array<{sku: string, path: string, quantity: number, price: Object}>} items + * Cart items in API format + * @returns {Promise<{ rates: Array<{ id: string, label: string, rate: number }> }>} + * @throws {CommerceApiError} */ export async function estimateShipping(country, state, items) { return post('/estimate/shipping', { @@ -37,28 +61,44 @@ export async function estimateShipping(country, state, items) { } /** - * Preview an order to lock in estimates and get an estimateToken. - * @param {Object} orderBody - Full order body including shippingMethod.id - * @returns {Promise<{subtotal, taxAmount, taxRate, shippingMethod, discounts, total, estimateToken}>} + * Previews an order to lock in tax, discounts, and shipping cost, and + * obtain an `estimateToken` that must be included when placing the order. + * Call this after the user selects a shipping method and before payment. + * + * @param {Object} orderBody - Full order body including customer, shipping, items, + * and shippingMethod.id + * @returns {Promise<{ subtotal: number, taxAmount: number, taxRate: number, + * shippingMethod: Object, discounts: Array, total: number, estimateToken: string }>} + * @throws {CommerceApiError} */ export async function previewOrder(orderBody) { return post('/orders/preview', orderBody); } /** - * Create an order. - * @param {Object} orderBody - Full order body including estimateToken - * @returns {Promise<{order: Object}>} + * Places an order. The request body must include the `estimateToken` returned + * by `previewOrder` to confirm the quoted totals. If the user is logged in, + * the order is automatically associated with their account via the Bearer token. + * + * @param {Object} orderBody - Full order body including customer, shipping, items, + * shippingMethod, and estimateToken + * @returns {Promise<{ order: Object }>} + * @throws {CommerceApiError} */ export async function createOrder(orderBody) { return post('/orders', orderBody); } /** - * Initiate payment for an order. - * @param {string} orderId - * @param {string} idempotencyKey - * @returns {Promise<{orderId, paymentAttemptId, status, action, redirectUrl}>} + * Initiates a Chase card payment for a placed order. + * The idempotency key ensures that retrying the same request (e.g. after a + * network failure) does not result in a duplicate charge. + * + * @param {string} orderId - The order ID returned by `createOrder` + * @param {string} idempotencyKey - A unique key for this payment attempt (e.g. a UUID) + * @returns {Promise<{ orderId: string, paymentAttemptId: string, status: string, + * action: string, redirectUrl: string }>} + * @throws {CommerceApiError} */ export async function initiatePayment(orderId, idempotencyKey) { return post(`/orders/${orderId}/payments`, { diff --git a/scripts/scripts.js b/scripts/scripts.js index 2ada5e55..777aacdc 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -21,13 +21,11 @@ const { hostname } = window.location; export const ORDERS_API_ORIGIN = 'https://api-stage.adobecommerce.live/aemsites/sites/vitamix'; -const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('uat.vitamix.com'); -const { locale } = getLocaleAndLanguage(); -window.cartMode = (isEdgeHost && locale === 'ca') ? 'edge' : 'legacy'; -if (['edge', 'legacy'].includes(localStorage.getItem('cartMode'))) { - window.cartMode = localStorage.getItem('cartMode'); -} +// Locales enabled for edge cart on production. +// Add locale codes here as each region goes live (e.g., 'ca', 'fr_ca'). +const EDGE_CART_LOCALES = []; +const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('uat.vitamix.com'); const isProdHost = hostname.includes('vitamix.com'); export const FORMS_ENDPOINT = isProdHost ? 'https://main--vitamix--aemsites.aem.network' // TODO: make empty string when Akamai ready @@ -1162,9 +1160,15 @@ async function simulatePDPPreview() { * @param {Element} doc The container element */ async function loadEager(doc) { - const locale = window.location.pathname.split('/')[2]; - const language = locale ? locale.split('_')[0] : 'en'; - document.documentElement.lang = language; + const { locale, language } = getLocaleAndLanguage(); + document.documentElement.lang = language ? language.split('_')[0] : 'en'; + + // Dev/staging hosts: edge cart enabled for all locales. + // Production: edge cart enabled only for locales listed in EDGE_CART_LOCALES. + window.cartMode = (isEdgeHost || EDGE_CART_LOCALES.includes(locale)) ? 'edge' : 'legacy'; + if (['edge', 'legacy'].includes(localStorage.getItem('cartMode'))) { + window.cartMode = localStorage.getItem('cartMode'); + } /* simulation date */ const params = new URLSearchParams(window.location.search); @@ -1295,8 +1299,8 @@ async function loadLazy(doc) { function decorateExternalLinks() { const externalLinks = document.querySelectorAll('a[href^="https://"]'); externalLinks.forEach((link) => { - const { hostname } = new URL(link.href); - if (!link.href.includes('vitamix') || hostname === 'localhost') { + const { hostname: linkHostname } = new URL(link.href); + if (!link.href.includes('vitamix') || linkHostname === 'localhost') { link.setAttribute('target', '_blank'); link.setAttribute('rel', 'noopener'); } diff --git a/scripts/slide-panel.js b/scripts/slide-panel.js new file mode 100644 index 00000000..220f393d --- /dev/null +++ b/scripts/slide-panel.js @@ -0,0 +1,58 @@ +/** + * Creates a reusable slide-out dialog panel. + * @param {string} id - Element ID for the dialog + * @param {string} title - Header title text + * @param {string} className - CSS class for the dialog + * @returns {{ dialog: HTMLDialogElement, content: HTMLElement, open: Function, close: Function }} + */ +export default function createSlidePanel(id, title, className) { + const dialog = document.createElement('dialog'); + dialog.id = id; + dialog.className = className; + dialog.setAttribute('aria-expanded', 'false'); + + const headerRow = document.createElement('div'); + headerRow.className = 'slide-panel-header'; + + const titleEl = document.createElement('h2'); + titleEl.textContent = title; + headerRow.append(titleEl); + + const closeBtn = document.createElement('button'); + closeBtn.className = 'slide-panel-close'; + closeBtn.textContent = '\u00D7'; + closeBtn.setAttribute('aria-label', 'Close'); + headerRow.append(closeBtn); + + const content = document.createElement('div'); + content.className = 'slide-panel-content'; + + dialog.append(headerRow, content); + + function open() { + dialog.showModal(); + dialog.setAttribute('aria-expanded', 'true'); + } + + function close() { + dialog.setAttribute('aria-expanded', 'false'); + setTimeout(() => { + dialog.close(); + }, 300); + } + + closeBtn.addEventListener('click', close); + + dialog.addEventListener('click', (e) => { + const rect = dialog.getBoundingClientRect(); + const inside = rect.top <= e.clientY + && e.clientY <= rect.top + rect.height + && rect.left <= e.clientX + && e.clientX <= rect.left + rect.width; + if (!inside) close(); + }); + + return { + dialog, content, open, close, + }; +} From 967805a1b497246a431dcbbb63c5ca7816f1448f Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 1 Apr 2026 21:15:19 -0400 Subject: [PATCH 22/89] fix: cart fixes.. new checkout routes --- blocks/cart/cart.css | 28 ++++++++++++++++++ blocks/cart/cart.js | 13 ++++++++- blocks/checkout/checkout.js | 6 ++-- blocks/header/header.css | 42 +++++++-------------------- blocks/header/header.js | 41 +++++++++++++++++--------- blocks/order-summary/order-summary.js | 8 ++++- scripts/cart.js | 1 + scripts/commerce-api.js | 3 +- scripts/scripts.js | 11 +++++++ 9 files changed, 103 insertions(+), 50 deletions(-) diff --git a/blocks/cart/cart.css b/blocks/cart/cart.css index e519dd68..e9a1f43a 100644 --- a/blocks/cart/cart.css +++ b/blocks/cart/cart.css @@ -194,4 +194,32 @@ div.section.cart-section .cart-wrapper { width: 100%; text-align: center; box-sizing: border-box; +} + +/* Empty cart state */ +.cart-empty-message { + display: none; +} + +.cart.cart-is-empty .cart-items-header, +.cart.cart-is-empty .cart-items-footer, +.cart.cart-is-empty .cart-controls { + display: none; +} + +.cart.cart-is-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - var(--header-height, 80px) - 120px); +} + +.cart.cart-is-empty .cart-empty-message { + display: block; + text-align: center; + color: #555; +} + +.minicart .cart.cart-is-empty { + min-height: calc(100vh - var(--header-height) - 16em); } \ No newline at end of file diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index f9998794..756da0f2 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -1,5 +1,6 @@ import { loadCSS, createOptimizedPicture } from '../../scripts/aem.js'; import cart from '../../scripts/cart.js'; +import { getOrderPath } from '../../scripts/scripts.js'; const itemTemplate = /* html */`
          @@ -37,8 +38,11 @@ const template = /* html */`
          +
          +

          Your cart is empty.

          +
          `; @@ -56,10 +60,17 @@ export default async function decorate(block, parent) { } block.innerHTML = template; + block.querySelector('.cart-checkout').href = getOrderPath('checkout'); const itemList = block.querySelector('.cart-items-list'); + const cartEl = block.querySelector('.cart'); + + const updateEmptyState = () => { + cartEl.classList.toggle('cart-is-empty', cart.items.length === 0); + }; const populatelist = () => { itemList.innerHTML = ''; + updateEmptyState(); // add each item to the list cart.items.forEach((item) => { diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 823773cd..d5c8507e 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -4,7 +4,7 @@ import { loadBlock, loadScript, } from '../../scripts/aem.js'; -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getLocaleAndLanguage, getOrderPath } from '../../scripts/scripts.js'; import { estimateShipping, previewOrder, @@ -12,7 +12,7 @@ import { initiatePayment, } from '../../scripts/commerce-api.js'; -const ADDRESS_FORM = 'https://main--vitamix--aemsites.aem.page/drafts/maxed/checkout/address-form.json'; +const ADDRESS_FORM = () => `https://main--vitamix--aemsites.aem.page${getOrderPath('address-form')}.json`; let currentEstimateToken = null; let currentPreview = null; @@ -250,7 +250,7 @@ export default async function decorate(block) { summaryColumn.className = 'checkout-summary-column'; // Build the form block - const formContent = [[``]]; + const formContent = [[``]]; const formBlock = buildBlock('form', formContent); formColumn.appendChild(formBlock); decorateBlock(formBlock); diff --git a/blocks/header/header.css b/blocks/header/header.css index 29cedc0a..edc9c61b 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -593,27 +593,6 @@ header .minicart[aria-expanded="true"] { transform: translateX(-100%); } -header .minicart button.close { - padding: 0; - cursor: pointer; - font-weight: 100; - transform: scaleX(1.3); - font-size: 1.3em; - line-height: 1; - margin-left: auto; -} - -header .minicart h2 { - margin: 0; - font-size: var(--font-size-600); - line-height: 1; -} - -header .minicart .minicart-header { - display: flex; - align-items: center; - margin-bottom: 1em; -} header .minicart > div { margin-top: 1em; @@ -806,26 +785,26 @@ header .auth-panel::backdrop { } .auth-step h3 { - margin: 0 0 4px; + margin: 0 0 8px; font-size: 1.125rem; font-weight: 600; } .auth-step-desc { - color: var(--color-gray-500); + color: var(--color-gray-800, #333); font-size: 0.875rem; - margin: 0 0 1em; + margin: 0 0 1.5em; } .auth-form { display: flex; flex-direction: column; - gap: 8px; + gap: 12px; } .auth-input { width: 100%; - padding: 0.625rem 0.75rem; + padding: 0.75rem 0.875rem; border: 1px solid var(--color-gray-700); border-radius: 4px; font-family: inherit; @@ -843,13 +822,14 @@ header .auth-panel::backdrop { .auth-code-boxes { display: flex; - gap: 8px; + gap: 10px; justify-content: center; + margin: 8px 0; } .auth-code-box { - width: 44px; - height: 52px; + width: 48px; + height: 56px; border: 1px solid var(--color-gray-700); border-radius: 4px; text-align: center; @@ -872,7 +852,7 @@ header .auth-panel::backdrop { .auth-submit { width: 100%; padding: 0.75rem; - margin-top: 8px; + margin-top: 16px; background: var(--color-charcoal); color: var(--color-white); border: none; @@ -907,7 +887,7 @@ header .auth-panel::backdrop { font-size: 0.8125rem; cursor: pointer; padding: 0; - margin-top: 12px; + margin-top: 20px; text-align: left; } diff --git a/blocks/header/header.js b/blocks/header/header.js index fb9c9b10..e1854f57 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -1,10 +1,9 @@ import { getMetadata, toClassName } from '../../scripts/aem.js'; -import { swapIcons, getCookies } from '../../scripts/scripts.js'; +import { swapIcons, getCookies, getOrderPath } from '../../scripts/scripts.js'; import { loadFragment } from '../fragment/fragment.js'; import { AUTH_TOKEN_KEY } from '../../scripts/auth-api.js'; -const EDGE_CART_PATH = '/drafts/maxed/checkout/cart'; -// const EDGE_CART_PATH = '/checkout/cart'; +const EDGE_CART_PATH = () => getOrderPath('cart'); // media query match that indicates desktop width const isDesktop = window.matchMedia('(width >= 1000px)'); @@ -484,12 +483,27 @@ export default async function decorate(block) { }); } - const cartItems = cookies.cart_items_count; const cartLink = block.querySelector('.icon-cart').parentElement; - if (cartItems && +cartItems > 0) { - cartLink.dataset.cartItems = cartItems; - cartLink.lastChild.textContent = `Cart (${cartItems})`; + // Read item count from locale-specific localStorage key (same logic as Cart.STORAGE_KEY) + // to avoid stale domain-wide cookie when switching between locales (e.g. CA → US). + const getStoredCartCount = () => { + try { + const locale = window.location.pathname.split('/')[1] || 'default'; + const key = `cart:${locale === 'drafts' ? 'ca' : locale}`; + const stored = localStorage.getItem(key); + if (!stored) return 0; + const parsed = JSON.parse(stored); + return (parsed.items || []).reduce((acc, item) => acc + item.quantity, 0); + } catch { + return 0; + } + }; + + const initialCount = getStoredCartCount(); + if (initialCount > 0) { + cartLink.dataset.cartItems = initialCount; + cartLink.lastChild.textContent = `Cart (${initialCount})`; } // update cart qty bubble on change @@ -506,12 +520,12 @@ export default async function decorate(block) { // change to edge cart link if (window.cartMode === 'edge') { - cartLink.href = EDGE_CART_PATH; + cartLink.href = EDGE_CART_PATH(); } const openOrRedirect = async (e) => { // if already on cart page, do nothing - if (window.location.pathname.includes(EDGE_CART_PATH)) { + if (window.location.pathname.includes(EDGE_CART_PATH())) { return; } @@ -549,15 +563,16 @@ export default async function decorate(block) { block.append(minicart); const headerRow = document.createElement('div'); - headerRow.className = 'minicart-header'; + headerRow.className = 'slide-panel-header'; const cartTitle = document.createElement('h2'); cartTitle.textContent = 'Cart'; headerRow.append(cartTitle); const cartClose = document.createElement('button'); - cartClose.className = 'close'; - cartClose.textContent = 'X'; + cartClose.className = 'slide-panel-close'; + cartClose.textContent = '\u00D7'; + cartClose.setAttribute('aria-label', 'Close'); cartClose.addEventListener('click', () => { minicart.closeModal(); }); @@ -609,7 +624,7 @@ export default async function decorate(block) { } catch (error) { setTimeout(() => { // redirect to cart page - window.location.href = EDGE_CART_PATH; + window.location.href = EDGE_CART_PATH(); }, 1000); } }; diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js index e746c69d..3117b9cd 100644 --- a/blocks/order-summary/order-summary.js +++ b/blocks/order-summary/order-summary.js @@ -1,3 +1,5 @@ +import { getOrderPath } from '../../scripts/scripts.js'; + /** * Order confirmation / cancellation page. * @@ -25,7 +27,11 @@ export default async function decorate(block) { container.appendChild(msg); const link = document.createElement('p'); - link.innerHTML = 'Return to checkout'; + const returnLink = document.createElement('a'); + returnLink.href = getOrderPath('checkout'); + returnLink.className = 'button emphasis'; + returnLink.textContent = 'Return to checkout'; + link.appendChild(returnLink); container.appendChild(link); block.replaceChildren(container); diff --git a/scripts/cart.js b/scripts/cart.js index 0b9a016a..76c37e44 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -21,6 +21,7 @@ export class Cart { constructor() { this.#restore(); + this.#persistNow(); } #restore() { diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index c169fc30..5073bdc9 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -100,10 +100,11 @@ export async function createOrder(orderBody) { * action: string, redirectUrl: string }>} * @throws {CommerceApiError} */ -export async function initiatePayment(orderId, idempotencyKey) { +export async function initiatePayment(orderId, idempotencyKey, fraudToken) { return post(`/orders/${orderId}/payments`, { provider: 'chase', paymentMethod: 'card', idempotencyKey, + ...(fraudToken ? { fraudToken } : {}), }); } diff --git a/scripts/scripts.js b/scripts/scripts.js index 777aacdc..b6f8851a 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -87,6 +87,17 @@ export function getLocaleAndLanguage(forceEnCA = false) { return { locale, language }; } +/** + * Returns the path for an order-flow page (cart, checkout, complete, cancel) + * scoped to the current locale and language. + * @param {'cart'|'checkout'|'complete'|'cancel'} page + * @returns {string} + */ +export function getOrderPath(page) { + const { locale, language } = getLocaleAndLanguage(); + return `/${locale}/${language}/order/${page}`; +} + /** * Gets the form submission URL for the current locale and language. * @returns {string} The form submission URL From 59e520427f13f0a40afed464633b2e217692a9bb Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 1 Apr 2026 22:02:27 -0400 Subject: [PATCH 23/89] fix: more checkout text input tweaks --- blocks/checkout/checkout.css | 62 ++++++++++++++++++++++++++++++++---- blocks/checkout/checkout.js | 43 +++++++++++++++++++++++++ blocks/header/header.css | 10 +++--- 3 files changed, 103 insertions(+), 12 deletions(-) diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 1239be82..c9272d68 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -58,6 +58,10 @@ gap: var(--spacing-80); } +.checkout-form-column .form .form-field + .form-field { + margin-top: 8px; +} + .checkout-form-column form input, .checkout-form-column form select { font-weight: normal; @@ -77,6 +81,7 @@ .checkout-form-column form .field-help-text { font-size: var(--body-size-xs); + padding-left: 8px; } .checkout-form-column .form form fieldset[data-name="optIn"] { @@ -263,12 +268,7 @@ span.payment-button.paypal .button img { font-style: italic; } -/* Required field indicator — label comes before input in the DOM */ -.checkout-form-column form .form-field:has(input[required]) > label::after, -.checkout-form-column form .form-field:has(select[required]) > label::after { - content: ' *'; - color: #c00; -} +/* Required indicator is now appended directly to the floating label text in JS */ /* Required field error state */ .checkout-form-column form input.field-required, @@ -310,3 +310,53 @@ span.payment-button.paypal .button img { opacity: 0.7; pointer-events: none; } + +/* Floating label */ +.checkout-form-column .floating-label-field { + position: relative; +} + +.checkout-form-column .floating-label-field input, +.checkout-form-column .floating-label-field select, +.checkout-form-column .floating-label-field textarea { + padding-top: 20px; + padding-bottom: 4px; + height: 52px; +} + +.checkout-form-column .floating-label-field select { + appearance: none; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23666' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + padding-right: 36px; +} + +.checkout-form-column .floating-label-field textarea { + height: auto; + padding-top: 24px; +} + +.checkout-form-column .floating-label-field label { + position: absolute; + left: var(--spacing-100); + top: 16px; + margin: 0; + font-size: 1rem; + color: #888; + pointer-events: none; + transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease; +} + +/* Float label up when focused or filled */ +.checkout-form-column .floating-label-field:has(input:focus) label, +.checkout-form-column .floating-label-field:has(input:not(:placeholder-shown)) label, +.checkout-form-column .floating-label-field:has(select:focus) label, +.checkout-form-column .floating-label-field:has(select.has-value) label, +.checkout-form-column .floating-label-field:has(textarea:focus) label, +.checkout-form-column .floating-label-field:has(textarea:not(:placeholder-shown)) label { + top: 8px; + transform: translateY(0); + font-size: 0.7rem; + color: #555; +} diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index d5c8507e..b413121c 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -273,6 +273,49 @@ export default async function decorate(block) { loadBlock(summaryBlock), ]); + // Floating label effect: label sits inside the input and floats up on focus/fill. + // Placeholder is set to a single space so :placeholder-shown can detect empty state. + formColumn.querySelectorAll('.form-field:not(.checkbox-field):not(.radio-field)').forEach((fieldEl) => { + const label = fieldEl.querySelector('label'); + const input = fieldEl.querySelector('input:not([type="checkbox"]):not([type="radio"]), select, textarea'); + if (!label || !input) return; + const required = input.required || input.hasAttribute('required'); + // Append required indicator to label text + if (required && !label.textContent.endsWith('*')) { + label.textContent = `${label.textContent} *`; + } + // Single space placeholder enables :placeholder-shown to detect empty state + input.placeholder = ' '; + fieldEl.classList.add('floating-label-field'); + + // selects don't support :placeholder-shown — toggle a class instead + if (input.tagName === 'SELECT') { + const update = () => input.classList.toggle('has-value', input.value !== ''); + input.addEventListener('change', update); + + const ensureBlankSelected = () => { + if (input.options.length === 0) return; + // Remove any disabled placeholder text — the floating label serves as placeholder + input.querySelectorAll('option[disabled]').forEach((o) => o.remove()); + if (!input.querySelector('option[value=""]')) { + const blank = document.createElement('option'); + blank.value = ''; + blank.hidden = true; + input.prepend(blank); + } + input.selectedIndex = 0; + update(); + }; + + // Handle options already present (appendSelectOptions may have resolved before us) + ensureBlankSelected(); + + // Handle options loading asynchronously + const observer = new MutationObserver(ensureBlankSelected); + observer.observe(input, { childList: true }); + } + }); + // The form JSON has two sections both named "shipping": // 1. Shipping Address (address fields) // 2. Payment Method (billingEquals checkbox + payment radios) diff --git a/blocks/header/header.css b/blocks/header/header.css index edc9c61b..437fef51 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -577,7 +577,7 @@ header .minicart { width: min(400px, 100vw); min-height: 100vh; background: var(--layer-elevated); - padding: 1em 2em 2em; + padding: 2em 2em 2em; box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); margin: 0 0 0 100%; border: none; @@ -594,7 +594,7 @@ header .minicart[aria-expanded="true"] { } -header .minicart > div { +header .minicart > div:not(.slide-panel-header) { margin-top: 1em; } @@ -743,9 +743,7 @@ header.header-commercial .nav-sections .submenu-wrapper ul > li.submenu-wrapper .slide-panel-close { padding: 0; cursor: pointer; - font-weight: 100; - transform: scaleX(1.3); - font-size: 1.3em; + font-size: 1.5rem; line-height: 1; margin-left: auto; border: none; @@ -759,7 +757,7 @@ header .auth-panel { width: min(400px, 100vw); min-height: 100vh; background: var(--layer-elevated); - padding: 1em 2em 2em; + padding: 2em 2em 2em; box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); margin: 0 0 0 100%; border: none; From f0639bd691c3de661effc7c46398503f8cd6ffdb Mon Sep 17 00:00:00 2001 From: dylandepass Date: Thu, 2 Apr 2026 15:37:20 -0400 Subject: [PATCH 24/89] fix: include forter script and send to payment init call --- blocks/checkout/checkout.css | 8 ++++---- blocks/checkout/checkout.js | 5 +++-- blocks/header/header.css | 4 ++-- scripts/forter-snippet.js | 22 ++++++++++++++++++++++ scripts/scripts.js | 10 ++++++++++ 5 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 scripts/forter-snippet.js diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index c9272d68..20d03507 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -58,10 +58,6 @@ gap: var(--spacing-80); } -.checkout-form-column .form .form-field + .form-field { - margin-top: 8px; -} - .checkout-form-column form input, .checkout-form-column form select { font-weight: normal; @@ -123,6 +119,10 @@ grid-column: 1 / -1; } +.checkout-form-column .form .form-field + .form-field { + margin-top: 8px; +} + /* Company — full width, after lastname */ .checkout-form-column .section-shipping .form-field[data-name="company"], .checkout-form-column .section-billing .form-field[data-name="company"] { order: 3; } diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index b413121c..a3584741 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -603,9 +603,10 @@ export default async function decorate(block) { const { order: createdOrder } = await createOrder(order); sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); - // initiate payment + // initiate payment — include Forter fraud token if available const idempotencyKey = crypto.randomUUID(); - const payment = await initiatePayment(createdOrder.id, idempotencyKey); + const fraudToken = sessionStorage.getItem('forter_token') || undefined; + const payment = await initiatePayment(createdOrder.id, idempotencyKey, fraudToken); if (payment.action === 'redirect' && payment.redirectUrl) { // redirect to Chase hosted payment page window.location.href = payment.redirectUrl; diff --git a/blocks/header/header.css b/blocks/header/header.css index 437fef51..28f430c4 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -577,7 +577,7 @@ header .minicart { width: min(400px, 100vw); min-height: 100vh; background: var(--layer-elevated); - padding: 2em 2em 2em; + padding: 2em; box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); margin: 0 0 0 100%; border: none; @@ -757,7 +757,7 @@ header .auth-panel { width: min(400px, 100vw); min-height: 100vh; background: var(--layer-elevated); - padding: 2em 2em 2em; + padding: 2em; box-shadow: -4px 4px 18px rgb(0 0 0 / 20%); margin: 0 0 0 100%; border: none; diff --git a/scripts/forter-snippet.js b/scripts/forter-snippet.js new file mode 100644 index 00000000..9a474e8b --- /dev/null +++ b/scripts/forter-snippet.js @@ -0,0 +1,22 @@ +/** + * Forter anti-fraud bootstrap (vendor snippet). Injected at end of body via loadDelayed. + * @see https://www.forter.com/ + */ +const FORTER_SITE_ID = 'f2bc58b1eab5'; + +const FORTER_IIFE = `(function () { + var merchantConfig = { + csp: false, + }; + + var siteId = '${FORTER_SITE_ID}'; +function t(t,e){for(var n=t.split(""),r=0;r=u&&d>0;){var w=s.slice(-u).join(".");document.cookie=escape(t)+"="+escape(e)+c+"; path="+f+"; domain="+w;var l=D.read(t);null!=l&&l==e||(w="."+w,document.cookie=escape(t)+"="+escape(e)+c+"; path="+f+"; domain="+w),h=-1===document.cookie.indexOf(t+"="+e),u++,d--}},read:function(t){var e=null;try{for(var n=escape(t)+"=",r=document.cookie.split(";"),o=32,i=0;i>8&255]+n[t>>16&255]+n[t>>24&255]+a+n[255&e]+n[e>>8&255]+a+n[e>>16&15|64]+n[e>>24&255]+a+n[63&r|128]+n[r>>8&255]+a+n[r>>16&255]+n[r>>24&255]+n[255&o]+n[o>>8&255]+n[o>>16&255]+n[o>>24&255]},i=function(){if(window.Uint32Array&&window.crypto&&window.crypto.getRandomValues){var t=new window.Uint32Array(4);return window.crypto.getRandomValues(t),{d0:t[0],d1:t[1],d2:t[2],d3:t[3]}}return{d0:4294967296*Math.random()>>>0,d1:4294967296*Math.random()>>>0,d2:4294967296*Math.random()>>>0,d3:4294967296*Math.random()>>>0}},a=function(){var t="",e=function(t,e){for(var n="",r=t;r>0;--r)n+=e.charAt(1e3*Math.random()%e.length);return n};return t+=e(2,"0123456789"),t+=e(1,"123456789"),t+=e(8,"0123456789")};return t.safeGenerateNoDash=function(){try{var t=i();return o(t.d0,t.d1,t.d2,t.d3,!1)}catch(t){try{return e+a()}catch(t){}}},t.isValidNumericalToken=function(t){return t&&t.toString().length<=11&&t.length>=9&&parseInt(t,10).toString().length<=11&&parseInt(t,10).toString().length>=9},t.isValidUUIDToken=function(t){return t&&32===t.toString().length&&/^[a-z0-9]+$/.test(t)},t.isValidFGUToken=function(t){return 0==t.indexOf(e)&&t.length>=12},t}(),z={uDF:"UDF",dUAL:"dUAL",uAS:"UAS",uDS:"UDS",uDAD:"UDAD",uFP:"UFP",mLd:"1",eTlu:"2",eUoe:"3",uS:"4",uF:"9",tmos:["T5","T10","T15"],tmosSecs:[5,10,15],bIR:"43",uB:"u",uBr:"b",cP:"c",nIL:"i",s:"s"};try{var $=X();try{$.id&&(Q.isValidNumericalToken($.id)||Q.isValidUUIDToken($.id)||Q.isValidFGUToken($.id))?window.ftr__ncd=!1:($.id=Q.safeGenerateNoDash(),window.ftr__ncd=!0),$.ts=window.ftr__startScriptLoad,H($),window.ftr__snp_cwc=!!D.read(A),window.ftr__snp_cwc||(I=V);for(var J="for"+"ter"+".co"+"m",K="ht"+"tps://c"+"dn9."+J,W="ht"+"tps://"+$.id+"-"+siteId+".cd"+"n."+J,Y="http"+"s://cd"+"n3."+J,Z=[K,W,Y],tt=0;tt=0){var o=n.getResponseHeader(b);x=window.ftr__altd2=t(atob(o),-_-1)}if(r.indexOf(q.toLowerCase())<0)return;var i=n.getResponseHeader(q),a=t(atob(i),-_-1);if(a){var c=a.split(":");if(c&&2===c.length){for(var f=c[0],u=c[1],s="",d=0,h=0;d<20;++d)s+=d%3>0&&h<12?siteId.charAt(h++):$.id.charAt(d);var w=u.split(",");if(w.length>1){var l=w[0],g=w[1];M=f+"/"+l+"."+s+"."+g}}}e()}catch(t){}},function(t,e){n&&n(t,e)},r)},window.ftr__radd=function(t,e){function n(e){try{var n=e.response,r=function(t){function e(t,n,i){try{if(i>=r)return{name:"",nextOffsetToProcess:n,error:"Max pointer dereference depth exceeded"};for(var a=[],c=n,f=t.getUint8(c),u=0;u0)){if(0!==f)return{name:"",nextOffsetToProcess:c,error:"Unexpected length at the end of name: "+f.toString()};return{name:a.join("."),nextOffsetToProcess:c+1}}for(var w="",l=1;l<=f;l++)w+=String.fromCharCode(t.getUint8(c+l));a.push(w),c+=f+1,f=t.getUint8(c)}return{name:"",nextOffsetToProcess:c,error:"Max iterations exceeded"}}catch(t){return{name:"",nextOffsetToProcess:n,error:"Unexpected error while parsing response: "+t.toString()}}}var n,r=4,o=100,i=16,a=new DataView(t),c=a.getUint16(0),f=a.getUint16(2),u=a.getUint16(4),s=a.getUint16(6),d=a.getUint16(8),h=a.getUint16(10),w=12,l=[],g=0;for(g=0;g=o)throw new Error("Max iterations exceeded while reading TXT data");w+=A,_.push({name:y,type:U,class:T,ttl:x,data:S})}return{transactionId:c,flags:f,questionCount:u,answerCount:s,authorityCount:d,additionalCount:h,questions:l,answers:_}}(n);if(!r)throw new Error("Error parsing DNS response");if(!("answers"in r))throw new Error("Unexpected response");var o=r.answers;if(0===o.length)throw new Error("No answers found");var i=o[0].data;i=i.replace(/^"(.*)"$/,"$1");var a=function(t){var e=40,n=32,r=126;try{for(var o=atob(t),i="",a=0;a0&&r<12?siteId.charAt(r++):$.id.charAt(n);return t.replace("/PRM1","").replace("/PRM2","/main.").replace("/PRM3",e).replace("/PRM4",".js")}(f),T=window.ftr__altd3=u,t()}catch(t){}}function r(t,n){e&&e(t,n)}if(U)return window.ftr__altd3=T,void t();window.ftr__config.m.dr==="N"+"D"+"R"&&e(new Error("N"+"D"+"R"),!1),F&&k||e(new Error("D"+"P"+"P"),!1),U=!0;try{var o=function(t){for(var e=new Uint8Array([0,0]),n=new Uint8Array([1,0]),r=new Uint8Array([0,1]),o=new Uint8Array([0,0]),i=new Uint8Array([0,0]),a=new Uint8Array([0,0]),c=t.split("."),f=[],u=0;u { + const token = evt.detail; + try { + sessionStorage.setItem('forter_token', token); + } catch { /* ignore */ } + }); + setTimeout(decorateExternalLinks, 1000); } From 82fbeda767c8561f9c3a7f12df1bfa111b487992 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Thu, 9 Apr 2026 15:21:20 -0400 Subject: [PATCH 25/89] feat: paypal support --- blocks/checkout/checkout.js | 81 ++++++++++++++++++++++++++- blocks/order-summary/order-summary.js | 7 ++- scripts/commerce-api.js | 11 ++-- 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index a3584741..3b1a3967 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -517,6 +517,81 @@ export default async function decorate(block) { if (paymentMethod === 'PayPal') { span.classList.add('paypal'); span.appendChild(button); + button.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (!selectedShippingMethodId) { + showError(formColumn, 'Please select a shipping method.'); + return; + } + + const isValid = form.checkValidity(); + if (!isValid) { + form.reportValidity(); + return; + } + + clearError(formColumn); + button.disabled = true; + + const reenableButton = () => { + button.disabled = false; + }; + + try { + const formData = Object.fromEntries(new FormData(form).entries()); + const { + email, + 'shipping-firstname': firstName, + 'shipping-lastname': lastName, + 'shipping-telephone': phone, + } = formData; + + const shipping = collectAddress(form, formData, 'shipping-', email); + const { default: cart } = await import('../../scripts/cart.js'); + + if (!currentEstimateToken) { + await updatePreview(form, formData, cart); + } + + if (!currentEstimateToken || !currentPreview) { + showError(formColumn, 'Unable to calculate shipping and taxes. Please try again.'); + reenableButton(); + return; + } + + const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { + shippingMethod: selectedShippingMethodId, + estimateToken: currentEstimateToken, + locale: getLocale(), + country: getCountry(), + }); + + sessionStorage.setItem('checkout_email', email); + sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); + if (currentPreview) { + sessionStorage.setItem('checkout_preview', JSON.stringify(currentPreview)); + } + + const { order: createdOrder } = await createOrder(order); + sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); + + const idempotencyKey = crypto.randomUUID(); + const fraudToken = sessionStorage.getItem('forter_token') || undefined; + const payment = await initiatePayment(createdOrder.id, idempotencyKey, fraudToken, 'paypal', 'paypal'); + if (payment.action === 'redirect' && payment.redirectUrl) { + window.location.href = payment.redirectUrl; + } else { + showError(formColumn, 'Unexpected payment response. Please try again.'); + reenableButton(); + } + } catch (error) { + const message = error.body?.message || error.message || 'Something went wrong. Please try again.'; + showError(formColumn, message); + reenableButton(); + } + }); } else if (paymentMethod === 'Apple Pay') { span.classList.add('apple-pay'); loadScript('https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'); @@ -634,10 +709,14 @@ export default async function decorate(block) { paymentMethodGroup.addEventListener('change', () => { const paymentMethod = paymentMethodGroup.querySelector('input:checked').value; - if (sameShipBillCheckbox.checked || paymentMethod !== 'Credit Card') { + // PayPal collects billing address on their hosted page — hide the section entirely + const isPayPal = paymentMethod === 'PayPal'; + if (isPayPal || sameShipBillCheckbox.checked) { billingAddressSection.setAttribute('aria-hidden', true); + billingAddressSection.setAttribute('disabled', ''); } else { billingAddressSection.removeAttribute('aria-hidden'); + billingAddressSection.removeAttribute('disabled'); } payButtons.forEach((btn) => { diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js index 3117b9cd..4d0d2025 100644 --- a/blocks/order-summary/order-summary.js +++ b/blocks/order-summary/order-summary.js @@ -3,10 +3,11 @@ import { getOrderPath } from '../../scripts/scripts.js'; /** * Order confirmation / cancellation page. * - * Success: Chase → API → redirect here with ?orderId=... - * Cancel: Chase → API → redirect here with ?orderId=...&reason=... + * Success: Chase / PayPal → API → redirect here with ?orderId=... + * Cancel: Chase / PayPal → API → redirect here with ?orderId=...&reason=... + * reason values: customer_cancelled | payment_failed | declined * - * Order display data comes from sessionStorage (saved before Chase redirect). + * Order display data comes from sessionStorage (saved before the payment redirect). */ export default async function decorate(block) { const params = Object.fromEntries(new URLSearchParams(window.location.search).entries()); diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index 5073bdc9..0d770ef1 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -90,20 +90,23 @@ export async function createOrder(orderBody) { } /** - * Initiates a Chase card payment for a placed order. + * Initiates a payment for a placed order. * The idempotency key ensures that retrying the same request (e.g. after a * network failure) does not result in a duplicate charge. * * @param {string} orderId - The order ID returned by `createOrder` * @param {string} idempotencyKey - A unique key for this payment attempt (e.g. a UUID) + * @param {string} [fraudToken] - Optional fraud provider session token + * @param {string} [provider='chase'] - Payment provider ('chase' or 'paypal') + * @param {string} [paymentMethod='card'] - Payment method ('card' or 'paypal') * @returns {Promise<{ orderId: string, paymentAttemptId: string, status: string, * action: string, redirectUrl: string }>} * @throws {CommerceApiError} */ -export async function initiatePayment(orderId, idempotencyKey, fraudToken) { +export async function initiatePayment(orderId, idempotencyKey, fraudToken, provider = 'chase', paymentMethod = 'card') { return post(`/orders/${orderId}/payments`, { - provider: 'chase', - paymentMethod: 'card', + provider, + paymentMethod, idempotencyKey, ...(fraudToken ? { fraudToken } : {}), }); From e9f7daf4525b98d1e3a66c16553433bdbfad5293 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 15 Apr 2026 08:24:30 -0400 Subject: [PATCH 26/89] feat: affirm checkout --- blocks/checkout/checkout.js | 132 +++++++++++++++++++++++++++++++++++- scripts/cart.js | 4 +- scripts/scripts.js | 5 ++ 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 3b1a3967..17eca8bc 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -495,6 +495,22 @@ export default async function decorate(block) { }); // -- Pay buttons -- + // If Affirm is offered as a payment option but no Affirm submit button exists in the form + // document, inject one programmatically so it goes through the standard button-wrapping flow. + const affirmPaymentOption = formColumn.querySelector('fieldset[data-name="paymentMethod"] input[value="Affirm"]'); + const existingButtons = [...formColumn.querySelectorAll('form .button-wrapper button[type="submit"]')]; + const hasAffirmButton = existingButtons.some((b) => b.textContent.toLowerCase().includes('affirm')); + if (affirmPaymentOption && !hasAffirmButton) { + const buttonWrapper = formColumn.querySelector('form .button-wrapper'); + if (buttonWrapper) { + const affirmBtn = document.createElement('button'); + affirmBtn.type = 'submit'; + affirmBtn.textContent = 'Pay with Affirm'; + affirmBtn.classList.add('button'); + buttonWrapper.appendChild(affirmBtn); + } + } + const submitButtons = [...formColumn.querySelectorAll('form .button-wrapper button[type="submit"]')]; submitButtons.forEach((button) => { let paymentMethod = 'Credit Card'; @@ -505,6 +521,8 @@ export default async function decorate(block) { icon.src = `${window.hlx.codeBasePath}/icons/paypal.svg`; icon.classList.add('icon', 'icon-paypal'); button.appendChild(icon); + } else if (button.textContent.toLowerCase().includes('affirm')) { + paymentMethod = 'Affirm'; } else if (button.textContent.toLowerCase().includes('apple')) { paymentMethod = 'Apple Pay'; } @@ -592,6 +610,114 @@ export default async function decorate(block) { reenableButton(); } }); + } else if (paymentMethod === 'Affirm') { + span.classList.add('affirm'); + span.appendChild(button); + button.addEventListener('click', async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + + if (!selectedShippingMethodId) { + showError(formColumn, 'Please select a shipping method.'); + return; + } + + const isValid = form.checkValidity(); + if (!isValid) { + form.reportValidity(); + return; + } + + clearError(formColumn); + button.disabled = true; + + const reenableButton = () => { + button.disabled = false; + }; + + try { + const formData = Object.fromEntries(new FormData(form).entries()); + const { + email, + 'shipping-firstname': firstName, + 'shipping-lastname': lastName, + 'shipping-telephone': phone, + } = formData; + + const shipping = collectAddress(form, formData, 'shipping-', email); + const { default: cart } = await import('../../scripts/cart.js'); + + if (!currentEstimateToken) { + await updatePreview(form, formData, cart); + } + + if (!currentEstimateToken || !currentPreview) { + showError(formColumn, 'Unable to calculate shipping and taxes. Please try again.'); + reenableButton(); + return; + } + + const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { + shippingMethod: selectedShippingMethodId, + estimateToken: currentEstimateToken, + locale: getLocale(), + country: getCountry(), + }); + + sessionStorage.setItem('checkout_email', email); + sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); + if (currentPreview) { + sessionStorage.setItem('checkout_preview', JSON.stringify(currentPreview)); + } + + const { order: createdOrder } = await createOrder(order); + sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); + + const idempotencyKey = crypto.randomUUID(); + const payment = await initiatePayment(createdOrder.id, idempotencyKey, undefined, 'affirm', 'bnpl'); + + if (payment.checkoutObject) { + // Load the Affirm JS SDK with the Global integration config, then + // pass the server-built checkout object to affirm.checkout() and open. + // On completion Affirm redirects the browser to our server's + // confirmation URL which handles authorization and redirects to the + // order-summary page. + const country = getCountry(); + const affirmCountryCode = country === 'ca' ? 'CAN' : 'USA'; + const { language } = getLocaleAndLanguage(true); + // Use the public key and JS SDK URL from the server response so they + // always match the environment and region of the merchant config. + // eslint-disable-next-line camelcase, no-underscore-dangle + window._affirm_config = { + public_api_key: payment.checkoutObject.merchant.public_api_key, + script: payment.affirmJsUrl, + locale: language, + country_code: affirmCountryCode, + }; + // Bootstrap the Affirm JS SDK — this IIFE creates the global + // affirm object with stub methods, then loads the script from + // _affirm_config.script. Required before calling affirm.checkout(). + /* eslint-disable */ + (function(m,g,n,d,a,e,h,c){var b=m[n]||{},k=document.createElement(e),p=document.getElementsByTagName(e)[0],l=function(a,b,c){return function(){a[b]._.push([c,arguments])}};b[d]=l(b,d,"set");var f=b[d];b[a]={};b[a]._=[];f._=[];b._=[];b[a][h]=l(b,a,h);b[c]=function(){b._.push([h,arguments])};a=0;for(c="set add save post open empty reset on off trigger ready setProduct".split(" ");a { + affirm.checkout(payment.checkoutObject); + affirm.checkout.open(); + }); + /* eslint-enable no-undef */ + } else { + showError(formColumn, 'Unexpected payment response. Please try again.'); + reenableButton(); + } + } catch (error) { + const message = error.body?.error === 'ORDER_TOTAL_OUT_OF_RANGE' + ? 'Affirm is not available for this order total.' + : error.body?.message || error.message || 'Something went wrong. Please try again.'; + showError(formColumn, message); + reenableButton(); + } + }); } else if (paymentMethod === 'Apple Pay') { span.classList.add('apple-pay'); loadScript('https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'); @@ -709,9 +835,9 @@ export default async function decorate(block) { paymentMethodGroup.addEventListener('change', () => { const paymentMethod = paymentMethodGroup.querySelector('input:checked').value; - // PayPal collects billing address on their hosted page — hide the section entirely - const isPayPal = paymentMethod === 'PayPal'; - if (isPayPal || sameShipBillCheckbox.checked) { + // PayPal and Affirm collect billing address on their hosted page — hide the section entirely + const hidesBilling = paymentMethod === 'PayPal' || paymentMethod === 'Affirm'; + if (hidesBilling || sameShipBillCheckbox.checked) { billingAddressSection.setAttribute('aria-hidden', true); billingAddressSection.setAttribute('disabled', ''); } else { diff --git a/scripts/cart.js b/scripts/cart.js index 76c37e44..ab9c1b2d 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -174,7 +174,7 @@ export class Cart { /** * Returns cart items in API-compatible format. * @returns {Array<{sku: string, path: string, quantity: number, name: string, - * price: {final: string, currency: string}}>} + * price: {final: string, currency: string}, imageUrl?: string, productUrl?: string}>} */ getItemsForAPI() { return this.items.map((item) => ({ @@ -186,6 +186,8 @@ export class Cart { final: String(item.price), currency: window.location.pathname.startsWith('/ca/') ? 'CAD' : 'USD', }, + ...(item.image ? { imageUrl: item.image } : {}), + ...(item.url ? { productUrl: item.url } : {}), })); } diff --git a/scripts/scripts.js b/scripts/scripts.js index 751f6839..aa955e9e 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -27,6 +27,11 @@ const EDGE_CART_LOCALES = []; const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('uat.vitamix.com'); const isProdHost = hostname.includes('vitamix.com'); + +// Affirm public API key — safe to expose client-side (used for PDP promo widgets). +// Checkout gets its key from the server's checkout object so it always matches the +// environment of the merchant config. +export const AFFIRM_PUBLIC_KEY = isProdHost ? 'LIVE_PUBLIC_KEY' : 'GH4VQBRG3LHDS5CM'; export const FORMS_ENDPOINT = isProdHost ? 'https://main--vitamix--aemsites.aem.network' // TODO: make empty string when Akamai ready : 'https://main--vitamix--aemsites.aem.network'; From 42b367d514ad37aaec1d398b84ab794b469e30e0 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 15 Apr 2026 10:20:28 -0400 Subject: [PATCH 27/89] fix: uppercase locale for affirm --- blocks/checkout/checkout.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 17eca8bc..5f17384c 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -685,13 +685,16 @@ export default async function decorate(block) { const country = getCountry(); const affirmCountryCode = country === 'ca' ? 'CAN' : 'USA'; const { language } = getLocaleAndLanguage(true); + // Affirm expects locale as en_US, en_CA, or fr_CA (uppercase country suffix). + const [lang, region] = language.split('_'); + const affirmLocale = region ? `${lang}_${region.toUpperCase()}` : language; // Use the public key and JS SDK URL from the server response so they // always match the environment and region of the merchant config. // eslint-disable-next-line camelcase, no-underscore-dangle window._affirm_config = { public_api_key: payment.checkoutObject.merchant.public_api_key, script: payment.affirmJsUrl, - locale: language, + locale: affirmLocale, country_code: affirmCountryCode, }; // Bootstrap the Affirm JS SDK — this IIFE creates the global From ffdadae440b197d4ad88fbc31523152a0714c4a2 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 15 Apr 2026 10:24:14 -0400 Subject: [PATCH 28/89] fix: reenable affirm button on ui close --- blocks/checkout/checkout.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 5f17384c..73c5d86b 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -705,6 +705,9 @@ export default async function decorate(block) { /* eslint-enable */ /* eslint-disable no-undef */ affirm.ui.ready(() => { + affirm.ui.error.on('close', () => { + reenableButton(); + }); affirm.checkout(payment.checkoutObject); affirm.checkout.open(); }); From 9a49ec059122061390c8a610d9b85136f6d7a729 Mon Sep 17 00:00:00 2001 From: awasthiruchi Date: Tue, 21 Apr 2026 15:37:33 -0400 Subject: [PATCH 29/89] Update apple-developer-merchantid-domain-association --- ...eveloper-merchantid-domain-association.txt | 48 +++++++++---------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/.well-known/apple-developer-merchantid-domain-association.txt b/.well-known/apple-developer-merchantid-domain-association.txt index cd4be7b3..5ffcb10b 100644 --- a/.well-known/apple-developer-merchantid-domain-association.txt +++ b/.well-known/apple-developer-merchantid-domain-association.txt @@ -1,25 +1,25 @@ MIIQYgYJKoZIhvcNAQcCoIIQUzCCEE8CAQExCzAJBgUrDgMCGgUAMHEGCSqGSIb3DQEHAaBkBGJ7 InRlYW1JZCI6IkRRVkZYTUxYNTkiLCJkb21haW4iOiJ1YXQudml0YW1peC5jb20iLCJkYXRlQ3Jl -YXRlZCI6IjIwMjUtMTEtMTQsMTk6NDQ6MzIiLCJ2ZXJzaW9uIjoxfaCCDT8wggQ0MIIDHKADAgEC -Agg9Wfg36tHYnzANBgkqhkiG9w0BAQsFADBzMS0wKwYDVQQDDCRBcHBsZSBpUGhvbmUgQ2VydGlm +YXRlZCI6IjIwMjYtMDQtMjAsMTQ6MzU6MDYiLCJ2ZXJzaW9uIjoxfaCCDT8wggQ0MIIDHKADAgEC +AghGIC28A0-PtjANBgkqhkiG9w0BAQsFADBzMS0wKwYDVQQDDCRBcHBsZSBpUGhvbmUgQ2VydGlm aWNhdGlvbiBBdXRob3JpdHkxIDAeBgNVBAsMF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYD -VQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yNDEyMTYxOTIxMDFaFw0yOTEyMTExODEz -NTlaMFkxNTAzBgNVBAMMLEFwcGxlIGlQaG9uZSBPUyBQcm92aXNpb25pbmcgUHJvZmlsZSBTaWdu +VQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yNjAxMjgxOTU1NTJaFw0zMDEyMzEwMDAw +MDBaMFkxNTAzBgNVBAMMLEFwcGxlIGlQaG9uZSBPUyBQcm92aXNpb25pbmcgUHJvZmlsZSBTaWdu aW5nMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQAD -ggEPADCCAQoCggEBANCTMav4Ux7frR4vZPfJTdeWvl9LPXlkXEPuKcNA0vovHKC2vBFz7_AisN_e --fnOVeP1QgG1I2VBEjv3fEZ9iRNFlUTslpViZpeQAwDZ4K7F2bGcIC2W4IXtb2vTUtODPNQBIyXp -5cbUEdh5qgjC3RVY9e-Kk0sNS-4NtoeTdREQVcsMeAfbN3BGO5f6xOt4KeD07HjjYdpAV4AHu4ic -pcdJbcgm05UfTSGijWhzgx7mWVqFllVUsJUuJdx3DWGHgY2JpAN7PAB3LIlqWdNkRNl0pVuKsVJh -X24EMNTz4hA0DJWMS-F71iuFg_InOY1wCCPiFIj_k_QtbUwm4os3hi0CAwEAAaOB5TCB4jAMBgNV +ggEPADCCAQoCggEBALgBxgKn-i0gq85GrRVYcy7pvDVl5axy2QZYHRmXOgOikB3nqkF96_nkA15r +CeKodI8Ps0IzxUed09OFrIsqYi5tLE2OiXrKDFPp3aQebqUCafx26ARsOD0t8qozdYEjy4cJ3ubb +_3qZEp-cUoNxMVX5fq07JXVSU-Ays2BhThmrewRFvKtXawgPlM6VMkQCW4qtciDAqmc7Ms83h1VF +o5Oi4h5w_6RvggWEwZk-adZqdxMKWUE0WhENpC4Q2bHGaz9hVSADQqNOQYr5PEXf1ZcEv00oTXwQ +uX5tLD6wI3rdaJaTaSdajqe5dBGB58Eg2_ydm2TKDy3MDZlUMwMXvn0CAwEAAaOB5TCB4jAMBgNV HRMBAf8EAjAAMB8GA1UdIwQYMBaAFG_xlRhiXODI8cXtbBjJ4NNkUpggMEAGCCsGAQUFBwEBBDQw MjAwBggrBgEFBQcwAYYkaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1haXBjYTA3MC8GA1Ud -HwQoMCYwJKAioCCGHmh0dHA6Ly9jcmwuYXBwbGUuY29tL2FpcGNhLmNybDAdBgNVHQ4EFgQUvLXF -6b38y9Ce3JSwHvghlFz_CS4wDgYDVR0PAQH_BAQDAgeAMA8GCSqGSIb3Y2QGOgQCBQAwDQYJKoZI -hvcNAQELBQADggEBADI0wul3ql_gxsqi83dZ54pnuPFR8_uw9pe_sRGj4aE8uyOS6RKTonEdvPGa -cW-kPG82krbgR4Kik-PnuI-73yVEYgLPzbz3-42KCXB4ZcIZTSXLcmIh5Klo-RCaLnoPKL6mAwbR -VWEfr3z4lNRxDuLTJVSLzq3VaAdbvS17x2JFebmph0z4GDuArhBLcdh4K-YKr5rn2U3M6lu3o5dV -a-wNoHjHwLDPy9wQTDCSE3GU1q_g7MnpyZvOJTLuEQ0hFySL8ZUuImJGRX_g29cWVMG5PtPairll -9rS0I394XdlydmRjpwhVx9m3lNsjv_OTp9QEREMNyuJWsiuUKKQ9cocwggREMIIDLKADAgECAghc +HwQoMCYwJKAioCCGHmh0dHA6Ly9jcmwuYXBwbGUuY29tL2FpcGNhLmNybDAdBgNVHQ4EFgQUOxRo +EE8mLY7aC0lQF22uZD7_lhEwDgYDVR0PAQH_BAQDAgeAMA8GCSqGSIb3Y2QGOgQCBQAwDQYJKoZI +hvcNAQELBQADggEBADpAeKnFbAscjuDx_hJtM06-bg_SVs4vvgk24rZpkUmxCqN8aBJDWHr5Y8fj +nusHfkRGlvB1p6R8eh7-PfzfQva8c4yzQMpROdFC8-H3in3WyGdqPvMkwPVNwhwn9zrrhV7m7ztj +G3xIBrIwiMO3AFlo9UDthc-YGuHW0mItTJWb0i5L5C3qHcGBRJ5qM4EuFkvyV1CNDs4JNpgXID3- +BOCy_JzTOmOlIv7TgDDjImMPnXdADqNs4sjmGrdZjTUh2PKeN508RU3aMYZTqx6S8UliACWwTg8M +YhEw62J0cPzoBNmGV6FeKNPi5dqKGHkW5HDBeZi5Q4zPxwXf3ipxp78wggREMIIDLKADAgECAghc Y8rkSjdTyTANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5j LjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxl IFJvb3QgQ0EwHhcNMTcwNTEwMjEyNzMwWhcNMzAxMjMxMDAwMDAwWjBzMS0wKwYDVQQDDCRBcHBs @@ -62,13 +62,13 @@ JdXZD9Zr1KIkIxH3oayPc4FgxhtbCS-SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL_lTaltkwGMz d_c6ByxW69oPIQ7aunMZT7XZNn_Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX UKqK1drk_NAJBzewdXUhMYIChTCCAoECAQEwfzBzMS0wKwYDVQQDDCRBcHBsZSBpUGhvbmUgQ2Vy dGlmaWNhdGlvbiBBdXRob3JpdHkxIDAeBgNVBAsMF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMw -EQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUwIIPVn4N-rR2J8wCQYFKw4DAhoFAKCB3DAY -BgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yNTExMTQxOTQ0MzJaMCMG -CSqGSIb3DQEJBDEWBBTDYcbzmjvgUD_BJy52hE3C2PQ5BDApBgkqhkiG9w0BCTQxHDAaMAkGBSsO +EQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUwIIRiAtvANPj7YwCQYFKw4DAhoFAKCB3DAY +BgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yNjA0MjAxNDM1MDZaMCMG +CSqGSIb3DQEJBDEWBBQHa2ioa90f--ynr-k2ManlB2nKWTApBgkqhkiG9w0BCTQxHDAaMAkGBSsO AwIaBQChDQYJKoZIhvcNAQEBBQAwUgYJKoZIhvcNAQkPMUUwQzAKBggqhkiG9w0DBzAOBggqhkiG 9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcN -AQEBBQAEggEAd87EgZRUrfBCi-D60noQGneyElcfShOPjalmNPCAIduV1Xt5SIFZ3OgFL24kVZzw -_V9wFFzqb0RRP9ZDTijNrGREzMORTa0z_absTqNFI526Rkq9ivXAzEiNBsxBxqtFY0Od3NIr60bd -ADUV5zdT4kj69MOXO0iFsuuUwtWknfr8-rFwHqX5Bkhmk7UzsseLrDnVnvRGMSDJYyeH4PBiw5E4 -moYy3-M-QcjM-GUPOnZTmmiCsHuWfHE98wXYUwxMJCpFWpecSMP3TCSzZgqWrk_SYfTrXyizohvZ -4lJpYKlWwGcTJLtJW-qjNWZ9FmAcyr57W9SDxkxzKw62-ibmXw +AQEBBQAEggEAjdqSLpgROSYZcb7TBV5A90GHJOmAcZF1o3Rcvd0G92Z5bThaQLPgDE22PE1tvPkW +AWKbRq7Nsh7s7rGiqQwVRPTfX0NU9rxxrxSn4tf6q98uWO-v4dm4AcW6-P1M2uKQB9riuQTEoTyg +dcqI1ECAfsK1Bf3Efi6prd2kfiV6BZLNcCnyzfjQVllktIikCCHFWEHE9ys5_z6jz32VlmCbkwax +ypAIZQUTDUVoSQHT5WbTzizUyZWJdS5a8xdCfWJN6rPaE2bb4XAG68nBJWPxxsrhllI8O49HMkjI +FBjIFCx3Fse03xt16WmSiryGETSkwon7CO-2RTN_0NJGcg-VZQ From b2e1fc75dc782a88b41184911ac652abf31203c2 Mon Sep 17 00:00:00 2001 From: awasthiruchi Date: Wed, 22 Apr 2026 16:19:32 -0400 Subject: [PATCH 30/89] Update apple-developer-merchantid-domain-association --- ...eveloper-merchantid-domain-association.txt | 148 +++++++++--------- 1 file changed, 74 insertions(+), 74 deletions(-) diff --git a/.well-known/apple-developer-merchantid-domain-association.txt b/.well-known/apple-developer-merchantid-domain-association.txt index cd4be7b3..e995fbd2 100644 --- a/.well-known/apple-developer-merchantid-domain-association.txt +++ b/.well-known/apple-developer-merchantid-domain-association.txt @@ -1,74 +1,74 @@ -MIIQYgYJKoZIhvcNAQcCoIIQUzCCEE8CAQExCzAJBgUrDgMCGgUAMHEGCSqGSIb3DQEHAaBkBGJ7 -InRlYW1JZCI6IkRRVkZYTUxYNTkiLCJkb21haW4iOiJ1YXQudml0YW1peC5jb20iLCJkYXRlQ3Jl -YXRlZCI6IjIwMjUtMTEtMTQsMTk6NDQ6MzIiLCJ2ZXJzaW9uIjoxfaCCDT8wggQ0MIIDHKADAgEC -Agg9Wfg36tHYnzANBgkqhkiG9w0BAQsFADBzMS0wKwYDVQQDDCRBcHBsZSBpUGhvbmUgQ2VydGlm -aWNhdGlvbiBBdXRob3JpdHkxIDAeBgNVBAsMF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYD -VQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzAeFw0yNDEyMTYxOTIxMDFaFw0yOTEyMTExODEz -NTlaMFkxNTAzBgNVBAMMLEFwcGxlIGlQaG9uZSBPUyBQcm92aXNpb25pbmcgUHJvZmlsZSBTaWdu -aW5nMRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcNAQEBBQAD -ggEPADCCAQoCggEBANCTMav4Ux7frR4vZPfJTdeWvl9LPXlkXEPuKcNA0vovHKC2vBFz7_AisN_e --fnOVeP1QgG1I2VBEjv3fEZ9iRNFlUTslpViZpeQAwDZ4K7F2bGcIC2W4IXtb2vTUtODPNQBIyXp -5cbUEdh5qgjC3RVY9e-Kk0sNS-4NtoeTdREQVcsMeAfbN3BGO5f6xOt4KeD07HjjYdpAV4AHu4ic -pcdJbcgm05UfTSGijWhzgx7mWVqFllVUsJUuJdx3DWGHgY2JpAN7PAB3LIlqWdNkRNl0pVuKsVJh -X24EMNTz4hA0DJWMS-F71iuFg_InOY1wCCPiFIj_k_QtbUwm4os3hi0CAwEAAaOB5TCB4jAMBgNV -HRMBAf8EAjAAMB8GA1UdIwQYMBaAFG_xlRhiXODI8cXtbBjJ4NNkUpggMEAGCCsGAQUFBwEBBDQw -MjAwBggrBgEFBQcwAYYkaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1haXBjYTA3MC8GA1Ud -HwQoMCYwJKAioCCGHmh0dHA6Ly9jcmwuYXBwbGUuY29tL2FpcGNhLmNybDAdBgNVHQ4EFgQUvLXF -6b38y9Ce3JSwHvghlFz_CS4wDgYDVR0PAQH_BAQDAgeAMA8GCSqGSIb3Y2QGOgQCBQAwDQYJKoZI -hvcNAQELBQADggEBADI0wul3ql_gxsqi83dZ54pnuPFR8_uw9pe_sRGj4aE8uyOS6RKTonEdvPGa -cW-kPG82krbgR4Kik-PnuI-73yVEYgLPzbz3-42KCXB4ZcIZTSXLcmIh5Klo-RCaLnoPKL6mAwbR -VWEfr3z4lNRxDuLTJVSLzq3VaAdbvS17x2JFebmph0z4GDuArhBLcdh4K-YKr5rn2U3M6lu3o5dV -a-wNoHjHwLDPy9wQTDCSE3GU1q_g7MnpyZvOJTLuEQ0hFySL8ZUuImJGRX_g29cWVMG5PtPairll -9rS0I394XdlydmRjpwhVx9m3lNsjv_OTp9QEREMNyuJWsiuUKKQ9cocwggREMIIDLKADAgECAghc -Y8rkSjdTyTANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5j -LjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxl -IFJvb3QgQ0EwHhcNMTcwNTEwMjEyNzMwWhcNMzAxMjMxMDAwMDAwWjBzMS0wKwYDVQQDDCRBcHBs -ZSBpUGhvbmUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxIDAeBgNVBAsMF0NlcnRpZmljYXRpb24g -QXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCASIwDQYJKoZIhvcN -AQEBBQADggEPADCCAQoCggEBAMlFagEPPoMEhsf8v9xe8B6B7hcwc2MmLt49eiTNkz5POUe6db7z -wNLxWaKrH_4KhjzZLZoH8g5ruSmRGl8iCovxclgFrkxLRMV5p4A8sIjgjAwnhF0Z5YcZNsvjxXa3 -sPRBclH0BVyDS6JtplG48Sbfe16tZQzGsphRjLt9G0zBTsgIx9LtZAu03RuNT0B9G49IlpJb89CY -ftm8pBkOmWG7QV0BzFt3en0k0NzTU__D3MWULLZaTY4YIzm92cZSPtHy9CWKoSqH_dgMRilR_-0X -bIkla4e_imkUn3efwxW3aLOIRb2E5gYCQWQPrSoouBXJ4KynirpyBDSyeIz4soUCAwEAAaOB7DCB -6TAPBgNVHRMBAf8EBTADAQH_MB8GA1UdIwQYMBaAFCvQaUeUdgn-9GuNLkCm90dNfwheMEQGCCsG -AQUFBwEBBDgwNjA0BggrBgEFBQcwAYYoaHR0cDovL29jc3AuYXBwbGUuY29tL29jc3AwMy1hcHBs -ZXJvb3RjYTAuBgNVHR8EJzAlMCOgIaAfhh1odHRwOi8vY3JsLmFwcGxlLmNvbS9yb290LmNybDAd -BgNVHQ4EFgQUb_GVGGJc4Mjxxe1sGMng02RSmCAwDgYDVR0PAQH_BAQDAgEGMBAGCiqGSIb3Y2QG -AhIEAgUAMA0GCSqGSIb3DQEBCwUAA4IBAQA6z6yYjb6SICEJrZXzsVwh-jYtVyBEdHNkkgizlqz3 -bZf6WzQ4J88SRtM8EfAHyZmQsdHoEQml46VrbGMIP54l-tWZnEzm5c6Osk1o7Iuro6JPihEVPtwU -KxzGRLZvZ8VbT5UpLYdcP9yDHndP7dpUpy3nE4HBY8RUCxtLCmooIgjUN5J8f2coX689P7esWR04 -NGRa7jNKGUJEKcTKGGvhwVMtLfRNwhX2MzIYePEmb4pN65RMo-j_D7MDi2Xa6y7YZVCf3J-K3zGo -hFTcUlJB0rITHTFGR4hfPu7D8owjBJXrrIo-gmwGny7ji0OaYls0DfSZzyzuunKGGSOl_I61MIIE -uzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBw -bGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMT -DUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQG -EwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBB -dXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAw -ggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne-Uts9QerIjAC6Bg--FAJ039BqJj5 -0cpmnCRrEdCju-QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1-b8iUDulWPTV0N8WQ1 -IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj-zrW5DtleHNbL -PbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz-PvjcM3mo0xFfh9Ma1CWQYnEdGILEINB -hzOKgbEwWOxaBDKMaLOPHd5lc_9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8B -Af8EBAMCAQYwDwYDVR0TAQH_BAUwAwEB_zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01_CF4w -HwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01_CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqG -SIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHD -BggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5 -IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1z -IGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0 -aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3 -m_J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4_2vIB-x9OYOLUyDT -OMSxv5pPCmv_K_xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx-IjXK -JdXZD9Zr1KIkIxH3oayPc4FgxhtbCS-SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL_lTaltkwGMz -d_c6ByxW69oPIQ7aunMZT7XZNn_Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AX -UKqK1drk_NAJBzewdXUhMYIChTCCAoECAQEwfzBzMS0wKwYDVQQDDCRBcHBsZSBpUGhvbmUgQ2Vy -dGlmaWNhdGlvbiBBdXRob3JpdHkxIDAeBgNVBAsMF0NlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMw -EQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUwIIPVn4N-rR2J8wCQYFKw4DAhoFAKCB3DAY -BgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yNTExMTQxOTQ0MzJaMCMG -CSqGSIb3DQEJBDEWBBTDYcbzmjvgUD_BJy52hE3C2PQ5BDApBgkqhkiG9w0BCTQxHDAaMAkGBSsO -AwIaBQChDQYJKoZIhvcNAQEBBQAwUgYJKoZIhvcNAQkPMUUwQzAKBggqhkiG9w0DBzAOBggqhkiG -9w0DAgICAIAwDQYIKoZIhvcNAwICAUAwBwYFKw4DAgcwDQYIKoZIhvcNAwICASgwDQYJKoZIhvcN -AQEBBQAEggEAd87EgZRUrfBCi-D60noQGneyElcfShOPjalmNPCAIduV1Xt5SIFZ3OgFL24kVZzw -_V9wFFzqb0RRP9ZDTijNrGREzMORTa0z_absTqNFI526Rkq9ivXAzEiNBsxBxqtFY0Od3NIr60bd -ADUV5zdT4kj69MOXO0iFsuuUwtWknfr8-rFwHqX5Bkhmk7UzsseLrDnVnvRGMSDJYyeH4PBiw5E4 -moYy3-M-QcjM-GUPOnZTmmiCsHuWfHE98wXYUwxMJCpFWpecSMP3TCSzZgqWrk_SYfTrXyizohvZ -4lJpYKlWwGcTJLtJW-qjNWZ9FmAcyr57W9SDxkxzKw62-ibmXw +MIIQagYJKoZIhvcNAQcCoIIQWzCCEFcCAQExCzAJBgUrDgMCGgUAMHkGCSqGSIb3DQEHAaBsBGp7 +InRlYW1JZCI6IkRRVkZYTUxYNTkiLCJkb21haW4iOiJpbnRlZ3JhdGlvbi52aXRhbWl4LmNvbSIs +ImRhdGVDcmVhdGVkIjoiMjAyNi0wNC0yMiwyMDoxMzo1NiIsInZlcnNpb24iOjF9oIINPzCCBDQw +ggMcoAMCAQICCEYgLbwDT4-2MA0GCSqGSIb3DQEBCwUAMHMxLTArBgNVBAMMJEFwcGxlIGlQaG9u +ZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRob3Jp +dHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMB4XDTI2MDEyODE5NTU1MloXDTMw +MTIzMTAwMDAwMFowWTE1MDMGA1UEAwwsQXBwbGUgaVBob25lIE9TIFByb3Zpc2lvbmluZyBQcm9m +aWxlIFNpZ25pbmcxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuAHGAqf6LSCrzkatFVhzLum8NWXlrHLZBlgdGZc6A6KQHeeq +QX3r-eQDXmsJ4qh0jw-zQjPFR53T04WsiypiLm0sTY6JesoMU-ndpB5upQJp_HboBGw4PS3yqjN1 +gSPLhwne5tv_epkSn5xSg3ExVfl-rTsldVJT4DKzYGFOGat7BEW8q1drCA-UzpUyRAJbiq1yIMCq +ZzsyzzeHVUWjk6LiHnD_pG-CBYTBmT5p1mp3EwpZQTRaEQ2kLhDZscZrP2FVIANCo05Bivk8Rd_V +lwS_TShNfBC5fm0sPrAjet1olpNpJ1qOp7l0EYHnwSDb_J2bZMoPLcwNmVQzAxe-fQIDAQABo4Hl +MIHiMAwGA1UdEwEB_wQCMAAwHwYDVR0jBBgwFoAUb_GVGGJc4Mjxxe1sGMng02RSmCAwQAYIKwYB +BQUHAQEENDAyMDAGCCsGAQUFBzABhiRodHRwOi8vb2NzcC5hcHBsZS5jb20vb2NzcDAzLWFpcGNh +MDcwLwYDVR0fBCgwJjAkoCKgIIYeaHR0cDovL2NybC5hcHBsZS5jb20vYWlwY2EuY3JsMB0GA1Ud +DgQWBBQ7FGgQTyYtjtoLSVAXba5kPv-WETAOBgNVHQ8BAf8EBAMCB4AwDwYJKoZIhvdjZAY6BAIF +ADANBgkqhkiG9w0BAQsFAAOCAQEAOkB4qcVsCxyO4PH-Em0zTr5uD9JWzi--CTbitmmRSbEKo3xo +EkNYevljx-Oe6wd-REaW8HWnpHx6Hv49_N9C9rxzjLNAylE50ULz4feKfdbIZ2o-8yTA9U3CHCf3 +OuuFXubvO2MbfEgGsjCIw7cAWWj1QO2Fz5ga4dbSYi1MlZvSLkvkLeodwYFEnmozgS4WS_JXUI0O +zgk2mBcgPf4E4LL8nNM6Y6Ui_tOAMOMiYw-dd0AOo2ziyOYat1mNNSHY8p43nTxFTdoxhlOrHpLx +SWIAJbBODwxiETDrYnRw_OgE2YZXoV4o0-Ll2ooYeRbkcMF5mLlDjM_HBd_eKnGnvzCCBEQwggMs +oAMCAQICCFxjyuRKN1PJMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpB +cHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEWMBQGA1UE +AxMNQXBwbGUgUm9vdCBDQTAeFw0xNzA1MTAyMTI3MzBaFw0zMDEyMzEwMDAwMDBaMHMxLTArBgNV +BAMMJEFwcGxlIGlQaG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlm +aWNhdGlvbiBBdXRob3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTMIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyUVqAQ8-gwSGx_y_3F7wHoHuFzBzYyYu3j16JM2T +Pk85R7p1vvPA0vFZoqsf_gqGPNktmgfyDmu5KZEaXyIKi_FyWAWuTEtExXmngDywiOCMDCeEXRnl +hxk2y-PFdrew9EFyUfQFXINLom2mUbjxJt97Xq1lDMaymFGMu30bTMFOyAjH0u1kC7TdG41PQH0b +j0iWklvz0Jh-2bykGQ6ZYbtBXQHMW3d6fSTQ3NNT_8PcxZQstlpNjhgjOb3ZxlI-0fL0JYqhKof9 +2AxGKVH_7RdsiSVrh7-KaRSfd5_DFbdos4hFvYTmBgJBZA-tKii4FcngrKeKunIENLJ4jPiyhQID +AQABo4HsMIHpMA8GA1UdEwEB_wQFMAMBAf8wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01_ +CF4wRAYIKwYBBQUHAQEEODA2MDQGCCsGAQUFBzABhihodHRwOi8vb2NzcC5hcHBsZS5jb20vb2Nz +cDAzLWFwcGxlcm9vdGNhMC4GA1UdHwQnMCUwI6AhoB-GHWh0dHA6Ly9jcmwuYXBwbGUuY29tL3Jv +b3QuY3JsMB0GA1UdDgQWBBRv8ZUYYlzgyPHF7WwYyeDTZFKYIDAOBgNVHQ8BAf8EBAMCAQYwEAYK +KoZIhvdjZAYCEgQCBQAwDQYJKoZIhvcNAQELBQADggEBADrPrJiNvpIgIQmtlfOxXCH6Ni1XIER0 +c2SSCLOWrPdtl_pbNDgnzxJG0zwR8AfJmZCx0egRCaXjpWtsYwg_niX61ZmcTOblzo6yTWjsi6uj +ok-KERU-3BQrHMZEtm9nxVtPlSkth1w_3IMed0_t2lSnLecTgcFjxFQLG0sKaigiCNQ3knx_Zyhf +rz0_t6xZHTg0ZFruM0oZQkQpxMoYa-HBUy0t9E3CFfYzMhh48SZvik3rlEyj6P8PswOLZdrrLthl +UJ_cn4rfMaiEVNxSUkHSshMdMUZHiF8-7sPyjCMEleusij6CbAafLuOLQ5piWzQN9JnPLO66coYZ +I6X8jrUwggS7MIIDo6ADAgECAgECMA0GCSqGSIb3DQEBBQUAMGIxCzAJBgNVBAYTAlVTMRMwEQYD +VQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEW +MBQGA1UEAxMNQXBwbGUgUm9vdCBDQTAeFw0wNjA0MjUyMTQwMzZaFw0zNTAyMDkyMTQwMzZaMGIx +CzAJBgNVBAYTAlVTMRMwEQYDVQQKEwpBcHBsZSBJbmMuMSYwJAYDVQQLEx1BcHBsZSBDZXJ0aWZp +Y2F0aW9uIEF1dGhvcml0eTEWMBQGA1UEAxMNQXBwbGUgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAOSRqQkfkdseR1DrBe1eeYQt6zaiV0xV7IsZid75S2z1B6siMALoGD74 +UAnTf0GomPnRymacJGsR0KO75Bsqwx-VnnoMpEeLW9QWNzPLxA9NzhRp0ckZcvVdDtV_X5vyJQO6 +VY9NXQ3xZDUjFUsVWR2zlPf2nJ7PULrBWFBnjwi0IPfLrCwgb3C2PwEwjLdDzw-dPfMrSSgayP7O +tbkO2V4c1ss9tTqt9A8OAJILsSEWLnTVPA3bYharo3GSR1NVwa8vQbP4--NwzeajTEV-H0xrUJZB +icR0YgsQg0GHM4qBsTBY7FoEMoxos48d3mVz_2deZbxJ2HafMxRloXeUyS0CAwEAAaOCAXowggF2 +MA4GA1UdDwEB_wQEAwIBBjAPBgNVHRMBAf8EBTADAQH_MB0GA1UdDgQWBBQr0GlHlHYJ_vRrjS5A +pvdHTX8IXjAfBgNVHSMEGDAWgBQr0GlHlHYJ_vRrjS5ApvdHTX8IXjCCAREGA1UdIASCAQgwggEE +MIIBAAYJKoZIhvdjZAUBMIHyMCoGCCsGAQUFBwIBFh5odHRwczovL3d3dy5hcHBsZS5jb20vYXBw +bGVjYS8wgcMGCCsGAQUFBwICMIG2GoGzUmVsaWFuY2Ugb24gdGhpcyBjZXJ0aWZpY2F0ZSBieSBh +bnkgcGFydHkgYXNzdW1lcyBhY2NlcHRhbmNlIG9mIHRoZSB0aGVuIGFwcGxpY2FibGUgc3RhbmRh +cmQgdGVybXMgYW5kIGNvbmRpdGlvbnMgb2YgdXNlLCBjZXJ0aWZpY2F0ZSBwb2xpY3kgYW5kIGNl +cnRpZmljYXRpb24gcHJhY3RpY2Ugc3RhdGVtZW50cy4wDQYJKoZIhvcNAQEFBQADggEBAFw2mUwt +eLftjJvc83eb8nbSdzBPwR-Fg4UbmT1HN_Kpm0COLNSxkBLYvvRzm-7SZA_LeU802KI--Xj_a8gH +7H05g4tTINM4xLG_mk8Ka_8r_FmnBQl8F0BWER5007eLIztHo9VvJOLr0bdw3w9F4SfK8W147ee1 +Fxeo3H4iNcol1dkP1mvUoiQjEfehrI9zgWDGG1sJL5Ky-ERI8GA4nhX1PSZnIIozavcNgs_e66Mv +-VNqW2TAYzN39zoHLFbr2g8hDtq6cxlPtdk2f8GHVdmnmbkyQvvY1XGefqFStxu9k0IkEirHDx22 +TZxeY8hLgBdQqorV2uT80AkHN7B1dSExggKFMIICgQIBATB_MHMxLTArBgNVBAMMJEFwcGxlIGlQ +aG9uZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTEgMB4GA1UECwwXQ2VydGlmaWNhdGlvbiBBdXRo +b3JpdHkxEzARBgNVBAoMCkFwcGxlIEluYy4xCzAJBgNVBAYTAlVTAghGIC28A0-PtjAJBgUrDgMC +GgUAoIHcMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8XDTI2MDQyMjIw +MTM1NlowIwYJKoZIhvcNAQkEMRYEFKkKE52twp4cRLnVuyaDJsj3edyfMCkGCSqGSIb3DQEJNDEc +MBowCQYFKw4DAhoFAKENBgkqhkiG9w0BAQEFADBSBgkqhkiG9w0BCQ8xRTBDMAoGCCqGSIb3DQMH +MA4GCCqGSIb3DQMCAgIAgDANBggqhkiG9w0DAgIBQDAHBgUrDgMCBzANBggqhkiG9w0DAgIBKDAN +BgkqhkiG9w0BAQEFAASCAQA0X_G2tsTS9YcF9QyDLM3qWmHiMz8qKwHte_f7yyZpc7-GFqoV1OzT +39lMtIm37fQvBU5c-i3lad-FdU-bHzNzdM1CSPOr8lgF-uQ4eKEjHnw3uc_2gEzFw5LUj3tPTKZU +6O0xHhOiZuN2uWkNcEG7gLPYQNyudGgAXDp7FCdCEj_E9EvgqMGyjM1nK4W-FDdFq8fuGbLAesu2 +bjl5cugH7HPAlPI-fLl-gQUU_32UCUYOB5lvsxDgwG-FjLd7kfWb7OPkaGwJ-BlOcZp93ZbPMMXp +MdZpZzIUuZZIT5A4W0lQ-9x3hdJkGIIFNVobwB5qZ_Ur7ZgX0shTopQJBkG6 From bc56e81ae10962a3920e9316a03448e5dd1d20f7 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 22 Apr 2026 16:45:08 -0400 Subject: [PATCH 31/89] fix: update for new normalize locale --- blocks/checkout/checkout.js | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 73c5d86b..f1a61714 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -20,22 +20,19 @@ let selectedShippingMethodId = null; /** * Derive country code from the current locale. - * TODO: Remove drafts fallback before merging */ function getCountry() { const { locale } = getLocaleAndLanguage(); - if (locale === 'drafts') return 'ca'; return locale; } /** - * Get the locale string for the API. - * TODO: Remove drafts fallback before merging + * Get the BCP-47 locale string for the API (e.g. 'en-US', 'fr-CA'). */ function getLocale() { - const { locale, language } = getLocaleAndLanguage(); - if (locale === 'drafts') return 'ca/fr_ca'; - return `${locale}/${language}`; + const { language } = getLocaleAndLanguage(); + const [lang, region] = language.split('_'); + return region ? `${lang}-${region.toUpperCase()}` : lang; } /** From 8b0c0e65b124c332cf9c01ac6b5eb7766ca19ca8 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 29 Apr 2026 15:34:40 -0400 Subject: [PATCH 32/89] fix: update login api --- blocks/header/auth-panel.js | 4 +++- scripts/auth-api.js | 6 ++++-- scripts/scripts.js | 23 ++++++++++++++++++++--- 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/blocks/header/auth-panel.js b/blocks/header/auth-panel.js index f990406e..eee9bba7 100644 --- a/blocks/header/auth-panel.js +++ b/blocks/header/auth-panel.js @@ -1,5 +1,6 @@ import createSlidePanel from '../../scripts/slide-panel.js'; import { login, verifyCode } from '../../scripts/auth-api.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; /** * Builds the first step of the auth flow: an email input form. @@ -176,7 +177,8 @@ export default function createAuthPanel() { btn.textContent = 'Sending code\u2026'; try { - otpState = await login(email); + const { locale: country, language: locale } = getLocaleAndLanguage(false, true); + otpState = await login(email, country, locale); // eslint-disable-next-line no-use-before-define showCodeStep(email); } catch (err) { diff --git a/scripts/auth-api.js b/scripts/auth-api.js index 2c7256c1..089a90b9 100644 --- a/scripts/auth-api.js +++ b/scripts/auth-api.js @@ -25,14 +25,16 @@ function dispatchAuthEvent(loggedIn, email) { * passed back to `verifyCode` to complete authentication. * * @param {string} email - The user's email address + * @param {string} [country] - ISO 3166-1 alpha-2 country code (e.g. 'us', 'ca') + * @param {string} [locale] - BCP-47 locale (e.g. 'en-US', 'fr-CA') * @returns {Promise<{ email: string, hash: string, exp: number }>} * @throws {Error} If the request fails or the API returns an error */ -export async function login(email) { +export async function login(email, country, locale) { const resp = await fetch(`${ORDERS_API_ORIGIN}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), + body: JSON.stringify({ email, country, locale }), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); diff --git a/scripts/scripts.js b/scripts/scripts.js index aa955e9e..b800d110 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -76,17 +76,24 @@ export function formatPrice(value, ph) { /** * Gets the locale and language from the window.location.pathname. + * @param {boolean} [forceEnCA] - Remap en_us → en_ca for Canadian English paths. + * @param {boolean} [bcp47] - Return language as a BCP-47 tag (e.g. 'en-US') instead of + * the underscore form used in URL paths (e.g. 'en_us'). * @returns {Object} Object with locale and language. */ -export function getLocaleAndLanguage(forceEnCA = false) { +export function getLocaleAndLanguage(forceEnCA = false, bcp47 = false) { const pathSegments = window.location.pathname.split('/').filter(Boolean); const locale = pathSegments[0] || 'us'; // fallback to 'us' if not found - const language = pathSegments[1] || 'en_us'; // fallback to 'en_us' if not found + let language = pathSegments[1] || 'en_us'; // fallback to 'en_us' if not found // Commerce backend uses the language code en_ca for the Canada english store view. // On the frontend they are incorrectly using the en_us language code. if (forceEnCA && locale === 'ca' && language === 'en_us') { - return { locale, language: 'en_ca' }; + language = 'en_ca'; + } + + if (bcp47) { + language = language.replace('_', '-').replace(/-([a-z]{2})$/, (_, r) => `-${r.toUpperCase()}`); } return { locale, language }; @@ -103,6 +110,16 @@ export function getOrderPath(page) { return `/${locale}/${language}/order/${page}`; } +/** + * Returns the path for an account page scoped to the current locale and language. + * @param {string} [page] - Optional sub-page (e.g., 'orders', 'order-detail') + * @returns {string} + */ +export function getAccountPath(page) { + const { locale, language } = getLocaleAndLanguage(); + return page ? `/${locale}/${language}/account/${page}` : `/${locale}/${language}/account`; +} + /** * Gets the form submission URL for the current locale and language. * @returns {string} The form submission URL From 45462a701a918b2bededce615acb183caee961e8 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Mon, 4 May 2026 22:47:08 -0400 Subject: [PATCH 33/89] fix: default billing set to shipping addr (when billing not present) --- scripts/cart.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/cart.js b/scripts/cart.js index ab9c1b2d..f27ad0bb 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -208,12 +208,15 @@ export class Cart { phone, }, shipping: cleanAddr(shippingAddr), + // Default billing to shipping when the customer didn't enter a + // separate billing address (the "billing same as shipping" flow). + // The API treats both as required for order downstream consumers + // (transactional emails, payment-provider risk checks, etc.) — sending + // shipping as billing keeps every consumer happy without forcing the + // customer to type their address twice. + billing: cleanAddr(billingAddr ?? shippingAddr), items: this.getItemsForAPI(), }; - - if (billingAddr) { - order.billing = cleanAddr(billingAddr); - } if (shippingMethod) { order.shippingMethod = { id: shippingMethod }; } From b126afb9bc8681392b70f7064ebbbc39015db801 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 5 May 2026 16:28:30 -0400 Subject: [PATCH 34/89] fix: enable on integration --- scripts/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index b800d110..172e8a6f 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -25,7 +25,7 @@ export const ORDERS_API_ORIGIN = 'https://api-stage.adobecommerce.live/aemsites/ // Add locale codes here as each region goes live (e.g., 'ca', 'fr_ca'). const EDGE_CART_LOCALES = []; -const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('uat.vitamix.com'); +const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('integration.vitamix.com'); const isProdHost = hostname.includes('vitamix.com'); // Affirm public API key — safe to expose client-side (used for PDP promo widgets). From be5d765ddc18fe28ce6575bcdab22b8fe0309201 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 5 May 2026 19:22:04 -0400 Subject: [PATCH 35/89] feat: apple pay integration --- blocks/checkout/checkout.js | 118 +++++++++++++++++++++++++++++++++++- scripts/commerce-api.js | 29 +++++++-- 2 files changed, 142 insertions(+), 5 deletions(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index f1a61714..70263b25 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -10,6 +10,7 @@ import { previewOrder, createOrder, initiatePayment, + validateApplePayMerchant, } from '../../scripts/commerce-api.js'; const ADDRESS_FORM = () => `https://main--vitamix--aemsites.aem.page${getOrderPath('address-form')}.json`; @@ -724,7 +725,122 @@ export default async function decorate(block) { } else if (paymentMethod === 'Apple Pay') { span.classList.add('apple-pay'); loadScript('https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'); - span.innerHTML = ''; + span.innerHTML = ``; + + const applePayBtn = span.querySelector('apple-pay-button'); + applePayBtn.addEventListener('click', () => { + if (!selectedShippingMethodId) { + showError(formColumn, 'Please select a shipping method.'); + return; + } + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + if (!currentEstimateToken || !currentPreview) { + showError(formColumn, 'Please wait for shipping and tax estimates to load.'); + return; + } + if (!window.ApplePaySession?.canMakePayments()) { + showError(formColumn, 'Apple Pay is not available on this device or browser.'); + return; + } + + clearError(formColumn); + + const country = getCountry(); + const currencyCode = country === 'ca' ? 'CAD' : 'USD'; + // Stable key for this session — reused on `onpaymentauthorized` retry + const idempotencyKey = crypto.randomUUID(); + // Snapshot form state before async session callbacks run + const formData = Object.fromEntries(new FormData(form).entries()); + + const session = new window.ApplePaySession(3, { + countryCode: country.toUpperCase(), + currencyCode, + supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], + merchantCapabilities: ['supports3DS'], + total: { + label: 'Vitamix', + amount: parseFloat(currentPreview.total ?? 0).toFixed(2), + }, + }); + + session.onvalidatemerchant = async (event) => { + try { + const { merchantSession } = await validateApplePayMerchant( + event.validationURL, + country, + getLocale(), + ); + session.completeMerchantValidation(merchantSession); + } catch { + session.abort(); + showError(formColumn, 'Apple Pay merchant validation failed. Please try again.'); + } + }; + + session.onpaymentauthorized = async (event) => { + try { + const { token, billingContact } = event.payment; + const { + email, + 'shipping-firstname': firstName, + 'shipping-lastname': lastName, + 'shipping-telephone': phone, + } = formData; + const shipping = collectAddress(form, formData, 'shipping-', email); + + const { default: cart } = await import('../../scripts/cart.js'); + + const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { + shippingMethod: selectedShippingMethodId, + estimateToken: currentEstimateToken, + locale: getLocale(), + country, + }); + + sessionStorage.setItem('checkout_email', email); + sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); + sessionStorage.setItem('checkout_preview', JSON.stringify(currentPreview)); + + const { order: createdOrder } = await createOrder(order); + sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); + + const fraudToken = sessionStorage.getItem('forter_token') || undefined; + const payment = await initiatePayment( + createdOrder.id, + idempotencyKey, + fraudToken, + 'chase-wallet', + 'apple-pay', + { token, billingContact }, + ); + + if (payment.status === 'completed') { + session.completePayment({ status: window.ApplePaySession.STATUS_SUCCESS }); + window.location.href = `${getOrderPath('order-summary')}?orderId=${createdOrder.id}`; + } else { + session.completePayment({ status: window.ApplePaySession.STATUS_FAILURE }); + const declineMessages = { + payment_declined: 'Your payment was declined. Please try a different card.', + fraud_declined: 'We were unable to process your payment. Please try again or contact support.', + token_already_used: 'This payment session has already been used. Please try again.', + provider_error: 'A payment error occurred. Please try again.', + }; + showError(formColumn, declineMessages[payment.reason] || 'Your payment could not be processed. Please try again.'); + } + } catch (err) { + session.completePayment({ status: window.ApplePaySession.STATUS_FAILURE }); + const message = err.body?.message || err.message || 'Something went wrong. Please try again.'; + showError(formColumn, message); + } + }; + + session.oncancel = () => {}; + + session.begin(); + }); } else { span.classList.add('credit-card'); span.appendChild(button); diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index 0d770ef1..d35178c5 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -97,17 +97,38 @@ export async function createOrder(orderBody) { * @param {string} orderId - The order ID returned by `createOrder` * @param {string} idempotencyKey - A unique key for this payment attempt (e.g. a UUID) * @param {string} [fraudToken] - Optional fraud provider session token - * @param {string} [provider='chase'] - Payment provider ('chase' or 'paypal') - * @param {string} [paymentMethod='card'] - Payment method ('card' or 'paypal') + * @param {string} [provider='chase'] - Payment provider ('chase', 'paypal', 'chase-wallet') + * @param {string} [paymentMethod='card'] - Payment method ('card', 'paypal', 'apple-pay') + * @param {Object} [extra={}] - Additional provider-specific fields (e.g. token, billingContact) * @returns {Promise<{ orderId: string, paymentAttemptId: string, status: string, - * action: string, redirectUrl: string }>} + * action?: string, redirectUrl?: string, transactionId?: string, reason?: string }>} * @throws {CommerceApiError} */ -export async function initiatePayment(orderId, idempotencyKey, fraudToken, provider = 'chase', paymentMethod = 'card') { +export async function initiatePayment(orderId, idempotencyKey, fraudToken, provider = 'chase', paymentMethod = 'card', extra = {}) { return post(`/orders/${orderId}/payments`, { provider, paymentMethod, idempotencyKey, ...(fraudToken ? { fraudToken } : {}), + ...extra, + }); +} + +/** + * Requests an Apple Pay merchant session from the Commerce API. + * Called inside `ApplePaySession.onvalidatemerchant` — the API makes a mutual-TLS + * POST to Apple's gateway and returns the opaque merchant session object. + * + * @param {string} validationUrl - The URL provided by Apple in the onvalidatemerchant event + * @param {string} [country] - ISO country code (e.g. 'us', 'ca') for provider config resolution + * @param {string} [locale] - BCP-47 locale string (e.g. 'en-US') for provider config resolution + * @returns {Promise<{ merchantSession: Object }>} + * @throws {CommerceApiError} + */ +export async function validateApplePayMerchant(validationUrl, country, locale) { + return post('/payments/apple-pay/validate-merchant', { + validationUrl, + ...(country ? { country } : {}), + ...(locale ? { locale } : {}), }); } From 1fddba53661cc755ed037f584bfdcaac6102e349 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 5 May 2026 19:37:26 -0400 Subject: [PATCH 36/89] fix: apple pay btn update --- blocks/checkout/checkout.js | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 70263b25..ac8942c5 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -727,8 +727,14 @@ export default async function decorate(block) { loadScript('https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'); span.innerHTML = ``; - const applePayBtn = span.querySelector('apple-pay-button'); - applePayBtn.addEventListener('click', () => { + // Listen on the span wrapper, not the custom element. + // The custom element renders as zero-height on non-Safari browsers, so + // listening on the host span is more reliable for event propagation. + span.addEventListener('click', () => { + if (!window.ApplePaySession?.canMakePayments()) { + showError(formColumn, 'Apple Pay is not available on this device or browser.'); + return; + } if (!selectedShippingMethodId) { showError(formColumn, 'Please select a shipping method.'); return; @@ -741,10 +747,6 @@ export default async function decorate(block) { showError(formColumn, 'Please wait for shipping and tax estimates to load.'); return; } - if (!window.ApplePaySession?.canMakePayments()) { - showError(formColumn, 'Apple Pay is not available on this device or browser.'); - return; - } clearError(formColumn); From a0540b577b2a36c313e4b9fc2eec308a3295dc37 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 5 May 2026 21:10:13 -0400 Subject: [PATCH 37/89] fix: apple pay btn --- blocks/checkout/checkout.css | 8 +++++++ blocks/checkout/checkout.js | 42 +++++++++++++++++++++++++----------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 20d03507..a1546cfb 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -190,6 +190,14 @@ width: 100%; } +/* The span wrapper needs explicit block display so width:100% applies and + the element is always clickable — is zero-height until + the SDK upgrades it, which would make the span unclickable on first render. */ +.checkout-form-column .form form .button-wrapper span.payment-button.apple-pay { + display: block; + min-height: 44px; +} + apple-pay-button { --apple-pay-button-width: 100%; --apple-pay-button-height: 40px; diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index ac8942c5..4bd0249c 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -731,8 +731,12 @@ export default async function decorate(block) { // The custom element renders as zero-height on non-Safari browsers, so // listening on the host span is more reliable for event propagation. span.addEventListener('click', () => { - if (!window.ApplePaySession?.canMakePayments()) { - showError(formColumn, 'Apple Pay is not available on this device or browser.'); + // The Apple Pay JS SDK (1.latest / v1.2.0+) polyfills window.ApplePaySession on + // Chrome and other non-Safari browsers, enabling the "Scan with iPhone" QR code + // flow (requires iOS 18+ on the user's iPhone). If the SDK hasn't loaded yet, + // ApplePaySession will be undefined and we show a retry prompt. + if (!window.ApplePaySession) { + showError(formColumn, 'Apple Pay is still loading — please try again in a moment.'); return; } if (!selectedShippingMethodId) { @@ -757,16 +761,22 @@ export default async function decorate(block) { // Snapshot form state before async session callbacks run const formData = Object.fromEntries(new FormData(form).entries()); - const session = new window.ApplePaySession(3, { - countryCode: country.toUpperCase(), - currencyCode, - supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], - merchantCapabilities: ['supports3DS'], - total: { - label: 'Vitamix', - amount: parseFloat(currentPreview.total ?? 0).toFixed(2), - }, - }); + let session; + try { + session = new window.ApplePaySession(3, { + countryCode: country.toUpperCase(), + currencyCode, + supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], + merchantCapabilities: ['supports3DS'], + total: { + label: 'Vitamix', + amount: parseFloat(currentPreview.total ?? 0).toFixed(2), + }, + }); + } catch (err) { + showError(formColumn, 'Apple Pay is not available. Please try a different payment method.'); + return; + } session.onvalidatemerchant = async (event) => { try { @@ -841,7 +851,11 @@ export default async function decorate(block) { session.oncancel = () => {}; - session.begin(); + try { + session.begin(); + } catch (err) { + showError(formColumn, 'Unable to start Apple Pay. Please try a different payment method.'); + } }); } else { span.classList.add('credit-card'); @@ -978,5 +992,7 @@ export default async function decorate(block) { } } }); + + clearError(formColumn); }); } From 04b4892bed6aaf21d43c2e5f484aca2095b20225 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 5 May 2026 21:26:44 -0400 Subject: [PATCH 38/89] fix: wire up Apple Pay button click handler --- blocks/checkout/checkout.css | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index a1546cfb..9fcd1733 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -190,12 +190,15 @@ width: 100%; } -/* The span wrapper needs explicit block display so width:100% applies and - the element is always clickable — is zero-height until - the SDK upgrades it, which would make the span unclickable on first render. */ +/* The span is the actual click target — the shadow DOM + swallows pointer events before they can bubble to the span. Setting + pointer-events:none makes clicks fall through to the span, which holds + our ApplePaySession logic. display:block + min-height ensure the span + is always clickable even before the SDK upgrades . */ .checkout-form-column .form form .button-wrapper span.payment-button.apple-pay { display: block; min-height: 44px; + cursor: pointer; } apple-pay-button { @@ -203,6 +206,7 @@ apple-pay-button { --apple-pay-button-height: 40px; --apple-pay-button-border-radius: 5px; --apple-pay-button-padding: 5px 0px; + pointer-events: none; } .checkout-form-column .form form .button-wrapper span.payment-button.paypal .button { From 0fbe13996d9c1feb4f99fbf74721e579ea680185 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 5 May 2026 21:33:46 -0400 Subject: [PATCH 39/89] fix: fix Apple Pay button click handling and estimate race condition --- blocks/checkout/checkout.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 4bd0249c..5f6b5441 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -748,7 +748,19 @@ export default async function decorate(block) { return; } if (!currentEstimateToken || !currentPreview) { - showError(formColumn, 'Please wait for shipping and tax estimates to load.'); + // Clicking Apple Pay blurs the previously-focused field, which fires + // updatePreview asynchronously — but we're already in the click handler + // before that call completes (race condition). If the form looks complete, + // kick off a fresh estimate and ask the user to tap again once it's ready. + const fd = Object.fromEntries(new FormData(form).entries()); + const canEstimate = selectedShippingMethodId + && fd.email && fd['shipping-firstname'] && fd['shipping-lastname']; + if (canEstimate) { + import('../../scripts/cart.js').then(({ default: cart }) => updatePreview(form, fd, cart)); + showError(formColumn, 'Calculating shipping and taxes — please try again in a moment.'); + } else { + showError(formColumn, 'Please complete your shipping address and select a shipping method.'); + } return; } From c622f04812d625225e886e8002fba1fc02b37b09 Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 6 May 2026 09:55:05 -0700 Subject: [PATCH 40/89] feat: ebs forms -> staginguat (#422) * fix: add product-registration form * fix: add order-status form * fix: show result text * fix: increase font size for result * fix: toast on error * chore: compare longest string first for form input errors * fix: fuzzier input finding * fix: address comments * fix: tweak modal style --- blocks/modal/modal.css | 20 ++ widgets/forms/consult-expert.js | 18 +- widgets/forms/contact-foundation.js | 18 +- widgets/forms/contact-us.js | 18 +- widgets/forms/create-account.js | 18 +- widgets/forms/edit-account.js | 18 +- widgets/forms/login.js | 25 ++- widgets/forms/manage-address.js | 18 +- widgets/forms/media-contact.js | 18 +- widgets/forms/order-status.css | 33 ++- widgets/forms/order-status.html | 5 +- widgets/forms/order-status.js | 75 ++++++- widgets/forms/order-status.json | 44 +++- widgets/forms/product-registration.js | 35 +++- widgets/forms/sales-advice.js | 18 +- widgets/forms/toast.css | 48 +++++ widgets/forms/util.js | 288 ++++++++++++++++++++++++++ widgets/forms/wellness-program.js | 18 +- 18 files changed, 663 insertions(+), 72 deletions(-) create mode 100644 widgets/forms/toast.css create mode 100644 widgets/forms/util.js diff --git a/blocks/modal/modal.css b/blocks/modal/modal.css index 6d19fd85..9ad16e04 100644 --- a/blocks/modal/modal.css +++ b/blocks/modal/modal.css @@ -85,6 +85,26 @@ body.modal-open { padding: 0; } +/* product registration find serial modal */ +.modal[data-modal-path*="product-registration-find-serial"] dialog { + max-width: 1000px; + max-height: 750px; + height: 90dvh; + width: 90dvw; +} + +.modal[data-modal-path*="product-registration-find-serial"] dialog .modal-content { + background-color: var(--color-gray-300); + display: flex; + max-height: +} + +@media (width >= 1000px) { + .modal[data-modal-path*="product-registration-find-serial"] dialog .modal-content { + max-height: calc(90dvh - 64px); + } +} + /* sign-up modal */ .modal[data-modal-path*="sign-up"] dialog { max-width: 1000px; diff --git a/widgets/forms/consult-expert.js b/widgets/forms/consult-expert.js index cbe23e39..fcc8e784 100644 --- a/widgets/forms/consult-expert.js +++ b/widgets/forms/consult-expert.js @@ -54,6 +54,7 @@ export default async function decorate(widget) { const vitamixInternationalPath = `/${locale}/${language}/vitamix-international`; const countryCode = (locale || 'us').toUpperCase(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang).catch(() => ({})); const stateOptions = await getStatesProvincesOptions(countryCode, lang).catch(() => []); const labels = copy.labels || {}; @@ -131,6 +132,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -138,16 +140,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/consult-expert-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Consult expert form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/contact-foundation.js b/widgets/forms/contact-foundation.js index caad8d2d..7b9ca08b 100644 --- a/widgets/forms/contact-foundation.js +++ b/widgets/forms/contact-foundation.js @@ -41,6 +41,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -92,6 +93,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -99,16 +101,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/contact-foundation-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Contact foundation form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/contact-us.js b/widgets/forms/contact-us.js index 64c5978c..d512410d 100644 --- a/widgets/forms/contact-us.js +++ b/widgets/forms/contact-us.js @@ -41,6 +41,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -88,6 +89,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -95,16 +97,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Forms API submission failed with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/contact-us-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Contact us form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/create-account.js b/widgets/forms/create-account.js index dbf980bf..bab5a336 100644 --- a/widgets/forms/create-account.js +++ b/widgets/forms/create-account.js @@ -29,6 +29,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -99,6 +100,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(SHEET_LOGGER_URL, { method: 'POST', @@ -106,16 +108,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/create-account-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Create account form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/edit-account.js b/widgets/forms/edit-account.js index 1057730d..71502edf 100644 --- a/widgets/forms/edit-account.js +++ b/widgets/forms/edit-account.js @@ -30,6 +30,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -93,6 +94,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(SHEET_LOGGER_URL, { method: 'POST', @@ -100,16 +102,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/edit-account-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Edit account form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/login.js b/widgets/forms/login.js index 27a7beaa..aa944194 100644 --- a/widgets/forms/login.js +++ b/widgets/forms/login.js @@ -32,6 +32,7 @@ export default async function decorate(widget) { const { language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -90,10 +91,15 @@ export default async function decorate(widget) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ email, pageUrl: window.location.href, resend: true }), }); - if (!resp.ok) throw new Error(`Sheet logger responded with ${resp.status}`); + if (!resp.ok) { + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + } } catch (err) { // eslint-disable-next-line no-console console.error('Login resend failed', err); + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); } resendBtn.disabled = false; resendBtn.textContent = originalText; @@ -113,6 +119,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didAdvance = false; try { const resp = await fetch(SHEET_LOGGER_URL, { method: 'POST', @@ -120,8 +127,11 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didAdvance = true; form.setAttribute('hidden', ''); form.classList.add('is-hidden'); verifyEl.removeAttribute('hidden'); @@ -130,9 +140,14 @@ export default async function decorate(widget) { } catch (err) { // eslint-disable-next-line no-console console.error('Login form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didAdvance) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/manage-address.js b/widgets/forms/manage-address.js index e453ac7c..9268a4c0 100644 --- a/widgets/forms/manage-address.js +++ b/widgets/forms/manage-address.js @@ -46,6 +46,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const countryCode = (locale || 'us').toUpperCase(); const copy = await loadFormCopy(lang); const provinceOptions = await getStatesProvincesOptions(countryCode, lang).catch(() => []); @@ -126,6 +127,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(SHEET_LOGGER_URL, { method: 'POST', @@ -133,16 +135,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/manage-address-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Manage address form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/media-contact.js b/widgets/forms/media-contact.js index 1b5bbdfb..8e2eefb6 100644 --- a/widgets/forms/media-contact.js +++ b/widgets/forms/media-contact.js @@ -41,6 +41,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -99,6 +100,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -106,16 +108,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Forms API submission failed with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/corporate-information/media-center/media-request-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Media contact form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/order-status.css b/widgets/forms/order-status.css index 3e0f9c13..1df29978 100644 --- a/widgets/forms/order-status.css +++ b/widgets/forms/order-status.css @@ -39,21 +39,40 @@ border-color: var(--color-gray-900); } -/* Result JSON display below the form */ +/* Result display below the form */ .forms-order-status .order-status-result { margin-top: var(--spacing-300); padding: var(--spacing-200); background-color: var(--color-white); border: 1px solid var(--color-gray-300); border-radius: var(--rounding-m); - font-family: var(--font-family-mono, monospace); - font-size: var(--font-size-20); - white-space: pre-wrap; - word-break: break-word; - max-height: 20rem; - overflow: auto; + font-size: var(--font-size-60); } .forms-order-status .order-status-result:empty { display: none; } + +.forms-order-status .order-status-result-list { + margin: 0; + display: flex; + flex-direction: column; + gap: var(--spacing-100); +} + +.forms-order-status .order-status-result-row { + display: flex; + gap: 0.35em; +} + +.forms-order-status .order-status-result-row dt { + font-weight: bold; +} + +.forms-order-status .order-status-result-row dt::after { + content: ':'; +} + +.forms-order-status .order-status-result-row dd { + margin: 0; +} diff --git a/widgets/forms/order-status.html b/widgets/forms/order-status.html index c190b13e..f38641a9 100644 --- a/widgets/forms/order-status.html +++ b/widgets/forms/order-status.html @@ -3,10 +3,11 @@ - +
          - + \ No newline at end of file diff --git a/widgets/forms/order-status.js b/widgets/forms/order-status.js index 1b2f85d5..9ace1e61 100644 --- a/widgets/forms/order-status.js +++ b/widgets/forms/order-status.js @@ -1,7 +1,4 @@ -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; - -/** Sheet logger endpoint for order status lookup */ -const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/order-status'; +import { getLocaleAndLanguage, getFormSubmissionUrl } from '../../scripts/scripts.js'; /** * Loads form copy from the widget's local JSON (same name as the script). @@ -18,9 +15,59 @@ async function loadFormCopy(lang) { return data[key]; } +/** + * Derives a status key from the API response. + * @param {Object|null} result - Parsed API response + * @returns {string} Status key matching a key in result.statuses + */ +function deriveStatus(result) { + if (!result?.succeeded) return 'unavailable'; + if (result.outcome === 'Cancelled') return 'cancelled'; + const deliveries = result.order?.delivery ?? []; + const shippedCount = deliveries.filter((d) => d.shipped).length; + if (shippedCount === 0) return deliveries.length ? 'processed' : 'received'; + if (shippedCount < deliveries.length) return 'partiallyShipped'; + return 'shipped'; +} + +/** + * Renders the order result as a formatted definition list into the given container. + * @param {Object|null} result - Parsed API response + * @param {Object} copy - Localised copy for the current language + * @param {HTMLElement} container - Element to render into + */ +function renderResult(result, copy, container) { + const resultLabels = copy.result?.labels ?? {}; + const resultStatuses = copy.result?.statuses ?? {}; + + const orderNumber = result?.order?.key ?? '—'; + const statusKey = deriveStatus(result); + const orderStatus = resultStatuses[statusKey] ?? statusKey; + + const rows = [ + [resultLabels.orderNumber ?? 'Order Number', orderNumber], + [resultLabels.orderStatus ?? 'Order Status', orderStatus], + ]; + + const dl = document.createElement('dl'); + dl.className = 'order-status-result-list'; + rows.forEach(([label, value]) => { + const div = document.createElement('div'); + div.className = 'order-status-result-row'; + const dt = document.createElement('dt'); + dt.textContent = label; + const dd = document.createElement('dd'); + dd.textContent = value; + div.append(dt, dd); + dl.append(div); + }); + + container.replaceChildren(dl); +} + /** * Decorates the order-status widget: applies copy from JSON and configures form. - * Submits POST with JSON to sheet-logger and displays the response JSON below the form. + * Submits POST with JSON to sheet-logger and displays the order result below the form. * @param {HTMLElement} widget - The widget root element */ export default async function decorate(widget) { @@ -28,8 +75,9 @@ export default async function decorate(widget) { const resultEl = widget.querySelector('.order-status-result'); if (!form || !resultEl) return; - const { language } = getLocaleAndLanguage(); + const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -46,6 +94,7 @@ export default async function decorate(widget) { e.preventDefault(); const data = new FormData(form); const payload = Object.fromEntries(data.entries()); + payload.formId = `${locale}/${language}/order-status`; payload.pageUrl = window.location.href; [...form.elements].forEach((el) => { el.disabled = true; }); @@ -58,11 +107,16 @@ export default async function decorate(widget) { resultEl.textContent = ''; try { - const resp = await fetch(SHEET_LOGGER_URL, { + const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); + if (resp.status === 400) { + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; + } const text = await resp.text(); let result; try { @@ -70,15 +124,14 @@ export default async function decorate(widget) { } catch { result = { status: resp.status, ok: resp.ok, body: text }; } + renderResult(result, copy, resultEl); resultEl.hidden = false; - resultEl.textContent = JSON.stringify(result, null, 2); resultEl.classList.add('order-status-result-visible'); } catch (err) { // eslint-disable-next-line no-console console.error('Order status lookup failed', err); - resultEl.hidden = false; - resultEl.textContent = JSON.stringify({ error: err.message }, null, 2); - resultEl.classList.add('order-status-result-visible'); + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); } finally { [...form.elements].forEach((el) => { el.disabled = false; }); if (submitBtn) submitBtn.textContent = submitBtn.dataset.originalLabel || originalSubmitText; diff --git a/widgets/forms/order-status.json b/widgets/forms/order-status.json index 9e541464..6f7ec94f 100644 --- a/widgets/forms/order-status.json +++ b/widgets/forms/order-status.json @@ -7,6 +7,20 @@ }, "inputPlaceholders": { "orderNumber": "" + }, + "result": { + "labels": { + "orderNumber": "Order Number", + "orderStatus": "Order Status" + }, + "statuses": { + "received": "Order Received", + "processed": "Order Processed", + "partiallyShipped": "Partially Shipped", + "shipped": "Order Shipped", + "cancelled": "Order Cancelled", + "unavailable": "Status Unavailable" + } } }, "fr": { @@ -17,6 +31,20 @@ }, "inputPlaceholders": { "orderNumber": "" + }, + "result": { + "labels": { + "orderNumber": "Numéro de commande", + "orderStatus": "État de la commande" + }, + "statuses": { + "received": "Commande reçue", + "processed": "Commande traitée", + "partiallyShipped": "Partiellement expédiée", + "shipped": "Commande expédiée", + "cancelled": "Commande annulée", + "unavailable": "État indisponible" + } } }, "es": { @@ -27,6 +55,20 @@ }, "inputPlaceholders": { "orderNumber": "" + }, + "result": { + "labels": { + "orderNumber": "Número de pedido", + "orderStatus": "Estado del pedido" + }, + "statuses": { + "received": "Pedido recibido", + "processed": "Pedido procesado", + "partiallyShipped": "Parcialmente enviado", + "shipped": "Pedido enviado", + "cancelled": "Pedido cancelado", + "unavailable": "Estado no disponible" + } } } -} +} \ No newline at end of file diff --git a/widgets/forms/product-registration.js b/widgets/forms/product-registration.js index ee53e9d2..9aa3da12 100644 --- a/widgets/forms/product-registration.js +++ b/widgets/forms/product-registration.js @@ -1,9 +1,6 @@ -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getFormSubmissionUrl, getLocaleAndLanguage } from '../../scripts/scripts.js'; import getStatesProvincesOptions from './states-provinces.js'; -/** Sheet logger endpoint for product registration form */ -const SHEET_LOGGER_URL = 'https://sheet-logger.david8603.workers.dev/vitamix.com/forms-testing/product-registration'; - /** * Loads form copy from the widget's local JSON (same name as the script). * @param {string} lang - Language key (en, fr, es) @@ -45,6 +42,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const countryCode = (locale || 'us').toUpperCase(); const copy = await loadFormCopy(lang); const provinceOptions = await getStatesProvincesOptions(countryCode, lang).catch(() => []); @@ -69,7 +67,14 @@ export default async function decorate(widget) { if (serialInput) serialInput.placeholder = inputHints.serialNumber ?? ''; const findLink = form.querySelector('.find-serial-link'); - if (findLink) findLink.textContent = labels.findYourSerialNumber ?? 'Find your serial number'; + if (findLink) { + findLink.textContent = labels.findYourSerialNumber ?? 'Find your serial number'; + findLink.addEventListener('click', async (e) => { + e.preventDefault(); + const { openModal } = await import('../../blocks/modal/modal.js'); + await openModal(`/${locale}/${language}/customer-service/product-registration-find-serial`); + }); + } const radioLegend = form.querySelector('.product-registration-radio-group .radio-legend'); if (radioLegend) radioLegend.textContent = labels.iPlanToUseIt ?? 'I plan to use it'; @@ -148,6 +153,7 @@ export default async function decorate(widget) { e.preventDefault(); const data = new FormData(form); const payload = Object.fromEntries(data.entries()); + payload.formId = `${locale}/${language}/product-registration`; payload.pageUrl = window.location.href; const submitButton = form.querySelector('button[type="submit"]'); @@ -158,23 +164,32 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { - const resp = await fetch(SHEET_LOGGER_URL, { + const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Sheet logger responded with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/product-registration-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Product registration form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/sales-advice.js b/widgets/forms/sales-advice.js index 91c8cbe4..db044959 100644 --- a/widgets/forms/sales-advice.js +++ b/widgets/forms/sales-advice.js @@ -55,6 +55,7 @@ export default async function decorate(widget) { const vitamixInternationalPath = `/${locale}/${language}/vitamix-international`; const countryCode = (locale || 'us').toUpperCase(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang).catch(() => ({})); const stateOptions = await getStatesProvincesOptions(countryCode, lang).catch(() => []); const labels = copy.labels || {}; @@ -158,6 +159,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -165,16 +167,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Forms API submission failed with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/commercial/sales-advice-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Sales advice form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); diff --git a/widgets/forms/toast.css b/widgets/forms/toast.css new file mode 100644 index 00000000..35984269 --- /dev/null +++ b/widgets/forms/toast.css @@ -0,0 +1,48 @@ +.toast-container { + position: fixed; + top: 20px; + right: 20px; + z-index: 10000; + display: flex; + flex-direction: column; + gap: 8px; + pointer-events: none; + max-width: calc(100% - 40px); +} + +.toast { + min-width: 240px; + max-width: 420px; + padding: 12px 16px; + border-radius: 6px; + background: #2f3e4e; + color: #fff; + font-size: 14px; + line-height: 1.4; + box-shadow: 0 2px 8px rgb(0 0 0 / 20%); + opacity: 0; + transform: translateY(-8px); + transition: opacity 200ms ease, transform 200ms ease; + pointer-events: auto; + word-break: break-word; +} + +.toast-visible { + opacity: 1; + transform: translateY(0); +} + +.toast-info { background: #2f3e4e; } +.toast-success { background: #2f7a4c; } +.toast-error { background: #b3261e; } + +.form-field-error { + color: var(--color-red, #c8102e); + font-size: var(--font-size-20, 0.875rem); + margin: 0.25em 0 0; + line-height: 1.3; +} + +[aria-invalid="true"] { + border-color: var(--color-red, #c8102e); +} diff --git a/widgets/forms/util.js b/widgets/forms/util.js new file mode 100644 index 00000000..660081db --- /dev/null +++ b/widgets/forms/util.js @@ -0,0 +1,288 @@ +import { loadCSS } from '../../scripts/aem.js'; + +const TOAST_DURATION_MS = 5000; +const TOAST_EXIT_MS = 250; + +let toastContainer = null; +let formStylesLoaded = false; + +function ensureFormStyles() { + if (formStylesLoaded) return; + loadCSS(`${window.hlx?.codeBasePath || ''}/widgets/forms/toast.css`); + formStylesLoaded = true; +} + +function ensureToastContainer() { + ensureFormStyles(); + if (!toastContainer || !document.body.contains(toastContainer)) { + toastContainer = document.createElement('div'); + toastContainer.className = 'toast-container'; + toastContainer.setAttribute('role', 'status'); + toastContainer.setAttribute('aria-live', 'polite'); + document.body.append(toastContainer); + } + return toastContainer; +} + +/** + * Shows a dismissible toast message. + * @param {string} message - Text to display + * @param {'info'|'success'|'error'} [level] - Visual level; defaults to 'info' + */ +export function toast(message, level = 'info') { + if (!message) return; + const root = ensureToastContainer(); + const el = document.createElement('div'); + el.className = `toast toast-${level}`; + el.textContent = message; + root.append(el); + requestAnimationFrame(() => el.classList.add('toast-visible')); + setTimeout(() => { + el.classList.remove('toast-visible'); + setTimeout(() => el.remove(), TOAST_EXIT_MS); + }, TOAST_DURATION_MS); +} + +/** + * Extracts candidate error messages from a 400 response in priority order: + * 1. body.details[].message + * 2. body.error + * 3. 'x-error' response header + */ +function extractErrorMessages(response, body) { + if (Array.isArray(body?.details)) { + const messages = body.details + .map((d) => d?.message) + .filter((m) => typeof m === 'string' && m.trim()); + if (messages.length) return messages; + } + if (typeof body?.error === 'string' && body.error.trim()) { + return [body.error]; + } + const headerMessage = response?.headers?.get?.('x-error'); + if (headerMessage) return [headerMessage]; + return []; +} + +/** + * Finds the first input whose `name` attribute appears (case-insensitively) + * inside one of the given messages. + */ +/** + * Human-readable rewrites of field names: e.g. 'emailAddress' -> 'email address'. + */ +function splitCamel(name) { + return name + .replace(/([a-z])([A-Z])/g, '$1 $2') + .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2') + .replace(/[-_]+/g, ' ') + .toLowerCase() + .trim(); +} + +/** + * Alternate phrases that may appear in error messages -> canonical input name. + */ +const FIELD_ALIASES = { + 'purchase date': 'purchasedOn', + 'date of purchase': 'purchasedOn', + 'date purchased': 'purchasedOn', + 'purchase location': 'purchasedFrom', + retailer: 'purchasedFrom', + store: 'purchasedFrom', + serial: 'serialNumber', + zip: 'zipCode', + postcode: 'postalCode', + surname: 'lastName', + 'family name': 'lastName', + 'given name': 'firstName', + forename: 'firstName', +}; + +function findMatchingInput(form, messages) { + const namedInputs = [...form.querySelectorAll('[name]')].filter((el) => el.name); + const inputsByName = new Map(namedInputs.map((el) => [el.name, el])); + const candidates = []; + namedInputs.forEach((input) => { + const lower = input.name.toLowerCase(); + candidates.push({ keyword: lower, input }); + const split = splitCamel(input.name); + if (split && split !== lower) candidates.push({ keyword: split, input }); + }); + Object.entries(FIELD_ALIASES).forEach(([alias, fieldName]) => { + const input = inputsByName.get(fieldName); + if (input) candidates.push({ keyword: alias.toLowerCase(), input }); + }); + candidates.sort((a, b) => b.keyword.length - a.keyword.length); + let match = null; + messages.find((message) => { + const lower = message.toLowerCase(); + const c = candidates.find((cand) => lower.includes(cand.keyword)); + if (c) match = { input: c.input, message }; + return !!c; + }); + return match; +} + +/** + * Returns the input's "field row" — the closest ancestor whose parent is the + * form or a fieldset. This is where we render an inline error message. + */ +function getFieldWrapper(input) { + let el = input; + while ( + el?.parentElement + && el.parentElement.tagName !== 'FORM' + && el.parentElement.tagName !== 'FIELDSET' + ) { + el = el.parentElement; + } + return el; +} + +function getOrCreateErrorElement(input) { + const wrapper = getFieldWrapper(input); + if (!wrapper) return null; + let errorEl = wrapper.querySelector(':scope > .form-field-error'); + if (!errorEl) { + errorEl = document.createElement('p'); + errorEl.className = 'form-field-error'; + errorEl.setAttribute('aria-live', 'polite'); + errorEl.hidden = true; + wrapper.appendChild(errorEl); + } + return errorEl; +} + +function showInlineError(input, message) { + const errorEl = getOrCreateErrorElement(input); + if (!errorEl) return; + errorEl.textContent = message; + errorEl.hidden = false; + input.setAttribute('aria-invalid', 'true'); +} + +function clearInlineError(input) { + const wrapper = getFieldWrapper(input); + const errorEl = wrapper?.querySelector(':scope > .form-field-error'); + if (errorEl) { + errorEl.textContent = ''; + errorEl.hidden = true; + } + input.removeAttribute('aria-invalid'); +} + +function applyInputError(input, message) { + if (typeof input.setCustomValidity !== 'function') return; + input.setCustomValidity(message); + showInlineError(input, message); + if (typeof input.scrollIntoView === 'function') { + input.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + if (typeof input.focus === 'function') input.focus(); + const clear = () => { + input.setCustomValidity(''); + clearInlineError(input); + input.removeEventListener('input', clear); + input.removeEventListener('change', clear); + }; + input.addEventListener('input', clear); + input.addEventListener('change', clear); +} + +/** + * Localized native validation messages, keyed by ValidityState flag. + * `default` is the fallback when none of the more specific keys apply. + */ +const VALIDATION_MESSAGES = { + en: { + valueMissing: 'This field is required.', + default: 'Please enter a valid value.', + }, + fr: { + valueMissing: 'Ce champ est obligatoire.', + default: 'Veuillez saisir une valeur valide.', + }, + es: { + valueMissing: 'Este campo es obligatorio.', + default: 'Por favor, introduzca un valor válido.', + }, +}; + +function pickValidationMessage(validity, messages) { + if (validity.customError) return null; + if (validity.valueMissing) return messages.valueMissing; + if (validity.valid) return null; + return messages.default; +} + +/** + * Installs localized native-validation messages on every form control. + * Listens for `invalid` events and replaces the browser's default message + * via setCustomValidity. Custom errors set by other code are not overridden. + * @param {HTMLFormElement} form + * @param {string} [lang] - Language key (en, fr, es); defaults to 'en' + */ +export function setupFormValidation(form, lang = 'en') { + ensureFormStyles(); + const messages = VALIDATION_MESSAGES[lang] || VALIDATION_MESSAGES.en; + [...form.querySelectorAll('input, select, textarea')].forEach((input) => { + input.addEventListener('invalid', (e) => { + e.preventDefault(); + const message = pickValidationMessage(input.validity, messages); + if (message) { + input.setCustomValidity(message); + showInlineError(input, message); + } + }); + const clear = () => { + if (input.validity.customError) input.setCustomValidity(''); + clearInlineError(input); + }; + input.addEventListener('input', clear); + input.addEventListener('change', clear); + }); + + form.addEventListener('submit', (e) => { + if (form.checkValidity()) return; + e.preventDefault(); + e.stopImmediatePropagation(); + const firstInvalid = form.querySelector(':invalid'); + if (firstInvalid) { + firstInvalid.focus(); + firstInvalid.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + }, true); +} + +/** + * Handles a non-ok form submission response. For a 400, extracts the preferred + * error message and applies it inline to a matching input or shows a toast. + * For any other non-ok status, shows a toast with the fallback message. + * @param {Response} response - The fetch Response + * @param {HTMLFormElement} form - Form whose inputs may be matched by field name + * @param {string} fallbackMessage - Shown when no structured error is found + * @returns {Promise} + */ +export async function handleFormSubmitError(response, form, fallbackMessage) { + if (response?.status === 400) { + let body = null; + try { + const text = await response.text(); + body = text ? JSON.parse(text) : null; + } catch { + body = null; + } + const messages = extractErrorMessages(response, body); + if (messages.length) { + const match = findMatchingInput(form, messages); + if (match) { + applyInputError(match.input, match.message); + return; + } + toast(messages[0], 'error'); + return; + } + } + if (fallbackMessage) toast(fallbackMessage, 'error'); +} diff --git a/widgets/forms/wellness-program.js b/widgets/forms/wellness-program.js index 348486de..154df14e 100644 --- a/widgets/forms/wellness-program.js +++ b/widgets/forms/wellness-program.js @@ -25,6 +25,7 @@ export default async function decorate(widget) { const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; + import('./util.js').then(({ setupFormValidation }) => setupFormValidation(form, lang)); const copy = await loadFormCopy(lang); const labels = copy.labels || {}; const inputHints = copy.inputPlaceholders || {}; @@ -78,6 +79,7 @@ export default async function decorate(widget) { submitButton.textContent = labels.sending ?? 'Sending...'; } + let didNavigate = false; try { const resp = await fetch(getFormSubmissionUrl(), { method: 'POST', @@ -85,16 +87,24 @@ export default async function decorate(widget) { body: JSON.stringify(payload), }); if (!resp.ok) { - throw new Error(`Forms API submission failed with ${resp.status}`); + const { handleFormSubmitError } = await import('./util.js'); + await handleFormSubmitError(resp, form, labels.submissionFailed ?? 'Something went wrong. Please try again.'); + return; } + didNavigate = true; const thankYouPath = `/${locale}/${language}/wellness-program-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console console.error('Wellness program form submission failed', err); - [...form.elements].forEach((el) => { el.disabled = false; }); - if (submitButton) { - submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + const { toast } = await import('./util.js'); + toast(labels.networkError ?? 'Could not reach the server. Please try again.', 'error'); + } finally { + if (!didNavigate) { + [...form.elements].forEach((el) => { el.disabled = false; }); + if (submitButton) { + submitButton.textContent = submitButton.dataset.originalLabel || buttonLabel; + } } } }); From 5d25d2994bd2361489dd289e234a00136ae96f22 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 6 May 2026 21:26:54 -0400 Subject: [PATCH 41/89] fix: since name and telephone don't affect the estimate clear the preview --- blocks/checkout/checkout.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 5f6b5441..4b52458e 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -412,10 +412,18 @@ export default async function decorate(block) { if (h3) section.prepend(h3); }); - // Invalidate estimates when address fields change + // Invalidate estimates when address fields that affect tax/shipping change. + // Name and telephone don't affect the estimate, so changes to those fields + // don't need to clear the preview — avoiding a race where the user fills in + // phone last and then immediately clicks Apple Pay. const form = formColumn.querySelector('form'); + const ESTIMATE_AFFECTING_FIELDS = new Set([ + 'shipping-street-0', 'shipping-street-1', 'shipping-city', + 'shipping-state', 'shipping-zip', + ]); const shippingInputs = shippingAddressSection.querySelectorAll('input, select'); shippingInputs.forEach((input) => { + if (!ESTIMATE_AFFECTING_FIELDS.has(input.name)) return; input.addEventListener('change', () => { currentEstimateToken = null; currentPreview = null; From 34e1cf9705280b47e7d5a4d4810fae6ff51239b1 Mon Sep 17 00:00:00 2001 From: Sagar Sane Date: Thu, 7 May 2026 16:07:01 -0400 Subject: [PATCH 42/89] feat: integrate reCAPTCHA on frontend (#419) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: integrate reCAPTCHA Enterprise on protected commerce endpoints Adds client-side reCAPTCHA Enterprise integration for the three unauthenticated write endpoints enforced by the Commerce API: POST /auth/login → action 'auth_login' POST /orders/preview → action 'orders_preview' POST /orders → action 'orders_create' * remove recaptcha hostmane --- blocks/checkout/checkout.css | 1 + blocks/checkout/checkout.js | 23 ++++++ blocks/header/auth-panel.js | 5 ++ scripts/auth-api.js | 6 +- scripts/commerce-api.js | 19 ++++- scripts/recaptcha.js | 138 ++++++++++++++++++++++++++++++++ scripts/scripts.js | 2 +- styles/styles.css | 23 ++++++ tests/scripts/recaptcha.spec.js | 105 ++++++++++++++++++++++++ 9 files changed, 316 insertions(+), 6 deletions(-) create mode 100644 scripts/recaptcha.js create mode 100644 tests/scripts/recaptcha.spec.js diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 9fcd1733..022ee6a7 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -206,6 +206,7 @@ apple-pay-button { --apple-pay-button-height: 40px; --apple-pay-button-border-radius: 5px; --apple-pay-button-padding: 5px 0px; + pointer-events: none; } diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 4b52458e..14fc1064 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -517,6 +517,29 @@ export default async function decorate(block) { } } + // reCAPTCHA Enterprise attribution. The badge itself is hidden globally + // in styles.css; this notice satisfies Google's Terms of Service + // requirement to disclose reCAPTCHA usage. + const noticeAnchor = formColumn.querySelector('form .button-wrapper'); + if (noticeAnchor && !formColumn.querySelector('.recaptcha-notice')) { + const notice = document.createElement('p'); + notice.className = 'recaptcha-notice'; + notice.append(document.createTextNode('This site is protected by reCAPTCHA and the Google ')); + const privacyLink = document.createElement('a'); + privacyLink.href = 'https://policies.google.com/privacy'; + privacyLink.target = '_blank'; + privacyLink.rel = 'noopener noreferrer'; + privacyLink.textContent = 'Privacy Policy'; + notice.append(privacyLink, document.createTextNode(' and ')); + const termsLink = document.createElement('a'); + termsLink.href = 'https://policies.google.com/terms'; + termsLink.target = '_blank'; + termsLink.rel = 'noopener noreferrer'; + termsLink.textContent = 'Terms of Service'; + notice.append(termsLink, document.createTextNode(' apply.')); + noticeAnchor.insertAdjacentElement('afterend', notice); + } + const submitButtons = [...formColumn.querySelectorAll('form .button-wrapper button[type="submit"]')]; submitButtons.forEach((button) => { let paymentMethod = 'Credit Card'; diff --git a/blocks/header/auth-panel.js b/blocks/header/auth-panel.js index eee9bba7..d56509c7 100644 --- a/blocks/header/auth-panel.js +++ b/blocks/header/auth-panel.js @@ -21,6 +21,11 @@ function buildEmailStep() {

          +

          + This site is protected by reCAPTCHA and the Google + Privacy Policy and + Terms of Service apply. +

          `; return step; } diff --git a/scripts/auth-api.js b/scripts/auth-api.js index 089a90b9..d620fc0f 100644 --- a/scripts/auth-api.js +++ b/scripts/auth-api.js @@ -1,4 +1,5 @@ import { ORDERS_API_ORIGIN } from './scripts.js'; +import { mintRecaptchaToken, RECAPTCHA_ACTIONS, RECAPTCHA_HEADER } from './recaptcha.js'; /** sessionStorage key for the JWT issued after OTP verification */ export const AUTH_TOKEN_KEY = 'auth_token'; @@ -31,9 +32,12 @@ function dispatchAuthEvent(loggedIn, email) { * @throws {Error} If the request fails or the API returns an error */ export async function login(email, country, locale) { + const headers = { 'Content-Type': 'application/json' }; + const recaptchaToken = await mintRecaptchaToken(RECAPTCHA_ACTIONS.AUTH_LOGIN); + if (recaptchaToken) headers[RECAPTCHA_HEADER] = recaptchaToken; const resp = await fetch(`${ORDERS_API_ORIGIN}/auth/login`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers, body: JSON.stringify({ email, country, locale }), }); if (!resp.ok) { diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index d35178c5..ecac17b1 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -1,4 +1,6 @@ import { ORDERS_API_ORIGIN } from './scripts.js'; +import { AUTH_TOKEN_KEY } from './auth-api.js'; +import { mintRecaptchaToken, RECAPTCHA_ACTIONS, RECAPTCHA_HEADER } from './recaptcha.js'; /** * Error thrown when the Commerce API returns a non-2xx response. @@ -17,17 +19,26 @@ class CommerceApiError extends Error { * Makes an authenticated POST request to the Commerce API. * Attaches a Bearer token from sessionStorage if one is present, so orders * created while a user is logged in are associated with their account. + * When `recaptchaAction` is provided AND no Bearer token is available, + * mints a reCAPTCHA Enterprise token and attaches it as `X-Recaptcha-Token`. + * Bypass if authenticated. * * @param {string} path - API path relative to ORDERS_API_ORIGIN (e.g. '/orders') * @param {Object} body - Request payload, serialised as JSON + * @param {string} [recaptchaAction] - One of RECAPTCHA_ACTIONS, or undefined to skip * @returns {Promise} Parsed JSON response body * @throws {CommerceApiError} If the response status is not 2xx */ -async function post(path, body) { +async function post(path, body, recaptchaAction) { const headers = { 'Content-Type': 'application/json' }; - const token = sessionStorage.getItem('auth_token'); + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); if (token) headers.Authorization = `Bearer ${token}`; + if (recaptchaAction && !token) { + const recaptchaToken = await mintRecaptchaToken(recaptchaAction); + if (recaptchaToken) headers[RECAPTCHA_HEADER] = recaptchaToken; + } + const resp = await fetch(`${ORDERS_API_ORIGIN}${path}`, { method: 'POST', headers, @@ -72,7 +83,7 @@ export async function estimateShipping(country, state, items) { * @throws {CommerceApiError} */ export async function previewOrder(orderBody) { - return post('/orders/preview', orderBody); + return post('/orders/preview', orderBody, RECAPTCHA_ACTIONS.ORDERS_PREVIEW); } /** @@ -86,7 +97,7 @@ export async function previewOrder(orderBody) { * @throws {CommerceApiError} */ export async function createOrder(orderBody) { - return post('/orders', orderBody); + return post('/orders', orderBody, RECAPTCHA_ACTIONS.ORDERS_CREATE); } /** diff --git a/scripts/recaptcha.js b/scripts/recaptcha.js new file mode 100644 index 00000000..1e642de7 --- /dev/null +++ b/scripts/recaptcha.js @@ -0,0 +1,138 @@ +import { loadScript } from './aem.js'; + +/** + * Google reCAPTCHA Enterprise integration for the protected unauthenticated + * write endpoints on the Commerce API: + * + * POST /auth/login → action 'auth_login' + * POST /orders/preview → action 'orders_preview' + * POST /orders → action 'orders_create' + * + * When `RECAPTCHA_SITE_KEY` is empty (e.g. a + * dev environment without the secret provisioned) this module degrades + * gracefully and the request goes out without a token. + */ + +/** + * reCAPTCHA Enterprise site key. Public — safe to expose client-side. + * + * Empty string disables the integration (graceful degradation). + */ +// TODO: Sitekey for api-stage only for Vitamix. +export const RECAPTCHA_SITE_KEY = '6LfaNtosAAAAABpYKw5c-zu9FWjxxfZJDpVu4JjG'; + +/** Action name constants mirroring the server's RECAPTCHA_ACTIONS. */ +export const RECAPTCHA_ACTIONS = Object.freeze({ + AUTH_LOGIN: 'auth_login', + ORDERS_PREVIEW: 'orders_preview', + ORDERS_CREATE: 'orders_create', +}); + +export const RECAPTCHA_HEADER = 'X-Recaptcha-Token'; + +/** + * Maximum time (ms) to wait for the SDK script to load before giving up. + */ +const SDK_LOAD_TIMEOUT_MS = 5000; + +let sdkPromise = null; + +/** + * Lazily inject the reCAPTCHA Enterprise SDK and wait until it is fully + * initialized — not just until the script tag has loaded. Returns the same + * promise on every call so concurrent callers share a single load. + * + * Important: reCAPTCHA Enterprise has two-stage initialization. After the + * script's `onload` fires, `grecaptcha.enterprise.ready` is available but + * `grecaptcha.enterprise.execute` is not. `execute` only becomes callable + * once the SDK's internal bootstrap completes, which is signalled via the + * `enterprise.ready(callback)` queue. We chain both stages so this promise + * represents "SDK is ready to mint tokens", letting callers skip race-prone + * existence checks. Rejects on load error or after {@link SDK_LOAD_TIMEOUT_MS} + * so callers don't hang indefinitely on a third-party outage. + * + * @param {string} [siteKey=RECAPTCHA_SITE_KEY] + * @returns {Promise} + */ +export function loadRecaptchaSdk(siteKey = RECAPTCHA_SITE_KEY) { + if (!siteKey) return Promise.resolve(); + if (typeof window === 'undefined') return Promise.resolve(); + if (window.grecaptcha?.enterprise?.execute) return Promise.resolve(); + if (sdkPromise) return sdkPromise; + + const src = `https://www.google.com/recaptcha/enterprise.js?render=${encodeURIComponent(siteKey)}`; + const load = loadScript(src, { async: 'true', defer: 'true' }) + .then(() => new Promise((resolve, reject) => { + if (!window.grecaptcha?.enterprise?.ready) { + reject(new Error('grecaptcha.enterprise.ready unavailable after SDK load')); + return; + } + window.grecaptcha.enterprise.ready(resolve); + })); + + let timer; + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error('reCAPTCHA SDK load timed out')), + SDK_LOAD_TIMEOUT_MS, + ); + }); + + sdkPromise = Promise.race([load, timeout]) + .then(() => { clearTimeout(timer); }) + .catch((err) => { + clearTimeout(timer); + sdkPromise = null; + throw err; + }); + + return sdkPromise; +} + +/** + * Mint a single-use reCAPTCHA Enterprise token for the given action. + * + * Lazily ensures the SDK is loaded — first call awaits the script load + * (~150 KB), subsequent calls are instant. + * + * The third parameter is an optional injection point for tests. Pass an + * object with the same shape as `window.grecaptcha` to bypass the real + * SDK; leave undefined in production to use the lazy-loaded SDK. + * + * @param {string} action - One of {@link RECAPTCHA_ACTIONS}. + * @param {string} [siteKey=RECAPTCHA_SITE_KEY] - Override for tests. + * @param {object} [grecaptcha] - Override for tests; production reads window.grecaptcha. + * @returns {Promise} A token string, or '' on any failure. + */ +export async function mintRecaptchaToken( + action, + siteKey = RECAPTCHA_SITE_KEY, // eslint-disable-line default-param-last + grecaptcha, +) { + if (!siteKey) return ''; + let sdk = grecaptcha; + if (sdk === undefined) { + if (typeof window === 'undefined') return ''; + // Always await loadRecaptchaSdk — it short-circuits when the SDK is + // already fully initialized, and otherwise chains through both the + // script load AND enterprise.ready() so callers don't race the + // SDK's two-stage init. + try { + await loadRecaptchaSdk(siteKey); + } catch { + return ''; + } + sdk = window.grecaptcha; + } + if (!sdk?.enterprise?.execute) return ''; + try { + // ready() is a no-op queue when SDK is already initialized (production + // path via loadRecaptchaSdk), but kept as a safety net for the test + // injection path where the caller passes a fresh mock SDK. + await new Promise((resolve) => { sdk.enterprise.ready(resolve); }); + const token = await sdk.enterprise.execute(siteKey, { action }); + return token || ''; + } catch { + return ''; + } +} diff --git a/scripts/scripts.js b/scripts/scripts.js index 172e8a6f..12c68e72 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -25,7 +25,7 @@ export const ORDERS_API_ORIGIN = 'https://api-stage.adobecommerce.live/aemsites/ // Add locale codes here as each region goes live (e.g., 'ca', 'fr_ca'). const EDGE_CART_LOCALES = []; -const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('integration.vitamix.com'); +const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('integration.vitamix.com') || hostname.includes('uat.vitamix.com'); const isProdHost = hostname.includes('vitamix.com'); // Affirm public API key — safe to expose client-side (used for PDP promo widgets). diff --git a/styles/styles.css b/styles/styles.css index 6b2dfbbc..a49f7def 100644 --- a/styles/styles.css +++ b/styles/styles.css @@ -899,3 +899,26 @@ body.corp main .default-content-wrapper h1 { body.corp main .default-content-wrapper h2 { font-size: var(--font-size-500); } + +/* + * reCAPTCHA Enterprise badge — hidden globally. Google's Terms of Service + * permit hiding the badge as long as attribution text is shown near every + * form that uses reCAPTCHA. The required `.recaptcha-notice` block is + * rendered inside the auth panel and the checkout form for that purpose. + */ +.grecaptcha-badge { + visibility: hidden !important; +} + +.recaptcha-notice { + font-size: var(--font-size-100); + color: #666; + margin-top: 12px; + line-height: 1.4; +} + +.recaptcha-notice a { + color: inherit; + text-decoration: underline; +} + diff --git a/tests/scripts/recaptcha.spec.js b/tests/scripts/recaptcha.spec.js new file mode 100644 index 00000000..c27973bb --- /dev/null +++ b/tests/scripts/recaptcha.spec.js @@ -0,0 +1,105 @@ +import { test, expect } from '@playwright/test'; + +/** + * Unit tests for mintRecaptchaToken (from scripts/recaptcha.js). + * + * The function is inlined here because scripts/recaptcha.js imports + * loadScript from aem.js, and aem.js cannot be imported in the Node + * test runner — it sets `window.hlx = ...` at module load. This mirrors + * the existing pattern in pricing.spec.js for the same reason. + * + * Keep this copy in sync with the real module's behavior. The contract: + * returns '' on any failure (missing site key, missing SDK, execute() + * rejection, falsy token); otherwise returns the token from + * grecaptcha.enterprise.execute(siteKey, { action }). + */ + +async function mintRecaptchaToken(action, siteKey, grecaptcha) { + if (!siteKey) return ''; + if (!grecaptcha?.enterprise?.execute) return ''; + try { + await new Promise((resolve) => { grecaptcha.enterprise.ready(resolve); }); + const token = await grecaptcha.enterprise.execute(siteKey, { action }); + return token || ''; + } catch { + return ''; + } +} + +function makeFakeGrecaptcha({ token = 'fake-token', execute, ready } = {}) { + return { + enterprise: { + ready: ready || ((cb) => cb()), + execute: execute || (async () => token), + }, + }; +} + +test.describe('mintRecaptchaToken', () => { + test('returns empty string when site key is empty (graceful degradation)', async () => { + const grecaptcha = makeFakeGrecaptcha(); + const result = await mintRecaptchaToken('auth_login', '', grecaptcha); + expect(result).toBe(''); + }); + + test('returns empty string when grecaptcha is undefined', async () => { + const result = await mintRecaptchaToken('auth_login', 'site-key', undefined); + expect(result).toBe(''); + }); + + test('returns empty string when grecaptcha.enterprise is missing', async () => { + const result = await mintRecaptchaToken('auth_login', 'site-key', {}); + expect(result).toBe(''); + }); + + test('returns the token from grecaptcha.enterprise.execute on happy path', async () => { + const grecaptcha = makeFakeGrecaptcha({ token: 'real-token-123' }); + const result = await mintRecaptchaToken('auth_login', 'site-key', grecaptcha); + expect(result).toBe('real-token-123'); + }); + + test('passes the action and site key to grecaptcha.enterprise.execute', async () => { + let observedSiteKey; + let observedAction; + const grecaptcha = makeFakeGrecaptcha({ + execute: async (siteKey, opts) => { + observedSiteKey = siteKey; + observedAction = opts.action; + return 'token'; + }, + }); + await mintRecaptchaToken('orders_create', 'site-key-abc', grecaptcha); + expect(observedSiteKey).toBe('site-key-abc'); + expect(observedAction).toBe('orders_create'); + }); + + test('waits for grecaptcha.enterprise.ready before executing', async () => { + const callOrder = []; + const grecaptcha = makeFakeGrecaptcha({ + ready: (cb) => { + callOrder.push('ready'); + setTimeout(cb, 10); + }, + execute: async () => { + callOrder.push('execute'); + return 'token'; + }, + }); + await mintRecaptchaToken('auth_login', 'site-key', grecaptcha); + expect(callOrder).toEqual(['ready', 'execute']); + }); + + test('returns empty string when execute throws (fail-open)', async () => { + const grecaptcha = makeFakeGrecaptcha({ + execute: async () => { throw new Error('execute failed'); }, + }); + const result = await mintRecaptchaToken('auth_login', 'site-key', grecaptcha); + expect(result).toBe(''); + }); + + test('returns empty string when execute returns falsy', async () => { + const grecaptcha = makeFakeGrecaptcha({ execute: async () => '' }); + const result = await mintRecaptchaToken('auth_login', 'site-key', grecaptcha); + expect(result).toBe(''); + }); +}); From 48bdeeb67f933d937f90b832719ee5408d92bc84 Mon Sep 17 00:00:00 2001 From: Max Date: Thu, 7 May 2026 22:58:37 -0700 Subject: [PATCH 43/89] fix: correct links, thank-you page (#428) --- widgets/forms/product-registration.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/widgets/forms/product-registration.js b/widgets/forms/product-registration.js index 9aa3da12..ba05d6f1 100644 --- a/widgets/forms/product-registration.js +++ b/widgets/forms/product-registration.js @@ -140,9 +140,17 @@ export default async function decorate(widget) { const emailConsentEl = form.querySelector('.email-consent-disclaimer'); if (emailConsentEl) emailConsentEl.textContent = labels.emailConsentDisclaimer ?? ''; form.querySelector('.click-here-prefix').textContent = labels.clickHereToConsult ?? 'Click here to consult our '; - form.querySelector('.privacy-policy-link').textContent = labels.privacyPolicyLinkText ?? 'privacy policy'; + const privacyPolicyLink = form.querySelector('.privacy-policy-link'); + if (privacyPolicyLink) { + privacyPolicyLink.textContent = labels.privacyPolicyLinkText ?? 'privacy policy'; + privacyPolicyLink.href = `/${locale}/${language}/privacy-statement`; + } form.querySelector('.and-our').textContent = labels.andOur ?? ' and our '; - form.querySelector('.terms-link').textContent = labels.termsOfUseLinkText ?? 'terms of use.'; + const termsOfUseLink = form.querySelector('.terms-link'); + if (termsOfUseLink) { + termsOfUseLink.textContent = labels.termsOfUseLinkText ?? 'terms of use.'; + termsOfUseLink.href = `/${locale}/${language}/legal-notice`; + } const submitBtn = form.querySelector('button[type="submit"]'); const clearBtn = form.querySelector('.clear-form-btn'); @@ -177,7 +185,7 @@ export default async function decorate(widget) { return; } didNavigate = true; - const thankYouPath = `/${locale}/${language}/product-registration-thankyou`; + const thankYouPath = `/${locale}/${language}/customer-service/product-registration-thankyou`; window.location.href = thankYouPath; } catch (err) { // eslint-disable-next-line no-console From 4b0b256fc501e1fd6e6dac1bd2dae3aafba033e2 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 8 May 2026 12:18:20 -0400 Subject: [PATCH 44/89] fix(checkout): refresh estimate token with current form state on autofill and payment method change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chrome AutoFill fires the `change` event on the state/province select while still filling remaining fields (zip, city, etc.). `fetchAndPreview` captured a `formData` snapshot at that moment, then called `updatePreview` with it after the async `estimateShipping` request returned — by which point the zip `change` handler had already nullified `currentEstimateToken`, but the late-resolving `updatePreview` overwrote `null` with a token hashed against an incomplete address. The order was then sent with the correct (fully-filled) zip, causing a backend hash mismatch: "estimate token does not match this request". Fix 1: in `fetchAndPreview`, re-read `formData` from the live form after `estimateShipping` resolves. AutoFill completes synchronously well before the async API call returns, so the fresh read always captures the full address. Fix 2: the `paymentMethodGroup` change listener now calls `updatePreview` so selecting a payment provider fetches a fresh estimate. This also prepares the frontend for the upcoming API change where payment method will be included in the estimate hash. --- blocks/checkout/checkout.js | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 14fc1064..c01ac453 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -202,7 +202,11 @@ async function fetchAndPreview(form, formData, shippingMethodsContainer) { const firstRate = shippingMethodsContainer.querySelector('input[name="shippingMethod"]:checked'); if (firstRate) { selectedShippingMethodId = firstRate.value; - await updatePreview(form, formData, cart); + // Re-read form data after the async estimateShipping call: browser AutoFill + // may have continued filling fields (e.g. zip) after the state-change event + // fired, so the snapshot passed in can be stale and produce a mismatched token. + const latestFormData = Object.fromEntries(new FormData(form).entries()); + await updatePreview(form, latestFormData, cart); } } catch (err) { renderShippingMethods(shippingMethodsContainer, []); @@ -1010,7 +1014,7 @@ export default async function decorate(block) { // handle payment method change const paymentMethodGroup = formColumn.querySelector('fieldset[data-name="paymentMethod"]'); const payButtons = [...formColumn.querySelectorAll('form .button-wrapper span.payment-button')]; - paymentMethodGroup.addEventListener('change', () => { + paymentMethodGroup.addEventListener('change', async () => { const paymentMethod = paymentMethodGroup.querySelector('input:checked').value; // PayPal and Affirm collect billing address on their hosted page — hide the section entirely @@ -1036,6 +1040,14 @@ export default async function decorate(block) { } }); + // Refresh the estimate when the payment method changes so the token reflects + // the selected provider (required once payment method is included in the hash). + if (selectedShippingMethodId) { + const { default: cart } = await import('../../scripts/cart.js'); + const currentFormData = Object.fromEntries(new FormData(form).entries()); + await updatePreview(form, currentFormData, cart); + } + clearError(formColumn); }); } From 7aa4e3fd22c95984b57ff753150fe89850ff1995 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 8 May 2026 13:46:19 -0400 Subject: [PATCH 45/89] feat(checkout): handle Affirm modal onSuccess and onFail callbacks Affirm modal mode replaces the automatic `user_confirmation_url` redirect with client-side callbacks. `onSuccess` now manually navigates to the API return URL with the `checkout_token` appended, preserving the existing `handleReturn` flow. `onFail` re-enables the Pay button. --- blocks/checkout/checkout.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index c01ac453..486b9ea0 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -742,7 +742,16 @@ export default async function decorate(block) { reenableButton(); }); affirm.checkout(payment.checkoutObject); - affirm.checkout.open(); + const openOpts = payment.checkoutMode === 'modal' ? { + onSuccess: (checkoutToken) => { + const confirmUrl = payment.checkoutObject.merchant.user_confirmation_url; + window.location.href = `${confirmUrl}&checkout_token=${encodeURIComponent(checkoutToken)}`; + }, + onFail: () => { + reenableButton(); + }, + } : {}; + affirm.checkout.open(openOpts); }); /* eslint-enable no-undef */ } else { From 29625b216a2e6818b53aadafac4d5f21b405f5b1 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 8 May 2026 14:13:55 -0400 Subject: [PATCH 46/89] fix: destructure checkout_token from Affirm onSuccess object The Affirm SDK passes `onSuccess` a success object `{ checkout_token }` rather than a bare string. Destructure the token correctly to avoid encoding `[object Object]` in the API return URL. --- blocks/checkout/checkout.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 486b9ea0..a7056bba 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -743,7 +743,7 @@ export default async function decorate(block) { }); affirm.checkout(payment.checkoutObject); const openOpts = payment.checkoutMode === 'modal' ? { - onSuccess: (checkoutToken) => { + onSuccess: ({ checkout_token: checkoutToken }) => { const confirmUrl = payment.checkoutObject.merchant.user_confirmation_url; window.location.href = `${confirmUrl}&checkout_token=${encodeURIComponent(checkoutToken)}`; }, From d88ac582d97a56988ca3acd19b0c2afd871e9804 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 8 May 2026 19:21:04 -0400 Subject: [PATCH 47/89] fix: redirect apple pay to /complete --- blocks/checkout/checkout.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index a7056bba..e81813bf 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -887,7 +887,7 @@ export default async function decorate(block) { if (payment.status === 'completed') { session.completePayment({ status: window.ApplePaySession.STATUS_SUCCESS }); - window.location.href = `${getOrderPath('order-summary')}?orderId=${createdOrder.id}`; + window.location.href = `${getOrderPath('complete')}?orderId=${createdOrder.id}`; } else { session.completePayment({ status: window.ApplePaySession.STATUS_FAILURE }); const declineMessages = { From 3122b7996b427673158ec8e9cea98b526c886fbc Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 8 May 2026 19:30:53 -0400 Subject: [PATCH 48/89] fix: clear cart and page --- blocks/checkout/checkout.js | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index e81813bf..60e4a699 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -887,6 +887,7 @@ export default async function decorate(block) { if (payment.status === 'completed') { session.completePayment({ status: window.ApplePaySession.STATUS_SUCCESS }); + cart.clear(); window.location.href = `${getOrderPath('complete')}?orderId=${createdOrder.id}`; } else { session.completePayment({ status: window.ApplePaySession.STATUS_FAILURE }); @@ -1059,4 +1060,16 @@ export default async function decorate(block) { clearError(formColumn); }); + + // When the browser restores this page from bfcache (e.g. the user hits Back + // after a completed payment), re-check the cart. If it's now empty the order + // was already placed, so replace the form with the empty-cart state rather + // than letting the user submit a duplicate order. + window.addEventListener('pageshow', async (e) => { + if (!e.persisted) return; + const { default: restoredCart } = await import('../../scripts/cart.js'); + if (restoredCart.itemCount === 0) { + showEmptyCart(block); + } + }); } From f56f6190b74a2ed489a410f839ac3a338e9a3d51 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Mon, 11 May 2026 22:39:19 -0400 Subject: [PATCH 49/89] feat: new checkout experience --- blocks/cart-summary/cart-summary.css | 415 +++--- blocks/cart-summary/cart-summary.js | 338 ++--- blocks/cart/cart.css | 242 +-- blocks/cart/cart.js | 205 +-- .../checkout-progress/checkout-progress.css | 101 ++ blocks/checkout-progress/checkout-progress.js | 55 + blocks/checkout/checkout-address.js | 148 ++ blocks/checkout/checkout-form.js | 469 ++++++ blocks/checkout/checkout-order.js | 240 +++ blocks/checkout/checkout-payment.js | 230 +++ blocks/checkout/checkout-shipping.js | 194 +++ blocks/checkout/checkout.css | 987 ++++++++++--- blocks/checkout/checkout.js | 1297 ++++------------- blocks/feature-cards/feature-cards.css | 80 + blocks/feature-cards/feature-cards.js | 33 + blocks/header/header.js | 14 +- blocks/order-complete/order-complete.css | 172 +++ blocks/order-complete/order-complete.js | 285 ++++ blocks/order-summary/order-summary.css | 275 ++-- blocks/order-summary/order-summary.js | 427 ++---- blocks/pdp/add-to-cart.js | 2 +- scripts/auth-api.js | 8 +- scripts/cart.js | 57 +- scripts/commerce-api.js | 40 +- scripts/commerce-config.js | 92 ++ scripts/commerce/cart-item.css | 158 ++ scripts/commerce/cart-item.js | 108 ++ scripts/payments/affirm.js | 119 ++ scripts/payments/apple-pay.js | 327 +++++ scripts/payments/google-pay.js | 25 + scripts/payments/paypal.js | 203 +++ scripts/scripts.js | 23 +- styles/commerce-tokens.css | 66 + 33 files changed, 5038 insertions(+), 2397 deletions(-) create mode 100644 blocks/checkout-progress/checkout-progress.css create mode 100644 blocks/checkout-progress/checkout-progress.js create mode 100644 blocks/checkout/checkout-address.js create mode 100644 blocks/checkout/checkout-form.js create mode 100644 blocks/checkout/checkout-order.js create mode 100644 blocks/checkout/checkout-payment.js create mode 100644 blocks/checkout/checkout-shipping.js create mode 100644 blocks/feature-cards/feature-cards.css create mode 100644 blocks/feature-cards/feature-cards.js create mode 100644 blocks/order-complete/order-complete.css create mode 100644 blocks/order-complete/order-complete.js create mode 100644 scripts/commerce-config.js create mode 100644 scripts/commerce/cart-item.css create mode 100644 scripts/commerce/cart-item.js create mode 100644 scripts/payments/affirm.js create mode 100644 scripts/payments/apple-pay.js create mode 100644 scripts/payments/google-pay.js create mode 100644 scripts/payments/paypal.js create mode 100644 styles/commerce-tokens.css diff --git a/blocks/cart-summary/cart-summary.css b/blocks/cart-summary/cart-summary.css index 5f3e7982..36daff22 100644 --- a/blocks/cart-summary/cart-summary.css +++ b/blocks/cart-summary/cart-summary.css @@ -1,263 +1,333 @@ -.cart-summary { - background-color: var(--color-gray-100, #f5f5f5); - border-radius: 8px; - overflow: hidden; +/* Two-column layout: section containing cart-summary becomes a flex row. + Pull horizontal spacing onto the section and strip it from inner wrappers, + which global styles would otherwise pad with --horizontal-spacing each side. */ +.section:has(.cart-summary-wrapper) { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 24px 48px; + padding-block: 40px; + padding-inline: var(--horizontal-spacing); } -.cart-summary-header { +.section:has(.cart-summary-wrapper):has(.checkout-progress-wrapper) { + padding-block-start: 10px; +} + +/* Heading authored alongside the cart sits above the two columns */ +div.section.cart-section:has(.cart-summary-wrapper) > .default-content-wrapper { + flex-basis: 100%; + margin: 0; + padding: 0; display: flex; + align-items: baseline; justify-content: space-between; - align-items: center; - padding: 20px 24px; - background: transparent; - border: none; - width: 100%; - cursor: pointer; - border-bottom: 1px solid var(--color-gray-300, #ddd); } -.cart-summary-header h3 { - margin: 0; - font-size: var(--heading-size-l); +div.section.cart-section:has(.cart-summary-wrapper) > .default-content-wrapper h1 { + margin-top: 0; + padding-top: 0; } -.cart-summary-header-right { - display: flex; - align-items: center; - gap: 12px; +.cart-continue-shopping { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + text-decoration: none; + white-space: nowrap; } -.cart-summary-total { - font-size: 20px; - font-weight: 600; +.cart-continue-shopping:hover { + color: var(--color-text); + text-decoration: underline; } -.cart-summary-header .chevron { - transition: transform 0.3s ease; +/* Hide the cart block's own checkout button — cart-summary provides it */ +.section:has(.cart-summary-wrapper) .cart-controls { + display: none; } -.cart-summary-header[aria-expanded="true"] .chevron { - transform: rotate(180deg); +.cart-summary-wrapper { + width: var(--commerce-summary-width); + flex-shrink: 0; } -.cart-summary-content { - display: none; - padding: 24px; +.section:has(.cart-summary-wrapper) > .cart-wrapper { + flex: 1; + min-width: 0; } -.cart-summary-header[aria-expanded="true"] ~ .cart-summary-content { - display: block; +.section:has(.cart-summary-wrapper) > .cart-wrapper, +.section:has(.cart-summary-wrapper) > .cart-summary-wrapper { + padding: 0; + margin: 0; } -/* Mobile: collapsed by default */ -@media (width < 1000px) { - .cart-summary-header { - cursor: pointer; - } +/* Remove margin-top that cart.css adds to .cart-wrapper so it top-aligns with the summary */ +div.section.cart-section:has(.cart-summary-wrapper) > .cart-wrapper { + margin-top: 0; } -/* Desktop: always expanded, no chevron */ -@media (width >= 1000px) { - .cart-summary-header { - cursor: default; - border-bottom: none; - padding-bottom: 12px; +@media (width <= 999px) { + .section:has(.cart-summary-wrapper) { + flex-direction: column; + padding-inline: var(--horizontal-spacing); + padding-block-start: 20px; } - .cart-summary-header .chevron, - .cart-summary-header .cart-summary-total { - display: none; + .section:has(.cart-summary-wrapper) > .cart-wrapper { + width: 100%; + flex: none; } - .cart-summary-content { - display: block; - padding-top: 0; + .cart-summary-wrapper { + width: 100%; + position: static; } - .cart-summary-header[aria-expanded="false"] ~ .cart-summary-content { - display: block; + div.section.cart-section:has(.cart-summary-wrapper) > .default-content-wrapper { + width: 100%; } } -/* Cart Items */ -.cart-summary-items { - display: flex; - flex-direction: column; - gap: 16px; - margin-bottom: 24px; +/* Card */ +.cart-summary { + background-color: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + overflow: hidden; } -.cart-summary-item { - display: grid; - grid-template-columns: auto 1fr auto; - gap: 12px; - align-items: flex-start; +/* Header */ +.cart-summary-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--commerce-color-border); } -.cart-summary-item-image-wrapper { - position: relative; - width: 64px; - height: 64px; - border: 1px solid var(--color-gray-300, #ddd); - border-radius: 8px; - overflow: visible; - background: white; +.cart-summary-header h3 { + margin: 0; + font-size: var(--heading-size-l); } -.cart-summary-item-image { - width: 100%; - height: 100%; - object-fit: cover; - border-radius: 8px; +/* Content panel — always visible */ +.cart-summary-content { + display: block; + padding: var(--commerce-card-padding); } -.cart-summary-item-details { - flex: 1; - min-width: 0; +/* Express checkout buttons */ +.cart-summary-express-section { + margin-bottom: 20px; } -/* Quantity controls */ -.cart-summary-item-actions { +.cart-summary-express-buttons { display: flex; - align-items: center; + flex-direction: column; gap: 10px; - margin-top: 8px; } -.cart-summary .cart-summary-qty-control { +.cart-summary-express-buttons apple-pay-button { + --apple-pay-button-width: 100%; + --apple-pay-button-height: 48px; + --apple-pay-button-border-radius: var(--commerce-button-radius); + --apple-pay-button-padding: 0; + + display: block; +} + +.cart-summary-express-divider { display: flex; align-items: center; - border: 1px solid var(--color-gray-400, #ccc); - border-radius: 0; - overflow: hidden; + gap: 12px; + margin-top: 16px; + color: var(--commerce-color-text-muted); + font-size: var(--commerce-font-size-sm); +} + +.cart-summary-express-divider::before, +.cart-summary-express-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--commerce-color-border); } -.cart-summary .cart-summary-qty-control button { - width: 30px; - height: 30px; +/* PayPal stub buttons */ +.cart-summary-express-buttons .paypal-express-btn { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 48px; + background: #ffc439; border: none; - border-radius: 0; - background: white; - font-size: 14px; - font-weight: 600; + border-radius: var(--commerce-button-radius); cursor: pointer; - color: var(--color-text); + transition: background 0.15s; + box-sizing: border-box; + overflow: hidden; } -.cart-summary-qty-control button:hover { - background: var(--color-gray-100, #f5f5f5); +.cart-summary-express-buttons .pp-wordmark { + font-family: Arial, Helvetica, sans-serif; + font-size: 20px; + font-weight: bold; + line-height: 1; } -.cart-summary .cart-summary-qty-control .qty-input { - width: 32px; - height: 30px; - border: none; - border-left: 1px solid var(--color-gray-400, #ccc); - border-right: 1px solid var(--color-gray-400, #ccc); - border-radius: 0; - text-align: center; +.cart-summary-express-buttons .pp-pay { color: #003087; } + +.cart-summary-express-buttons .pp-pal { color: #009cde; } + +.cart-summary-express-buttons .pp-later { + font-family: Arial, Helvetica, sans-serif; font-size: 13px; - font-weight: 600; - padding: 0; - appearance: textfield; + font-weight: bold; + color: #003087; + margin-left: 6px; } -.cart-summary-qty-control .qty-input::-webkit-inner-spin-button, -.cart-summary-qty-control .qty-input::-webkit-outer-spin-button { - appearance: none; - margin: 0; +.cart-summary-express-buttons .paypal-express-btn:hover { + background: #f0b929; } -.cart-summary-item-remove { - background: none; - border: none; +.cart-summary-express-buttons .paypal-express-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + font-size: var(--commerce-font-size-sm); + color: #003087; +} + +.cart-summary-express-buttons .paylater-express-btn { + background: #e8f4fb; + border: 1px solid #b0d8f0; +} + +.cart-summary-express-buttons .paylater-express-btn:hover { + background: #d0eaf7; +} + +/* Modal dialog for not-configured Apple Pay / PayPal (appended to body) */ +dialog.paypal-not-configured-dialog { + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + padding: var(--commerce-card-padding); + min-width: 280px; + text-align: center; +} + +dialog.paypal-not-configured-dialog p { + margin: 0 0 16px; + font-size: var(--commerce-font-size-sm); +} + +dialog.paypal-not-configured-dialog::backdrop { + background: rgb(0 0 0 / 40%); +} + +.cart-summary-express-buttons .paypal-not-configured-dialog { + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + padding: var(--commerce-card-padding); + width: 100%; + text-align: center; + box-sizing: border-box; +} + +.cart-summary-express-buttons .paypal-not-configured-dialog p { + margin: 0 0 16px; + font-size: var(--commerce-font-size-sm); +} + +/* Promo code disclosure */ +.cart-summary-promo { + margin-bottom: 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--commerce-color-border); +} + +.cart-summary-promo-toggle { + font-size: var(--commerce-font-size-sm); cursor: pointer; - color: #555; - padding: 4px; + list-style: none; display: flex; align-items: center; + color: var(--commerce-color-text); } -.cart-summary-item-remove:hover { - color: var(--color-text); -} - -.cart-summary-item-name { - margin: 0 0 4px; - font-size: 14px; - font-weight: 500; - line-height: 1.4; +.cart-summary-promo-toggle::-webkit-details-marker { + display: none; } -.cart-summary-item-variant { - margin: 0; - font-size: 13px; - color: #555; +.cart-summary-promo-toggle::after { + content: '+'; + font-size: 16px; + margin-left: auto; } -.cart-summary-item-price { - font-size: 14px; - font-weight: 600; - white-space: nowrap; +.cart-summary-promo[open] .cart-summary-promo-toggle::after { + content: '−'; } -/* Discount */ .cart-summary-discount { display: flex; - gap: 12px; - margin-bottom: 24px; - padding-bottom: 24px; - border-bottom: 1px solid var(--color-gray-300, #ddd); + gap: var(--commerce-gap-sm); + margin-top: 12px; } -.discount-input { +.cart-summary .discount-input { flex: 1; padding: 12px 16px; - border: 1px solid var(--color-gray-400, #ccc); - border-radius: 4px; - font-size: 14px; + border: 1px solid var(--commerce-input-border); + border-radius: var(--commerce-input-radius); + font-size: var(--commerce-font-size-sm); + background: var(--commerce-input-background); } -.discount-input:focus { +.cart-summary .discount-input:focus { outline: none; - border-color: var(--color-primary, #000); + border-color: var(--commerce-input-border-focus); } -.discount-apply { +.cart-summary .discount-apply { padding: 12px 24px; - background: var(--color-gray-500, #999); - color: var(--color-gray-200, #eee); + background: var(--commerce-color-text-muted); + color: #fff; border: none; - border-radius: 4px; - font-size: 14px; - font-weight: 600; + border-radius: var(--commerce-button-radius); + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); cursor: pointer; white-space: nowrap; } -.discount-apply:hover { - background: var(--color-gray-600, #777); +.cart-summary .discount-apply:hover { + background: var(--commerce-color-text); } /* Totals */ .cart-summary-totals { display: flex; flex-direction: column; - gap: 12px; + gap: var(--commerce-gap-sm); } .cart-summary-row { display: flex; justify-content: space-between; align-items: center; - font-size: 14px; + font-size: var(--commerce-font-size-sm); +} + +.cart-summary-shipping { + color: var(--commerce-color-text-muted); + font-style: italic; } .cart-summary-row.cart-summary-final { - margin-top: 12px; + margin-top: var(--commerce-gap-sm); padding-top: 16px; - border-top: 1px solid var(--color-gray-300, #ddd); - font-size: 18px; + border-top: 1px solid var(--commerce-color-border); + font-size: var(--commerce-font-size-base); } .cart-summary-final-amount { @@ -267,16 +337,36 @@ } .cart-summary-final-amount .currency { - font-size: 13px; + font-size: var(--commerce-font-size-xs); font-weight: 400; - color: var(--color-gray-600, #666); + color: var(--commerce-color-text-muted); } .cart-summary-final-amount strong { - font-size: 20px; + font-size: var(--commerce-font-size-total); +} + +/* Error */ +.cart-summary-error { + margin-top: 12px; + padding: 12px; + background: var(--commerce-color-error-bg); + border: 1px solid var(--commerce-color-error-border); + border-radius: var(--commerce-radius-badge); + color: var(--commerce-color-error); + font-size: var(--commerce-font-size-sm); } -/* Loading state — fade content and show spinner */ +/* Checkout CTA */ +.cart-summary-checkout-btn { + display: block; + width: 100%; + margin-top: 20px; + text-align: center; + box-sizing: border-box; +} + +/* Loading state */ .cart-summary-content.loading { position: relative; opacity: 0.4; @@ -291,8 +381,8 @@ width: 32px; height: 32px; margin: -16px 0 0 -16px; - border: 3px solid #ddd; - border-top-color: #333; + border: 3px solid var(--commerce-color-border); + border-top-color: var(--commerce-color-text); border-radius: 50%; animation: cart-summary-spin 0.7s linear infinite; } @@ -300,4 +390,3 @@ @keyframes cart-summary-spin { to { transform: rotate(360deg); } } - diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js index 1e1bae5b..ee37701e 100644 --- a/blocks/cart-summary/cart-summary.js +++ b/blocks/cart-summary/cart-summary.js @@ -1,206 +1,216 @@ +import { loadCSS, getMetadata } from '../../scripts/aem.js'; +import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; import cart from '../../scripts/cart.js'; -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { previewOrder, createOrder, initiatePayment } from '../../scripts/commerce-api.js'; +import applePay from '../../scripts/payments/apple-pay.js'; +import googlePay from '../../scripts/payments/google-pay.js'; +import paypal from '../../scripts/payments/paypal.js'; + +loadCSS('/styles/commerce-tokens.css'); + +const ALL_PROVIDERS = [applePay, googlePay, paypal]; + +const LOCAL_STRINGS = { + 'en-us': { + havePromoCode: 'Have a promo code?', + shippingPlaceholder: 'Calculated at checkout', + checkoutSecurely: 'Checkout securely', + }, + 'fr-ca': { + havePromoCode: 'Vous avez un code promo?', + shippingPlaceholder: 'Calculée à la caisse', + checkoutSecurely: 'Payer en toute sécurité', + }, +}; + +function getStrings() { + const config = getConfig(); + const lang = config.getLanguage().toLowerCase().replace('_', '-'); + return { ...config.getStrings(), ...(LOCAL_STRINGS[lang] || LOCAL_STRINGS['en-us']) }; +} + +function getCurrencyCode() { + const config = getConfig(); + return typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; +} -function getCurrency() { - const { locale } = getLocaleAndLanguage(); - return (locale === 'ca' || locale === 'drafts') ? 'CAD' : 'USD'; +/** + * If cart-summary is authored in its own section, move its wrapper into the + * adjacent section containing the cart block so the CSS two-column layout fires. + * @param {HTMLDivElement} block + */ +function colocateWithCart(block) { + const wrapper = block.closest('.cart-summary-wrapper'); + const mySection = wrapper?.closest('.section'); + if (!wrapper || !mySection) return; + if (mySection.querySelector('.cart-wrapper')) return; + + const main = mySection.closest('main') || document; + const target = [...main.querySelectorAll('.section')] + .find((s) => s !== mySection && s.querySelector('.cart-wrapper')); + if (!target) return; + + target.appendChild(wrapper); + if (!mySection.children.length) mySection.remove(); } -const template = /* html */` +function buildTemplate(s) { + return /* html */`
          - +
          +

          ${s.orderSummary}

          +
          -
          -
          - - + +
          + ${s.havePromoCode} +
          + + +
          +
          - Subtotal + ${s.subtotal}
          - Shipping - + ${s.shipping} + ${s.shippingPlaceholder}
          - Estimated taxes - + ${s.estimatedTaxes} + --
          - Total + ${s.total}
          + + ${s.checkoutSecurely}
          `; +} -const itemTemplate = /* html */` -
          -
          - -
          -
          -

          -

          -
          -
          - - - -
          - -
          -
          -
          -
          -`; +/** + * Filters ALL_PROVIDERS using the `disabled-providers` page metadata and the + * `?enable-providers=` query param override used for testing. + * @param {Object[]} providers + * @returns {Object[]} + */ +function filterProviders(providers) { + const disabled = (getMetadata('disabled-providers') || '') + .split(',').map((p) => p.trim().toLowerCase()).filter(Boolean); + const reEnabled = (new URLSearchParams(window.location.search).get('enable-providers') || '') + .split(',').map((p) => p.trim().toLowerCase()).filter(Boolean); + + return providers.filter((p) => { + if (reEnabled.includes(p.id)) return true; + if (disabled.includes(p.id)) return false; + return true; + }); +} /** + * Decorates the cart-summary block. + * + * 1. Colocate with the cart section for two-column CSS layout + * 2. Build template with i18n strings, set checkout href and currency + * 3. Bind cart:change to keep subtotal and total in sync + * 4. Restore saved promo code; persist to sessionStorage on apply + * 5. Build express-checkout callbacks, load SDKs, render available wallet buttons + * * @param {HTMLDivElement} block */ -export default function decorate(block) { - block.innerHTML = template; +export default async function decorate(block) { + const config = getConfig(); + const s = getStrings(); + + // 1. Colocate with the cart section for two-column CSS layout + colocateWithCart(block); + block.innerHTML = buildTemplate(s); - const header = block.querySelector('.cart-summary-header'); - const itemsList = block.querySelector('.cart-summary-items'); const subtotalEl = block.querySelector('.cart-summary-subtotal'); - const shippingEl = block.querySelector('.cart-summary-shipping'); - const taxesEl = block.querySelector('.cart-summary-taxes'); const grandTotalEl = block.querySelector('.cart-summary-grand-total'); - const headerTotalEl = block.querySelector('.cart-summary-total'); const currencyEl = block.querySelector('.currency'); - currencyEl.textContent = getCurrency(); - - // Toggle expansion on mobile - header.addEventListener('click', () => { - if (window.innerWidth < 1000) { - const isExpanded = header.getAttribute('aria-expanded') === 'true'; - header.setAttribute('aria-expanded', !isExpanded); - } - }); - - // Disable toggle on desktop - const handleResize = () => { - if (window.innerWidth >= 1000) { - header.setAttribute('aria-expanded', 'true'); - header.style.cursor = 'default'; - } else { - header.style.cursor = 'pointer'; - } - }; - - window.addEventListener('resize', handleResize); - handleResize(); - - const renderItems = () => { - itemsList.innerHTML = ''; - - cart.items.forEach((item) => { - const itemEl = document.createElement('div'); - itemEl.innerHTML = itemTemplate; - - const img = itemEl.querySelector('.cart-summary-item-image'); - img.src = item.image; - img.alt = item.name; - - const name = itemEl.querySelector('.cart-summary-item-name'); - name.textContent = item.name; - - const variant = itemEl.querySelector('.cart-summary-item-variant'); - if (item.variant) { - variant.textContent = item.variant; - } else { - variant.remove(); - } - - const price = itemEl.querySelector('.cart-summary-item-price'); - const itemPrice = typeof item.price === 'string' - ? parseFloat(item.price) - : item.price; - price.textContent = `$${(itemPrice * item.quantity).toFixed(2)}`; - - // quantity controls - const qtyInput = itemEl.querySelector('.qty-input'); - qtyInput.value = item.quantity; - - const updateQty = (newQty) => { - if (newQty < 1) { - cart.removeItem(item.sku); - return; - } - cart.updateItem(item.sku, newQty); - }; - - itemEl.querySelector('.qty-dec').addEventListener('click', () => updateQty(+qtyInput.value - 1)); - itemEl.querySelector('.qty-inc').addEventListener('click', () => updateQty(+qtyInput.value + 1)); - qtyInput.addEventListener('change', (e) => updateQty(+e.target.value)); - - itemEl.querySelector('.cart-summary-item-remove').addEventListener('click', () => { - cart.removeItem(item.sku); - }); - - itemsList.appendChild(itemEl.firstElementChild); - }); - }; - + const expressSection = block.querySelector('.cart-summary-express-section'); + const expressContainer = block.querySelector('.cart-summary-express-buttons'); + const discountInput = block.querySelector('.discount-input'); + const discountApply = block.querySelector('.discount-apply'); + const errorEl = block.querySelector('.cart-summary-error'); + const checkoutBtn = block.querySelector('.cart-summary-checkout-btn'); + + // 2. Set checkout href and currency + checkoutBtn.href = config.getOrderPath('checkout'); + currencyEl.textContent = getCurrencyCode(); + + // 3. Bind cart:change to keep totals in sync const updateTotals = () => { - subtotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; - // show placeholder until real estimates arrive - shippingEl.textContent = '--'; - taxesEl.textContent = '--'; - grandTotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; - headerTotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; + const formatted = formatPrice(cart.subtotal, getCurrencyCode()); + subtotalEl.textContent = formatted; + grandTotalEl.textContent = formatted; }; - - // Initial render - renderItems(); updateTotals(); - - // Listen for cart changes - document.addEventListener('cart:change', () => { - renderItems(); - updateTotals(); + document.addEventListener('cart:change', updateTotals); + + // 4. Restore saved promo code; persist to sessionStorage on apply + const savedCoupon = sessionStorage.getItem('checkout_coupon_code') || ''; + if (savedCoupon) { + discountInput.value = savedCoupon; + block.querySelector('.cart-summary-promo').open = true; + } + discountApply.addEventListener('click', () => { + const code = discountInput.value.trim(); + if (code) { + sessionStorage.setItem('checkout_coupon_code', code); + } else { + sessionStorage.removeItem('checkout_coupon_code'); + } }); - // Show loading state while preview is in flight - const summaryContent = block.querySelector('.cart-summary-content'); - document.addEventListener('checkout:preview-loading', () => { - summaryContent?.classList.add('loading'); - }); + // 5. Build express-checkout callbacks, load SDKs, render available wallet buttons + const state = { currentEstimateToken: null }; + + const callbacks = { + getCart: () => cart, + getConfig: () => config, + getState: () => state, + previewOrderDirect: async (body) => { + const couponCode = sessionStorage.getItem('checkout_coupon_code') || undefined; + const result = await previewOrder({ ...body, ...(couponCode ? { couponCode } : {}) }); + if (result.estimateToken) state.currentEstimateToken = result.estimateToken; + return result; + }, + createOrder: (orderBody) => createOrder(orderBody), + initiatePayment: (...args) => initiatePayment(...args), + showError: (msg) => { + errorEl.textContent = msg; + errorEl.hidden = false; + }, + onComplete: () => { + cart.clear(); + window.location.href = config.getOrderPath('complete'); + }, + }; - // Listen for real estimates from checkout preview - document.addEventListener('checkout:preview', (e) => { - summaryContent?.classList.remove('loading'); - const { preview } = e.detail || {}; - if (!preview) return; - - const subtotal = parseFloat(preview.subtotal) || cart.subtotal; - const taxAmount = parseFloat(preview.taxAmount) || 0; - const shippingRate = preview.shippingMethod?.rate ?? 0; - const total = parseFloat(preview.total) || (subtotal + taxAmount + shippingRate); - - subtotalEl.textContent = `$${subtotal.toFixed(2)}`; - shippingEl.textContent = shippingRate === 0 ? 'Free' : `$${parseFloat(shippingRate).toFixed(2)}`; - taxesEl.textContent = `$${taxAmount.toFixed(2)}`; - grandTotalEl.textContent = `$${total.toFixed(2)}`; - headerTotalEl.textContent = `$${total.toFixed(2)}`; + const active = filterProviders(ALL_PROVIDERS).filter((p) => p.supportsExpress); + await Promise.all(active.map(async (p) => { + try { await p.load(config); } catch { /* provider load failure handled by isAvailable check */ } + })); + const available = active.filter((p) => { + try { return p.isAvailable(); } catch { return false; } }); + if (available.length) { + available.forEach((p) => p.renderExpressButton(expressContainer, callbacks)); + expressSection.hidden = false; + } } diff --git a/blocks/cart/cart.css b/blocks/cart/cart.css index e9a1f43a..7b47a427 100644 --- a/blocks/cart/cart.css +++ b/blocks/cart/cart.css @@ -1,225 +1,103 @@ div.section.cart-section { - max-width: 1440px; + max-width: var(--commerce-max-width); margin: 0 auto; } div.section.cart-section > .default-content-wrapper { - margin: var(--spacing-l) auto; + margin: var(--spacing-l) auto; } div.section.cart-section .cart-wrapper { - margin-top: var(--spacing-l); + margin-top: var(--spacing-l); } -.cart-items-header { - display: flex; - justify-content: space-between; - font-size: 11px; - letter-spacing: 0.05em; - color: #555; - padding-bottom: 12px; - border-bottom: 1px solid #0000002b; - margin-bottom: 16px; +.cart-items { + background: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + padding: 0 var(--commerce-card-padding); } .cart-items-list { - display: flex; - flex-direction: column; - gap: 1em; + display: flex; + flex-direction: column; } -.minicart .cart-items-list { - min-height: calc(100vh - var(--header-height) - 16em); - max-height: calc(100vh - var(--header-height) - 16em); - overflow-y: auto; +/* Larger image on the full cart page */ +.cart-items .cart-item-image { + width: 88px; + height: 88px; } -/* Cart item layout: image | details | total */ -.cart-item { - display: grid; - grid-template-columns: 80px 1fr auto; - gap: 16px; - align-items: start; +/* Cart controls (checkout + view cart buttons) */ +.cart-view-cart { + width: 100%; + text-align: center; + box-sizing: border-box; } -.cart-items-list > span:not(:last-child) { - border-bottom: 1px solid #0000002b; - padding-bottom: 1em; -} - -/* Image */ -.cart-item .cart-item-image { - width: 80px; - height: 80px; - display: flex; - align-items: center; - justify-content: center; -} - -.cart-item .cart-item-image img { - max-width: 80px; - max-height: 80px; - width: auto; - height: auto; - object-fit: contain; -} - -/* Details column */ -.cart-item .cart-item-details { - display: flex; - flex-direction: column; - gap: 4px; -} - -.cart-item .cart-item-name a { - font-weight: 600; - font-size: 14px; - line-height: 1.4; - text-decoration: none; - color: var(--color-text); -} - -.cart-item .cart-item-price { - font-size: 14px; - color: var(--color-gray-800); -} - -.cart-item .cart-item-variant { - font-size: 13px; - color: #555; -} - -/* Actions row: quantity picker + trash icon */ -.cart-item .cart-item-actions { - display: flex; - align-items: center; - gap: 12px; - margin-top: 8px; -} - -.cart-item .cart-item-quantity { - display: flex; -} - -.cart-item .cart-item-quantity .quantity-control { - display: flex; - flex-direction: row; - align-items: center; - border: 1px solid var(--color-gray-400); - border-radius: 0; - overflow: hidden; -} - -.quantity-control button { - width: 36px; - height: 36px; - border: none; - border-radius: 0; - background-color: var(--color-white); - color: var(--color-text); - font-size: 16px; - font-weight: 600; - cursor: pointer; -} - -.quantity-control button:hover { - background-color: var(--color-gray-100); -} - -.quantity-control input { - width: 40px; - height: 36px; - border: none; - border-left: 1px solid var(--color-gray-400); - border-right: 1px solid var(--color-gray-400); - border-radius: 0; - text-align: center; - font-size: 14px; - font-weight: 600; - padding-inline: 0; - appearance: textfield; +.cart-controls { + margin: 2em 0 0; + display: flex; + flex-direction: column; + gap: 10px; } -.quantity-control input::-webkit-inner-spin-button, -.quantity-control input::-webkit-outer-spin-button { - appearance: none; - margin: 0; +.cart-controls .cart-checkout { + width: 100%; + text-align: center; + box-sizing: border-box; } -/* Remove (trash) button */ -.cart-item .cart-item-remove { - background: none; - border: none; - cursor: pointer; - color: #555; - padding: 4px; - display: flex; - align-items: center; +/* In the minicart, remove the full-height stretch */ +.minicart .cart { + display: block; } -.cart-item .cart-item-remove:hover { - color: var(--color-text); -} -/* Total */ -.cart-item .cart-item-total { - font-weight: 600; - font-size: 14px; - text-align: right; +/* Empty state */ +.cart-empty-message { + display: none; } -.cart-footer-subtotal { - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; - margin-top: 2em; - border-top: 1px solid #0000002b; - padding-top: 1em; - font-weight: 600; - font-size: 16px; +.cart.cart-is-empty .cart-controls { + display: none; } -.cart-footer-note { - margin: 4px 0 0; - font-size: 13px; - color: #555; +.cart.cart-is-empty { + display: flex; + align-items: center; + justify-content: center; + min-height: calc(100vh - var(--header-height, 80px) - 120px); } -.cart-controls { - margin: 2em 0 0; +.cart.cart-is-empty .cart-empty-message { + display: block; + text-align: center; + color: #555; } -.cart-controls .cart-checkout { - width: 100%; - text-align: center; - box-sizing: border-box; +.minicart .cart.cart-is-empty { + min-height: unset; } -/* Empty cart state */ -.cart-empty-message { - display: none; +/* Compact layout for narrow drawer */ +.minicart .cart-item { + grid-template-columns: 64px 1fr auto; + padding: 12px 0; } -.cart.cart-is-empty .cart-items-header, -.cart.cart-is-empty .cart-items-footer, -.cart.cart-is-empty .cart-controls { - display: none; +.minicart .cart-item-image { + width: 64px; + height: 100%; + align-self: stretch; } -.cart.cart-is-empty { - display: flex; - align-items: center; - justify-content: center; - min-height: calc(100vh - var(--header-height, 80px) - 120px); +.minicart .cart-items { + padding: 0 16px; } -.cart.cart-is-empty .cart-empty-message { - display: block; - text-align: center; - color: #555; +.minicart .cart-item-qty-control { + flex-shrink: 0; + border-radius: 6px; } - -.minicart .cart.cart-is-empty { - min-height: calc(100vh - var(--header-height) - 16em); -} \ No newline at end of file diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index 756da0f2..4ea17094 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -1,69 +1,77 @@ -import { loadCSS, createOptimizedPicture } from '../../scripts/aem.js'; +import { loadCSS } from '../../scripts/aem.js'; import cart from '../../scripts/cart.js'; -import { getOrderPath } from '../../scripts/scripts.js'; - -const itemTemplate = /* html */` -
          -
          -
          -
          -
          -
          -
          -
          - -
          -
          -
          -
          `; +import { getConfig } from '../../scripts/commerce-config.js'; +import buildCartItem from '../../scripts/commerce/cart-item.js'; + +loadCSS('/styles/commerce-tokens.css'); +loadCSS('/blocks/cart/cart.css'); + +const LOCAL_STRINGS = { + 'en-us': { + cartEmpty: 'Your cart is empty.', + viewCart: 'View cart', + checkout: 'Checkout', + }, + 'fr-ca': { + cartEmpty: 'Votre panier est vide.', + viewCart: 'Voir le panier', + checkout: 'Passer à la caisse', + }, +}; + +function getStrings(config) { + const lang = config.getLanguage().toLowerCase().replace('_', '-'); + return { ...config.getStrings(), ...(LOCAL_STRINGS[lang] || LOCAL_STRINGS['en-us']) }; +} -const template = /* html */` +function buildTemplate(s) { + return /* html */`
          -
          - PRODUCT - TOTAL -
          -
          -

          Your cart is empty.

          +

          ${s.cartEmpty}

          + ${s.continueShopping}
          `; +} + +export default async function decorate(block) { + block.closest('div.section')?.classList.add('cart-section'); + + const config = getConfig(); + const s = getStrings(config); + const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + + block.innerHTML = buildTemplate(s); + block.querySelector('.cart-checkout').href = config.getOrderPath('checkout'); -/** - * Cart page or minicart popover - * @param {HTMLElement} block - * @param {HTMLElement} [parent] defined if minicart - */ -export default async function decorate(block, parent) { - if (parent) { - // load styles, using minicart - loadCSS(`${window.hlx.codeBasePath}/blocks/cart/cart.css`); + const viewCartBtn = block.querySelector('.cart-view-cart'); + if (block.closest('.minicart')) { + viewCartBtn.href = config.getOrderPath('cart'); } else { - block.closest('div.section').classList.add('cart-section'); + viewCartBtn.remove(); } - block.innerHTML = template; - block.querySelector('.cart-checkout').href = getOrderPath('checkout'); const itemList = block.querySelector('.cart-items-list'); const cartEl = block.querySelector('.cart'); + const heading = block.closest('.section')?.querySelector('.default-content-wrapper h1, .default-content-wrapper h2'); + if (heading) { + const shopRoot = `/${config.getLocale()}/${config.getLanguage()}/`; + const link = document.createElement('a'); + link.href = shopRoot; + link.className = 'cart-continue-shopping'; + link.textContent = s.continueShopping; + heading.insertAdjacentElement('afterend', link); + } + const updateEmptyState = () => { cartEl.classList.toggle('cart-is-empty', cart.items.length === 0); }; @@ -72,103 +80,20 @@ export default async function decorate(block, parent) { itemList.innerHTML = ''; updateEmptyState(); - // add each item to the list cart.items.forEach((item) => { - const itemElement = document.createElement('span'); - itemElement.innerHTML = itemTemplate; - itemList.appendChild(itemElement); - const cartItem = itemElement.querySelector('.cart-item'); - cartItem.classList.add(`cart-item-${item.sku}`); - - // image - const imageEl = itemElement.querySelector('.cart-item-image'); - const pictureEl = createOptimizedPicture(item.image, item.name || '', true); - imageEl.appendChild(pictureEl); - - // name as link - const nameEl = itemElement.querySelector('.cart-item-name'); - const linkEl = document.createElement('a'); - linkEl.textContent = item.name; - let path; - try { - path = new URL(item.url).pathname; - } catch (error) { - path = item.url; - } - linkEl.setAttribute('href', path); - nameEl.appendChild(linkEl); - - // price - const priceEl = itemElement.querySelector('.cart-item-price'); - priceEl.textContent = `$${item.price}`; - - // variant (e.g., color) - const variantEl = itemElement.querySelector('.cart-item-variant'); - if (item.variant) { - variantEl.textContent = `Color: ${item.variant}`; - } else { - variantEl.remove(); - } - - // total (qty * unit price) - const totalElement = itemElement.querySelector('.cart-item-total'); - totalElement.textContent = `$${(item.price * item.quantity).toFixed(2)}`; - - // quantity picker - const qtyElement = itemElement.querySelector('.cart-item-quantity'); - const qtyControlEl = document.createElement('div'); - qtyControlEl.classList.add('quantity-control'); - - const qtyInput = document.createElement('input'); - qtyInput.type = 'number'; - qtyInput.id = `qty-input-${item.sku}`; - qtyInput.value = item.quantity; - qtyInput.classList.add('quantity-input'); - - const decrementButton = document.createElement('button'); - decrementButton.textContent = '\u2013'; - decrementButton.classList.add('quantity-button'); - - const incrementButton = document.createElement('button'); - incrementButton.textContent = '+'; - incrementButton.classList.add('quantity-button'); - - qtyControlEl.append(decrementButton, qtyInput, incrementButton); - qtyElement.appendChild(qtyControlEl); - - // remove button - const removeBtn = itemElement.querySelector('.cart-item-remove'); - - // event handlers - const updateQty = (newQty) => { - if (newQty < 1) { - cart.removeItem(item.sku); - itemElement.remove(); - return; - } - cart.updateItem(item.sku, newQty); - qtyInput.value = newQty; - totalElement.textContent = `$${(item.price * newQty).toFixed(2)}`; - }; - - decrementButton.addEventListener('click', () => updateQty(+qtyInput.value - 1)); - incrementButton.addEventListener('click', () => updateQty(+qtyInput.value + 1)); - qtyInput.addEventListener('change', (e) => updateQty(+e.target.value)); - removeBtn.addEventListener('click', (ev) => { - ev.preventDefault(); - cart.removeItem(item.sku); - itemElement.remove(); - }); + const itemEl = buildCartItem( + item, + { + onQtyChange: (sku, qty) => cart.updateItem(sku, qty), + onRemove: (sku) => cart.removeItem(sku), + currencyCode, + }, + { remove: s.remove, removeItem: s.removeItem }, + ); + itemList.appendChild(itemEl); }); }; populatelist(); document.addEventListener('cart:change', populatelist); - - // footer subtotal - const subtotalEl = block.querySelector('.cart-footer-total'); - subtotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; - document.addEventListener('cart:change', () => { - subtotalEl.textContent = `$${cart.subtotal.toFixed(2)}`; - }); } diff --git a/blocks/checkout-progress/checkout-progress.css b/blocks/checkout-progress/checkout-progress.css new file mode 100644 index 00000000..59ac72c4 --- /dev/null +++ b/blocks/checkout-progress/checkout-progress.css @@ -0,0 +1,101 @@ +.section > .checkout-progress-wrapper { + margin: 0 auto; + width: 100%; +} + +.checkout-progress nav { + display: flex; + justify-content: center; + padding: 16px 0; +} + +.checkout-progress ol { + display: flex; + align-items: center; + list-style: none; + margin: 0; + padding: 0; + gap: 0; + counter-reset: step-counter; +} + +.checkout-progress .step { + display: flex; + align-items: center; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +/* Connector line between steps */ +.checkout-progress .step + .step::before { + content: ''; + display: block; + width: 48px; + height: 2px; + background: var(--commerce-step-color-inactive); + margin: 0 12px; +} + +.checkout-progress .step-complete + .step::before { + background: var(--commerce-color-text); +} + +.checkout-progress .step a, +.checkout-progress .step span { + display: flex; + align-items: center; + gap: 8px; + text-decoration: none; + color: inherit; + white-space: nowrap; +} + +/* Circle — inactive (future) steps: white fill, border only, no number */ +.checkout-progress .step a::before, +.checkout-progress .step span::before { + content: ''; + counter-increment: step-counter; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border-radius: 50%; + font-size: 11px; + font-weight: var(--commerce-font-weight-bold); + line-height: 1; + flex-shrink: 0; + background: #fff; + border: 2px solid var(--commerce-color-border); + color: #fff; + box-sizing: border-box; +} + +/* Complete steps: dark fill with checkmark */ +.checkout-progress .step-complete a::before { + content: '✓'; + background: var(--commerce-color-text); + border-color: var(--commerce-color-text); + color: #fff; +} + +/* Active step: brand fill with step number */ +.checkout-progress .step-active span::before { + content: counter(step-counter); + background: var(--commerce-step-color-active); + border-color: var(--commerce-step-color-active); + color: #fff; +} + +.checkout-progress .step-complete a { + color: var(--commerce-color-text); +} + +.checkout-progress .step-active span { + color: var(--commerce-color-text); + font-weight: var(--commerce-font-weight-bold); +} + +.checkout-progress .step-complete a:hover { + text-decoration: underline; +} diff --git a/blocks/checkout-progress/checkout-progress.js b/blocks/checkout-progress/checkout-progress.js new file mode 100644 index 00000000..5f536fec --- /dev/null +++ b/blocks/checkout-progress/checkout-progress.js @@ -0,0 +1,55 @@ +import { loadCSS } from '../../scripts/aem.js'; +import { getConfig } from '../../scripts/commerce-config.js'; + +loadCSS('/styles/commerce-tokens.css'); + +const STEP_KEYS = ['cart-new', 'checkout', 'complete']; + +export default function decorate(block) { + const config = getConfig(); + const s = config.getStrings(); + const currentPath = window.location.pathname; + + const steps = [ + { key: 'cart-new', label: s.stepCart }, + { key: 'checkout', label: s.stepCheckout }, + { key: 'complete', label: s.stepConfirmation }, + ]; + + const stepPaths = STEP_KEYS.map((key) => config.getOrderPath(key)); + const activeIndex = stepPaths.findIndex((path) => currentPath.includes(path)); + + const nav = document.createElement('nav'); + nav.setAttribute('aria-label', s.checkoutStepsLabel); + + const ol = document.createElement('ol'); + + steps.forEach(({ key, label }, i) => { + const li = document.createElement('li'); + li.className = 'step'; + li.classList.add(`step-${key}`); + + if (i < activeIndex) { + li.classList.add('step-complete'); + const a = document.createElement('a'); + a.href = stepPaths[i]; + a.textContent = label; + li.appendChild(a); + } else if (i === activeIndex) { + li.classList.add('step-active'); + li.setAttribute('aria-current', 'step'); + const span = document.createElement('span'); + span.textContent = label; + li.appendChild(span); + } else { + const span = document.createElement('span'); + span.textContent = label; + li.appendChild(span); + } + + ol.appendChild(li); + }); + + nav.appendChild(ol); + block.replaceChildren(nav); +} diff --git a/blocks/checkout/checkout-address.js b/blocks/checkout/checkout-address.js new file mode 100644 index 00000000..3afb9d57 --- /dev/null +++ b/blocks/checkout/checkout-address.js @@ -0,0 +1,148 @@ +const US_STATES = [ + ['AL', 'Alabama'], ['AK', 'Alaska'], ['AZ', 'Arizona'], ['AR', 'Arkansas'], + ['CA', 'California'], ['CO', 'Colorado'], ['CT', 'Connecticut'], ['DE', 'Delaware'], + ['DC', 'District of Columbia'], ['FL', 'Florida'], ['GA', 'Georgia'], ['HI', 'Hawaii'], + ['ID', 'Idaho'], ['IL', 'Illinois'], ['IN', 'Indiana'], ['IA', 'Iowa'], + ['KS', 'Kansas'], ['KY', 'Kentucky'], ['LA', 'Louisiana'], ['ME', 'Maine'], + ['MD', 'Maryland'], ['MA', 'Massachusetts'], ['MI', 'Michigan'], ['MN', 'Minnesota'], + ['MS', 'Mississippi'], ['MO', 'Missouri'], ['MT', 'Montana'], ['NE', 'Nebraska'], + ['NV', 'Nevada'], ['NH', 'New Hampshire'], ['NJ', 'New Jersey'], ['NM', 'New Mexico'], + ['NY', 'New York'], ['NC', 'North Carolina'], ['ND', 'North Dakota'], ['OH', 'Ohio'], + ['OK', 'Oklahoma'], ['OR', 'Oregon'], ['PA', 'Pennsylvania'], ['RI', 'Rhode Island'], + ['SC', 'South Carolina'], ['SD', 'South Dakota'], ['TN', 'Tennessee'], ['TX', 'Texas'], + ['UT', 'Utah'], ['VT', 'Vermont'], ['VA', 'Virginia'], ['WA', 'Washington'], + ['WV', 'West Virginia'], ['WI', 'Wisconsin'], ['WY', 'Wyoming'], + ['AS', 'American Samoa'], ['GU', 'Guam'], ['MP', 'Northern Mariana Islands'], + ['PR', 'Puerto Rico'], ['VI', 'U.S. Virgin Islands'], +]; + +const CA_PROVINCES = [ + ['AB', 'Alberta'], ['BC', 'British Columbia'], ['MB', 'Manitoba'], + ['NB', 'New Brunswick'], ['NL', 'Newfoundland and Labrador'], + ['NS', 'Nova Scotia'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut'], + ['ON', 'Ontario'], ['PE', 'Prince Edward Island'], ['QC', 'Quebec'], + ['SK', 'Saskatchewan'], ['YT', 'Yukon'], +]; + +const ESTIMATE_FIELDS = new Set([ + 'shipping-street-0', 'shipping-street-1', 'shipping-city', 'shipping-state', 'shipping-zip', +]); + +/** + * Builds an address object from form data for the given prefix ('shipping-' or 'billing-'). + * @param {HTMLFormElement} form + * @param {FormData} formData + * @param {string} prefix + * @param {string} email + * @param {string} country + * @returns {Object} + */ +export function collectAddress(form, formData, prefix, email, country) { + const get = (name) => formData.get(`${prefix}${name}`) || ''; + return { + name: `${get('firstname')} ${get('lastname')}`.trim(), + company: get('company'), + address1: get('street-0'), + address2: get('street-1'), + city: get('city'), + state: get('state'), + zip: get('zip'), + country, + phone: get('telephone').replace(/\D/g, ''), + email, + }; +} + +/** + * Populates the state/province select for the given prefix. + * @param {HTMLFormElement} form + * @param {string} prefix + * @param {boolean} isCanada + * @param {Object} strings + */ +function populateStateSelect(form, prefix, isCanada, strings) { + const select = form.querySelector(`[name="${prefix}state"]`); + if (!select) return; + + const regions = isCanada ? CA_PROVINCES : US_STATES; + const label = select.closest('.form-field')?.querySelector('label'); + if (label) label.textContent = isCanada ? strings.province : strings.state; + + select.innerHTML = ''; + regions.forEach(([code, name]) => { + const option = document.createElement('option'); + option.value = code; + option.textContent = name; + select.appendChild(option); + }); + + select.addEventListener('change', () => { + select.classList.toggle('has-value', !!select.value); + }); +} + +/** + * Wires the billing address radio cards and keeps the shipping summary in sync. + * @param {HTMLFormElement} form + */ +function wireBillingToggle(form) { + const billingFieldsWrapper = form.querySelector('.billing-fields-wrapper'); + const sameDetail = form.querySelector('.billing-option-detail'); + if (!billingFieldsWrapper) return; + + const getShippingSummary = () => { + const data = new FormData(form); + const street = data.get('shipping-street-0') || ''; + const city = data.get('shipping-city') || ''; + const stateCode = data.get('shipping-state') || ''; + const zip = data.get('shipping-zip') || ''; + const cityStateZip = [city, [stateCode, zip].filter(Boolean).join(' ')].filter(Boolean).join(', '); + return [street, cityStateZip].filter(Boolean).join(', '); + }; + + const updateBillingVisibility = () => { + const useDifferent = form.querySelector('[name="billing-choice"]:checked')?.value === 'different'; + billingFieldsWrapper.hidden = !useDifferent; + billingFieldsWrapper.querySelectorAll('input, select').forEach((el) => { + el.disabled = !useDifferent; + }); + if (!useDifferent && sameDetail) { + sameDetail.textContent = getShippingSummary(); + } + }; + + form.querySelectorAll('[name="billing-choice"]').forEach((radio) => { + radio.addEventListener('change', updateBillingVisibility); + }); + + const syncSummary = (e) => { + if (!e.target.name?.startsWith('shipping-')) return; + const isSame = form.querySelector('[name="billing-choice"]:checked')?.value !== 'different'; + if (isSame && sameDetail) sameDetail.textContent = getShippingSummary(); + }; + + form.addEventListener('input', syncSummary); + form.addEventListener('change', syncSummary); + updateBillingVisibility(); +} + +/** + * @param {HTMLFormElement} form + * @param {Object} state + * @param {Object} config + * @param {Object} strings + */ +export function initAddress(form, state, config, strings) { + const isCanada = config.getLocale() === 'ca'; + populateStateSelect(form, 'shipping-', isCanada, strings); + populateStateSelect(form, 'billing-', isCanada, strings); + wireBillingToggle(form); + + // Invalidate estimate token when estimate-affecting fields change + form.addEventListener('change', (e) => { + if (ESTIMATE_FIELDS.has(e.target.name)) { + state.currentEstimateToken = null; + state.currentPreview = null; + } + }); +} diff --git a/blocks/checkout/checkout-form.js b/blocks/checkout/checkout-form.js new file mode 100644 index 00000000..cccd419d --- /dev/null +++ b/blocks/checkout/checkout-form.js @@ -0,0 +1,469 @@ +import { getMetadata } from '../../scripts/aem.js'; + +const SUBMIT_LOCK_SVG = ''; + +function getContactFields(strings) { + return [ + { + name: 'email', type: 'email', label: strings.email, required: true, autocomplete: 'email', + }, + { name: 'newsletter', type: 'checkbox', label: strings.newsletter }, + ]; +} + +function getAddressFields(strings, isCanada) { + return [ + { + name: 'firstname', type: 'text', label: strings.firstName, required: true, autocomplete: 'given-name', width: 'half', + }, + { + name: 'lastname', type: 'text', label: strings.lastName, required: true, autocomplete: 'family-name', width: 'half', + }, + { + name: 'street-0', type: 'text', label: strings.address, required: true, autocomplete: 'address-line1', + }, + { + name: 'street-1', type: 'text', label: strings.addressLine2, autocomplete: 'address-line2', + }, + { + name: 'city', type: 'text', label: strings.city, required: true, autocomplete: 'address-level2', width: 'third', + }, + { + name: 'state', type: 'select', label: isCanada ? strings.province : strings.state, required: true, autocomplete: 'address-level1', width: 'third', + }, + { + name: 'zip', type: 'text', label: isCanada ? strings.postalCode : strings.zip, required: true, autocomplete: 'postal-code', width: 'third', inputmode: isCanada ? undefined : 'numeric', + }, + { + name: 'telephone', type: 'tel', label: strings.phone, autocomplete: 'tel', format: 'phone', + }, + ]; +} + +// North American Numbering Plan: (555) 123-4567 +function formatPhoneDisplay(raw) { + let digits = raw.replace(/\D/g, ''); + // Strip the +1 country code if present — North America only, so leading digit is always 1 + if (digits.length === 11) digits = digits.slice(1); + digits = digits.slice(0, 10); + if (digits.length < 4) return digits; + if (digits.length < 7) return `(${digits.slice(0, 3)}) ${digits.slice(3)}`; + return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`; +} + +function attachPhoneFormatter(input) { + input.addEventListener('input', () => { + const { selectionStart } = input; + const digitsBefore = input.value.slice(0, selectionStart).replace(/\D/g, '').length; + + const formatted = formatPhoneDisplay(input.value); + input.value = formatted; + + // Restore cursor to the same logical digit position + let newPos = formatted.length; + if (digitsBefore === 0) { + newPos = 0; + } else { + let count = 0; + for (let i = 0; i < formatted.length; i += 1) { + if (/\d/.test(formatted[i])) count += 1; + if (count === digitsBefore) { newPos = i + 1; break; } + } + } + // Advance past any trailing punctuation so cursor lands on the next digit + while (newPos < formatted.length && !/\d/.test(formatted[newPos])) newPos += 1; + + input.setSelectionRange(newPos, newPos); + }); +} + +function buildBreakdownRow(label, cls) { + const row = document.createElement('div'); + row.className = 'order-breakdown-row'; + const labelEl = document.createElement('span'); + labelEl.textContent = label; + const valueEl = document.createElement('span'); + valueEl.className = cls; + row.append(labelEl, valueEl); + return row; +} + +/** + * Creates a floating-label field wrapper. + * @param {Object} field + * @param {string} namePrefix + * @returns {HTMLElement} + */ +function buildField(field, namePrefix = '') { + const fullName = `${namePrefix}${field.name}`; + + if (field.type === 'checkbox') { + const wrapper = document.createElement('div'); + wrapper.className = 'form-field form-field-checkbox'; + const label = document.createElement('label'); + const input = document.createElement('input'); + input.type = 'checkbox'; + input.name = fullName; + input.id = fullName; + const span = document.createElement('span'); + span.textContent = field.label; + label.append(input, span); + wrapper.appendChild(label); + return wrapper; + } + + const wrapper = document.createElement('div'); + wrapper.className = 'form-field floating-label-field'; + if (field.width) wrapper.classList.add(`field-${field.width}`); + + let input; + if (field.type === 'select') { + input = document.createElement('select'); + // select doesn't support :placeholder-shown — use has-value class toggle + input.addEventListener('change', () => { + input.classList.toggle('has-value', !!input.value); + }); + } else { + input = document.createElement('input'); + input.type = field.type; + input.placeholder = ' '; + if (field.autocomplete) input.autocomplete = field.autocomplete; + if (field.inputmode) input.inputMode = field.inputmode; + if (field.format === 'phone') attachPhoneFormatter(input); + } + + input.name = fullName; + input.id = fullName; + if (field.required) input.required = true; + + const label = document.createElement('label'); + label.htmlFor = fullName; + label.textContent = field.label; + + wrapper.append(input, label); + return wrapper; +} + +/** + * Builds an address fieldset for the given prefix. + * @param {string} prefix + * @param {string} legend + * @param {Object} strings + * @param {boolean} isCanada + * @returns {HTMLElement} + */ +function buildAddressSection(prefix, legend, strings, isCanada) { + const section = document.createElement('div'); + section.className = `form-section ${prefix.replace('-', '')}-address-section`; + + const heading = document.createElement('h3'); + heading.textContent = legend; + section.appendChild(heading); + + const grid = document.createElement('div'); + grid.className = 'form-fields'; + getAddressFields(strings, isCanada).forEach((field) => { + grid.appendChild(buildField(field, prefix)); + }); + section.appendChild(grid); + return section; +} + +/** + * Builds and returns the checkout form element. + * @param {HTMLElement} container + * @param {Object} config + * @param {Object} strings + * @returns {HTMLFormElement} + */ +export default function buildForm(container, config, strings) { + const isCanada = config.getLocale() === 'ca'; + const form = document.createElement('form'); + form.className = 'checkout-form'; + form.noValidate = true; + + // Express checkout section (populated by checkout-payment.js) + const expressSection = document.createElement('div'); + expressSection.className = 'form-section express-checkout-section'; + + const expressHeader = document.createElement('div'); + expressHeader.className = 'express-checkout-header'; + const expressHeading = document.createElement('p'); + expressHeading.className = 'express-checkout-label'; + expressHeading.textContent = strings.expressCheckout; + const expressTagline = document.createElement('p'); + expressTagline.className = 'express-checkout-tagline'; + expressTagline.textContent = strings.expressTagline; + expressHeader.append(expressHeading, expressTagline); + + const expressButtons = document.createElement('div'); + expressButtons.className = 'express-checkout-buttons'; + + const dividerText = document.createElement('p'); + dividerText.className = 'express-checkout-divider'; + dividerText.textContent = strings.expressOr; + + expressSection.append(expressHeader, expressButtons, dividerText); + form.appendChild(expressSection); + + // Contact section + const contactSection = document.createElement('div'); + contactSection.className = 'form-section contact-section'; + const contactHeading = document.createElement('h3'); + contactHeading.textContent = strings.contact; + contactSection.appendChild(contactHeading); + const contactFields = document.createElement('div'); + contactFields.className = 'form-fields'; + getContactFields(strings).forEach((field) => { + contactFields.appendChild(buildField(field, '')); + }); + contactSection.appendChild(contactFields); + form.appendChild(contactSection); + + // Shipping address + form.appendChild(buildAddressSection('shipping-', strings.shippingAddress, strings, isCanada)); + + // Gift message (conditional) + if (getMetadata('gift-message') === 'true') { + const giftSection = document.createElement('div'); + giftSection.className = 'form-section gift-message-section'; + const giftHeading = document.createElement('h3'); + giftHeading.textContent = strings.giftMessage; + giftSection.appendChild(giftHeading); + const giftField = document.createElement('div'); + giftField.className = 'form-field floating-label-field'; + const textarea = document.createElement('textarea'); + textarea.name = 'gift-message'; + textarea.id = 'gift-message'; + textarea.maxLength = 500; + textarea.placeholder = ' '; + textarea.rows = 3; + const giftLabel = document.createElement('label'); + giftLabel.htmlFor = 'gift-message'; + giftLabel.textContent = strings.giftMessagePlaceholder; + const charCount = document.createElement('span'); + charCount.className = 'char-count'; + charCount.textContent = '0 / 500'; + textarea.addEventListener('input', () => { + charCount.textContent = `${textarea.value.length} / 500`; + }); + giftField.append(textarea, giftLabel, charCount); + giftSection.appendChild(giftField); + form.appendChild(giftSection); + } + + // Billing address section + const billingSection = document.createElement('div'); + billingSection.className = 'form-section billing-section'; + + const billingHeading = document.createElement('h3'); + billingHeading.textContent = strings.billingAddress; + billingSection.appendChild(billingHeading); + + const billingSubtitle = document.createElement('p'); + billingSubtitle.className = 'billing-subtitle'; + billingSubtitle.textContent = strings.billingSubtitle; + billingSection.appendChild(billingSubtitle); + + const billingOptions = document.createElement('div'); + billingOptions.className = 'billing-options'; + + const sameCard = document.createElement('label'); + sameCard.className = 'billing-option-card'; + const sameRadio = document.createElement('input'); + sameRadio.type = 'radio'; + sameRadio.name = 'billing-choice'; + sameRadio.value = 'same'; + sameRadio.checked = true; + const sameContent = document.createElement('div'); + sameContent.className = 'billing-option-content'; + const sameLabelSpan = document.createElement('span'); + sameLabelSpan.className = 'billing-option-label'; + sameLabelSpan.textContent = strings.billingSame; + const sameDetail = document.createElement('span'); + sameDetail.className = 'billing-option-detail'; + sameContent.append(sameLabelSpan, sameDetail); + sameCard.append(sameRadio, sameContent); + + const differentCard = document.createElement('label'); + differentCard.className = 'billing-option-card'; + const differentRadio = document.createElement('input'); + differentRadio.type = 'radio'; + differentRadio.name = 'billing-choice'; + differentRadio.value = 'different'; + const differentContent = document.createElement('div'); + differentContent.className = 'billing-option-content'; + const differentLabelSpan = document.createElement('span'); + differentLabelSpan.className = 'billing-option-label'; + differentLabelSpan.textContent = strings.billingDifferent; + differentContent.appendChild(differentLabelSpan); + differentCard.append(differentRadio, differentContent); + + billingOptions.append(sameCard, differentCard); + billingSection.appendChild(billingOptions); + + const billingFieldsWrapper = document.createElement('div'); + billingFieldsWrapper.className = 'billing-fields-wrapper'; + billingFieldsWrapper.hidden = true; + const billingGrid = document.createElement('div'); + billingGrid.className = 'form-fields'; + getAddressFields(strings, isCanada).forEach((field) => { + billingGrid.appendChild(buildField(field, 'billing-')); + }); + billingFieldsWrapper.appendChild(billingGrid); + billingSection.appendChild(billingFieldsWrapper); + + form.appendChild(billingSection); + + // Shipping methods container (populated by checkout-shipping.js) + const shippingMethodsContainer = document.createElement('fieldset'); + shippingMethodsContainer.className = 'form-section shipping-methods'; + const shippingLegend = document.createElement('legend'); + shippingLegend.textContent = strings.shipping; + shippingMethodsContainer.appendChild(shippingLegend); + form.appendChild(shippingMethodsContainer); + + // Payment method section (populated by checkout-payment.js) + const paymentSection = document.createElement('fieldset'); + paymentSection.className = 'form-section payment-method-section'; + const paymentLegend = document.createElement('legend'); + paymentLegend.textContent = strings.paymentMethod; + paymentSection.appendChild(paymentLegend); + form.appendChild(paymentSection); + + // Error container + const errorContainer = document.createElement('div'); + errorContainer.className = 'checkout-error'; + errorContainer.hidden = true; + form.appendChild(errorContainer); + + // Order total + submit panel + const orderTotalSection = document.createElement('div'); + orderTotalSection.className = 'form-section order-total-section'; + + const orderTotalLeft = document.createElement('div'); + orderTotalLeft.className = 'order-total-left'; + + const orderTotalLabel = document.createElement('span'); + orderTotalLabel.className = 'order-total-label'; + orderTotalLabel.textContent = strings.orderTotal; + + const orderTotalAmount = document.createElement('span'); + orderTotalAmount.className = 'order-total-amount'; + orderTotalAmount.textContent = '--'; + + const orderTotalTnc = document.createElement('p'); + orderTotalTnc.className = 'order-total-tnc'; + orderTotalTnc.textContent = strings.tnc; + + const breakdown = document.createElement('div'); + breakdown.className = 'order-total-breakdown'; + breakdown.append( + buildBreakdownRow(strings.subtotal, 'order-breakdown-subtotal'), + buildBreakdownRow(strings.shipping, 'order-breakdown-shipping'), + buildBreakdownRow(strings.estimatedTaxes, 'order-breakdown-taxes'), + ); + + orderTotalLeft.append(orderTotalLabel, breakdown, orderTotalAmount, orderTotalTnc); + + const submitBtn = document.createElement('button'); + submitBtn.type = 'button'; + submitBtn.className = 'button emphasis checkout-submit-btn'; + + const lockIconSpan = document.createElement('span'); + lockIconSpan.className = 'submit-lock-icon'; + lockIconSpan.innerHTML = SUBMIT_LOCK_SVG; + + const submitText = document.createElement('span'); + submitText.className = 'submit-btn-text'; + submitText.textContent = strings.continueToPayment; + + submitBtn.append(lockIconSpan, submitText); + orderTotalSection.append(orderTotalLeft, submitBtn); + form.appendChild(orderTotalSection); + + container.appendChild(form); + return form; +} + +/** + * Wires a form section to collapse into a compact summary bar after valid input. + * @param {HTMLElement} section + * @param {Object} opts + * @param {function(): boolean} opts.getIsValid + * @param {function(): string} opts.getSummary + * @param {boolean} [opts.autoCollapse] + * @param {Object} strings + * @returns {{ collapse: function, expand: function }} + */ +export function initCollapse(section, opts = {}, strings = {}) { + const { getIsValid, getSummary, autoCollapse = true } = opts; + const title = section.querySelector('h3, legend')?.textContent || ''; + + const bar = document.createElement('div'); + bar.className = 'section-collapsed-bar'; + bar.hidden = true; + + const checkIcon = document.createElement('span'); + checkIcon.className = 'section-check'; + checkIcon.innerHTML = ''; + + const textBlock = document.createElement('div'); + textBlock.className = 'section-summary-text'; + + const titleSpan = document.createElement('span'); + titleSpan.className = 'section-summary-title'; + titleSpan.textContent = title; + + const detailSpan = document.createElement('span'); + detailSpan.className = 'section-summary-detail'; + + textBlock.append(titleSpan, detailSpan); + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'section-edit-btn'; + editBtn.textContent = strings.edit || 'Edit'; + + bar.append(checkIcon, textBlock, editBtn); + section.prepend(bar); + + const collapse = () => { + if (!getIsValid?.()) return; + detailSpan.textContent = getSummary?.() || ''; + bar.hidden = false; + section.classList.add('is-collapsed'); + }; + + const expand = () => { + bar.hidden = true; + section.classList.remove('is-collapsed'); + // Re-sync floating label state: browsers may not re-evaluate :placeholder-shown + // after an ancestor transitions from display:none + section.querySelectorAll('input[placeholder], textarea[placeholder]').forEach((el) => { + if (el.value) { + const { value } = el; + el.value = ''; + el.value = value; + } + }); + section.querySelectorAll('select').forEach((el) => { + el.classList.toggle('has-value', !!el.value); + }); + const firstInput = section.querySelector( + 'input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), select, textarea', + ); + firstInput?.focus(); + }; + + editBtn.addEventListener('click', expand); + + if (autoCollapse) { + section.addEventListener('focusout', (e) => { + if (section.contains(e.relatedTarget)) return; + collapse(); + }); + } + + return { collapse, expand }; +} diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js new file mode 100644 index 00000000..7acd7d34 --- /dev/null +++ b/blocks/checkout/checkout-order.js @@ -0,0 +1,240 @@ +import { createOrder, initiatePayment, previewOrder } from '../../scripts/commerce-api.js'; +import { collectAddress } from './checkout-address.js'; +import { updatePreview } from './checkout-shipping.js'; + +/** + * Writes checkout state to sessionStorage before a payment redirect. + * @param {string} email + * @param {Object} cart + * @param {Object|null} preview + * @param {Object|null} order + */ +export function saveCheckoutSession(email, cart, preview, order) { + try { + sessionStorage.setItem('checkout_email', email); + sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); + if (preview) sessionStorage.setItem('checkout_preview', JSON.stringify(preview)); + if (order) sessionStorage.setItem('checkout_order', JSON.stringify(order)); + } catch { /* ignore */ } +} + +/** + * Assembles the order JSON payload from form data, cart, and checkout state. + * @param {FormData} formData + * @param {HTMLFormElement} form + * @param {Object} cart + * @param {Object} state + * @param {Object} config + * @returns {Object} + */ +export function buildOrderJSON(formData, form, cart, state, config) { + const email = formData.get('email') || ''; + const locale = config.getLocale(); + const language = config.getLanguage(); + const country = locale; + + const shippingAddr = collectAddress(form, formData, 'shipping-', email, country); + const sameAsBilling = form.querySelector('[name="billing-choice"]:checked')?.value !== 'different'; + const billingAddr = sameAsBilling ? null : collectAddress(form, formData, 'billing-', email, country); + + const cleanAddr = (addr) => Object.fromEntries( + Object.entries(addr).filter(([, v]) => v !== ''), + ); + + const order = { + customer: { + firstName: formData.get('shipping-firstname') || '', + lastName: formData.get('shipping-lastname') || '', + email, + phone: formData.get('shipping-telephone') || '', + }, + shipping: cleanAddr(shippingAddr), + billing: cleanAddr(billingAddr ?? shippingAddr), + items: cart.getItemsForAPI(), + }; + + if (state.selectedShippingMethodId) { + order.shippingMethod = { id: state.selectedShippingMethodId }; + } + if (state.currentEstimateToken) { + order.estimateToken = state.currentEstimateToken; + } + + const giftMessage = formData.get('gift-message'); + if (giftMessage?.trim()) { + order.giftMessage = giftMessage.trim(); + } + + order.locale = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; + order.country = locale; + + return order; +} + +/** + * Shows an error message inside the form. + * @param {HTMLFormElement} form + * @param {string} message + */ +function showError(form, message) { + const el = form.querySelector('.checkout-error'); + if (!el) return; + el.textContent = message; + el.hidden = false; + el.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +/** + * Clears the error message. + * @param {HTMLFormElement} form + */ +function clearError(form) { + const el = form.querySelector('.checkout-error'); + if (el) { el.hidden = true; el.textContent = ''; } +} + +/** + * Wires the Chase (credit card) submit button and provides a shared callbacks object + * that payment providers can use. + * + * @param {HTMLFormElement} form + * @param {Object} cart + * @param {Object} state + * @param {Object} config + * @param {Object} strings + * @returns {Object} callbacks + */ +export function initOrder(form, cart, state, config, strings) { + const callbacks = { + getCart: () => cart, + getConfig: () => config, + getFormData: () => new FormData(form), + getState: () => state, + updatePreview: () => updatePreview(form, cart, state, config), + previewOrderDirect: (body) => previewOrder(body), + buildOrderJSON: (formData) => buildOrderJSON(formData, form, cart, state, config), + saveCheckoutSession: (email, c, preview, order) => ( + saveCheckoutSession(email, c, preview, order) + ), + createOrder: (orderBody) => createOrder(orderBody), + initiatePayment: (...args) => initiatePayment(...args), + showError: (msg) => showError(form, msg), + clearError: () => clearError(form), + onComplete: () => { + cart.clear(); + window.location.href = config.getOrderPath('complete'); + }, + }; + + // Wire Chase submit button + const submitBtn = form.querySelector('.checkout-submit-btn'); + const submitTextEl = submitBtn?.querySelector('.submit-btn-text'); + + if (submitBtn) { + submitBtn.addEventListener('click', async (e) => { + e.preventDefault(); + clearError(form); + + if (!form.checkValidity()) { + form.reportValidity(); + return; + } + + if (!state.selectedShippingMethodId) { + showError(form, strings.errorSelectShipping); + return; + } + + // Delegate to the selected provider's own button if not credit-card + const selectedMethod = form.querySelector('[name="paymentMethod"]:checked')?.value || 'credit-card'; + + if (selectedMethod === 'apple-pay') { + submitBtn.disabled = true; + submitBtn.classList.add('is-loading'); + if (submitTextEl) submitTextEl.textContent = strings.processing; + try { + await callbacks.beginApplePay(); + } catch (err) { + let msg = 'Apple Pay payment failed. Please try again.'; + if (err.message === 'not-available') msg = 'Apple Pay is not available. Please try a different payment method.'; + if (err.message === 'no-preview') msg = 'Please complete your shipping information first.'; + callbacks.showError(msg); + } finally { + submitBtn.disabled = false; + submitBtn.classList.remove('is-loading'); + if (submitTextEl) submitTextEl.textContent = strings.continueToPayment; + } + return; + } + + if (selectedMethod !== 'credit-card') { + const providerBtn = form.querySelector(`.payment-button-container[data-provider="${selectedMethod}"] button`); + if (providerBtn) { + submitBtn.disabled = true; + submitBtn.classList.add('is-loading'); + if (submitTextEl) submitTextEl.textContent = strings.processing; + + // Restore submit button when the provider re-enables its own button (on fail/close) + const observer = new MutationObserver(() => { + if (!providerBtn.disabled) { + submitBtn.disabled = false; + submitBtn.classList.remove('is-loading'); + if (submitTextEl) submitTextEl.textContent = strings.continueToPayment; + observer.disconnect(); + } + }); + observer.observe(providerBtn, { attributes: true, attributeFilter: ['disabled'] }); + + providerBtn.click(); + } + return; + } + + if (!state.currentEstimateToken) { + await callbacks.updatePreview(); + if (!state.currentEstimateToken) { + showError(form, strings.errorCalculateTotals); + return; + } + } + + const formData = new FormData(form); + const email = formData.get('email') || ''; + const orderBody = buildOrderJSON(formData, form, cart, state, config); + + submitBtn.disabled = true; + if (submitTextEl) submitTextEl.textContent = strings.processing; + + try { + const createdOrder = await createOrder(orderBody); + saveCheckoutSession(email, cart, state.currentPreview, createdOrder.order ?? createdOrder); + + const fraudToken = config.getFraudToken?.(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; + const payment = await initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + config.cardProvider || 'chase', + 'card', + ); + + if (payment.action === 'redirect' && payment.redirectUrl) { + window.location.href = payment.redirectUrl; + } else if (payment.status === 'completed') { + callbacks.onComplete(createdOrder); + } else { + showError(form, payment.reason || strings.errorGeneric); + submitBtn.disabled = false; + if (submitTextEl) submitTextEl.textContent = strings.continueToPayment; + } + } catch (err) { + showError(form, err.body?.message || strings.errorGeneric); + submitBtn.disabled = false; + if (submitTextEl) submitTextEl.textContent = strings.continueToPayment; + } + }); + } + + return callbacks; +} diff --git a/blocks/checkout/checkout-payment.js b/blocks/checkout/checkout-payment.js new file mode 100644 index 00000000..68518a5c --- /dev/null +++ b/blocks/checkout/checkout-payment.js @@ -0,0 +1,230 @@ +import { getMetadata } from '../../scripts/aem.js'; + +/** + * Filters the provider list using the `disabled-providers` metadata kill switch. + * The `?enable-providers=` query param re-enables specific providers for testing. + * @param {Array} allProviders + * @returns {Array} + */ +export function getActiveProviders(allProviders) { + const disabled = (getMetadata('disabled-providers') || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + const reenabled = (new URLSearchParams(window.location.search).get('enable-providers') || '') + .split(',') + .map((s) => s.trim().toLowerCase()) + .filter(Boolean); + + return allProviders.filter((p) => { + if (reenabled.includes(p.id)) return true; + if (disabled.includes(p.id)) return false; + return true; + }); +} + +const LOCK_ICON_SVG = ''; + +const CARD_ICON_SVG = ''; + +function getProviderMeta(strings) { + return { + paypal: { + label: strings.paypalLabel, + sublabel: strings.paypalSub, + icon: '', + }, + 'apple-pay': { + label: strings.applePayLabel, + sublabel: strings.applePaySub, + icon: '', + }, + affirm: { + label: strings.affirmLabel, + sublabel: strings.affirmSub, + icon: '', + }, + }; +} + +/** + * Renders the payment section into the given fieldset. + * @param {HTMLFieldSetElement} container + * @param {Array} activeProviders + * @param {Object} callbacks + * @param {Object} strings + */ +export function renderPaymentSection(container, activeProviders, callbacks, strings) { + const providerMeta = getProviderMeta(strings); + + // Security notice banner + const notice = document.createElement('div'); + notice.className = 'payment-security-notice'; + + const noticeIcon = document.createElement('span'); + noticeIcon.className = 'payment-security-icon'; + noticeIcon.innerHTML = LOCK_ICON_SVG; + + const noticeText = document.createElement('div'); + noticeText.className = 'payment-security-text'; + + const noticeTitle = document.createElement('strong'); + noticeTitle.textContent = strings.paymentSecureTitle; + + const noticeSub = document.createElement('span'); + noticeSub.textContent = strings.paymentSecureSub; + + noticeText.append(noticeTitle, noticeSub); + notice.append(noticeIcon, noticeText); + container.appendChild(notice); + + // Section label + const howLabel = document.createElement('p'); + howLabel.className = 'payment-how-label'; + howLabel.textContent = strings.paymentHow; + container.appendChild(howLabel); + + // Options wrapper + const optionsWrapper = document.createElement('div'); + optionsWrapper.className = 'payment-options'; + + // Credit card option + const cardLabel = document.createElement('label'); + cardLabel.className = 'payment-option-card payment-option-active'; + + const cardRadio = document.createElement('input'); + cardRadio.type = 'radio'; + cardRadio.name = 'paymentMethod'; + cardRadio.value = 'credit-card'; + cardRadio.checked = true; + cardRadio.id = 'payment-credit-card'; + + const cardIcon = document.createElement('span'); + cardIcon.className = 'payment-option-icon'; + cardIcon.innerHTML = CARD_ICON_SVG; + + const cardContent = document.createElement('div'); + cardContent.className = 'payment-option-content'; + + const cardTitle = document.createElement('span'); + cardTitle.className = 'payment-option-label'; + cardTitle.textContent = strings.creditCard; + + const cardSub = document.createElement('span'); + cardSub.className = 'payment-option-sublabel'; + cardSub.textContent = strings.creditCardSub; + + cardContent.append(cardTitle, cardSub); + + const cardBadges = document.createElement('div'); + cardBadges.className = 'payment-option-badges'; + ['VISA', 'MC', 'AMEX'].forEach((name) => { + const badge = document.createElement('span'); + badge.className = 'card-badge'; + badge.textContent = name; + cardBadges.appendChild(badge); + }); + + cardLabel.append(cardRadio, cardIcon, cardContent, cardBadges); + optionsWrapper.appendChild(cardLabel); + + // Provider options + activeProviders.forEach((provider) => { + const meta = providerMeta[provider.id] || { label: provider.id, sublabel: '', icon: '' }; + + const optLabel = document.createElement('label'); + optLabel.className = 'payment-option-card'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'paymentMethod'; + radio.value = provider.id; + radio.id = `payment-${provider.id}`; + + const optIcon = document.createElement('span'); + optIcon.className = 'payment-option-icon'; + optIcon.innerHTML = meta.icon; + + const optContent = document.createElement('div'); + optContent.className = 'payment-option-content'; + + const optTitle = document.createElement('span'); + optTitle.className = 'payment-option-label'; + optTitle.textContent = meta.label || provider.label; + + const optSub = document.createElement('span'); + optSub.className = 'payment-option-sublabel'; + optSub.textContent = meta.sublabel; + + optContent.append(optTitle, optSub); + optLabel.append(radio, optIcon, optContent); + optionsWrapper.appendChild(optLabel); + + // Hidden container for provider SDK button (outside label to avoid click conflicts) + const btnContainer = document.createElement('div'); + btnContainer.className = 'payment-button-container'; + btnContainer.dataset.provider = provider.id; + btnContainer.setAttribute('aria-hidden', 'true'); + optionsWrapper.appendChild(btnContainer); + + provider.renderCheckoutButton(btnContainer, callbacks); + }); + + container.appendChild(optionsWrapper); + + // Wire radio changes + container.addEventListener('change', (e) => { + if (e.target.name !== 'paymentMethod') return; + const selectedId = e.target.value; + + optionsWrapper.querySelectorAll('.payment-option-card').forEach((opt) => { + const optRadio = opt.querySelector('input[type="radio"]'); + opt.classList.toggle('payment-option-active', optRadio?.value === selectedId); + }); + + // Hide billing for providers that collect it themselves + const selectedProvider = activeProviders.find((p) => p.id === selectedId); + const billingSection = container.closest('form')?.querySelector('.billing-section'); + if (billingSection) { + billingSection.hidden = selectedProvider?.hidesBilling ?? false; + } + }); +} + +/** + * @param {HTMLFieldSetElement} container + * @param {Array} providers + * @param {Object} callbacks + * @param {Object} config + * @param {Object} strings + */ +export async function initPayment(container, providers, callbacks, config, strings) { + const active = getActiveProviders(providers); + + await Promise.all( + active.map(async (provider) => { + try { + await provider.load(config); + } catch { /* provider failed to load — will be filtered by isAvailable() */ } + }), + ); + + const available = active.filter((p) => { + try { return p.isAvailable(); } catch { return false; } + }); + + renderPaymentSection(container, available, callbacks, strings); + + // Render express buttons on the cart page (order-summary block) + const expressContainer = document.querySelector('.express-checkout-buttons'); + if (expressContainer) { + available.filter((p) => p.supportsExpress).forEach((p) => { + p.renderExpressButton(expressContainer, callbacks); + }); + const expressSection = expressContainer.closest('.express-checkout-section'); + if (expressSection) { + expressSection.hidden = !available.some((p) => p.supportsExpress); + } + } +} diff --git a/blocks/checkout/checkout-shipping.js b/blocks/checkout/checkout-shipping.js new file mode 100644 index 00000000..127af99c --- /dev/null +++ b/blocks/checkout/checkout-shipping.js @@ -0,0 +1,194 @@ +import { estimateShipping, previewOrder } from '../../scripts/commerce-api.js'; +import { formatPrice } from '../../scripts/commerce-config.js'; + +/** + * Renders shipping method radio buttons into the container. + * @param {HTMLFieldSetElement} container + * @param {Array<{id: string, label: string, rate: number, eta?: string}>} rates + * @param {Object} strings + */ +export function renderShippingMethods(container, rates, strings, currencyCode = 'USD') { + [...container.children].forEach((child) => { + if (child.tagName !== 'LEGEND') child.remove(); + }); + + if (!rates?.length) { + const empty = document.createElement('p'); + empty.className = 'shipping-methods-empty'; + empty.textContent = strings.noShippingMethods; + container.appendChild(empty); + return; + } + + rates.forEach((rate, i) => { + const label = document.createElement('label'); + label.className = 'shipping-method-option'; + + const radio = document.createElement('input'); + radio.type = 'radio'; + radio.name = 'shippingMethod'; + radio.value = rate.id; + if (i === 0) radio.checked = true; + + const body = document.createElement('div'); + body.className = 'shipping-method-body'; + + const labelText = document.createElement('span'); + labelText.className = 'shipping-method-label'; + labelText.textContent = rate.label; + + const isFree = rate.rate === 0; + const price = document.createElement('span'); + price.className = isFree ? 'shipping-method-price shipping-method-price-free' : 'shipping-method-price'; + price.textContent = isFree ? strings.free : formatPrice(parseFloat(rate.rate), currencyCode); + + body.append(labelText, price); + + if (rate.eta) { + const eta = document.createElement('span'); + eta.className = 'shipping-method-eta'; + eta.textContent = rate.eta; + body.appendChild(eta); + } + + label.append(body, radio); + + container.appendChild(label); + }); +} + +/** + * Fetches a preview and dispatches checkout:preview event. + * @param {HTMLFormElement} form + * @param {Object} cart + * @param {Object} state + * @param {Object} config + */ +export async function updatePreview(form, cart, state, config) { + if (!state.selectedShippingMethodId) return; + + const data = new FormData(form); + const email = data.get('email') || ''; + const firstName = data.get('shipping-firstname') || ''; + const lastName = data.get('shipping-lastname') || ''; + + // Preview requires customer identity — skip if not yet filled + if (!email || !firstName || !lastName) return; + const locale = config.getLocale(); + const language = config.getLanguage(); + + const shippingAddr = { + name: `${firstName} ${lastName}`.trim(), + company: data.get('shipping-company') || '', + address1: data.get('shipping-street-0') || '', + address2: data.get('shipping-street-1') || '', + city: data.get('shipping-city') || '', + state: data.get('shipping-state') || '', + zip: data.get('shipping-zip') || '', + country: locale, + phone: data.get('shipping-telephone') || '', + email, + }; + + const orderBody = { + customer: { + firstName, + lastName, + email, + phone: data.get('shipping-telephone') || '', + }, + shipping: Object.fromEntries(Object.entries(shippingAddr).filter(([, v]) => v !== '')), + billing: Object.fromEntries(Object.entries(shippingAddr).filter(([, v]) => v !== '')), + items: cart.getItemsForAPI(), + shippingMethod: { id: state.selectedShippingMethodId }, + locale: `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`, + country: locale, + }; + + document.dispatchEvent(new CustomEvent('checkout:preview-loading')); + + try { + const preview = await previewOrder(orderBody); + state.currentEstimateToken = preview.estimateToken; + state.currentPreview = preview; + document.dispatchEvent(new CustomEvent('checkout:preview', { detail: { preview } })); + } catch { + document.dispatchEvent(new CustomEvent('checkout:preview', { detail: { preview: null } })); + } +} + +/** + * Fetches shipping rates then triggers a preview. + * @param {HTMLFormElement} form + * @param {HTMLFieldSetElement} shippingContainer + * @param {Object} cart + * @param {Object} state + * @param {Object} config + * @param {Object} strings + */ +// eslint-disable-next-line max-len +async function fetchAndPreview(form, shippingContainer, cart, state, config, strings, currencyCode) { + const data = new FormData(form); + const stateCode = data.get('shipping-state'); + if (!stateCode || !cart.itemCount) return; + + const locale = config.getLocale(); + const country = locale === 'ca' ? 'ca' : 'us'; + + try { + const result = await estimateShipping(country, stateCode, cart.getItemsForAPI()); + renderShippingMethods(shippingContainer, result.rates || [], strings, currencyCode); + + const firstRadio = shippingContainer.querySelector('input[type="radio"]'); + if (firstRadio) { + state.selectedShippingMethodId = firstRadio.value; + shippingContainer.dispatchEvent(new CustomEvent('checkout:shipping-selected', { bubbles: true })); + await updatePreview(form, cart, state, config); + } + } catch { + renderShippingMethods(shippingContainer, [], strings, currencyCode); + } +} + +/** + * @param {HTMLFormElement} form + * @param {HTMLFieldSetElement} shippingContainer + * @param {Object} cart + * @param {Object} state + * @param {Object} config + * @param {Object} strings + */ +export function initShipping(form, shippingContainer, cart, state, config, strings) { + const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + + // Show placeholder until an address is entered + const placeholder = document.createElement('p'); + placeholder.className = 'shipping-methods-placeholder'; + placeholder.textContent = strings.shippingPlaceholder; + shippingContainer.appendChild(placeholder); + + // Fetch rates when state/province changes + const stateSelect = form.querySelector('[name="shipping-state"]'); + if (stateSelect) { + stateSelect.addEventListener('change', () => { + state.selectedShippingMethodId = null; + fetchAndPreview(form, shippingContainer, cart, state, config, strings, currencyCode); + }); + } + + // Update preview when shipping method is selected + shippingContainer.addEventListener('change', (e) => { + if (e.target.name === 'shippingMethod') { + state.selectedShippingMethodId = e.target.value; + shippingContainer.dispatchEvent(new CustomEvent('checkout:shipping-selected', { bubbles: true })); + updatePreview(form, cart, state, config); + } + }); + + // Re-run preview when cart changes + document.addEventListener('cart:change', () => { + if (state.selectedShippingMethodId) { + updatePreview(form, cart, state, config); + } + }); +} diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 022ee6a7..decdcb5e 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -1,238 +1,308 @@ -.checkout-container { - display: flex; - flex-direction: column; +/* Constrain the combined form + order summary to the commerce max-width */ +main > .section:has(.checkout-wrapper) { + max-width: var(--commerce-max-width); + margin-right: auto; + margin-left: auto; gap: 24px; - max-width: 1440px; - margin: 0 auto; - padding: 32px 0 0; } -/* Mobile: cart summary on top, form below */ -@media (width < 1000px) { - .checkout-container { - flex-direction: column; - } - - .checkout-summary-column { - order: -1; /* Cart summary appears first on mobile */ - } - - .checkout-form-column { - order: 1; - } +/* Stack section cards with spacing */ +.checkout-form { + display: flex; + flex-direction: column; + gap: 12px; } -/* Desktop: form on left, cart summary on right */ -@media (width >= 1000px) { - .checkout-container { - flex-direction: row; - align-items: flex-start; - gap: 40px; - } +/* Sign-in prompt — not a card, just a small text link above */ +.checkout-signin-prompt { + padding: 0 0 4px; + font-size: var(--commerce-font-size-sm); +} - .checkout-form-column { - flex: 1; - max-width: 600px; - } +.checkout-signin-link { + color: var(--commerce-color-text); + text-decoration: underline; +} - .checkout-summary-column { - flex: 0 0 450px; - position: sticky; - top: 100px; /* Keeps it visible while scrolling */ - } +/* ── Section cards — each section is a white bordered card ──────── */ +.checkout-form .form-section { + background: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + padding: 20px 24px; } -/* Remove default block wrapper margins */ -.checkout .form-wrapper, -.checkout .cart-summary-wrapper { +/* Fieldset sections inherit card styling */ +.checkout-form fieldset.form-section { margin: 0; } -/* Form styling adjustments for checkout context */ -.checkout-form-column .form { - background: transparent; +.checkout-form .form-section h3 { + margin: 0 0 16px; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); } -.checkout-form-column form { - display: grid; - gap: var(--spacing-80); +.checkout-form fieldset.form-section legend { + float: left; + grid-column: 1 / -1; + width: 100%; + padding: 0; + margin-bottom: 16px; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); } -.checkout-form-column form input, -.checkout-form-column form select { - font-weight: normal; - font-family: var(--sans-serif-font-family); - font-size: 16px; - box-sizing: border-box; - height: 48px; +/* Clearfix for fieldset legend float */ +.checkout-form fieldset.form-section::after { + content: ''; + display: block; + clear: both; } -.checkout-form-column .button-wrapper { - margin-top: var(--spacing-80); +/* Express checkout — card panel */ +.checkout-form .express-checkout-section { + background: var(--commerce-color-surface); } -.checkout-form-column .button { - width: 100%; +.checkout-form .express-checkout-section[hidden] { + display: none; } -.checkout-form-column form .field-help-text { - font-size: var(--body-size-xs); - padding-left: 8px; +/* Billing section — card with radio options */ + +/* ── Express checkout ─────────────────────────────────────────────── */ +.express-checkout-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; } -.checkout-form-column .form form fieldset[data-name="optIn"] { - margin-top: 0; +.express-checkout-label { + margin: 0; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); } -.checkout-form-column form fieldset[data-name="optIn"] legend { - display: none; +.express-checkout-tagline { + margin: 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); } -/* Shipping & billing address grid layout — 6-col grid for 2-col and 3-col rows */ -.checkout-form-column .section-shipping, -.checkout-form-column .section-billing { - display: grid; - grid-template-columns: repeat(6, 1fr); +.express-checkout-buttons { + container-type: inline-size; + display: flex; + align-items: stretch; + flex-wrap: wrap; gap: 12px; - align-items: end; + overflow: hidden; + max-width: 100%; } -/* Shipping methods */ -.shipping-methods { - border: none; - padding: 0; - width: 100%; +/* All buttons share one row at wide sizes — div covers SDK iframes, button covers stubs */ +.express-checkout-buttons > div, +.express-checkout-buttons > button { + flex: 1; + min-width: 0; + display: flex; + height: 58px; } -.shipping-methods h3 { - margin-bottom: var(--spacing-40); +/* Stub Pay Later button fills its wrapper in the default (wide) state */ +.paypal-paylater-wrapper .paypal-express-btn { + height: 58px; } -.checkout-form-column .section-shipping h3, -.checkout-form-column .section-billing h3 { - grid-column: 1 / -1; +/* Pay Later drops to a full-width second row when space is tight */ +@container (max-width: 480px) { + .express-checkout-buttons > .paypal-paylater-wrapper { + flex: 0 0 100%; + } } -/* Default: all fields full width */ -.checkout-form-column .section-shipping .form-field, -.checkout-form-column .section-billing .form-field { - grid-column: 1 / -1; +@media (width >= 1118px) and (width <= 1250px) { + .express-checkout-buttons > .paypal-paylater-wrapper { + flex: 0 0 100%; + } } -.checkout-form-column .form .form-field + .form-field { - margin-top: 8px; +@media (width >= 610px) and (width <= 714px) { + .express-checkout-buttons > .paypal-paylater-wrapper { + flex: 0 0 100%; + } } -/* Company — full width, after lastname */ -.checkout-form-column .section-shipping .form-field[data-name="company"], -.checkout-form-column .section-billing .form-field[data-name="company"] { order: 3; } +.express-checkout-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 16px 0 0; + padding-top: 14px; + font-size: 11px; + font-weight: var(--commerce-font-weight-bold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--commerce-color-text-muted); + white-space: nowrap; +} + +.express-checkout-divider::before, +.express-checkout-divider::after { + content: ''; + flex: 1; + height: 1px; + background: var(--commerce-color-border); +} -/* Reorder fields: firstname(1) lastname(2) street-0(3) street-1(4) city(5) state(6) zip(7) phone(8) */ -.checkout-form-column .section-shipping .form-field[data-name="firstname"], -.checkout-form-column .section-billing .form-field[data-name="firstname"] { order: 1; } +/* ── Field grid ───────────────────────────────────────────────────── */ +.form-fields { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 12px; +} -.checkout-form-column .section-shipping .form-field[data-name="lastname"], -.checkout-form-column .section-billing .form-field[data-name="lastname"] { order: 2; } +.form-field { + grid-column: 1 / -1; +} -.checkout-form-column .section-shipping .form-field[data-name="street-0"], -.checkout-form-column .section-billing .form-field[data-name="street-0"] { order: 4; } +.form-field.field-half { + grid-column: span 3; +} -.checkout-form-column .section-shipping .form-field[data-name="street-1"], -.checkout-form-column .section-billing .form-field[data-name="street-1"] { order: 5; } +.form-field.field-third { + grid-column: span 2; +} -.checkout-form-column .section-shipping .form-field[data-name="city"], -.checkout-form-column .section-billing .form-field[data-name="city"] { order: 6; } +/* ── Floating labels — checkbox first (lower specificity than :has() below) ── */ +.form-field-checkbox label { + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: var(--commerce-font-size-sm); +} -.checkout-form-column .section-shipping .form-field[data-name="state"], -.checkout-form-column .section-billing .form-field[data-name="state"] { order: 7; } +.form-field-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + flex-shrink: 0; + cursor: pointer; +} -.checkout-form-column .section-shipping .form-field[data-name="zip"], -.checkout-form-column .section-billing .form-field[data-name="zip"] { order: 8; } +.floating-label-field { + position: relative; +} -.checkout-form-column .section-shipping .form-field[data-name="telephone"], -.checkout-form-column .section-billing .form-field[data-name="telephone"] { order: 9; } +.floating-label-field input, +.floating-label-field select, +.floating-label-field textarea { + width: 100%; + box-sizing: border-box; + padding: 20px 14px 4px; + height: var(--commerce-input-height); + border: 1px solid var(--commerce-input-border); + border-radius: var(--commerce-input-radius); + background: var(--commerce-input-background); + font-size: var(--commerce-font-size-sm); + font-weight: 400; + font-family: inherit; + color: var(--commerce-color-text); + outline: none; + appearance: none; +} -/* Desktop layout — data-name uses original field names (without shipping-/billing- prefix) */ -@media (width >= 480px) { - /* First name / Last name — half width each */ - .checkout-form-column .section-shipping .form-field[data-name="firstname"], - .checkout-form-column .section-billing .form-field[data-name="firstname"] { - grid-column: 1 / 4; - } +.floating-label-field textarea { + height: auto; + padding-top: 24px; + resize: vertical; +} - .checkout-form-column .section-shipping .form-field[data-name="lastname"], - .checkout-form-column .section-billing .form-field[data-name="lastname"] { - grid-column: 4 / 7; - } +.floating-label-field select { + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23666' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); + background-repeat: no-repeat; + background-position: right 14px center; + padding-right: 36px; + cursor: pointer; +} - /* City / Province / Postal code — thirds */ - .checkout-form-column .section-shipping .form-field[data-name="city"], - .checkout-form-column .section-billing .form-field[data-name="city"] { - grid-column: 1 / 3; - } +.floating-label-field input:focus, +.floating-label-field select:focus, +.floating-label-field textarea:focus { + border-color: var(--commerce-input-border-focus); +} - .checkout-form-column .section-shipping .form-field[data-name="state"], - .checkout-form-column .section-billing .form-field[data-name="state"] { - grid-column: 3 / 5; - } +.floating-label-field input:-webkit-autofill, +.floating-label-field input:-webkit-autofill:hover, +.floating-label-field input:-webkit-autofill:focus { + box-shadow: 0 0 0 1000px var(--commerce-input-background) inset; + -webkit-text-fill-color: var(--commerce-color-text); +} - .checkout-form-column .section-shipping .form-field[data-name="zip"], - .checkout-form-column .section-billing .form-field[data-name="zip"] { - grid-column: 5 / 7; - } +.floating-label-field select:-webkit-autofill, +.floating-label-field select:-webkit-autofill:hover, +.floating-label-field select:-webkit-autofill:focus { + background-color: var(--commerce-input-background); + color: var(--commerce-color-text); } -.checkout-form-column .form form .form-field[data-name="street-1"] { - margin-top: -2px; +.floating-label-field input[required] + label::after, +.floating-label-field select[required] + label::after { + content: ' *'; + color: var(--commerce-color-error, #c00); } -.checkout-form-column .form form .button-wrapper span.payment-button { - width: 100%; +.floating-label-field label { + position: absolute; + left: 14px; + top: 16px; + margin: 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + pointer-events: none; + transition: top 0.15s ease, font-size 0.15s ease; } -/* The span is the actual click target — the shadow DOM - swallows pointer events before they can bubble to the span. Setting - pointer-events:none makes clicks fall through to the span, which holds - our ApplePaySession logic. display:block + min-height ensure the span - is always clickable even before the SDK upgrades . */ -.checkout-form-column .form form .button-wrapper span.payment-button.apple-pay { - display: block; - min-height: 44px; - cursor: pointer; +.floating-label-field:has(input:focus) label, +.floating-label-field:has(input:not(:placeholder-shown)) label, +.floating-label-field:has(select:focus) label, +.floating-label-field:has(select.has-value) label, +.floating-label-field:has(textarea:focus) label, +.floating-label-field:has(textarea:not(:placeholder-shown)) label { + top: 8px; + font-size: 0.7rem; } -apple-pay-button { - --apple-pay-button-width: 100%; - --apple-pay-button-height: 40px; - --apple-pay-button-border-radius: 5px; - --apple-pay-button-padding: 5px 0px; +.floating-label-field input.field-required, +.floating-label-field select.field-required { + border-color: var(--commerce-color-error); +} - pointer-events: none; +/* Gift message char count */ +.char-count { + display: block; + font-size: 0.7rem; + color: var(--commerce-color-text-muted); + text-align: right; + margin-top: 4px; } -.checkout-form-column .form form .button-wrapper span.payment-button.paypal .button { +/* ── Shipping methods — horizontal card layout ───────────────────── */ +.shipping-methods { display: flex; - justify-content: center; - gap: 6px; - padding-top: 13px; - padding-bottom: 13px; - align-items: center; + flex-wrap: wrap; + gap: 10px; } -span.payment-button.paypal .button img { - height: 24px; - width: 70px; +.checkout-form .shipping-methods:has(.shipping-methods-placeholder) { + padding-bottom: 20px; } -/* Form section ordering */ -.checkout-form-column form .section-shipping { order: 1; } -.checkout-form-column form > .form-field[data-name="billingEqualsShipping"] { order: 2; } -.checkout-form-column form .section-billing { order: 3; } -.checkout-form-column form .shipping-methods { order: 4; } -.checkout-form-column form .section-payment { order: 5; } -.checkout-form-column form > .form-field[data-name="email"] { order: 0; } -.checkout-form-column form > .form-field[data-name="optIn"] { order: 0; } -.checkout-form-column form .button-wrapper { order: 7; } +.checkout-form .shipping-methods:has(.shipping-methods-placeholder) legend { + margin-bottom: 8px; +} .shipping-methods.loading { opacity: 0.5; @@ -240,70 +310,500 @@ span.payment-button.paypal .button img { } .shipping-method-option { + flex: 1 1 auto; + min-width: 150px; display: flex; - align-items: center; + flex-direction: row; + align-items: flex-start; gap: 12px; - padding: 12px 16px; - border: 1px solid #ddd; - border-radius: 6px; - margin-bottom: 8px; + padding: 12px 14px; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-badge); cursor: pointer; transition: border-color 0.15s; - width: 100%; - box-sizing: border-box; } .shipping-method-option:hover { - border-color: #333; + border-color: var(--commerce-color-text); } .shipping-method-option:has(input:checked) { - border-color: #333; - background: #fafafa; + border-color: var(--commerce-color-text); + background: var(--commerce-color-surface); } .shipping-method-option input[type="radio"] { - margin: 0; + flex-shrink: 0; + margin: 2px 0 0; width: 18px; height: 18px; + cursor: pointer; + accent-color: var(--commerce-color-text); } -.shipping-method-label { +.shipping-method-body { flex: 1; + display: flex; + flex-direction: column; + gap: 4px; +} + +.shipping-method-label { + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); +} + +.shipping-method-eta { + font-size: 12px; + color: var(--commerce-color-text-muted); } .shipping-method-price { - font-weight: 600; + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); +} + +.shipping-method-price-free { + color: #2a7c43; } -.shipping-methods-empty { - color: #666; +.shipping-methods-empty, +.shipping-methods-placeholder { + color: var(--commerce-color-text-muted); + font-size: var(--commerce-font-size-sm); font-style: italic; + margin-top: 4px; +} + +/* ── Billing address ─────────────────────────────────────────────── */ +.billing-subtitle { + margin: 0 0 16px; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.billing-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.billing-option-card { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + cursor: pointer; + transition: border-color 0.15s; +} + +.billing-option-card:has(input:checked) { + border-color: var(--commerce-color-text); +} + +.billing-option-card input[type="radio"] { + width: 18px; + height: 18px; + margin: 2px 0 0; + flex-shrink: 0; + cursor: pointer; + accent-color: var(--commerce-color-text); +} + +.billing-option-content { + display: flex; + flex-direction: column; + gap: 3px; +} + +.billing-option-label { + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); +} + +.billing-option-detail { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.billing-fields-wrapper { + margin-top: 20px; +} + +/* ── Payment method ──────────────────────────────────────────────── */ +.payment-security-notice { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 16px; + margin-bottom: 20px; + background: var(--commerce-color-surface); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); +} + +.payment-security-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + background: #e8e8e8; + border-radius: 8px; + color: var(--commerce-color-text-muted); +} + +.payment-security-text { + display: flex; + flex-direction: column; + gap: 4px; + font-size: var(--commerce-font-size-sm); +} + +.payment-security-text strong { + color: var(--commerce-color-text); + font-weight: var(--commerce-font-weight-bold); } -/* Required indicator is now appended directly to the floating label text in JS */ +.payment-security-text span { + color: var(--commerce-color-text-muted); +} -/* Required field error state */ -.checkout-form-column form input.field-required, -.checkout-form-column form select.field-required { - border-color: #c00; +.payment-how-label { + margin: 0 0 12px; + font-size: 11px; + font-weight: var(--commerce-font-weight-bold); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--commerce-color-text-muted); } -/* Checkout error */ +.payment-options { + display: flex; + flex-direction: column; + gap: 8px; +} + +.payment-option-card { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + cursor: pointer; + transition: border-color 0.15s; +} + +.payment-option-card:hover { + border-color: var(--commerce-color-text-muted); +} + +.payment-option-card.payment-option-active { + border-color: var(--commerce-color-text); +} + +.payment-option-card input[type="radio"] { + width: 18px; + height: 18px; + margin: 0; + flex-shrink: 0; + cursor: pointer; + accent-color: var(--commerce-color-text); +} + +.payment-option-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 40px; + color: var(--commerce-color-text-muted); +} + +.payment-icon-pp { + font-family: Arial, Helvetica, sans-serif; + font-size: 14px; + font-weight: bold; + line-height: 1; +} + +.payment-icon-text { + font-size: 12px; + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text-muted); +} + +.payment-option-content { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.payment-option-label { + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text); +} + +.payment-option-sublabel { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.payment-option-badges { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.card-badge { + display: inline-flex; + align-items: center; + padding: 2px 5px; + border: 1px solid var(--commerce-color-border); + border-radius: 3px; + font-size: 10px; + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text-muted); + letter-spacing: 0.02em; +} + +.payment-button-container { + display: none; +} + +/* PayPal stub buttons */ +.paypal-btn, +.paypal-express-btn { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + min-width: 0; + width: 100%; + height: 48px; + overflow: hidden; + background: #ffc439; + border: none; + border-radius: var(--commerce-button-radius); + cursor: pointer; + transition: background 0.15s; + box-sizing: border-box; +} + +.pp-wordmark { + font-family: Arial, Helvetica, sans-serif; + font-size: 20px; + font-weight: bold; + line-height: 1; +} + +.pp-pay { color: #003087; } + +.pp-pal { color: #009cde; } + +.pp-later { + font-family: Arial, Helvetica, sans-serif; + font-size: 13px; + font-weight: bold; + color: #003087; + margin-left: 6px; +} + +.paypal-btn:hover, +.paypal-express-btn:hover { + background: #f0b929; +} + +.paypal-btn:disabled, +.paypal-express-btn:disabled { + opacity: 0.7; + cursor: not-allowed; + font-size: var(--commerce-font-size-sm); + color: #003087; +} + +.paylater-btn, +.paylater-express-btn { + background: #e8f4fb; + border: 1px solid #b0d8f0; +} + +.paylater-btn:hover, +.paylater-express-btn:hover { + background: #d0eaf7; +} + +/* Not-configured dialog */ +.paypal-not-configured-dialog { + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + padding: var(--commerce-card-padding); + min-width: 280px; + text-align: center; +} + +.paypal-not-configured-dialog p { + margin: 0 0 16px; + font-size: var(--commerce-font-size-sm); +} + +.paypal-not-configured-dialog::backdrop { + background: rgb(0 0 0 / 40%); +} + +/* Apple Pay */ +apple-pay-button { + --apple-pay-button-width: 100%; + --apple-pay-button-height: 58px; + --apple-pay-button-border-radius: var(--commerce-button-radius); + --apple-pay-button-padding: 0; + + flex: 1; + min-width: 0; + display: block; +} + + +/* ── Order total breakdown (mobile only) ─────────────────────────── */ +.order-total-breakdown { + display: none; +} + +.order-breakdown-row { + display: flex; + justify-content: space-between; + align-items: center; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +/* ── Order total + submit ─────────────────────────────────────────── */ +.order-total-section { + display: flex; + align-items: center; + justify-content: space-between; + gap: 24px; +} + +.order-total-left { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.order-total-label { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.order-total-amount { + font-size: 28px; + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text); + line-height: 1.1; +} + +.order-total-tnc { + margin: 0; + font-size: 11px; + color: var(--commerce-color-text-muted); +} + +button.checkout-submit-btn { + display: flex; + align-items: center; + gap: 12px; + flex-shrink: 0; + white-space: nowrap; + padding: 14px 24px; + font-size: var(--commerce-font-size-base); +} + +.submit-lock-icon { + display: flex; + align-items: center; + flex-shrink: 0; + line-height: 0; +} + +.checkout-submit-btn:disabled { + opacity: 0.7; + pointer-events: none; +} + +.checkout-submit-btn.is-loading .submit-lock-icon svg { + display: none; +} + +.checkout-submit-btn.is-loading .submit-lock-icon::after { + content: ''; + display: inline-block; + width: 14px; + height: 14px; + border: 2px solid rgb(255 255 255 / 40%); + border-top-color: #fff; + border-radius: 50%; + animation: submit-btn-spin 0.7s linear infinite; +} + +@keyframes submit-btn-spin { + to { transform: rotate(360deg); } +} + +@media (width < 1000px) { + .card-badge { + display: none; + } + + .order-total-breakdown { + display: flex; + flex-direction: column; + gap: 8px; + margin: 12px 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--commerce-color-border); + } + + .order-total-amount { + text-align: right; + padding-bottom: 8px; + } + + .order-total-section { + flex-direction: column; + align-items: stretch; + } + + button.checkout-submit-btn { + justify-content: center; + width: 100%; + } +} + +/* ── Error ───────────────────────────────────────────────────────── */ .checkout-error { - background: #fee; - border: 1px solid #c00; - color: #900; + background: var(--commerce-color-error-bg); + border: 1px solid var(--commerce-color-error-border); + border-radius: var(--commerce-radius-badge); + color: var(--commerce-color-error); + font-size: var(--commerce-font-size-sm); + margin-top: 4px; padding: 12px 16px; - border-radius: 6px; - margin-bottom: var(--spacing-40); } -.checkout-error[aria-hidden="true"] { +.checkout-error[hidden] { display: none; } -/* Empty cart */ +/* ── Empty cart ──────────────────────────────────────────────────── */ .checkout-empty { text-align: center; padding: 80px 24px; @@ -315,61 +815,76 @@ span.payment-button.paypal .button img { .checkout-empty p { margin: 0 0 24px; - color: #555; + color: var(--commerce-color-text-muted); } -/* Loading state for pay button */ -.checkout-form-column .button.loading { - opacity: 0.7; - pointer-events: none; +/* ── Section collapse ────────────────────────────────────────────── */ +.form-section.is-collapsed > :not(.section-collapsed-bar) { + display: none; } -/* Floating label */ -.checkout-form-column .floating-label-field { - position: relative; +.section-collapsed-bar:not([hidden]) { + display: flex; + align-items: center; + gap: 12px; } -.checkout-form-column .floating-label-field input, -.checkout-form-column .floating-label-field select, -.checkout-form-column .floating-label-field textarea { - padding-top: 20px; - padding-bottom: 4px; - height: 52px; +.section-check { + flex-shrink: 0; + display: flex; + align-items: center; } -.checkout-form-column .floating-label-field select { - appearance: none; - background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%23666' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); - background-repeat: no-repeat; - background-position: right 14px center; - padding-right: 36px; +.section-summary-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; } -.checkout-form-column .floating-label-field textarea { - height: auto; - padding-top: 24px; +.section-summary-title { + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text); } -.checkout-form-column .floating-label-field label { - position: absolute; - left: var(--spacing-100); - top: 16px; - margin: 0; - font-size: 1rem; - color: #888; - pointer-events: none; - transition: top 0.15s ease, font-size 0.15s ease, color 0.15s ease; +.section-summary-detail { + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } -/* Float label up when focused or filled */ -.checkout-form-column .floating-label-field:has(input:focus) label, -.checkout-form-column .floating-label-field:has(input:not(:placeholder-shown)) label, -.checkout-form-column .floating-label-field:has(select:focus) label, -.checkout-form-column .floating-label-field:has(select.has-value) label, -.checkout-form-column .floating-label-field:has(textarea:focus) label, -.checkout-form-column .floating-label-field:has(textarea:not(:placeholder-shown)) label { - top: 8px; - transform: translateY(0); - font-size: 0.7rem; - color: #555; +.section-edit-btn { + background: none; + border: none; + padding: 4px 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + cursor: pointer; + text-decoration: underline; + flex-shrink: 0; +} + +.section-edit-btn:hover { + color: var(--commerce-color-text); +} + +/* ── Mobile ──────────────────────────────────────────────────────── */ +@media (width < 480px) { + .form-field.field-half, + .form-field.field-third { + grid-column: 1 / -1; + } + + .shipping-methods { + grid-template-columns: 1fr; + } + + .checkout-form .form-section { + padding: 16px; + } + } diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 60e4a699..d2f6c736 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -1,1075 +1,276 @@ -import { - buildBlock, - decorateBlock, - loadBlock, - loadScript, -} from '../../scripts/aem.js'; -import { getLocaleAndLanguage, getOrderPath } from '../../scripts/scripts.js'; -import { - estimateShipping, - previewOrder, - createOrder, - initiatePayment, - validateApplePayMerchant, -} from '../../scripts/commerce-api.js'; - -const ADDRESS_FORM = () => `https://main--vitamix--aemsites.aem.page${getOrderPath('address-form')}.json`; - -let currentEstimateToken = null; -let currentPreview = null; -let selectedShippingMethodId = null; - -/** - * Derive country code from the current locale. - */ -function getCountry() { - const { locale } = getLocaleAndLanguage(); - return locale; -} - -/** - * Get the BCP-47 locale string for the API (e.g. 'en-US', 'fr-CA'). - */ -function getLocale() { - const { language } = getLocaleAndLanguage(); - const [lang, region] = language.split('_'); - return region ? `${lang}-${region.toUpperCase()}` : lang; -} - -/** - * Collect address fields from the form by prefix (shipping- or billing-). - */ -function collectAddress(form, formData, prefix, email) { - const firstName = formData[`${prefix}firstname`] || ''; - const lastName = formData[`${prefix}lastname`] || ''; - // Use the state/province code (e.g., "QC") — the API matches against codes, not names - const stateValue = formData[`${prefix}state`] || ''; - - return { - name: `${firstName} ${lastName}`.trim(), - company: formData[`${prefix}company`] || '', - address1: formData[`${prefix}street-0`] || '', - address2: formData[`${prefix}street-1`] || '', - city: formData[`${prefix}city`] || '', - state: stateValue, - zip: formData[`${prefix}zip`] || '', - country: getCountry(), - phone: formData[`${prefix}telephone`] || '', - email, - }; -} - -/** - * Show an error message in the checkout form. - */ -function showError(formColumn, message) { - let errorEl = formColumn.querySelector('.checkout-error'); - if (!errorEl) { - errorEl = document.createElement('div'); - errorEl.className = 'checkout-error'; - formColumn.querySelector('form')?.prepend(errorEl); - } - errorEl.textContent = message; - errorEl.removeAttribute('aria-hidden'); -} - -function clearError(formColumn) { - const errorEl = formColumn.querySelector('.checkout-error'); - if (errorEl) { - errorEl.setAttribute('aria-hidden', 'true'); - } +import { loadCSS, loadBlock } from '../../scripts/aem.js'; +import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; +import cart from '../../scripts/cart.js'; +import applePay, { beginCheckoutSession } from '../../scripts/payments/apple-pay.js'; +import paypal from '../../scripts/payments/paypal.js'; +import affirm from '../../scripts/payments/affirm.js'; +import buildForm, { initCollapse } from './checkout-form.js'; +import { initAddress } from './checkout-address.js'; +import { initShipping, updatePreview } from './checkout-shipping.js'; +import { initOrder } from './checkout-order.js'; +import { initPayment } from './checkout-payment.js'; +import { parsePreview } from '../../scripts/commerce-api.js'; + +loadCSS('/styles/commerce-tokens.css'); + +const ALL_PROVIDERS = [applePay, paypal, affirm]; + +const LOCAL_STRINGS = { + 'en-us': { + signIn: 'Sign in for faster checkout', + expressCheckout: 'Express checkout', + expressTagline: 'Skip the form — pay in seconds', + expressOr: 'or continue manually', + contact: 'Contact', + email: 'Email', + newsletter: 'Email me with news and offers', + shippingAddress: 'Shipping address', + firstName: 'First name', + lastName: 'Last name', + address: 'Address', + addressLine2: 'Apt, suite, etc. (optional)', + city: 'City', + state: 'State', + province: 'Province', + zip: 'ZIP code', + postalCode: 'Postal code', + phone: 'Phone (optional)', + giftMessage: 'Gift message', + giftMessagePlaceholder: 'Add a gift message (optional)', + billingAddress: 'Billing address', + billingSubtitle: "Needed for the card you'll use on the secure payment page.", + billingSame: 'Same as shipping address', + billingDifferent: 'Use a different billing address', + paymentMethod: 'Payment method', + paymentSecureTitle: 'Payment happens on our secure, PCI-compliant page', + paymentSecureSub: "Review your order here, then we'll redirect you to finish paying. No card details are ever stored on our servers.", + paymentHow: "How you'd like to pay", + creditCard: 'Credit or debit card', + creditCardSub: "You'll enter your card on the next secure page", + paypalLabel: 'PayPal', + paypalSub: 'Continue on paypal.com', + applePayLabel: 'Apple Pay', + applePaySub: 'Continue with Apple Pay', + affirmLabel: 'Affirm', + affirmSub: 'Pay over time with Affirm', + orderTotal: 'Order total', + tnc: 'By placing this order you agree to our Terms & Privacy Policy.', + continueToPayment: 'Continue to payment', + edit: 'Edit', + cartEmpty: 'Your cart is empty.', + noShippingMethods: 'No shipping methods available for this address.', + shippingPlaceholder: 'Enter your shipping address to see available methods.', + errorSelectShipping: 'Please select a shipping method.', + errorCalculateTotals: 'Unable to calculate totals. Please try again.', + errorGeneric: 'An error occurred. Please try again.', + processing: 'Processing…', + }, + 'fr-ca': { + signIn: 'Se connecter pour un paiement plus rapide', + expressCheckout: 'Paiement express', + expressTagline: 'Évitez le formulaire — payez en quelques secondes', + expressOr: 'ou continuer manuellement', + contact: 'Contact', + email: 'Courriel', + newsletter: "M'envoyer des nouvelles et des offres par courriel", + shippingAddress: 'Adresse de livraison', + firstName: 'Prénom', + lastName: 'Nom de famille', + address: 'Adresse', + addressLine2: 'App., suite, etc. (facultatif)', + city: 'Ville', + state: 'État', + province: 'Province', + zip: 'Code ZIP', + postalCode: 'Code postal', + phone: 'Téléphone (facultatif)', + giftMessage: 'Message cadeau', + giftMessagePlaceholder: 'Ajouter un message cadeau (facultatif)', + billingAddress: 'Adresse de facturation', + billingSubtitle: 'Nécessaire pour la carte que vous utiliserez sur la page de paiement sécurisée.', + billingSame: "Même que l'adresse de livraison", + billingDifferent: 'Utiliser une adresse de facturation différente', + paymentMethod: 'Mode de paiement', + paymentSecureTitle: "Le paiement s'effectue sur notre page sécurisée et conforme PCI", + paymentSecureSub: "Vérifiez votre commande ici, puis nous vous redirigerons pour finaliser le paiement. Aucune donnée de carte n'est jamais stockée sur nos serveurs.", + paymentHow: 'Comment souhaitez-vous payer', + creditCard: 'Carte de crédit ou de débit', + creditCardSub: 'Vous saisirez votre carte sur la prochaine page sécurisée', + paypalLabel: 'PayPal', + paypalSub: 'Continuer sur paypal.com', + applePayLabel: 'Apple Pay', + applePaySub: 'Continuer avec Apple Pay', + affirmLabel: 'Affirm', + affirmSub: 'Payer en plusieurs fois avec Affirm', + orderTotal: 'Total de la commande', + tnc: 'En passant cette commande, vous acceptez nos Conditions générales et notre Politique de confidentialité.', + continueToPayment: 'Continuer vers le paiement', + edit: 'Modifier', + cartEmpty: 'Votre panier est vide.', + noShippingMethods: 'Aucune méthode de livraison disponible pour cette adresse.', + shippingPlaceholder: 'Entrez votre adresse de livraison pour voir les méthodes disponibles.', + errorSelectShipping: 'Veuillez sélectionner une méthode de livraison.', + errorCalculateTotals: 'Impossible de calculer les totaux. Veuillez réessayer.', + errorGeneric: 'Une erreur est survenue. Veuillez réessayer.', + processing: 'Traitement en cours…', + }, +}; + +function getStrings(config) { + const lang = config.getLanguage().toLowerCase().replace('_', '-'); + return { ...config.getStrings(), ...(LOCAL_STRINGS[lang] || LOCAL_STRINGS['en-us']) }; } -/** - * Render shipping method radio buttons from API rates. - */ -function renderShippingMethods(container, rates) { - container.innerHTML = ''; - const heading = document.createElement('h3'); - heading.textContent = 'Shipping Method'; - container.appendChild(heading); - - if (!rates || rates.length === 0) { - const noRates = document.createElement('p'); - noRates.textContent = 'No shipping methods available for this address.'; - noRates.className = 'shipping-methods-empty'; - container.appendChild(noRates); +export default async function decorate(block) { + const config = getConfig(); + const strings = getStrings(config); + + // Empty cart guard + if (cart.itemCount === 0) { + block.innerHTML = ''; + const empty = document.createElement('div'); + empty.className = 'checkout-empty'; + const p = document.createElement('p'); + p.textContent = strings.cartEmpty; + const link = document.createElement('a'); + link.href = '/'; + link.className = 'button'; + link.textContent = strings.continueShopping; + empty.append(p, link); + block.appendChild(empty); return; } - rates.forEach((rate, index) => { - const label = document.createElement('label'); - label.className = 'shipping-method-option'; - - const radio = document.createElement('input'); - radio.type = 'radio'; - radio.name = 'shippingMethod'; - radio.value = rate.id; - radio.required = true; - if (index === 0) radio.checked = true; - - const text = document.createElement('span'); - text.className = 'shipping-method-label'; - text.textContent = rate.label; - - const price = document.createElement('span'); - price.className = 'shipping-method-price'; - price.textContent = rate.rate === 0 ? 'Free' : `$${parseFloat(rate.rate).toFixed(2)}`; + block.innerHTML = ''; - label.append(radio, text, price); - container.appendChild(label); + // If cart is emptied during checkout, reload to show the empty state + document.addEventListener('cart:change', () => { + if (cart.itemCount === 0) window.location.reload(); }); -} -/** - * Call the order preview API to lock in estimates. - */ -async function updatePreview(form, formData, cart) { - if (!selectedShippingMethodId) return; - - const email = formData.email || ''; - const firstName = formData['shipping-firstname'] || ''; - const lastName = formData['shipping-lastname'] || ''; + // Shared mutable state passed by reference to all modules + const state = { + selectedShippingMethodId: null, + currentEstimateToken: null, + currentPreview: null, + }; - // Skip preview if required fields are missing — highlight them - if (!firstName || !lastName || !email) { - ['shipping-firstname', 'shipping-lastname', 'email'].forEach((name) => { - const input = form.querySelector(`[name="${name}"]`); - if (input) { - if (!input.value) { - input.classList.add('field-required'); - } else { - input.classList.remove('field-required'); - } - } + // Build form + const form = buildForm(block, config, strings); + + // Seed and update order total amount + mobile breakdown + const orderTotalAmountEl = form.querySelector('.order-total-amount'); + if (orderTotalAmountEl) { + const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + const breakdownSubtotalEl = form.querySelector('.order-breakdown-subtotal'); + const breakdownShippingEl = form.querySelector('.order-breakdown-shipping'); + const breakdownTaxesEl = form.querySelector('.order-breakdown-taxes'); + + const updateBreakdown = (subtotal, shipping, taxes) => { + // eslint-disable-next-line max-len + if (breakdownSubtotalEl) breakdownSubtotalEl.textContent = formatPrice(subtotal, currencyCode); + if (breakdownShippingEl) breakdownShippingEl.textContent = shipping; + if (breakdownTaxesEl) breakdownTaxesEl.textContent = taxes; + }; + + const seedInitial = () => { + orderTotalAmountEl.textContent = formatPrice(cart.subtotal, currencyCode); + updateBreakdown(cart.subtotal, '--', '--'); + }; + seedInitial(); + + document.addEventListener('cart:change', () => { if (cart.itemCount > 0) seedInitial(); }); + + document.addEventListener('checkout:preview', (e) => { + const { preview } = e.detail || {}; + if (!preview) return; + const { + subtotal, taxAmount, shippingRate, total, + } = parsePreview(preview, cart.subtotal); + orderTotalAmountEl.textContent = formatPrice(total, currencyCode); + const shippingDisplay = shippingRate === 0 + ? strings.free + : formatPrice(parseFloat(shippingRate), currencyCode); + updateBreakdown(subtotal, shippingDisplay, formatPrice(taxAmount, currencyCode)); }); - return; } - const shipping = collectAddress(form, formData, 'shipping-', email); + // Wire address fields and billing toggle + initAddress(form, state, config, strings); - const previewBody = { - customer: { - firstName, - lastName, - email, + // Section collapse — contact + const contactSection = form.querySelector('.contact-section'); + initCollapse(contactSection, { + getIsValid: () => { + const email = form.querySelector('[name="email"]'); + return !!email?.checkValidity() && email.value.trim() !== ''; }, - shipping, - items: cart.getItemsForAPI(), - shippingMethod: { id: selectedShippingMethodId }, - country: getCountry(), - locale: getLocale(), - }; - - try { - document.dispatchEvent(new CustomEvent('checkout:preview-loading')); - const preview = await previewOrder(previewBody); - currentPreview = preview; - currentEstimateToken = preview.estimateToken; - - document.dispatchEvent(new CustomEvent('checkout:preview', { - detail: { preview }, - })); - } catch (err) { - currentPreview = null; - currentEstimateToken = null; - document.dispatchEvent(new CustomEvent('checkout:preview')); - } -} - -/** - * Fetch shipping rates and preview the order, dispatching an event with the results. - */ -async function fetchAndPreview(form, formData, shippingMethodsContainer) { - const country = getCountry(); - // Use the select value (province code like "QC") for API calls, - // not the display text ("Quebec") — the shipping sheet matches on codes - const stateValue = formData['shipping-state'] || ''; - - if (!stateValue) return; - - const { default: cart } = await import('../../scripts/cart.js'); - const items = cart.getItemsForAPI(); - if (items.length === 0) return; - - // fetch shipping rates - shippingMethodsContainer.classList.add('loading'); - try { - const { rates } = await estimateShipping(country, stateValue, items); - renderShippingMethods(shippingMethodsContainer, rates); - - // auto-select first rate and preview - const firstRate = shippingMethodsContainer.querySelector('input[name="shippingMethod"]:checked'); - if (firstRate) { - selectedShippingMethodId = firstRate.value; - // Re-read form data after the async estimateShipping call: browser AutoFill - // may have continued filling fields (e.g. zip) after the state-change event - // fired, so the snapshot passed in can be stale and produce a mismatched token. - const latestFormData = Object.fromEntries(new FormData(form).entries()); - await updatePreview(form, latestFormData, cart); - } - } catch (err) { - renderShippingMethods(shippingMethodsContainer, []); - } finally { - shippingMethodsContainer.classList.remove('loading'); - } -} - -/** - * Creates and decorates the checkout page with form and cart summary - * @param {HTMLElement} block - */ -function showEmptyCart(block) { - block.innerHTML = ''; - const empty = document.createElement('div'); - empty.className = 'checkout-empty'; - empty.innerHTML = ` -

          Your cart is empty

          -

          Add some products to your cart to continue.

          - Continue shopping - `; - block.appendChild(empty); -} - -export default async function decorate(block) { - // Check if cart has items - const { default: cartCheck } = await import('../../scripts/cart.js'); - if (cartCheck.itemCount === 0) { - showEmptyCart(block); - return; - } - - // Create the checkout layout - const checkoutContainer = document.createElement('div'); - checkoutContainer.className = 'checkout-container'; - - // Create left column (form) - const formColumn = document.createElement('div'); - formColumn.className = 'checkout-form-column'; - - // Create right column (cart summary) - const summaryColumn = document.createElement('div'); - summaryColumn.className = 'checkout-summary-column'; - - // Build the form block - const formContent = [[``]]; - const formBlock = buildBlock('form', formContent); - formColumn.appendChild(formBlock); - decorateBlock(formBlock); - - // Build the cart summary block - const summaryBlock = buildBlock('cart-summary', []); - summaryColumn.appendChild(summaryBlock); - decorateBlock(summaryBlock); - - // Add columns to container - checkoutContainer.appendChild(formColumn); - checkoutContainer.appendChild(summaryColumn); - - // Replace block content - block.replaceChildren(checkoutContainer); - - // Load both blocks - await Promise.all([ - loadBlock(formBlock), - loadBlock(summaryBlock), - ]); - - // Floating label effect: label sits inside the input and floats up on focus/fill. - // Placeholder is set to a single space so :placeholder-shown can detect empty state. - formColumn.querySelectorAll('.form-field:not(.checkbox-field):not(.radio-field)').forEach((fieldEl) => { - const label = fieldEl.querySelector('label'); - const input = fieldEl.querySelector('input:not([type="checkbox"]):not([type="radio"]), select, textarea'); - if (!label || !input) return; - const required = input.required || input.hasAttribute('required'); - // Append required indicator to label text - if (required && !label.textContent.endsWith('*')) { - label.textContent = `${label.textContent} *`; - } - // Single space placeholder enables :placeholder-shown to detect empty state - input.placeholder = ' '; - fieldEl.classList.add('floating-label-field'); - - // selects don't support :placeholder-shown — toggle a class instead - if (input.tagName === 'SELECT') { - const update = () => input.classList.toggle('has-value', input.value !== ''); - input.addEventListener('change', update); - - const ensureBlankSelected = () => { - if (input.options.length === 0) return; - // Remove any disabled placeholder text — the floating label serves as placeholder - input.querySelectorAll('option[disabled]').forEach((o) => o.remove()); - if (!input.querySelector('option[value=""]')) { - const blank = document.createElement('option'); - blank.value = ''; - blank.hidden = true; - input.prepend(blank); - } - input.selectedIndex = 0; - update(); - }; - - // Handle options already present (appendSelectOptions may have resolved before us) - ensureBlankSelected(); - - // Handle options loading asynchronously - const observer = new MutationObserver(ensureBlankSelected); - observer.observe(input, { childList: true }); - } - }); - - // The form JSON has two sections both named "shipping": - // 1. Shipping Address (address fields) - // 2. Payment Method (billingEquals checkbox + payment radios) - // Fix: identify them by heading, rename the payment one, and restructure - const allShippingSections = formColumn.querySelectorAll('fieldset.form-section.section-shipping'); - const shippingAddressSection = allShippingSections[0]; // "Shipping Address" - const paymentSection = allShippingSections[1]; // "Payment Method" (misnamed section-shipping) - - // Rename payment section to have its own class - if (paymentSection) { - paymentSection.classList.remove('section-shipping'); - paymentSection.classList.add('section-payment'); - } - - // Extract billingEqualsShipping checkbox from payment section → make it a form-level element - const sameShipBillCheckbox = formColumn.querySelector('fieldset[data-name="billingEqualsShipping"] input[type="checkbox"]'); - const billingEqualsField = sameShipBillCheckbox?.closest('.form-field'); - if (billingEqualsField) { - // Move it out of the payment section, right after shipping address - shippingAddressSection.after(billingEqualsField); - } - - // duplicate the shipping address form section to create billing address form - const billingAddressSection = shippingAddressSection.cloneNode(true); - billingAddressSection.classList.add('form-section', 'section-billing'); - billingAddressSection.querySelector('h3').textContent = 'Billing Address'; - billingAddressSection.dataset.name = 'billingAddress'; - - // add shipping- and billing- prefix to all input id, names, and labels - shippingAddressSection.querySelectorAll('input, select').forEach((input) => { - input.id = `shipping-${input.id}`; - input.name = `shipping-${input.name}`; - if (input.previousElementSibling?.tagName === 'LABEL') { - input.previousElementSibling.setAttribute('for', input.id); - } - }); - billingAddressSection.querySelectorAll('input, select').forEach((input) => { - input.id = `billing-${input.id}`; - input.name = `billing-${input.name}`; - if (input.previousElementSibling?.tagName === 'LABEL') { - input.previousElementSibling.setAttribute('for', input.id); - } - }); - - // Insert billing address after the checkbox - billingEqualsField.after(billingAddressSection); - - // hide billing address section by default - billingAddressSection.setAttribute('aria-hidden', true); - billingAddressSection.setAttribute('disabled', true); - - // show billing address section when sameShipBill is unchecked - sameShipBillCheckbox.addEventListener('change', () => { - if (sameShipBillCheckbox.checked) { - billingAddressSection.setAttribute('aria-hidden', true); - billingAddressSection.setAttribute('disabled', ''); - } else { - billingAddressSection.removeAttribute('aria-hidden'); - billingAddressSection.removeAttribute('disabled'); - } - }); - - // -- Shipping methods section -- - const shippingMethodsContainer = document.createElement('fieldset'); - shippingMethodsContainer.className = 'form-section shipping-methods'; - // insert before the payment method section wrapper - // The payment checkboxes are inside a section like fieldset.section-paymentMethod - const paymentMethodInner = formColumn.querySelector('fieldset[data-name="paymentMethod"]'); - const paymentMethodSection = paymentMethodInner?.closest('fieldset.form-section') || paymentMethodInner; - if (paymentMethodSection) { - paymentMethodSection.before(shippingMethodsContainer); - } else { - billingAddressSection.after(shippingMethodsContainer); - } - - // Listen for shipping method rate changes (registered once, not inside fetchAndPreview) - shippingMethodsContainer.addEventListener('change', async (e) => { - if (e.target.name === 'shippingMethod') { - selectedShippingMethodId = e.target.value; - const { default: cart } = await import('../../scripts/cart.js'); - const currentFormData = Object.fromEntries(new FormData(formColumn.querySelector('form')).entries()); - await updatePreview(formColumn.querySelector('form'), currentFormData, cart); - } - }); - - // Reorder DOM to match visual layout so tab order is correct - // The form JSON has fields in a different order than the CSS grid displays them - const fieldOrder = ['firstname', 'lastname', 'company', 'street-0', 'street-1', 'city', 'state', 'zip', 'telephone']; - [shippingAddressSection, billingAddressSection].forEach((section) => { - const h3 = section.querySelector('h3'); - fieldOrder.forEach((name) => { - const field = section.querySelector(`.form-field[data-name="${name}"]`); - if (field) section.appendChild(field); - }); - // Keep h3 heading first - if (h3) section.prepend(h3); - }); - - // Invalidate estimates when address fields that affect tax/shipping change. - // Name and telephone don't affect the estimate, so changes to those fields - // don't need to clear the preview — avoiding a race where the user fills in - // phone last and then immediately clicks Apple Pay. - const form = formColumn.querySelector('form'); - const ESTIMATE_AFFECTING_FIELDS = new Set([ - 'shipping-street-0', 'shipping-street-1', 'shipping-city', - 'shipping-state', 'shipping-zip', - ]); - const shippingInputs = shippingAddressSection.querySelectorAll('input, select'); - shippingInputs.forEach((input) => { - if (!ESTIMATE_AFFECTING_FIELDS.has(input.name)) return; - input.addEventListener('change', () => { - currentEstimateToken = null; - currentPreview = null; - }); - }); + getSummary: () => form.querySelector('[name="email"]')?.value || '', + }, strings); + + // Section collapse — shipping address + const shippingAddrSection = form.querySelector('.shipping-address-section'); + initCollapse(shippingAddrSection, { + getIsValid: () => [...shippingAddrSection.querySelectorAll('[required]')].every((el) => el.checkValidity()), + getSummary: () => { + const data = new FormData(form); + const name = `${data.get('shipping-firstname') || ''} ${data.get('shipping-lastname') || ''}`.trim(); + const city = data.get('shipping-city') || ''; + const stateCode = data.get('shipping-state') || ''; + const location = [city, stateCode].filter(Boolean).join(', '); + return [name, location].filter(Boolean).join(' · '); + }, + }, strings); - // Override state dropdowns with Canadian provinces for CA locale - const stateSelect = form.querySelector('select#shipping-state'); - if (getCountry() === 'ca') { - [stateSelect, form.querySelector('select#billing-state')].forEach((sel) => { - if (!sel) return; - const provinces = [ - ['', 'Select province...'], - ['AB', 'Alberta'], ['BC', 'British Columbia'], ['MB', 'Manitoba'], - ['NB', 'New Brunswick'], ['NL', 'Newfoundland and Labrador'], - ['NS', 'Nova Scotia'], ['NT', 'Northwest Territories'], ['NU', 'Nunavut'], - ['ON', 'Ontario'], ['PE', 'Prince Edward Island'], ['QC', 'Quebec'], - ['SK', 'Saskatchewan'], ['YT', 'Yukon'], - ]; - sel.innerHTML = ''; - sel.dataset.optionsOverridden = 'true'; - provinces.forEach(([value, label]) => { - const opt = document.createElement('option'); - opt.value = value; - opt.textContent = label; - if (!value) opt.disabled = true; - sel.appendChild(opt); - }); - const lbl = sel.previousElementSibling; - if (lbl?.tagName === 'LABEL') lbl.textContent = 'Province'; - }); - } + // Wire shipping rate fetching and preview + const shippingContainer = form.querySelector('.shipping-methods'); + initShipping(form, shippingContainer, cart, state, config, strings); - // Fetch shipping rates when state is selected - if (stateSelect) { - stateSelect.addEventListener('change', () => { - const formData = Object.fromEntries(new FormData(form).entries()); - fetchAndPreview(form, formData, shippingMethodsContainer); - }); - } - - // Retry preview when customer fields are filled, clear error styling + // Retry preview when customer identity fields are filled after shipping is already selected. + // This handles the case where state/province is chosen before email or name is entered, + // causing the first updatePreview call to bail out on the missing-fields guard. ['email', 'shipping-firstname', 'shipping-lastname'].forEach((name) => { const input = form.querySelector(`[name="${name}"]`); - if (input) { - input.addEventListener('input', () => { - if (input.value) input.classList.remove('field-required'); - }); - input.addEventListener('blur', async () => { - if (input.value) input.classList.remove('field-required'); - if (selectedShippingMethodId && !currentPreview) { - const { default: cart } = await import('../../scripts/cart.js'); - const formData = Object.fromEntries(new FormData(form).entries()); - await updatePreview(form, formData, cart); - } - }); - } - }); - - // Re-preview when cart items change (quantity update or item removed) - document.addEventListener('cart:change', async (e) => { - if (['update', 'remove'].includes(e.detail?.action)) { - // Show empty cart if all items removed - if (e.detail.cart.itemCount === 0) { - showEmptyCart(block); - return; + if (!input) return; + input.addEventListener('blur', () => { + if (input.value && state.selectedShippingMethodId && !state.currentPreview) { + updatePreview(form, cart, state, config); } - currentEstimateToken = null; - currentPreview = null; - if (selectedShippingMethodId) { - const formData = Object.fromEntries(new FormData(form).entries()); - fetchAndPreview(form, formData, shippingMethodsContainer); - } - } + }); }); - // -- Pay buttons -- - // If Affirm is offered as a payment option but no Affirm submit button exists in the form - // document, inject one programmatically so it goes through the standard button-wrapping flow. - const affirmPaymentOption = formColumn.querySelector('fieldset[data-name="paymentMethod"] input[value="Affirm"]'); - const existingButtons = [...formColumn.querySelectorAll('form .button-wrapper button[type="submit"]')]; - const hasAffirmButton = existingButtons.some((b) => b.textContent.toLowerCase().includes('affirm')); - if (affirmPaymentOption && !hasAffirmButton) { - const buttonWrapper = formColumn.querySelector('form .button-wrapper'); - if (buttonWrapper) { - const affirmBtn = document.createElement('button'); - affirmBtn.type = 'submit'; - affirmBtn.textContent = 'Pay with Affirm'; - affirmBtn.classList.add('button'); - buttonWrapper.appendChild(affirmBtn); - } - } - - // reCAPTCHA Enterprise attribution. The badge itself is hidden globally - // in styles.css; this notice satisfies Google's Terms of Service - // requirement to disclose reCAPTCHA usage. - const noticeAnchor = formColumn.querySelector('form .button-wrapper'); - if (noticeAnchor && !formColumn.querySelector('.recaptcha-notice')) { - const notice = document.createElement('p'); - notice.className = 'recaptcha-notice'; - notice.append(document.createTextNode('This site is protected by reCAPTCHA and the Google ')); - const privacyLink = document.createElement('a'); - privacyLink.href = 'https://policies.google.com/privacy'; - privacyLink.target = '_blank'; - privacyLink.rel = 'noopener noreferrer'; - privacyLink.textContent = 'Privacy Policy'; - notice.append(privacyLink, document.createTextNode(' and ')); - const termsLink = document.createElement('a'); - termsLink.href = 'https://policies.google.com/terms'; - termsLink.target = '_blank'; - termsLink.rel = 'noopener noreferrer'; - termsLink.textContent = 'Terms of Service'; - notice.append(termsLink, document.createTextNode(' apply.')); - noticeAnchor.insertAdjacentElement('afterend', notice); + // Wire Chase submit and get shared callbacks for providers + const callbacks = initOrder(form, cart, state, config, strings); + + // Apple Pay must be initiated synchronously from the submit button's trusted click gesture + callbacks.beginApplePay = () => beginCheckoutSession(config, callbacks); + + // Register providers, check availability, render buttons + const paymentContainer = form.querySelector('.payment-method-section'); + await initPayment(paymentContainer, ALL_PROVIDERS, callbacks, config, strings); + + // Inject order-summary block into this section if the author didn't add one + const section = block.closest('.section'); + if (section && !section.querySelector('.order-summary-wrapper')) { + const wrapper = document.createElement('div'); + wrapper.className = 'order-summary-wrapper'; + const orderSummaryBlock = document.createElement('div'); + orderSummaryBlock.className = 'order-summary block'; + orderSummaryBlock.dataset.blockName = 'order-summary'; + wrapper.appendChild(orderSummaryBlock); + section.appendChild(wrapper); + loadBlock(orderSummaryBlock); } - const submitButtons = [...formColumn.querySelectorAll('form .button-wrapper button[type="submit"]')]; - submitButtons.forEach((button) => { - let paymentMethod = 'Credit Card'; - if (button.textContent.toLowerCase().includes('paypal')) { - paymentMethod = 'PayPal'; - button.textContent = button.textContent.replace('PayPal', ''); - const icon = document.createElement('img'); - icon.src = `${window.hlx.codeBasePath}/icons/paypal.svg`; - icon.classList.add('icon', 'icon-paypal'); - button.appendChild(icon); - } else if (button.textContent.toLowerCase().includes('affirm')) { - paymentMethod = 'Affirm'; - } else if (button.textContent.toLowerCase().includes('apple')) { - paymentMethod = 'Apple Pay'; - } - - const span = document.createElement('span'); - span.classList.add('payment-button'); - span.dataset.paymentMethod = paymentMethod; - button.replaceWith(span); - - if (paymentMethod === 'PayPal') { - span.classList.add('paypal'); - span.appendChild(button); - button.addEventListener('click', async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - - if (!selectedShippingMethodId) { - showError(formColumn, 'Please select a shipping method.'); - return; - } - - const isValid = form.checkValidity(); - if (!isValid) { - form.reportValidity(); - return; - } - - clearError(formColumn); - button.disabled = true; - - const reenableButton = () => { - button.disabled = false; - }; - - try { - const formData = Object.fromEntries(new FormData(form).entries()); - const { - email, - 'shipping-firstname': firstName, - 'shipping-lastname': lastName, - 'shipping-telephone': phone, - } = formData; - - const shipping = collectAddress(form, formData, 'shipping-', email); - const { default: cart } = await import('../../scripts/cart.js'); - - if (!currentEstimateToken) { - await updatePreview(form, formData, cart); - } - - if (!currentEstimateToken || !currentPreview) { - showError(formColumn, 'Unable to calculate shipping and taxes. Please try again.'); - reenableButton(); - return; - } - - const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { - shippingMethod: selectedShippingMethodId, - estimateToken: currentEstimateToken, - locale: getLocale(), - country: getCountry(), - }); - - sessionStorage.setItem('checkout_email', email); - sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); - if (currentPreview) { - sessionStorage.setItem('checkout_preview', JSON.stringify(currentPreview)); - } - - const { order: createdOrder } = await createOrder(order); - sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); - - const idempotencyKey = crypto.randomUUID(); - const fraudToken = sessionStorage.getItem('forter_token') || undefined; - const payment = await initiatePayment(createdOrder.id, idempotencyKey, fraudToken, 'paypal', 'paypal'); - if (payment.action === 'redirect' && payment.redirectUrl) { - window.location.href = payment.redirectUrl; - } else { - showError(formColumn, 'Unexpected payment response. Please try again.'); - reenableButton(); - } - } catch (error) { - const message = error.body?.message || error.message || 'Something went wrong. Please try again.'; - showError(formColumn, message); - reenableButton(); - } - }); - } else if (paymentMethod === 'Affirm') { - span.classList.add('affirm'); - span.appendChild(button); - button.addEventListener('click', async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - - if (!selectedShippingMethodId) { - showError(formColumn, 'Please select a shipping method.'); - return; - } - - const isValid = form.checkValidity(); - if (!isValid) { - form.reportValidity(); - return; - } - - clearError(formColumn); - button.disabled = true; - - const reenableButton = () => { - button.disabled = false; - }; - - try { - const formData = Object.fromEntries(new FormData(form).entries()); - const { - email, - 'shipping-firstname': firstName, - 'shipping-lastname': lastName, - 'shipping-telephone': phone, - } = formData; - - const shipping = collectAddress(form, formData, 'shipping-', email); - const { default: cart } = await import('../../scripts/cart.js'); - - if (!currentEstimateToken) { - await updatePreview(form, formData, cart); - } - - if (!currentEstimateToken || !currentPreview) { - showError(formColumn, 'Unable to calculate shipping and taxes. Please try again.'); - reenableButton(); - return; - } - - const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { - shippingMethod: selectedShippingMethodId, - estimateToken: currentEstimateToken, - locale: getLocale(), - country: getCountry(), - }); - - sessionStorage.setItem('checkout_email', email); - sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); - if (currentPreview) { - sessionStorage.setItem('checkout_preview', JSON.stringify(currentPreview)); - } - - const { order: createdOrder } = await createOrder(order); - sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); - - const idempotencyKey = crypto.randomUUID(); - const payment = await initiatePayment(createdOrder.id, idempotencyKey, undefined, 'affirm', 'bnpl'); - - if (payment.checkoutObject) { - // Load the Affirm JS SDK with the Global integration config, then - // pass the server-built checkout object to affirm.checkout() and open. - // On completion Affirm redirects the browser to our server's - // confirmation URL which handles authorization and redirects to the - // order-summary page. - const country = getCountry(); - const affirmCountryCode = country === 'ca' ? 'CAN' : 'USA'; - const { language } = getLocaleAndLanguage(true); - // Affirm expects locale as en_US, en_CA, or fr_CA (uppercase country suffix). - const [lang, region] = language.split('_'); - const affirmLocale = region ? `${lang}_${region.toUpperCase()}` : language; - // Use the public key and JS SDK URL from the server response so they - // always match the environment and region of the merchant config. - // eslint-disable-next-line camelcase, no-underscore-dangle - window._affirm_config = { - public_api_key: payment.checkoutObject.merchant.public_api_key, - script: payment.affirmJsUrl, - locale: affirmLocale, - country_code: affirmCountryCode, - }; - // Bootstrap the Affirm JS SDK — this IIFE creates the global - // affirm object with stub methods, then loads the script from - // _affirm_config.script. Required before calling affirm.checkout(). - /* eslint-disable */ - (function(m,g,n,d,a,e,h,c){var b=m[n]||{},k=document.createElement(e),p=document.getElementsByTagName(e)[0],l=function(a,b,c){return function(){a[b]._.push([c,arguments])}};b[d]=l(b,d,"set");var f=b[d];b[a]={};b[a]._=[];f._=[];b._=[];b[a][h]=l(b,a,h);b[c]=function(){b._.push([h,arguments])};a=0;for(c="set add save post open empty reset on off trigger ready setProduct".split(" ");a { - affirm.ui.error.on('close', () => { - reenableButton(); - }); - affirm.checkout(payment.checkoutObject); - const openOpts = payment.checkoutMode === 'modal' ? { - onSuccess: ({ checkout_token: checkoutToken }) => { - const confirmUrl = payment.checkoutObject.merchant.user_confirmation_url; - window.location.href = `${confirmUrl}&checkout_token=${encodeURIComponent(checkoutToken)}`; - }, - onFail: () => { - reenableButton(); - }, - } : {}; - affirm.checkout.open(openOpts); - }); - /* eslint-enable no-undef */ - } else { - showError(formColumn, 'Unexpected payment response. Please try again.'); - reenableButton(); - } - } catch (error) { - const message = error.body?.error === 'ORDER_TOTAL_OUT_OF_RANGE' - ? 'Affirm is not available for this order total.' - : error.body?.message || error.message || 'Something went wrong. Please try again.'; - showError(formColumn, message); - reenableButton(); - } - }); - } else if (paymentMethod === 'Apple Pay') { - span.classList.add('apple-pay'); - loadScript('https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'); - span.innerHTML = ``; - - // Listen on the span wrapper, not the custom element. - // The custom element renders as zero-height on non-Safari browsers, so - // listening on the host span is more reliable for event propagation. - span.addEventListener('click', () => { - // The Apple Pay JS SDK (1.latest / v1.2.0+) polyfills window.ApplePaySession on - // Chrome and other non-Safari browsers, enabling the "Scan with iPhone" QR code - // flow (requires iOS 18+ on the user's iPhone). If the SDK hasn't loaded yet, - // ApplePaySession will be undefined and we show a retry prompt. - if (!window.ApplePaySession) { - showError(formColumn, 'Apple Pay is still loading — please try again in a moment.'); - return; - } - if (!selectedShippingMethodId) { - showError(formColumn, 'Please select a shipping method.'); - return; - } - if (!form.checkValidity()) { - form.reportValidity(); - return; - } - if (!currentEstimateToken || !currentPreview) { - // Clicking Apple Pay blurs the previously-focused field, which fires - // updatePreview asynchronously — but we're already in the click handler - // before that call completes (race condition). If the form looks complete, - // kick off a fresh estimate and ask the user to tap again once it's ready. - const fd = Object.fromEntries(new FormData(form).entries()); - const canEstimate = selectedShippingMethodId - && fd.email && fd['shipping-firstname'] && fd['shipping-lastname']; - if (canEstimate) { - import('../../scripts/cart.js').then(({ default: cart }) => updatePreview(form, fd, cart)); - showError(formColumn, 'Calculating shipping and taxes — please try again in a moment.'); - } else { - showError(formColumn, 'Please complete your shipping address and select a shipping method.'); - } - return; - } - - clearError(formColumn); - - const country = getCountry(); - const currencyCode = country === 'ca' ? 'CAD' : 'USD'; - // Stable key for this session — reused on `onpaymentauthorized` retry - const idempotencyKey = crypto.randomUUID(); - // Snapshot form state before async session callbacks run - const formData = Object.fromEntries(new FormData(form).entries()); - - let session; - try { - session = new window.ApplePaySession(3, { - countryCode: country.toUpperCase(), - currencyCode, - supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], - merchantCapabilities: ['supports3DS'], - total: { - label: 'Vitamix', - amount: parseFloat(currentPreview.total ?? 0).toFixed(2), - }, - }); - } catch (err) { - showError(formColumn, 'Apple Pay is not available. Please try a different payment method.'); - return; - } - - session.onvalidatemerchant = async (event) => { - try { - const { merchantSession } = await validateApplePayMerchant( - event.validationURL, - country, - getLocale(), - ); - session.completeMerchantValidation(merchantSession); - } catch { - session.abort(); - showError(formColumn, 'Apple Pay merchant validation failed. Please try again.'); - } - }; - - session.onpaymentauthorized = async (event) => { - try { - const { token, billingContact } = event.payment; - const { - email, - 'shipping-firstname': firstName, - 'shipping-lastname': lastName, - 'shipping-telephone': phone, - } = formData; - const shipping = collectAddress(form, formData, 'shipping-', email); - - const { default: cart } = await import('../../scripts/cart.js'); - - const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { - shippingMethod: selectedShippingMethodId, - estimateToken: currentEstimateToken, - locale: getLocale(), - country, - }); - - sessionStorage.setItem('checkout_email', email); - sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); - sessionStorage.setItem('checkout_preview', JSON.stringify(currentPreview)); - - const { order: createdOrder } = await createOrder(order); - sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); - - const fraudToken = sessionStorage.getItem('forter_token') || undefined; - const payment = await initiatePayment( - createdOrder.id, - idempotencyKey, - fraudToken, - 'chase-wallet', - 'apple-pay', - { token, billingContact }, - ); - - if (payment.status === 'completed') { - session.completePayment({ status: window.ApplePaySession.STATUS_SUCCESS }); - cart.clear(); - window.location.href = `${getOrderPath('complete')}?orderId=${createdOrder.id}`; - } else { - session.completePayment({ status: window.ApplePaySession.STATUS_FAILURE }); - const declineMessages = { - payment_declined: 'Your payment was declined. Please try a different card.', - fraud_declined: 'We were unable to process your payment. Please try again or contact support.', - token_already_used: 'This payment session has already been used. Please try again.', - provider_error: 'A payment error occurred. Please try again.', - }; - showError(formColumn, declineMessages[payment.reason] || 'Your payment could not be processed. Please try again.'); - } - } catch (err) { - session.completePayment({ status: window.ApplePaySession.STATUS_FAILURE }); - const message = err.body?.message || err.message || 'Something went wrong. Please try again.'; - showError(formColumn, message); - } - }; - - session.oncancel = () => {}; - - try { - session.begin(); - } catch (err) { - showError(formColumn, 'Unable to start Apple Pay. Please try a different payment method.'); - } - }); - } else { - span.classList.add('credit-card'); - span.appendChild(button); - button.addEventListener('click', async (ev) => { - ev.preventDefault(); - ev.stopPropagation(); - - // validate form - const isValid = form.checkValidity(); - if (!isValid) { - form.reportValidity(); - return; - } - - // must have a shipping method selected - if (!selectedShippingMethodId) { - showError(formColumn, 'Please select a shipping method.'); - return; - } - - clearError(formColumn); - - button.classList.add('loading'); - button.textContent = 'Processing...'; - button.disabled = true; - - const reenableButton = () => { - button.classList.remove('loading'); - button.textContent = 'Pay Now'; - button.disabled = false; - }; - - try { - const formData = Object.fromEntries(new FormData(form).entries()); - const { - email, - 'shipping-firstname': firstName, - 'shipping-lastname': lastName, - 'shipping-telephone': phone, - } = formData; - - const shipping = collectAddress(form, formData, 'shipping-', email); - const country = getCountry(); - - // collect billing address if different from shipping - let billingAddr; - if (!sameShipBillCheckbox.checked) { - billingAddr = collectAddress(form, formData, 'billing-', email); - } - - const { default: cart } = await import('../../scripts/cart.js'); - - // if we don't have a fresh preview, try to get one now - if (!currentEstimateToken) { - await updatePreview(form, formData, cart); - } - - // block checkout if preview/estimates failed - if (!currentEstimateToken || !currentPreview) { - showError(formColumn, 'Unable to calculate shipping and taxes. Please try again.'); - reenableButton(); - return; - } - const order = cart.getOrderJSON(email, firstName, lastName, phone, shipping, { - billingAddr, - shippingMethod: selectedShippingMethodId, - estimateToken: currentEstimateToken, - locale: getLocale(), - country, - }); - - // save checkout context for confirmation page - sessionStorage.setItem('checkout_email', email); - sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); - if (currentPreview) { - sessionStorage.setItem('checkout_preview', JSON.stringify(currentPreview)); - } - - // create order - const { order: createdOrder } = await createOrder(order); - sessionStorage.setItem('checkout_order', JSON.stringify(createdOrder)); - - // initiate payment — include Forter fraud token if available - const idempotencyKey = crypto.randomUUID(); - const fraudToken = sessionStorage.getItem('forter_token') || undefined; - const payment = await initiatePayment(createdOrder.id, idempotencyKey, fraudToken); - if (payment.action === 'redirect' && payment.redirectUrl) { - // redirect to Chase hosted payment page - window.location.href = payment.redirectUrl; - } else { - showError(formColumn, 'Unexpected payment response. Please try again.'); - reenableButton(); - } - } catch (error) { - const message = error.body?.message || error.message || 'Something went wrong. Please try again.'; - showError(formColumn, message); - reenableButton(); - } - }); - } - - // default to credit card - if (paymentMethod !== 'Credit Card') { - span.setAttribute('aria-hidden', true); - } - }); - - // handle payment method change - const paymentMethodGroup = formColumn.querySelector('fieldset[data-name="paymentMethod"]'); - const payButtons = [...formColumn.querySelectorAll('form .button-wrapper span.payment-button')]; - paymentMethodGroup.addEventListener('change', async () => { - const paymentMethod = paymentMethodGroup.querySelector('input:checked').value; - - // PayPal and Affirm collect billing address on their hosted page — hide the section entirely - const hidesBilling = paymentMethod === 'PayPal' || paymentMethod === 'Affirm'; - if (hidesBilling || sameShipBillCheckbox.checked) { - billingAddressSection.setAttribute('aria-hidden', true); - billingAddressSection.setAttribute('disabled', ''); - } else { - billingAddressSection.removeAttribute('aria-hidden'); - billingAddressSection.removeAttribute('disabled'); - } - - payButtons.forEach((btn) => { - if (paymentMethod !== btn.dataset.paymentMethod) { - btn.setAttribute('aria-hidden', true); - } else { - btn.removeAttribute('aria-hidden'); - if (paymentMethod === 'Apple Pay') { - const applePayButton = btn.querySelector('apple-pay-button'); - applePayButton.removeAttribute('hidden'); - applePayButton.removeAttribute('aria-hidden'); - } + // Restore page from bfcache after payment provider redirect + window.addEventListener('pageshow', (e) => { + if (e.persisted) { + const submitBtn = form.querySelector('.checkout-submit-btn'); + if (submitBtn) { + submitBtn.disabled = false; + const submitTextEl = submitBtn.querySelector('.submit-btn-text'); + if (submitTextEl) submitTextEl.textContent = strings.continueToPayment; } - }); - - // Refresh the estimate when the payment method changes so the token reflects - // the selected provider (required once payment method is included in the hash). - if (selectedShippingMethodId) { - const { default: cart } = await import('../../scripts/cart.js'); - const currentFormData = Object.fromEntries(new FormData(form).entries()); - await updatePreview(form, currentFormData, cart); - } - - clearError(formColumn); - }); - - // When the browser restores this page from bfcache (e.g. the user hits Back - // after a completed payment), re-check the cart. If it's now empty the order - // was already placed, so replace the form with the empty-cart state rather - // than letting the user submit a duplicate order. - window.addEventListener('pageshow', async (e) => { - if (!e.persisted) return; - const { default: restoredCart } = await import('../../scripts/cart.js'); - if (restoredCart.itemCount === 0) { - showEmptyCart(block); } }); } diff --git a/blocks/feature-cards/feature-cards.css b/blocks/feature-cards/feature-cards.css new file mode 100644 index 00000000..83caff52 --- /dev/null +++ b/blocks/feature-cards/feature-cards.css @@ -0,0 +1,80 @@ +main > .section > .feature-cards-wrapper { + padding: 0; +} + +.feature-cards { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + width: 100%; +} + +.feature-card { + background: #fff; + border: 1px solid var(--commerce-color-border, #e5e7eb); + border-radius: 12px; + padding: 20px; +} + +.feature-card-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 14px; +} + +.feature-card-icon { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.feature-card-icon img, +.feature-card-icon svg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.feature-card-title { + font-weight: 600; + font-size: 15px; + line-height: 1.3; +} + +.feature-card-body { + font-size: var(--commerce-font-size-sm, 14px); + line-height: 1.6; + color: var(--commerce-color-text, #111); +} + +.feature-card-body p { + margin: 0 0 8px; +} + +.feature-card-body p:last-child { + margin-bottom: 0; +} + +/* Reset any global .button styling on links inside card bodies */ +.feature-card-body a.button { + all: unset; + color: inherit; + text-decoration: underline; + cursor: pointer; +} + +@media (width <= 999px) { + .feature-cards { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width <= 599px) { + .feature-cards { + grid-template-columns: 1fr; + } +} diff --git a/blocks/feature-cards/feature-cards.js b/blocks/feature-cards/feature-cards.js new file mode 100644 index 00000000..6c8f4dc9 --- /dev/null +++ b/blocks/feature-cards/feature-cards.js @@ -0,0 +1,33 @@ +export default function decorate(block) { + const cards = [...block.querySelectorAll(':scope > div')].map((row) => { + const [titleCell, bodyCell] = row.querySelectorAll(':scope > div'); + const card = document.createElement('div'); + card.className = 'feature-card'; + + const header = document.createElement('div'); + header.className = 'feature-card-header'; + + const icon = titleCell.querySelector('img, svg, picture'); + if (icon) { + const iconWrap = document.createElement('span'); + iconWrap.className = 'feature-card-icon'; + iconWrap.appendChild(icon.closest('picture') || icon); + header.appendChild(iconWrap); + } + + const title = document.createElement('span'); + title.className = 'feature-card-title'; + title.innerHTML = titleCell.textContent.trim(); + header.appendChild(title); + + const body = document.createElement('div'); + body.className = 'feature-card-body'; + body.innerHTML = bodyCell.innerHTML; + + card.append(header, body); + return card; + }); + + block.innerHTML = ''; + block.append(...cards); +} diff --git a/blocks/header/header.js b/blocks/header/header.js index e1854f57..c5c42f77 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -461,8 +461,8 @@ export default async function decorate(block) { if (hasToken) updateAuthUI(true); } catch { /* ignore */ } - // --- Auth panel (edge cart mode only) --- - if (window.cartMode === 'edge') { + // --- Auth panel (edge checkout mode only) --- + if (window.useEdgeCheckout) { let authPanel = null; const ensureAuthPanel = async () => { if (authPanel) return authPanel; @@ -519,7 +519,7 @@ export default async function decorate(block) { }); // change to edge cart link - if (window.cartMode === 'edge') { + if (window.useEdgeCheckout) { cartLink.href = EDGE_CART_PATH(); } @@ -529,8 +529,8 @@ export default async function decorate(block) { return; } - // if on mobile or using legacy cart, redirect to cart page - if (window.cartMode === 'legacy' || window.innerWidth < 900) { + // if on mobile or not using edge checkout, redirect to cart page + if (!window.useEdgeCheckout || window.innerWidth < 900) { return; } @@ -542,9 +542,9 @@ export default async function decorate(block) { let minicart = document.querySelector('#minicart'); const scrollToItem = () => { + if (!e.detail?.item?.sku) return; setTimeout(() => { - const addedItem = minicart.querySelector(`.cart-item-${e.detail.item.sku}`); - addedItem.scrollIntoView({ behavior: 'smooth' }); + minicart.querySelector(`.cart-item-${e.detail.item.sku}`)?.scrollIntoView({ behavior: 'smooth' }); }, 300); }; diff --git a/blocks/order-complete/order-complete.css b/blocks/order-complete/order-complete.css new file mode 100644 index 00000000..31fdb314 --- /dev/null +++ b/blocks/order-complete/order-complete.css @@ -0,0 +1,172 @@ +/* Order confirmation page */ +.order-result { + max-width: 900px; + margin: 0 auto; + padding: 32px 24px 64px; +} + +/* Header */ +.order-header { + text-align: center; + padding-bottom: 32px; + border-bottom: 1px solid #e0e0e0; + margin-bottom: 32px; +} + +.order-checkmark { + margin-bottom: 16px; +} + +.order-header h2 { + margin: 0 0 8px; + font-size: 28px; +} + +.order-id { + margin: 0 0 4px; + font-size: 14px; + color: #555; +} + +.order-email { + margin: 0; + font-size: 14px; + color: #555; +} + +/* Details grid: items+totals left, address right */ +.order-details { + display: grid; + grid-template-columns: 1fr; + gap: 32px; +} + +@media (width >= 768px) { + .order-details { + grid-template-columns: 3fr 2fr; + } +} + +.order-details h3 { + font-size: 16px; + margin: 0 0 16px; + padding-bottom: 8px; + border-bottom: 1px solid #e0e0e0; +} + +/* Items */ +.order-items { + margin-bottom: 24px; +} + +.order-item { + display: grid; + grid-template-columns: 64px 1fr auto; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid #f0f0f0; + align-items: start; +} + +.order-item:last-child { + border-bottom: none; +} + +.order-item-image { + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + background: #f9f9f9; + border-radius: 4px; +} + +.order-item-image img { + max-width: 56px; + max-height: 56px; + object-fit: contain; +} + +.order-item-name { + margin: 0; + font-weight: 600; + font-size: 14px; + line-height: 1.4; +} + +.order-item-variant { + margin: 2px 0 0; + font-size: 13px; + color: #555; +} + +.order-item-qty { + margin: 2px 0 0; + font-size: 13px; + color: #555; +} + +.order-item-price { + font-weight: 600; + font-size: 14px; + text-align: right; + white-space: nowrap; +} + +/* Totals */ +.order-totals { + padding-top: 12px; + border-top: 1px solid #e0e0e0; +} + +.order-totals-row { + display: flex; + justify-content: space-between; + padding: 4px 0; + font-size: 14px; +} + +.order-totals-total { + padding-top: 12px; + margin-top: 8px; + border-top: 1px solid #e0e0e0; + font-size: 16px; +} + +/* Shipping address & contact */ +.order-shipping-address, +.order-contact { + margin-bottom: 24px; +} + +.order-shipping-address p, +.order-contact p { + margin: 0; + font-size: 14px; + line-height: 1.6; + color: #333; +} + +/* Actions */ +.order-actions { + text-align: center; + margin-top: 40px; + padding-top: 32px; + border-top: 1px solid #e0e0e0; +} + +/* Cancelled state */ +.order-cancelled { + text-align: center; + padding: 80px 24px; +} + +.order-cancelled h2 { + margin: 0 0 8px; +} + +.order-cancelled p { + margin: 0 0 24px; + color: #555; +} diff --git a/blocks/order-complete/order-complete.js b/blocks/order-complete/order-complete.js new file mode 100644 index 00000000..3cd4888e --- /dev/null +++ b/blocks/order-complete/order-complete.js @@ -0,0 +1,285 @@ +import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; + +/** + * Order confirmation / cancellation page. + * + * Success: Chase / PayPal → API → redirect here with ?orderId=... + * Cancel: Chase / PayPal → API → redirect here with ?orderId=...&reason=... + * reason values: customer_cancelled | payment_failed | declined + * + * Order display data comes from sessionStorage (saved before the payment redirect). + */ +export default async function decorate(block) { + const config = getConfig(); + const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + const params = Object.fromEntries(new URLSearchParams(window.location.search).entries()); + + // cancelled or failed payment + if (params.reason) { + const container = document.createElement('div'); + container.className = 'order-result order-cancelled'; + + const heading = document.createElement('h2'); + heading.textContent = 'Payment not completed'; + container.appendChild(heading); + + const msg = document.createElement('p'); + msg.textContent = params.reason === 'customer_cancelled' + ? 'You cancelled the payment.' + : 'Payment could not be processed. Please try again.'; + container.appendChild(msg); + + const link = document.createElement('p'); + const returnLink = document.createElement('a'); + returnLink.href = getConfig().getOrderPath('checkout'); + returnLink.className = 'button emphasis'; + returnLink.textContent = 'Return to checkout'; + link.appendChild(returnLink); + container.appendChild(link); + + block.replaceChildren(container); + return; + } + + // success flow — read from sessionStorage + const orderId = params.orderId || params.id; + const email = params.email || sessionStorage.getItem('checkout_email'); + const orderData = sessionStorage.getItem('checkout_order'); + const previewData = sessionStorage.getItem('checkout_preview'); + const cartItemsData = sessionStorage.getItem('checkout_cart_items'); + + if (!orderId) { + window.location.href = '/'; + return; + } + + let order; + let preview; + let cartItems; + try { + order = orderData ? JSON.parse(orderData) : null; + preview = previewData ? JSON.parse(previewData) : null; + cartItems = cartItemsData ? JSON.parse(cartItemsData) : null; + } catch { + order = null; + preview = null; + cartItems = null; + } + + // clear checkout session data + sessionStorage.removeItem('checkout_email'); + sessionStorage.removeItem('checkout_order'); + sessionStorage.removeItem('checkout_preview'); + sessionStorage.removeItem('checkout_cart_items'); + + // clear the cart + try { + const { default: cart } = await import('../../scripts/cart.js'); + cart.clear(); + } catch { + // cart may not be available + } + + // build confirmation page + const container = document.createElement('div'); + container.className = 'order-result order-confirmed'; + + // header section + const headerSection = document.createElement('div'); + headerSection.className = 'order-header'; + + const checkmark = document.createElement('div'); + checkmark.className = 'order-checkmark'; + checkmark.innerHTML = ''; + headerSection.appendChild(checkmark); + + const heading = document.createElement('h2'); + heading.textContent = 'Thank you for your order!'; + headerSection.appendChild(heading); + + const orderIdEl = document.createElement('p'); + orderIdEl.className = 'order-id'; + const orderIdLabel = document.createElement('span'); + orderIdLabel.textContent = 'Order ID: '; + const orderIdValue = document.createElement('strong'); + orderIdValue.textContent = orderId; + orderIdEl.append(orderIdLabel, orderIdValue); + headerSection.appendChild(orderIdEl); + + if (email) { + const emailEl = document.createElement('p'); + emailEl.className = 'order-email'; + emailEl.textContent = `A confirmation will be sent to ${email}.`; + headerSection.appendChild(emailEl); + } + + container.appendChild(headerSection); + + // two-column layout: items + totals on left, shipping on right + const detailsGrid = document.createElement('div'); + detailsGrid.className = 'order-details'; + + // left column: items + totals + const leftCol = document.createElement('div'); + leftCol.className = 'order-details-left'; + + // items + const displayItems = cartItems || order?.items; + if (displayItems?.length) { + const itemsSection = document.createElement('div'); + itemsSection.className = 'order-items'; + + const itemsHeading = document.createElement('h3'); + itemsHeading.textContent = 'Items ordered'; + itemsSection.appendChild(itemsHeading); + + displayItems.forEach((item) => { + const itemEl = document.createElement('div'); + itemEl.className = 'order-item'; + + if (item.image) { + const imgWrapper = document.createElement('div'); + imgWrapper.className = 'order-item-image'; + const img = document.createElement('img'); + img.src = item.image; + img.alt = item.name || ''; + imgWrapper.appendChild(img); + itemEl.appendChild(imgWrapper); + } + + const details = document.createElement('div'); + details.className = 'order-item-details'; + + const name = document.createElement('p'); + name.className = 'order-item-name'; + name.textContent = item.name || item.sku; + details.appendChild(name); + + if (item.variant) { + const variant = document.createElement('p'); + variant.className = 'order-item-variant'; + variant.textContent = item.variant; + details.appendChild(variant); + } + + const qty = document.createElement('p'); + qty.className = 'order-item-qty'; + qty.textContent = `Qty: ${item.quantity}`; + details.appendChild(qty); + + itemEl.appendChild(details); + + const price = document.createElement('div'); + price.className = 'order-item-price'; + const unitPrice = parseFloat(item.price?.final || item.price) || 0; + price.textContent = formatPrice(unitPrice * item.quantity, currencyCode); + itemEl.appendChild(price); + + itemsSection.appendChild(itemEl); + }); + leftCol.appendChild(itemsSection); + } + + // totals + if (preview) { + const totalsSection = document.createElement('div'); + totalsSection.className = 'order-totals'; + + const rows = [ + ['Subtotal', formatPrice(parseFloat(preview.subtotal), currencyCode)], + ['Shipping', preview.shippingMethod?.rate === 0 ? 'Free' : formatPrice(parseFloat(preview.shippingMethod?.rate || 0), currencyCode)], + ['Tax', formatPrice(parseFloat(preview.taxAmount), currencyCode)], + ]; + + rows.forEach(([label, value]) => { + const row = document.createElement('div'); + row.className = 'order-totals-row'; + const labelEl = document.createElement('span'); + labelEl.textContent = label; + const valueEl = document.createElement('span'); + valueEl.textContent = value; + row.append(labelEl, valueEl); + totalsSection.appendChild(row); + }); + + const totalRow = document.createElement('div'); + totalRow.className = 'order-totals-row order-totals-total'; + const totalLabel = document.createElement('strong'); + totalLabel.textContent = 'Total'; + const totalValue = document.createElement('strong'); + totalValue.textContent = formatPrice(parseFloat(preview.total), currencyCode); + totalRow.append(totalLabel, totalValue); + totalsSection.appendChild(totalRow); + + leftCol.appendChild(totalsSection); + } + + detailsGrid.appendChild(leftCol); + + // right column: shipping address + const rightCol = document.createElement('div'); + rightCol.className = 'order-details-right'; + + if (order?.shipping) { + const addrSection = document.createElement('div'); + addrSection.className = 'order-shipping-address'; + + const addrHeading = document.createElement('h3'); + addrHeading.textContent = 'Shipping address'; + addrSection.appendChild(addrHeading); + + const addr = order.shipping; + const lines = [ + addr.name, + addr.company, + addr.address1, + addr.address2, + `${addr.city}, ${addr.state} ${addr.zip}`, + addr.country?.toUpperCase(), + ].filter(Boolean); + + lines.forEach((line) => { + const p = document.createElement('p'); + p.textContent = line; + addrSection.appendChild(p); + }); + + rightCol.appendChild(addrSection); + } + + if (order?.customer) { + const contactSection = document.createElement('div'); + contactSection.className = 'order-contact'; + + const contactHeading = document.createElement('h3'); + contactHeading.textContent = 'Contact'; + contactSection.appendChild(contactHeading); + + const contactEmail = document.createElement('p'); + contactEmail.textContent = order.customer.email; + contactSection.appendChild(contactEmail); + + if (order.customer.phone) { + const contactPhone = document.createElement('p'); + contactPhone.textContent = order.customer.phone; + contactSection.appendChild(contactPhone); + } + + rightCol.appendChild(contactSection); + } + + detailsGrid.appendChild(rightCol); + container.appendChild(detailsGrid); + + // continue shopping + const actions = document.createElement('div'); + actions.className = 'order-actions'; + const continueLink = document.createElement('a'); + continueLink.href = '/'; + continueLink.className = 'button emphasis'; + continueLink.textContent = 'Continue shopping'; + actions.appendChild(continueLink); + container.appendChild(actions); + + block.replaceChildren(container); +} diff --git a/blocks/order-summary/order-summary.css b/blocks/order-summary/order-summary.css index 31fdb314..65fb327c 100644 --- a/blocks/order-summary/order-summary.css +++ b/blocks/order-summary/order-summary.css @@ -1,172 +1,219 @@ -/* Order confirmation page */ -.order-result { - max-width: 900px; - margin: 0 auto; - padding: 32px 24px 64px; +/* Two-column layout: section containing order-summary becomes flex row. + Pull horizontal spacing onto the section itself and strip it from the + inner block-wrapper divs, which the site's global styles would otherwise + pad with --horizontal-spacing (up to 64px each side = 128px wasted per item). */ +.section:has(.order-summary-wrapper) { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 24px 48px; + padding-block: 40px; + padding-inline: var(--horizontal-spacing); } -/* Header */ -.order-header { - text-align: center; - padding-bottom: 32px; - border-bottom: 1px solid #e0e0e0; - margin-bottom: 32px; +.section:has(.order-summary-wrapper):has(.checkout-progress-wrapper) { + padding-block-start: 10px; } -.order-checkmark { - margin-bottom: 16px; +.section:has(.order-summary-wrapper) > .default-content-wrapper h1 { + margin-top: 0; + padding-top: 0; } -.order-header h2 { - margin: 0 0 8px; - font-size: 28px; +.section:has(.order-summary-wrapper) > .default-content-wrapper { + flex-basis: 100%; + margin: 0; + padding: 0; } -.order-id { - margin: 0 0 4px; - font-size: 14px; - color: #555; +.order-summary-wrapper { + width: var(--commerce-summary-width); + flex-shrink: 0; + position: sticky; + top: var(--commerce-sticky-top); } -.order-email { - margin: 0; - font-size: 14px; - color: #555; +.cart-wrapper, +.checkout-wrapper { + flex: 1; + min-width: 0; } -/* Details grid: items+totals left, address right */ -.order-details { - display: grid; - grid-template-columns: 1fr; - gap: 32px; +/* Reset the site's per-wrapper padding/margin so flex items size correctly */ +.section:has(.order-summary-wrapper) > .cart-wrapper, +.section:has(.order-summary-wrapper) > .checkout-wrapper, +.section:has(.order-summary-wrapper) > .order-summary-wrapper { + padding: 0; + margin: 0; } -@media (width >= 768px) { - .order-details { - grid-template-columns: 3fr 2fr; - } +/* Fallback for browsers / authors who add commerce-layout class to section */ +.section.commerce-layout { + display: flex; + align-items: flex-start; + gap: 48px; + padding-block: 40px; + padding-inline: var(--horizontal-spacing); } -.order-details h3 { - font-size: 16px; - margin: 0 0 16px; - padding-bottom: 8px; - border-bottom: 1px solid #e0e0e0; +.section.commerce-layout > .cart-wrapper, +.section.commerce-layout > .checkout-wrapper, +.section.commerce-layout > .order-summary-wrapper { + padding: 0; + margin: 0; } -/* Items */ -.order-items { - margin-bottom: 24px; -} +@media (width <= 999px) { + .section:has(.order-summary-wrapper), + .section.commerce-layout { + flex-direction: column; + padding-inline: var(--horizontal-spacing); + } -.order-item { - display: grid; - grid-template-columns: 64px 1fr auto; - gap: 12px; - padding: 12px 0; - border-bottom: 1px solid #f0f0f0; - align-items: start; + .checkout-progress-wrapper { + order: -1; + } + + .order-summary-wrapper { + width: 100%; + position: static; + order: -1; + } } -.order-item:last-child { - border-bottom: none; +.order-summary { + background-color: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + overflow: hidden; } -.order-item-image { - width: 64px; - height: 64px; +.order-summary-header { display: flex; align-items: center; - justify-content: center; - background: #f9f9f9; - border-radius: 4px; + padding: 20px 24px 12px; + border-bottom: 1px solid var(--commerce-color-border); } -.order-item-image img { - max-width: 56px; - max-height: 56px; - object-fit: contain; +.order-summary-header h3 { + margin: 0; + font-size: var(--heading-size-l); } -.order-item-name { - margin: 0; - font-weight: 600; - font-size: 14px; - line-height: 1.4; +.order-summary-content { + display: block; + padding: var(--commerce-card-padding); } -.order-item-variant { - margin: 2px 0 0; - font-size: 13px; - color: #555; +/* Items list */ +.order-summary-items { + display: flex; + flex-direction: column; + margin-bottom: 24px; } -.order-item-qty { - margin: 2px 0 0; - font-size: 13px; - color: #555; +/* First item has no top padding (the card header provides the gap) */ +.order-summary-items .cart-item:first-child { + padding-top: 0; } -.order-item-price { - font-weight: 600; - font-size: 14px; - text-align: right; +/* Discount */ +.order-summary-discount { + display: flex; + gap: var(--commerce-gap-sm); + margin-bottom: var(--commerce-card-padding); + padding-bottom: var(--commerce-card-padding); + border-bottom: 1px solid var(--commerce-color-border); +} + +.discount-input { + flex: 1; + padding: 12px 16px; + border: 1px solid var(--commerce-input-border); + border-radius: var(--commerce-input-radius); + font-size: var(--commerce-font-size-sm); + background: var(--commerce-input-background); +} + +.discount-input:focus { + outline: none; + border-color: var(--commerce-input-border-focus); +} + +.discount-apply { + padding: 12px 24px; + background: var(--commerce-color-text-muted); + color: #fff; + border: none; + border-radius: var(--commerce-button-radius); + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); + cursor: pointer; white-space: nowrap; } +.discount-apply:hover { + background: var(--commerce-color-text); +} + /* Totals */ -.order-totals { - padding-top: 12px; - border-top: 1px solid #e0e0e0; +.order-summary-totals { + display: flex; + flex-direction: column; + gap: var(--commerce-gap-sm); } -.order-totals-row { +.order-summary-row { display: flex; justify-content: space-between; - padding: 4px 0; - font-size: 14px; + align-items: center; + font-size: var(--commerce-font-size-sm); } -.order-totals-total { - padding-top: 12px; - margin-top: 8px; - border-top: 1px solid #e0e0e0; - font-size: 16px; +.order-summary-row.order-summary-final { + margin-top: var(--commerce-gap-sm); + padding-top: 16px; + border-top: 1px solid var(--commerce-color-border); + font-size: var(--commerce-font-size-base); } -/* Shipping address & contact */ -.order-shipping-address, -.order-contact { - margin-bottom: 24px; +.order-summary-final-amount { + display: flex; + align-items: center; + gap: 8px; } -.order-shipping-address p, -.order-contact p { - margin: 0; - font-size: 14px; - line-height: 1.6; - color: #333; +.order-summary-final-amount .currency { + font-size: var(--commerce-font-size-xs); + font-weight: 400; + color: var(--commerce-color-text-muted); } -/* Actions */ -.order-actions { - text-align: center; - margin-top: 40px; - padding-top: 32px; - border-top: 1px solid #e0e0e0; +.order-summary-final-amount strong { + font-size: var(--commerce-font-size-total); } -/* Cancelled state */ -.order-cancelled { - text-align: center; - padding: 80px 24px; +/* Loading state */ +.order-summary-content.loading { + position: relative; + opacity: 0.4; + pointer-events: none; } -.order-cancelled h2 { - margin: 0 0 8px; +.order-summary-content.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 32px; + height: 32px; + margin: -16px 0 0 -16px; + border: 3px solid var(--commerce-color-border); + border-top-color: var(--commerce-color-text); + border-radius: 50%; + animation: order-summary-spin 0.7s linear infinite; } -.order-cancelled p { - margin: 0 0 24px; - color: #555; +@keyframes order-summary-spin { + to { transform: rotate(360deg); } } diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js index 4d0d2025..e17ab823 100644 --- a/blocks/order-summary/order-summary.js +++ b/blocks/order-summary/order-summary.js @@ -1,283 +1,160 @@ -import { getOrderPath } from '../../scripts/scripts.js'; +import { loadCSS } from '../../scripts/aem.js'; +import cart from '../../scripts/cart.js'; +import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; +import buildCartItem from '../../scripts/commerce/cart-item.js'; +import { parsePreview } from '../../scripts/commerce-api.js'; -/** - * Order confirmation / cancellation page. - * - * Success: Chase / PayPal → API → redirect here with ?orderId=... - * Cancel: Chase / PayPal → API → redirect here with ?orderId=...&reason=... - * reason values: customer_cancelled | payment_failed | declined - * - * Order display data comes from sessionStorage (saved before the payment redirect). - */ -export default async function decorate(block) { - const params = Object.fromEntries(new URLSearchParams(window.location.search).entries()); - - // cancelled or failed payment - if (params.reason) { - const container = document.createElement('div'); - container.className = 'order-result order-cancelled'; - - const heading = document.createElement('h2'); - heading.textContent = 'Payment not completed'; - container.appendChild(heading); - - const msg = document.createElement('p'); - msg.textContent = params.reason === 'customer_cancelled' - ? 'You cancelled the payment.' - : 'Payment could not be processed. Please try again.'; - container.appendChild(msg); - - const link = document.createElement('p'); - const returnLink = document.createElement('a'); - returnLink.href = getOrderPath('checkout'); - returnLink.className = 'button emphasis'; - returnLink.textContent = 'Return to checkout'; - link.appendChild(returnLink); - container.appendChild(link); - - block.replaceChildren(container); - return; - } - - // success flow — read from sessionStorage - const orderId = params.orderId || params.id; - const email = params.email || sessionStorage.getItem('checkout_email'); - const orderData = sessionStorage.getItem('checkout_order'); - const previewData = sessionStorage.getItem('checkout_preview'); - const cartItemsData = sessionStorage.getItem('checkout_cart_items'); - - if (!orderId) { - window.location.href = '/'; - return; - } - - let order; - let preview; - let cartItems; - try { - order = orderData ? JSON.parse(orderData) : null; - preview = previewData ? JSON.parse(previewData) : null; - cartItems = cartItemsData ? JSON.parse(cartItemsData) : null; - } catch { - order = null; - preview = null; - cartItems = null; - } - - // clear checkout session data - sessionStorage.removeItem('checkout_email'); - sessionStorage.removeItem('checkout_order'); - sessionStorage.removeItem('checkout_preview'); - sessionStorage.removeItem('checkout_cart_items'); - - // clear the cart - try { - const { default: cart } = await import('../../scripts/cart.js'); - cart.clear(); - } catch { - // cart may not be available - } - - // build confirmation page - const container = document.createElement('div'); - container.className = 'order-result order-confirmed'; - - // header section - const headerSection = document.createElement('div'); - headerSection.className = 'order-header'; - - const checkmark = document.createElement('div'); - checkmark.className = 'order-checkmark'; - checkmark.innerHTML = ''; - headerSection.appendChild(checkmark); - - const heading = document.createElement('h2'); - heading.textContent = 'Thank you for your order!'; - headerSection.appendChild(heading); - - const orderIdEl = document.createElement('p'); - orderIdEl.className = 'order-id'; - const orderIdLabel = document.createElement('span'); - orderIdLabel.textContent = 'Order ID: '; - const orderIdValue = document.createElement('strong'); - orderIdValue.textContent = orderId; - orderIdEl.append(orderIdLabel, orderIdValue); - headerSection.appendChild(orderIdEl); - - if (email) { - const emailEl = document.createElement('p'); - emailEl.className = 'order-email'; - emailEl.textContent = `A confirmation will be sent to ${email}.`; - headerSection.appendChild(emailEl); - } - - container.appendChild(headerSection); - - // two-column layout: items + totals on left, shipping on right - const detailsGrid = document.createElement('div'); - detailsGrid.className = 'order-details'; - - // left column: items + totals - const leftCol = document.createElement('div'); - leftCol.className = 'order-details-left'; - - // items - const displayItems = cartItems || order?.items; - if (displayItems?.length) { - const itemsSection = document.createElement('div'); - itemsSection.className = 'order-items'; - - const itemsHeading = document.createElement('h3'); - itemsHeading.textContent = 'Items ordered'; - itemsSection.appendChild(itemsHeading); - - displayItems.forEach((item) => { - const itemEl = document.createElement('div'); - itemEl.className = 'order-item'; - - if (item.image) { - const imgWrapper = document.createElement('div'); - imgWrapper.className = 'order-item-image'; - const img = document.createElement('img'); - img.src = item.image; - img.alt = item.name || ''; - imgWrapper.appendChild(img); - itemEl.appendChild(imgWrapper); - } - - const details = document.createElement('div'); - details.className = 'order-item-details'; - - const name = document.createElement('p'); - name.className = 'order-item-name'; - name.textContent = item.name || item.sku; - details.appendChild(name); - - if (item.variant) { - const variant = document.createElement('p'); - variant.className = 'order-item-variant'; - variant.textContent = item.variant; - details.appendChild(variant); - } - - const qty = document.createElement('p'); - qty.className = 'order-item-qty'; - qty.textContent = `Qty: ${item.quantity}`; - details.appendChild(qty); +loadCSS('/styles/commerce-tokens.css'); - itemEl.appendChild(details); - - const price = document.createElement('div'); - price.className = 'order-item-price'; - const unitPrice = parseFloat(item.price?.final || item.price) || 0; - price.textContent = `$${(unitPrice * item.quantity).toFixed(2)}`; - itemEl.appendChild(price); - - itemsSection.appendChild(itemEl); - }); - leftCol.appendChild(itemsSection); - } - - // totals - if (preview) { - const totalsSection = document.createElement('div'); - totalsSection.className = 'order-totals'; - - const rows = [ - ['Subtotal', `$${parseFloat(preview.subtotal).toFixed(2)}`], - ['Shipping', preview.shippingMethod?.rate === 0 ? 'Free' : `$${parseFloat(preview.shippingMethod?.rate || 0).toFixed(2)}`], - ['Tax', `$${parseFloat(preview.taxAmount).toFixed(2)}`], - ]; - - rows.forEach(([label, value]) => { - const row = document.createElement('div'); - row.className = 'order-totals-row'; - const labelEl = document.createElement('span'); - labelEl.textContent = label; - const valueEl = document.createElement('span'); - valueEl.textContent = value; - row.append(labelEl, valueEl); - totalsSection.appendChild(row); - }); - - const totalRow = document.createElement('div'); - totalRow.className = 'order-totals-row order-totals-total'; - const totalLabel = document.createElement('strong'); - totalLabel.textContent = 'Total'; - const totalValue = document.createElement('strong'); - totalValue.textContent = `$${parseFloat(preview.total).toFixed(2)}`; - totalRow.append(totalLabel, totalValue); - totalsSection.appendChild(totalRow); - - leftCol.appendChild(totalsSection); - } - - detailsGrid.appendChild(leftCol); - - // right column: shipping address - const rightCol = document.createElement('div'); - rightCol.className = 'order-details-right'; +function getStrings() { + return getConfig().getStrings(); +} - if (order?.shipping) { - const addrSection = document.createElement('div'); - addrSection.className = 'order-shipping-address'; +function getCurrencyCode() { + const { currency, getLocale } = getConfig(); + return typeof currency === 'function' ? currency(getLocale()) : currency; +} - const addrHeading = document.createElement('h3'); - addrHeading.textContent = 'Shipping address'; - addrSection.appendChild(addrHeading); +function buildTemplate(s) { + return /* html */` +
          +
          +

          ${s.orderSummary}

          +
          +
          +
          +
          + + +
          +
          +
          + ${s.subtotal} + +
          +
          + ${s.shipping} + +
          +
          + ${s.estimatedTaxes} + +
          +
          + ${s.total} +
          + + +
          +
          +
          +
          +
          +`; +} - const addr = order.shipping; - const lines = [ - addr.name, - addr.company, - addr.address1, - addr.address2, - `${addr.city}, ${addr.state} ${addr.zip}`, - addr.country?.toUpperCase(), - ].filter(Boolean); +/** + * If the order-summary is authored in its own section (separate from the + * cart/checkout block), move its wrapper into the adjacent form section so + * the CSS :has(.order-summary-wrapper) two-column layout rule fires correctly. + * @param {HTMLDivElement} block + */ +function colocateWithForm(block) { + const wrapper = block.closest('.order-summary-wrapper'); + const mySection = wrapper?.closest('.section'); + if (!wrapper || !mySection) return; + + // Already co-located — nothing to do + if (mySection.querySelector('.checkout-wrapper, .cart-wrapper')) return; + + const main = mySection.closest('main') || document; + const target = [...main.querySelectorAll('.section')] + .find((s) => s !== mySection && s.querySelector('.checkout-wrapper, .cart-wrapper')); + if (!target) return; + + target.appendChild(wrapper); + // Remove the now-empty section to avoid stray margins + if (!mySection.children.length) mySection.remove(); +} - lines.forEach((line) => { - const p = document.createElement('p'); - p.textContent = line; - addrSection.appendChild(p); +/** + * @param {HTMLDivElement} block + */ +export default function decorate(block) { + const s = getStrings(); + colocateWithForm(block); + block.innerHTML = buildTemplate(s); + + const itemsList = block.querySelector('.order-summary-items'); + const subtotalEl = block.querySelector('.order-summary-subtotal'); + const shippingEl = block.querySelector('.order-summary-shipping'); + const taxesEl = block.querySelector('.order-summary-taxes'); + const grandTotalEl = block.querySelector('.order-summary-grand-total'); + const currencyEl = block.querySelector('.currency'); + currencyEl.textContent = getCurrencyCode(); + + const renderItems = () => { + itemsList.innerHTML = ''; + + cart.items.forEach((item) => { + const itemEl = buildCartItem( + item, + { + onQtyChange: (sku, qty) => cart.updateItem(sku, qty), + onRemove: (sku) => cart.removeItem(sku), + currencyCode: getCurrencyCode(), + }, + { remove: s.remove, removeItem: s.removeItem }, + ); + itemsList.appendChild(itemEl); }); - - rightCol.appendChild(addrSection); - } - - if (order?.customer) { - const contactSection = document.createElement('div'); - contactSection.className = 'order-contact'; - - const contactHeading = document.createElement('h3'); - contactHeading.textContent = 'Contact'; - contactSection.appendChild(contactHeading); - - const contactEmail = document.createElement('p'); - contactEmail.textContent = order.customer.email; - contactSection.appendChild(contactEmail); - - if (order.customer.phone) { - const contactPhone = document.createElement('p'); - contactPhone.textContent = order.customer.phone; - contactSection.appendChild(contactPhone); - } - - rightCol.appendChild(contactSection); - } - - detailsGrid.appendChild(rightCol); - container.appendChild(detailsGrid); - - // continue shopping - const actions = document.createElement('div'); - actions.className = 'order-actions'; - const continueLink = document.createElement('a'); - continueLink.href = '/'; - continueLink.className = 'button emphasis'; - continueLink.textContent = 'Continue shopping'; - actions.appendChild(continueLink); - container.appendChild(actions); - - block.replaceChildren(container); + }; + + const updateTotals = () => { + const currency = getCurrencyCode(); + const subtotal = formatPrice(cart.subtotal, currency); + subtotalEl.textContent = subtotal; + shippingEl.textContent = '--'; + taxesEl.textContent = '--'; + grandTotalEl.textContent = subtotal; + }; + + renderItems(); + updateTotals(); + + const wrapper = block.closest('.order-summary-wrapper'); + const syncVisibility = () => { + wrapper?.toggleAttribute('hidden', cart.items.length === 0); + }; + + document.addEventListener('cart:change', () => { + renderItems(); + updateTotals(); + syncVisibility(); + }); + + const summaryContent = block.querySelector('.order-summary-content'); + document.addEventListener('checkout:preview-loading', () => { + summaryContent?.classList.add('loading'); + }); + + document.addEventListener('checkout:preview', (e) => { + summaryContent?.classList.remove('loading'); + const { preview } = e.detail || {}; + if (!preview) return; + + const { + subtotal, taxAmount, shippingRate, total, + } = parsePreview(preview, cart.subtotal); + + const currency = getCurrencyCode(); + subtotalEl.textContent = formatPrice(subtotal, currency); + shippingEl.textContent = shippingRate === 0 + ? s.free + : formatPrice(parseFloat(shippingRate), currency); + taxesEl.textContent = formatPrice(taxAmount, currency); + grandTotalEl.textContent = formatPrice(total, currency); + }); + + syncVisibility(); } diff --git a/blocks/pdp/add-to-cart.js b/blocks/pdp/add-to-cart.js index 1bdae301..958ce2f1 100644 --- a/blocks/pdp/add-to-cart.js +++ b/blocks/pdp/add-to-cart.js @@ -214,7 +214,7 @@ export default function renderAddToCart(ph, block, parent) { } try { - if (window.cartMode === 'edge') { + if (window.useEdgeCheckout) { const cartApi = (await import('../../scripts/cart.js')).default; const { sku: variantSku, price, name } = selectedVariant; diff --git a/scripts/auth-api.js b/scripts/auth-api.js index d620fc0f..e55c6548 100644 --- a/scripts/auth-api.js +++ b/scripts/auth-api.js @@ -1,4 +1,4 @@ -import { ORDERS_API_ORIGIN } from './scripts.js'; +import { getConfig } from './commerce-config.js'; import { mintRecaptchaToken, RECAPTCHA_ACTIONS, RECAPTCHA_HEADER } from './recaptcha.js'; /** sessionStorage key for the JWT issued after OTP verification */ @@ -35,7 +35,7 @@ export async function login(email, country, locale) { const headers = { 'Content-Type': 'application/json' }; const recaptchaToken = await mintRecaptchaToken(RECAPTCHA_ACTIONS.AUTH_LOGIN); if (recaptchaToken) headers[RECAPTCHA_HEADER] = recaptchaToken; - const resp = await fetch(`${ORDERS_API_ORIGIN}/auth/login`, { + const resp = await fetch(`${getConfig().apiOrigin}/auth/login`, { method: 'POST', headers, body: JSON.stringify({ email, country, locale }), @@ -62,7 +62,7 @@ export async function login(email, country, locale) { * @throws {Error} If the code is invalid, expired, or the request fails */ export async function verifyCode(email, code, hash, exp) { - const resp = await fetch(`${ORDERS_API_ORIGIN}/auth/callback`, { + const resp = await fetch(`${getConfig().apiOrigin}/auth/callback`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -97,7 +97,7 @@ export async function verifyCode(email, code, hash, exp) { export async function logout() { const token = sessionStorage.getItem(AUTH_TOKEN_KEY); try { - await fetch(`${ORDERS_API_ORIGIN}/auth/logout`, { + await fetch(`${getConfig().apiOrigin}/auth/logout`, { method: 'POST', headers: { 'Content-Type': 'application/json', diff --git a/scripts/cart.js b/scripts/cart.js index f27ad0bb..1ca27ab9 100644 --- a/scripts/cart.js +++ b/scripts/cart.js @@ -1,3 +1,5 @@ +import { getConfig } from './commerce-config.js'; + const debounce = (func, wait) => { let timeout; return (...args) => { @@ -8,14 +10,11 @@ const debounce = (func, wait) => { export class Cart { static get STORAGE_KEY() { - const locale = window.location.pathname.split('/')[1] || 'default'; - return `cart:${locale === 'drafts' ? 'ca' : locale}`; + return `cart:${getConfig().getLocale()}`; } static STORAGE_VERSION = 1; - static SHIPPING_THRESHOLD = 150; - /** @type {Record} */ #items = {}; @@ -90,10 +89,6 @@ export class Cart { ); } - get shipping() { - return this.subtotal < Cart.SHIPPING_THRESHOLD ? 10 : 0; - } - clear() { this.#items = {}; this.#persistNow(); @@ -177,6 +172,8 @@ export class Cart { * price: {final: string, currency: string}, imageUrl?: string, productUrl?: string}>} */ getItemsForAPI() { + const { currency, getLocale } = getConfig(); + const currencyCode = typeof currency === 'function' ? currency(getLocale()) : currency; return this.items.map((item) => ({ sku: item.sku, path: item.path || new URL(item.url, window.location.origin).pathname, @@ -184,55 +181,13 @@ export class Cart { name: item.name, price: { final: String(item.price), - currency: window.location.pathname.startsWith('/ca/') ? 'CAD' : 'USD', + currency: currencyCode, }, ...(item.image ? { imageUrl: item.image } : {}), ...(item.url ? { productUrl: item.url } : {}), })); } - getOrderJSON(email, firstName, lastName, phone, shippingAddr, { - billingAddr, shippingMethod, estimateToken, locale, country, - } = {}) { - // remove empty string values from addresses - const cleanAddr = (addr) => Object.fromEntries( - // eslint-disable-next-line no-unused-vars - Object.entries(addr).filter(([_, value]) => value !== ''), - ); - - const order = { - customer: { - firstName, - lastName, - email, - phone, - }, - shipping: cleanAddr(shippingAddr), - // Default billing to shipping when the customer didn't enter a - // separate billing address (the "billing same as shipping" flow). - // The API treats both as required for order downstream consumers - // (transactional emails, payment-provider risk checks, etc.) — sending - // shipping as billing keeps every consumer happy without forcing the - // customer to type their address twice. - billing: cleanAddr(billingAddr ?? shippingAddr), - items: this.getItemsForAPI(), - }; - if (shippingMethod) { - order.shippingMethod = { id: shippingMethod }; - } - if (estimateToken) { - order.estimateToken = estimateToken; - } - if (locale) { - order.locale = locale; - } - if (country) { - order.country = country; - } - - return order; - } - toJSON() { return { version: Cart.STORAGE_VERSION, diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index ecac17b1..82d35331 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -1,4 +1,4 @@ -import { ORDERS_API_ORIGIN } from './scripts.js'; +import { getConfig } from './commerce-config.js'; import { AUTH_TOKEN_KEY } from './auth-api.js'; import { mintRecaptchaToken, RECAPTCHA_ACTIONS, RECAPTCHA_HEADER } from './recaptcha.js'; @@ -39,7 +39,7 @@ async function post(path, body, recaptchaAction) { if (recaptchaToken) headers[RECAPTCHA_HEADER] = recaptchaToken; } - const resp = await fetch(`${ORDERS_API_ORIGIN}${path}`, { + const resp = await fetch(`${getConfig().apiOrigin}${path}`, { method: 'POST', headers, body: JSON.stringify(body), @@ -71,6 +71,26 @@ export async function estimateShipping(country, state, items) { }); } +/** + * Fetches fully computed totals for all shipping methods at an address during Apple Pay + * express checkout. Called inside `onshippingcontactselected` where only a partial address + * (city, state, country, zip — no street) is available. + * + * @param {string} country - ISO 3166-1 alpha-2 country code (e.g. 'us', 'ca') + * @param {string} state - State or province code (e.g. 'QC', 'CA') + * @param {string} zip - Postal/ZIP code + * @param {Array<{sku: string, path: string, quantity: number, price: Object}>} items + * @returns {Promise<{ subtotal: number, shippingMethods: Array }>} + * @throws {CommerceApiError} + */ +export async function estimateExpressCheckout(country, state, zip, items) { + return post('/estimate/express-checkout', { + country, + shipping: { country, state, zip }, + items, + }); +} + /** * Previews an order to lock in tax, discounts, and shipping cost, and * obtain an `estimateToken` that must be included when placing the order. @@ -143,3 +163,19 @@ export async function validateApplePayMerchant(validationUrl, country, locale) { ...(locale ? { locale } : {}), }); } + +/** + * Normalises a checkout:preview payload into scalar price values. + * @param {Object} preview + * @param {number} cartSubtotal - fallback when preview.subtotal is absent + * @returns {{ subtotal: number, taxAmount: number, shippingRate: number, total: number }} + */ +export function parsePreview(preview, cartSubtotal) { + const subtotal = parseFloat(preview.subtotal) || cartSubtotal; + const taxAmount = parseFloat(preview.taxAmount) || 0; + const shippingRate = preview.shippingMethod?.rate ?? 0; + const total = parseFloat(preview.total) || (subtotal + taxAmount + shippingRate); + return { + subtotal, taxAmount, shippingRate, total, + }; +} diff --git a/scripts/commerce-config.js b/scripts/commerce-config.js new file mode 100644 index 00000000..238aa8cd --- /dev/null +++ b/scripts/commerce-config.js @@ -0,0 +1,92 @@ +const { hostname } = window.location; + +function resolveApiOrigin(org, site) { + const isProduction = !hostname.endsWith('.aem.page') + && !hostname.endsWith('.aem.live') + && hostname !== 'localhost' + && !hostname.startsWith('127.'); + const base = isProduction + ? 'https://api.adobecommerce.live' + : 'https://api-stage.adobecommerce.live'; + return `${base}/${org}/sites/${site}`; +} + +const defaults = { + org: undefined, + site: undefined, + getLocale: () => window.location.pathname.split('/').filter(Boolean)[0] || 'us', + getLanguage: () => window.location.pathname.split('/').filter(Boolean)[1] || 'en_us', + getOrderPath(page) { + return `/${this.getLocale()}/${this.getLanguage()}/order/${page}`; + }, + getAccountPath(page) { + const base = `/${this.getLocale()}/${this.getLanguage()}/account`; + return page ? `${base}/${page}` : base; + }, + currency: (locale) => (locale === 'ca' ? 'CAD' : 'USD'), + cardProvider: 'chase', + getFraudToken() { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + }, + strings: { + 'en-us': { + stepCart: 'Cart', + stepCheckout: 'Checkout', + stepConfirmation: 'Confirmation', + checkoutStepsLabel: 'Checkout steps', + remove: 'Remove', + removeItem: 'Remove item', + continueShopping: 'Continue shopping', + apply: 'Apply', + subtotal: 'Subtotal', + shipping: 'Shipping', + estimatedTaxes: 'Estimated taxes', + total: 'Total', + free: 'Free', + or: 'or', + orderSummary: 'Order summary', + discountPlaceholder: 'Discount code or gift card', + }, + 'fr-ca': { + stepCart: 'Panier', + stepCheckout: 'Caisse', + stepConfirmation: 'Confirmation', + checkoutStepsLabel: 'Étapes de la caisse', + remove: 'Retirer', + removeItem: "Retirer l'article", + continueShopping: 'Continuer vos achats', + apply: 'Appliquer', + subtotal: 'Sous-total', + shipping: 'Livraison', + estimatedTaxes: 'Taxes estimées', + total: 'Total', + free: 'Gratuit', + or: 'ou', + orderSummary: 'Récapitulatif de commande', + discountPlaceholder: 'Code de réduction ou carte-cadeau', + }, + }, + getStrings() { + const key = this.getLanguage().toLowerCase().replace('_', '-'); + return this.strings[key] || this.strings['en-us']; + }, +}; + +/** + * Formats a numeric amount as a localized currency string. + * @param {number} amount + * @param {string} currencyCode - ISO 4217 code, e.g. 'USD', 'CAD' + * @returns {string} + */ +export function formatPrice(amount, currencyCode) { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: currencyCode || 'USD', + }).format(amount); +} + +export function getConfig() { + const merged = { ...defaults, ...(window.CommerceConfig || {}) }; + merged.apiOrigin = resolveApiOrigin(merged.org, merged.site); + return merged; +} diff --git a/scripts/commerce/cart-item.css b/scripts/commerce/cart-item.css new file mode 100644 index 00000000..d28e0da0 --- /dev/null +++ b/scripts/commerce/cart-item.css @@ -0,0 +1,158 @@ +/* Shared cart item component — used by cart, order-summary, and minicart */ + +.cart-item { + display: grid; + grid-template-columns: auto 1fr auto; + gap: 12px; + align-items: flex-start; + padding: 16px 0; + border-bottom: 1px solid var(--commerce-color-border, #e5e7eb); +} + +.cart-item:last-child { + border-bottom: none; +} + +/* Image */ +.cart-item-image { + width: var(--commerce-item-image-size); + height: var(--commerce-item-image-size); + border: 1px solid var(--commerce-color-border, #e5e7eb); + border-radius: var(--commerce-radius-badge, 8px); + overflow: hidden; + background: var(--commerce-color-background); + flex-shrink: 0; +} + +.cart-item-image img, +.cart-item-image picture img { + width: 100%; + height: 100%; + object-fit: contain; + padding: 4px; + box-sizing: border-box; +} + +/* Details column */ +.cart-item-details { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} + +.cart-item-name { + margin: 0 0 2px; + font-size: var(--commerce-font-size-sm, 14px); + font-weight: 600; + line-height: 1.4; +} + +.cart-item-name a { + text-decoration: none; + color: var(--commerce-color-text); +} + +.cart-item-name a:hover { + text-decoration: underline; +} + +.cart-item-variant { + margin: 0; + font-size: var(--commerce-font-size-xs); + color: var(--commerce-color-text-muted, #6b7280); +} + +/* Actions row */ +.cart-item-actions { + display: flex; + align-items: center; + margin-top: 10px; +} + +/* Qty control */ +.cart-item-qty-control { + display: flex; + align-items: center; + border: 1px solid var(--commerce-color-border, #e5e7eb); + border-radius: 6px; + overflow: hidden; +} + +.cart-item-qty-control button { + width: 28px; + height: 28px; + border: none; + border-radius: 0; + background: var(--commerce-color-background); + font-size: 14px; + font-weight: 600; + cursor: pointer; + color: var(--commerce-color-text); + display: flex; + align-items: center; + justify-content: center; +} + +.cart-item-qty-control button:hover { + background: var(--commerce-color-surface); +} + +.cart-item-qty-control .qty-input { + width: 32px; + height: 28px; + border: none; + border-left: 1px solid var(--commerce-color-border, #e5e7eb); + border-right: 1px solid var(--commerce-color-border, #e5e7eb); + text-align: center; + font-size: var(--commerce-font-size-xs); + font-weight: 600; + padding: 0; + background: transparent; + appearance: textfield; +} + +.cart-item-qty-control .qty-input::-webkit-inner-spin-button, +.cart-item-qty-control .qty-input::-webkit-outer-spin-button { + appearance: none; + margin: 0; +} + +/* Right column: price + remove */ +.cart-item-right { + display: flex; + flex-direction: column; + align-items: flex-end; + justify-content: space-between; + align-self: stretch; + gap: 4px; +} + +.cart-item-price { + font-size: var(--commerce-font-size-sm, 14px); + font-weight: 600; + white-space: nowrap; +} + +.cart-item-per-unit { + font-size: 12px; + color: var(--commerce-color-text-muted, #6b7280); + white-space: nowrap; +} + +.cart-item-remove { + background: none; + border: none; + cursor: pointer; + color: var(--commerce-color-text-muted, #6b7280); + padding: 0; + display: flex; + align-items: center; + gap: 4px; + font-size: var(--commerce-font-size-xs); +} + +.cart-item-remove:hover { + color: var(--commerce-color-text); +} diff --git a/scripts/commerce/cart-item.js b/scripts/commerce/cart-item.js new file mode 100644 index 00000000..2817fc49 --- /dev/null +++ b/scripts/commerce/cart-item.js @@ -0,0 +1,108 @@ +import { loadCSS, createOptimizedPicture } from '../aem.js'; +import { formatPrice } from '../commerce-config.js'; + +loadCSS('/scripts/commerce/cart-item.css'); + +const TRASH_ICON = /* html */``; + +/** + * @param {Object} item Cart item — sku, name, image, price, quantity, url?, variant? + * @param {{ onQtyChange: Function, onRemove: Function, currencyCode?: string }} callbacks + * @param {{ remove?: string, removeItem?: string }} [strings] + * @returns {HTMLElement} + */ +export default function buildCartItem(item, { onQtyChange, onRemove, currencyCode = 'USD' }, strings = {}) { + const { remove = 'Remove', removeItem = 'Remove item' } = strings; + + const el = document.createElement('div'); + el.className = `cart-item cart-item-${item.sku}`; + el.innerHTML = /* html */` +
          +
          +

          +
          +
          + + + +
          +
          +
          +
          +
          +
          + +
          `; + + // Image + el.querySelector('.cart-item-image').appendChild( + createOptimizedPicture(item.image, item.name || '', true), + ); + + // Name — link if url provided + const nameEl = el.querySelector('.cart-item-name'); + if (item.url) { + const a = document.createElement('a'); + a.textContent = item.name; + try { + a.href = new URL(item.url).pathname; + } catch { + a.href = item.url; + } + nameEl.appendChild(a); + } else { + nameEl.textContent = item.name; + } + + // Variant + if (item.variant) { + const variantEl = document.createElement('p'); + variantEl.className = 'cart-item-variant'; + variantEl.textContent = item.variant; + el.querySelector('.cart-item-actions').before(variantEl); + } + + // Price + const price = typeof item.price === 'string' ? parseFloat(item.price) : item.price; + const priceEl = el.querySelector('.cart-item-price'); + const perUnitEl = el.querySelector('.cart-item-per-unit'); + + const updatePrice = (qty) => { + priceEl.textContent = formatPrice(price * qty, currencyCode); + perUnitEl.textContent = qty > 1 ? `${formatPrice(price, currencyCode)} each` : ''; + }; + updatePrice(item.quantity); + + // Qty + const qtyInput = el.querySelector('.qty-input'); + qtyInput.value = item.quantity; + + const handleQtyChange = (newQty) => { + if (newQty < 1) { + onRemove(item.sku); + el.remove(); + return; + } + qtyInput.value = newQty; + updatePrice(newQty); + onQtyChange(item.sku, newQty); + }; + + el.querySelector('.qty-dec').addEventListener('click', () => handleQtyChange(+qtyInput.value - 1)); + el.querySelector('.qty-inc').addEventListener('click', () => handleQtyChange(+qtyInput.value + 1)); + qtyInput.addEventListener('change', (e) => handleQtyChange(+e.target.value)); + + el.querySelector('.cart-item-remove').addEventListener('click', (ev) => { + ev.preventDefault(); + onRemove(item.sku); + el.remove(); + }); + + return el; +} diff --git a/scripts/payments/affirm.js b/scripts/payments/affirm.js new file mode 100644 index 00000000..f3eabf7e --- /dev/null +++ b/scripts/payments/affirm.js @@ -0,0 +1,119 @@ +/** @type {import('./types').PaymentProvider} */ +export default { + id: 'affirm', + label: 'Affirm', + supportsExpress: false, + hidesBilling: true, + + // Affirm SDK is bootstrapped after initiatePayment returns env-specific config + load: async () => {}, + + isAvailable: () => true, + + renderExpressButton: () => {}, + + renderCheckoutButton(container, callbacks) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button affirm-button'; + btn.textContent = 'Pay with Affirm'; + container.appendChild(btn); + + btn.addEventListener('click', async () => { + callbacks.clearError(); + btn.disabled = true; + + const state = callbacks.getState(); + if (!state.currentEstimateToken) { + await callbacks.updatePreview(); + if (!callbacks.getState().currentEstimateToken) { + callbacks.showError('Unable to calculate totals. Please try again.'); + btn.disabled = false; + return; + } + } + + const formData = callbacks.getFormData(); + const email = formData.get('email') || ''; + + let createdOrder; + try { + const orderBody = callbacks.buildOrderJSON(formData); + createdOrder = await callbacks.createOrder(orderBody); + callbacks.saveCheckoutSession( + email, + callbacks.getCart(), + callbacks.getState().currentPreview, + createdOrder.order ?? createdOrder, + ); + } catch (err) { + callbacks.showError(err.body?.message || 'Unable to place order. Please try again.'); + btn.disabled = false; + return; + } + + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; + let payment; + try { + payment = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + undefined, + 'affirm', + 'bnpl', + ); + } catch (err) { + const message = err.body?.error === 'ORDER_TOTAL_OUT_OF_RANGE' + ? 'Affirm is not available for this order total.' + : err.body?.message || 'Affirm is unavailable. Please try another payment method.'; + callbacks.showError(message); + btn.disabled = false; + return; + } + + if (!payment.checkoutObject) { + callbacks.showError('Unexpected payment response. Please try again.'); + btn.disabled = false; + return; + } + + const config = callbacks.getConfig(); + const locale = config.getLocale(); + const language = config.getLanguage(); + const affirmCountryCode = locale === 'ca' ? 'CAN' : 'USA'; + const [lang, region] = language.split('_'); + const affirmLocale = region ? `${lang}_${region.toUpperCase()}` : language; + + // eslint-disable-next-line no-underscore-dangle + window._affirm_config = { + public_api_key: payment.checkoutObject.merchant.public_api_key, + script: payment.affirmJsUrl, + locale: affirmLocale, + country_code: affirmCountryCode, + }; + + /* eslint-disable */ + (function(m,g,n,d,a,e,h,c){var b=m[n]||{},k=document.createElement(e),p=document.getElementsByTagName(e)[0],l=function(a,b,c){return function(){a[b]._.push([c,arguments])}};b[d]=l(b,d,"set");var f=b[d];b[a]={};b[a]._=[];f._=[];b._=[];b[a][h]=l(b,a,h);b[c]=function(){b._.push([h,arguments])};a=0;for(c="set add save post open empty reset on off trigger ready setProduct".split(" ");a { + affirm.ui.error.on('close', () => { + btn.disabled = false; + }); + affirm.checkout(payment.checkoutObject); + const openOpts = payment.checkoutMode === 'modal' ? { + onSuccess: ({ checkout_token: checkoutToken }) => { + const confirmUrl = payment.checkoutObject.merchant.user_confirmation_url; + window.location.href = `${confirmUrl}&checkout_token=${encodeURIComponent(checkoutToken)}`; + }, + onFail: () => { + btn.disabled = false; + }, + } : {}; + affirm.checkout.open(openOpts); + }); + /* eslint-enable no-undef */ + }); + }, +}; diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js new file mode 100644 index 00000000..433592d3 --- /dev/null +++ b/scripts/payments/apple-pay.js @@ -0,0 +1,327 @@ +import { validateApplePayMerchant, estimateExpressCheckout } from '../commerce-api.js'; + +const APPLE_PAY_SDK_URL = 'https://applepay.cdn-apple.com/jsapi/1.latest/apple-pay-sdk.js'; + +async function loadSdk() { + if (document.querySelector(`script[src="${APPLE_PAY_SDK_URL}"]`)) return; + await new Promise((resolve, reject) => { + const script = document.createElement('script'); + script.src = APPLE_PAY_SDK_URL; + script.crossOrigin = 'anonymous'; + script.onload = resolve; + script.onerror = reject; + document.head.appendChild(script); + }); +} + +function createApplePayButton(locale) { + const btn = document.createElement('apple-pay-button'); + btn.setAttribute('buttonstyle', 'black'); + btn.setAttribute('type', 'buy'); + btn.setAttribute('locale', locale || 'en-US'); + return btn; +} + +function startExpressSession(btn, config, callbacks) { + btn.addEventListener('click', async () => { + const cart = callbacks.getCart(); + const locale = config.getLocale(); + const language = config.getLanguage(); + const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; + + const request = { + countryCode: locale.toUpperCase(), + currencyCode: typeof config.currency === 'function' ? config.currency(locale) : config.currency, + supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], + merchantCapabilities: ['supports3DS'], + requiredShippingContactFields: ['name', 'email', 'phone', 'postalAddress'], + total: { label: config.site || 'Store', amount: cart.subtotal.toFixed(2) }, + }; + + const session = new window.ApplePaySession(3, request); + + session.onvalidatemerchant = async (e) => { + try { + const { merchantSession } = await validateApplePayMerchant(e.validationURL, locale, bcp47); + session.completeMerchantValidation(merchantSession); + } catch { + session.abort(); + } + }; + + session.onshippingcontactselected = async (e) => { + const contact = e.shippingContact; + try { + const result = await estimateExpressCheckout( + contact.countryCode, + contact.administrativeArea, + contact.postalCode, + cart.getItemsForAPI(), + ); + const methods = result.shippingMethods || []; + if (!methods.length) { + session.completeShippingContactSelection({ + errors: [new window.ApplePayError('addressUnserviceable')], + newTotal: { label: config.site || 'Store', amount: '0.00' }, + newShippingMethods: [], + newLineItems: [], + }); + return; + } + const defaultMethod = methods[0]; + session.completeShippingContactSelection({ + newShippingMethods: methods.map((m) => ({ + identifier: m.id, + label: m.label, + detail: m.eta || '', + amount: String(m.rate), + })), + newTotal: { label: config.site || 'Store', amount: String(defaultMethod.total) }, + newLineItems: [ + { label: 'Subtotal', amount: String(result.subtotal) }, + { label: 'Tax', amount: String(defaultMethod.taxAmount) }, + { label: 'Shipping', amount: String(defaultMethod.rate) }, + ], + }); + } catch { + session.completeShippingContactSelection({ + errors: [new window.ApplePayError('addressUnserviceable')], + newTotal: { label: config.site || 'Store', amount: '0.00' }, + newShippingMethods: [], + newLineItems: [], + }); + } + }; + + session.onshippingmethodselected = async (e) => { + try { + const previewResult = await callbacks.previewOrderDirect({ + items: cart.getItemsForAPI(), + shippingMethod: { id: e.shippingMethod.identifier }, + }); + session.completeShippingMethodSelection({ + newTotal: { label: config.site || 'Store', amount: String(previewResult.total) }, + newLineItems: [ + { label: 'Subtotal', amount: String(previewResult.subtotal) }, + { label: 'Tax', amount: String(previewResult.taxAmount) }, + { label: 'Shipping', amount: String(previewResult.shippingMethod?.rate ?? 0) }, + ], + }); + } catch { + session.abort(); + } + }; + + session.onpaymentauthorized = async (e) => { + const { payment } = e; + const contact = payment.shippingContact; + try { + const shippingAddr = { + name: `${contact.givenName} ${contact.familyName}`.trim(), + address1: (contact.addressLines || [])[0] || '', + address2: (contact.addressLines || [])[1] || '', + city: contact.locality, + state: contact.administrativeArea, + zip: contact.postalCode, + country: contact.countryCode, + phone: contact.phoneNumber || '', + email: contact.emailAddress || '', + }; + + const orderBody = { + customer: { + firstName: contact.givenName || '', + lastName: contact.familyName || '', + email: contact.emailAddress || '', + phone: contact.phoneNumber || '', + }, + shipping: shippingAddr, + billing: shippingAddr, + items: cart.getItemsForAPI(), + shippingMethod: { id: e.payment.shippingMethod?.identifier || '' }, + estimateToken: callbacks.getState().currentEstimateToken, + country: locale, + }; + + const createdOrder = await callbacks.createOrder(orderBody); + const fraudToken = (() => { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + })(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}`; + const result = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'chase-wallet', + 'apple-pay', + { token: payment.token, billingContact: payment.billingContact }, + ); + + if (result.status === 'completed') { + session.completePayment(window.ApplePaySession.STATUS_SUCCESS); + callbacks.onComplete(createdOrder); + } else { + session.completePayment(window.ApplePaySession.STATUS_FAILURE); + callbacks.showError(result.reason || 'Apple Pay payment failed.'); + } + } catch { + session.completePayment(window.ApplePaySession.STATUS_FAILURE); + callbacks.showError('Apple Pay payment failed. Please try again.'); + } + }; + + session.begin(); + }); +} + +/** + * Begins an Apple Pay checkout session synchronously within a user gesture, + * then returns a Promise that resolves/rejects when the session completes. + * + * IMPORTANT: This must be called synchronously from a trusted click handler. + * session.begin() fires inside the Promise executor (which is synchronous), + * preserving the user gesture required by Apple Pay. + * + * @param {Object} config + * @param {Object} callbacks + * @returns {Promise} Resolves with 'success' or 'cancel' + */ +export function beginCheckoutSession(config, callbacks) { + return new Promise((resolve, reject) => { + const state = callbacks.getState(); + + if (!window.ApplePaySession) { + reject(new Error('not-available')); + return; + } + + if (!state.currentPreview) { + reject(new Error('no-preview')); + return; + } + + const locale = config.getLocale(); + const language = config.getLanguage(); + const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; + + const request = { + countryCode: locale.toUpperCase(), + currencyCode: typeof config.currency === 'function' ? config.currency(locale) : config.currency, + supportedNetworks: ['visa', 'masterCard', 'amex', 'discover'], + merchantCapabilities: ['supports3DS'], + requiredShippingContactFields: [], + total: { + label: config.site || 'Store', + amount: parseFloat(state.currentPreview.total).toFixed(2), + }, + }; + + let session; + try { + session = new window.ApplePaySession(3, request); + } catch { + reject(new Error('not-available')); + return; + } + + session.onvalidatemerchant = async (e) => { + try { + const { merchantSession } = await validateApplePayMerchant(e.validationURL, locale, bcp47); + session.completeMerchantValidation(merchantSession); + } catch { + session.abort(); + reject(new Error('merchant-validation-failed')); + } + }; + + session.oncancel = () => resolve('cancel'); + + session.onpaymentauthorized = async (e) => { + const formData = callbacks.getFormData(); + try { + const orderBody = callbacks.buildOrderJSON(formData); + const createdOrder = await callbacks.createOrder(orderBody); + + const fraudToken = (() => { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + })(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}`; + const result = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'chase-wallet', + 'apple-pay', + { token: e.payment.token, billingContact: e.payment.billingContact }, + ); + + if (result.status === 'completed') { + session.completePayment(window.ApplePaySession.STATUS_SUCCESS); + callbacks.onComplete(createdOrder); + resolve('success'); + } else { + session.completePayment(window.ApplePaySession.STATUS_FAILURE); + reject(new Error(result.reason || 'payment-failed')); + } + } catch { + session.completePayment(window.ApplePaySession.STATUS_FAILURE); + reject(new Error('payment-failed')); + } + }; + + try { + session.begin(); // synchronous — user gesture still active here + } catch { + reject(new Error('not-available')); + } + }); +} + +function showNotConfiguredDialog() { + const dialog = document.createElement('dialog'); + dialog.className = 'paypal-not-configured-dialog'; + dialog.innerHTML = /* html */` +

          Apple Pay Express Checkout is not configured yet.

          + + `; + dialog.querySelector('button').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => dialog.remove()); + document.body.appendChild(dialog); + dialog.showModal(); +} + +/** @type {import('./types').PaymentProvider} */ +export default { + id: 'apple-pay', + label: 'Apple Pay', + supportsExpress: true, + hidesBilling: true, + + load: async () => loadSdk(), + + isAvailable: () => Boolean(window.ApplePaySession), + + renderExpressButton(container, callbacks) { + const config = callbacks.getConfig(); + const locale = config.getLocale(); + const language = config.getLanguage(); + const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; + const btn = createApplePayButton(bcp47); + + if (!window.CommerceConfig?.applePay?.merchantId) { + btn.addEventListener('click', (e) => { + e.preventDefault(); + showNotConfiguredDialog(); + }); + } else { + startExpressSession(btn, config, callbacks); + } + + container.appendChild(btn); + }, + + renderCheckoutButton() { + // Apple Pay is initiated directly from the form's submit button gesture + // via callbacks.beginApplePay — no button rendered here. + }, +}; diff --git a/scripts/payments/google-pay.js b/scripts/payments/google-pay.js new file mode 100644 index 00000000..67840c9c --- /dev/null +++ b/scripts/payments/google-pay.js @@ -0,0 +1,25 @@ +/** + * Google Pay provider stub. + * + * Implementation requires: + * - Load: https://pay.google.com/gp/p/js/pay.js + * - isAvailable: new google.payments.api.PaymentsClient({environment:'PRODUCTION'}) + * .isReadyToPay({apiVersion:2, apiVersionMinor:0, allowedPaymentMethods:[...]}) + * - renderExpressButton: client.createButton({onClick, buttonType:'buy'}) + * - renderCheckoutButton: same + * - Payment token: passed to initiatePayment as provider='google-pay', paymentMethod='google-pay' + * - Merchant config (merchantId, merchantName) must come from CommerceConfig + * or the initiatePayment response + * + * @type {import('./types').PaymentProvider} + */ +export default { + id: 'google-pay', + label: 'Google Pay', + supportsExpress: true, + hidesBilling: true, + load: async () => { throw new Error('Google Pay is not implemented'); }, + isAvailable: () => false, + renderExpressButton: () => {}, + renderCheckoutButton: () => {}, +}; diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js new file mode 100644 index 00000000..a755c760 --- /dev/null +++ b/scripts/payments/paypal.js @@ -0,0 +1,203 @@ +let sdkLoadPromise = null; + +const PAY_LATER_LABELS = { + fr: 'Payer plus tard', + de: 'Später bezahlen', + es: 'Pagar después', + it: 'Paga dopo', + nl: 'Nu kopen, later betalen', + pt: 'Pague depois', + pl: 'Zapłać później', + zh: '先买后付', + ja: '後払い', +}; + +function getPayLaterLabel(language) { + const lang = (language || 'en').split('_')[0].toLowerCase(); + return PAY_LATER_LABELS[lang] || 'Pay Later'; +} + +function loadSdk(clientId, currency, locale) { + if (sdkLoadPromise) return sdkLoadPromise; + sdkLoadPromise = new Promise((resolve, reject) => { + if (window.paypal) { resolve(); return; } + const script = document.createElement('script'); + const params = new URLSearchParams({ + 'client-id': clientId, + currency, + components: 'buttons', + locale, + }); + script.src = `https://www.paypal.com/sdk/js?${params}`; + script.onload = () => resolve(); + script.onerror = () => reject(new Error('PayPal SDK failed to load')); + document.head.appendChild(script); + }); + return sdkLoadPromise; +} + +const PAYPAL_WORDMARK = /* html */` + + PayPal +`; + +function payLaterWordmark(label) { + return /* html */` + + PayPal + +${label}`; +} + +function createButton(innerHTML, className) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = className; + btn.innerHTML = innerHTML; + return btn; +} + +function showNotConfiguredDialog() { + const dialog = document.createElement('dialog'); + dialog.className = 'paypal-not-configured-dialog'; + dialog.innerHTML = /* html */` +

          PayPal Express Checkout is not configured yet.

          + + `; + dialog.querySelector('button').addEventListener('click', () => dialog.close()); + dialog.addEventListener('close', () => dialog.remove()); + document.body.appendChild(dialog); + dialog.showModal(); +} + +function sdkOnClick(actions) { + showNotConfiguredDialog(); + return actions.reject(); +} + +/** @type {import('./types').PaymentProvider} */ +export default { + id: 'paypal', + label: 'PayPal', + supportsExpress: true, + hidesBilling: true, + + load: async (config) => { + const clientId = window.CommerceConfig?.paypal?.clientId; + if (!clientId) return; + const currency = typeof config.currency === 'function' + ? config.currency(config.getLocale()) + : (config.currency || 'USD'); + const locale = config.getLanguage().replace('-', '_'); + try { + await loadSdk(clientId, currency, locale); + } catch { /* fall back to stub buttons */ } + }, + + isAvailable: () => true, + + renderExpressButton(container, callbacks) { + if (window.paypal) { + window.paypal.Buttons({ + style: { + layout: 'horizontal', color: 'gold', shape: 'rect', label: 'paypal', disableMaxHeight: true, + }, + onClick: (data, actions) => sdkOnClick(actions), + }).render(container); + + const payLaterBtn = window.paypal.Buttons({ + fundingSource: window.paypal.FUNDING.PAYLATER, + style: { + layout: 'horizontal', color: 'silver', shape: 'rect', label: 'pay_later', disableMaxHeight: true, + }, + onClick: (data, actions) => sdkOnClick(actions), + }); + if (payLaterBtn.isEligible()) { + const payLaterWrapper = document.createElement('div'); + payLaterWrapper.className = 'paypal-paylater-wrapper'; + container.appendChild(payLaterWrapper); + payLaterBtn.render(payLaterWrapper); + } + return; + } + + // No SDK — stub buttons with localized Pay Later label + const label = getPayLaterLabel(callbacks.getConfig().getLanguage()); + const paypalBtn = createButton(PAYPAL_WORDMARK, 'paypal-express-btn'); + paypalBtn.addEventListener('click', showNotConfiguredDialog); + container.appendChild(paypalBtn); + + const payLaterWrapper = document.createElement('div'); + payLaterWrapper.className = 'paypal-paylater-wrapper'; + const payLaterBtn = createButton(payLaterWordmark(label), 'paypal-express-btn paylater-express-btn'); + payLaterBtn.addEventListener('click', showNotConfiguredDialog); + payLaterWrapper.appendChild(payLaterBtn); + container.appendChild(payLaterWrapper); + }, + + renderCheckoutButton(container, callbacks) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button paypal-redirect-btn'; + btn.textContent = 'Continue with PayPal'; + container.appendChild(btn); + + btn.addEventListener('click', async () => { + callbacks.clearError(); + btn.disabled = true; + + const state = callbacks.getState(); + if (!state.currentEstimateToken) { + await callbacks.updatePreview(); + if (!callbacks.getState().currentEstimateToken) { + callbacks.showError('Unable to calculate totals. Please try again.'); + btn.disabled = false; + return; + } + } + + const formData = callbacks.getFormData(); + const email = formData.get('email') || ''; + + let createdOrder; + try { + const orderBody = callbacks.buildOrderJSON(formData); + createdOrder = await callbacks.createOrder(orderBody); + callbacks.saveCheckoutSession( + email, + callbacks.getCart(), + callbacks.getState().currentPreview, + createdOrder.order ?? createdOrder, + ); + } catch (err) { + callbacks.showError(err.body?.message || 'Unable to place order. Please try again.'); + btn.disabled = false; + return; + } + + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}-${Math.random()}`; + const fraudToken = (() => { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + })(); + + try { + const payment = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'paypal', + 'paypal', + ); + if (payment.action === 'redirect' && payment.redirectUrl) { + window.location.href = payment.redirectUrl; + } else { + callbacks.showError('Unexpected payment response. Please try again.'); + btn.disabled = false; + } + } catch (err) { + callbacks.showError(err.body?.message || 'Something went wrong. Please try again.'); + btn.disabled = false; + } + }); + }, +}; diff --git a/scripts/scripts.js b/scripts/scripts.js index 12c68e72..0e092d97 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -19,11 +19,9 @@ import { const { hostname } = window.location; -export const ORDERS_API_ORIGIN = 'https://api-stage.adobecommerce.live/aemsites/sites/vitamix'; - -// Locales enabled for edge cart on production. -// Add locale codes here as each region goes live (e.g., 'ca', 'fr_ca'). -const EDGE_CART_LOCALES = []; +// Locale+language pairs enabled for edge checkout on production. +// Format: '/' (e.g., 'ca/fr_ca'). Add pairs as each region goes live. +const EDGE_CHECKOUT_LOCALES = ['ca/fr_ca', 'ca/en_us', 'us/en_us']; const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('integration.vitamix.com') || hostname.includes('uat.vitamix.com'); const isProdHost = hostname.includes('vitamix.com'); @@ -36,6 +34,11 @@ export const FORMS_ENDPOINT = isProdHost ? 'https://main--vitamix--aemsites.aem.network' // TODO: make empty string when Akamai ready : 'https://main--vitamix--aemsites.aem.network'; +window.CommerceConfig = { + org: 'aemsites', + site: 'vitamix', +}; + /** * Load fonts.css and set a session storage flag. */ @@ -1196,11 +1199,11 @@ async function loadEager(doc) { const { locale, language } = getLocaleAndLanguage(); document.documentElement.lang = language ? language.split('_')[0] : 'en'; - // Dev/staging hosts: edge cart enabled for all locales. - // Production: edge cart enabled only for locales listed in EDGE_CART_LOCALES. - window.cartMode = (isEdgeHost || EDGE_CART_LOCALES.includes(locale)) ? 'edge' : 'legacy'; - if (['edge', 'legacy'].includes(localStorage.getItem('cartMode'))) { - window.cartMode = localStorage.getItem('cartMode'); + // Dev/staging hosts: edge checkout enabled for all locales. + // Production: edge checkout enabled only for locale+language pairs in EDGE_CHECKOUT_LOCALES. + window.useEdgeCheckout = isEdgeHost || EDGE_CHECKOUT_LOCALES.includes(`${locale}/${language}`); + if (localStorage.getItem('useEdgeCheckout') !== null) { + window.useEdgeCheckout = localStorage.getItem('useEdgeCheckout') === 'true'; } /* simulation date */ diff --git a/styles/commerce-tokens.css b/styles/commerce-tokens.css new file mode 100644 index 00000000..b88699bd --- /dev/null +++ b/styles/commerce-tokens.css @@ -0,0 +1,66 @@ +:root { + /* ── Colour: brand ─────────────────────────────────────────── */ + --commerce-color-primary: var(--link-color, #ea580c); + --commerce-color-primary-hover: color-mix(in srgb, var(--commerce-color-primary) 85%, black); + --commerce-color-primary-text: #fff; + + /* ── Colour: text ──────────────────────────────────────────── */ + --commerce-color-text: var(--text-color, #1a1a1a); + --commerce-color-text-muted: #6b7280; + --commerce-color-text-price: var(--commerce-color-text); + + /* ── Colour: semantic ──────────────────────────────────────── */ + --commerce-color-error: #dc2626; + --commerce-color-error-bg: #fef2f2; + --commerce-color-error-border: #fecaca; + --commerce-color-success: #16a34a; + --commerce-color-discount: #16a34a; + + /* ── Colour: surface ───────────────────────────────────────── */ + --commerce-color-background: var(--background-color, #fff); + --commerce-color-surface: #f9fafb; + --commerce-color-border: #e5e7eb; + + /* ── Typography ────────────────────────────────────────────── */ + --commerce-font-family: var(--body-font-family, sans-serif); + --commerce-font-size-base: 1rem; + --commerce-font-size-sm: 0.875rem; + --commerce-font-size-xs: 0.8125rem; + --commerce-font-size-price: 1rem; + --commerce-font-size-total: 1.25rem; + --commerce-font-weight-bold: 600; + + /* ── Buttons ───────────────────────────────────────────────── */ + --commerce-button-radius: 6px; + --commerce-button-height: 48px; + --commerce-button-font-size: 1rem; + --commerce-button-font-weight: var(--commerce-font-weight-bold); + + /* ── Inputs ────────────────────────────────────────────────── */ + --commerce-input-radius: 6px; + --commerce-input-border: var(--commerce-color-border); + --commerce-input-border-focus: var(--commerce-color-primary); + --commerce-input-background: var(--commerce-color-background); + --commerce-input-height: 52px; + + /* ── Shape ─────────────────────────────────────────────────── */ + --commerce-radius-card: 12px; + --commerce-radius-badge: 4px; + + /* ── Spacing ───────────────────────────────────────────────── */ + --commerce-gap: 1.5rem; + --commerce-gap-sm: 0.75rem; + --commerce-card-padding: 24px; + + /* ── Layout ────────────────────────────────────────────────── */ + --commerce-max-width: 1300px; + --commerce-summary-width: 420px; + --commerce-form-max-width: 600px; + --commerce-item-image-size: 80px; + --commerce-sticky-top: 100px; + + /* ── Checkout progress step indicator ─────────────────────── */ + --commerce-step-color-active: #40596b; + --commerce-step-color-complete: var(--commerce-color-primary); + --commerce-step-color-inactive: var(--commerce-color-border); +} From f1b2392d3f3bdda9cb33076b6ab664c2f40e5f36 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Mon, 11 May 2026 22:53:29 -0400 Subject: [PATCH 50/89] fix: spinner on order total block on mobile --- blocks/checkout/checkout.css | 24 ++++++++++++++++++++++++ blocks/checkout/checkout.js | 6 ++++++ scripts/commerce-config.js | 3 ++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index decdcb5e..72405ac0 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -770,6 +770,30 @@ button.checkout-submit-btn { margin: 12px 0; padding-bottom: 12px; border-bottom: 1px solid var(--commerce-color-border); + position: relative; + } + + .order-total-breakdown.loading { + opacity: 0.4; + pointer-events: none; + } + + .order-total-breakdown.loading::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 20px; + height: 20px; + margin: -10px 0 0 -10px; + border: 2px solid var(--commerce-color-border); + border-top-color: var(--commerce-color-text); + border-radius: 50%; + animation: order-breakdown-spin 0.7s linear infinite; + } + + @keyframes order-breakdown-spin { + to { transform: rotate(360deg); } } .order-total-amount { diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index d2f6c736..d66cd943 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -162,6 +162,7 @@ export default async function decorate(block) { const orderTotalAmountEl = form.querySelector('.order-total-amount'); if (orderTotalAmountEl) { const currencyCode = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + const breakdownEl = form.querySelector('.order-total-breakdown'); const breakdownSubtotalEl = form.querySelector('.order-breakdown-subtotal'); const breakdownShippingEl = form.querySelector('.order-breakdown-shipping'); const breakdownTaxesEl = form.querySelector('.order-breakdown-taxes'); @@ -181,7 +182,12 @@ export default async function decorate(block) { document.addEventListener('cart:change', () => { if (cart.itemCount > 0) seedInitial(); }); + document.addEventListener('checkout:preview-loading', () => { + breakdownEl?.classList.add('loading'); + }); + document.addEventListener('checkout:preview', (e) => { + breakdownEl?.classList.remove('loading'); const { preview } = e.detail || {}; if (!preview) return; const { diff --git a/scripts/commerce-config.js b/scripts/commerce-config.js index 238aa8cd..7cbf9f6d 100644 --- a/scripts/commerce-config.js +++ b/scripts/commerce-config.js @@ -4,7 +4,8 @@ function resolveApiOrigin(org, site) { const isProduction = !hostname.endsWith('.aem.page') && !hostname.endsWith('.aem.live') && hostname !== 'localhost' - && !hostname.startsWith('127.'); + && !hostname.startsWith('127.') + && !hostname.startsWith('integration.'); const base = isProduction ? 'https://api.adobecommerce.live' : 'https://api-stage.adobecommerce.live'; From 773b5cf3d837eca9e5ecc455b287bbd8d969f8b8 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Mon, 11 May 2026 23:10:01 -0400 Subject: [PATCH 51/89] fix: order complete top padding --- blocks/order-complete/order-complete.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/blocks/order-complete/order-complete.css b/blocks/order-complete/order-complete.css index 31fdb314..e2b583a4 100644 --- a/blocks/order-complete/order-complete.css +++ b/blocks/order-complete/order-complete.css @@ -1,3 +1,7 @@ +.section > .order-complete-wrapper { + margin-top: 32px; +} + /* Order confirmation page */ .order-result { max-width: 900px; From 577f713ade10ea78104747e5b85bc77f5b0157b8 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 10:37:28 -0400 Subject: [PATCH 52/89] fix: update preview on method change --- blocks/checkout-progress/checkout-progress.js | 15 +++++++++++---- blocks/checkout/checkout.js | 5 +++++ 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/blocks/checkout-progress/checkout-progress.js b/blocks/checkout-progress/checkout-progress.js index 5f536fec..365a3a87 100644 --- a/blocks/checkout-progress/checkout-progress.js +++ b/blocks/checkout-progress/checkout-progress.js @@ -31,10 +31,17 @@ export default function decorate(block) { if (i < activeIndex) { li.classList.add('step-complete'); - const a = document.createElement('a'); - a.href = stepPaths[i]; - a.textContent = label; - li.appendChild(a); + const isLastStep = activeIndex === steps.length - 1; + if (isLastStep) { + const span = document.createElement('span'); + span.textContent = label; + li.appendChild(span); + } else { + const a = document.createElement('a'); + a.href = stepPaths[i]; + a.textContent = label; + li.appendChild(a); + } } else if (i === activeIndex) { li.classList.add('step-active'); li.setAttribute('aria-current', 'step'); diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index d66cd943..6c0f784b 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -255,6 +255,11 @@ export default async function decorate(block) { const paymentContainer = form.querySelector('.payment-method-section'); await initPayment(paymentContainer, ALL_PROVIDERS, callbacks, config, strings); + // Re-run preview on payment method change — tax may vary by type (e.g. Avalara surcharge) + paymentContainer.addEventListener('change', (e) => { + if (e.target.name === 'paymentMethod') updatePreview(form, cart, state, config); + }); + // Inject order-summary block into this section if the author didn't add one const section = block.closest('.section'); if (section && !section.querySelector('.order-summary-wrapper')) { From 6e954a01ac6e4e87c409c78e7b4062e1e514aab0 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 12:16:59 -0400 Subject: [PATCH 53/89] fix: send `paymentMethod` in preview request and align radio value with card provider The preview request was not including `paymentMethod`, so Avalara's `paymentMethodFees` surcharge was never applied. The card radio button value was also `credit-card` rather than the provider name, misaligning with the `payments-chase.json` config key used everywhere else. The radio value now uses `config.cardProvider` and `paymentMethod` is included in the preview body, wiring up the full surcharge path from storefront to Avalara. --- blocks/checkout/checkout-order.js | 4 ++-- blocks/checkout/checkout-payment.js | 6 +++--- blocks/checkout/checkout-shipping.js | 1 + 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js index 7acd7d34..7abc395b 100644 --- a/blocks/checkout/checkout-order.js +++ b/blocks/checkout/checkout-order.js @@ -146,7 +146,7 @@ export function initOrder(form, cart, state, config, strings) { } // Delegate to the selected provider's own button if not credit-card - const selectedMethod = form.querySelector('[name="paymentMethod"]:checked')?.value || 'credit-card'; + const selectedMethod = form.querySelector('[name="paymentMethod"]:checked')?.value; if (selectedMethod === 'apple-pay') { submitBtn.disabled = true; @@ -167,7 +167,7 @@ export function initOrder(form, cart, state, config, strings) { return; } - if (selectedMethod !== 'credit-card') { + if (selectedMethod !== config.cardProvider) { const providerBtn = form.querySelector(`.payment-button-container[data-provider="${selectedMethod}"] button`); if (providerBtn) { submitBtn.disabled = true; diff --git a/blocks/checkout/checkout-payment.js b/blocks/checkout/checkout-payment.js index 68518a5c..2c3cfcc1 100644 --- a/blocks/checkout/checkout-payment.js +++ b/blocks/checkout/checkout-payment.js @@ -55,7 +55,7 @@ function getProviderMeta(strings) { * @param {Object} callbacks * @param {Object} strings */ -export function renderPaymentSection(container, activeProviders, callbacks, strings) { +export function renderPaymentSection(container, activeProviders, callbacks, strings, config = {}) { const providerMeta = getProviderMeta(strings); // Security notice banner @@ -96,7 +96,7 @@ export function renderPaymentSection(container, activeProviders, callbacks, stri const cardRadio = document.createElement('input'); cardRadio.type = 'radio'; cardRadio.name = 'paymentMethod'; - cardRadio.value = 'credit-card'; + cardRadio.value = config.cardProvider || 'chase'; cardRadio.checked = true; cardRadio.id = 'payment-credit-card'; @@ -214,7 +214,7 @@ export async function initPayment(container, providers, callbacks, config, strin try { return p.isAvailable(); } catch { return false; } }); - renderPaymentSection(container, available, callbacks, strings); + renderPaymentSection(container, available, callbacks, strings, config); // Render express buttons on the cart page (order-summary block) const expressContainer = document.querySelector('.express-checkout-buttons'); diff --git a/blocks/checkout/checkout-shipping.js b/blocks/checkout/checkout-shipping.js index 127af99c..3d318ee7 100644 --- a/blocks/checkout/checkout-shipping.js +++ b/blocks/checkout/checkout-shipping.js @@ -103,6 +103,7 @@ export async function updatePreview(form, cart, state, config) { shippingMethod: { id: state.selectedShippingMethodId }, locale: `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`, country: locale, + paymentMethod: data.get('paymentMethod') || null, }; document.dispatchEvent(new CustomEvent('checkout:preview-loading')); From f06b441025ff2c43a9098cd586a9ddf1e0f5cbef Mon Sep 17 00:00:00 2001 From: Max Date: Tue, 12 May 2026 11:15:43 -0700 Subject: [PATCH 54/89] fix: newsletter form (#435) --- blocks/form/form.js | 22 +++++++++++++++------- tests/pdp/integration.spec.js | 26 +++++++++++++++----------- 2 files changed, 30 insertions(+), 18 deletions(-) diff --git a/blocks/form/form.js b/blocks/form/form.js index ad52151b..45af380a 100644 --- a/blocks/form/form.js +++ b/blocks/form/form.js @@ -1,5 +1,5 @@ import { toCamelCase, toClassName } from '../../scripts/aem.js'; -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getFormSubmissionUrl, getLocaleAndLanguage } from '../../scripts/scripts.js'; /** * Creates an HTML element with an optional class name @@ -426,17 +426,25 @@ function enableFooterSignUp(form) { leadSource = `sub-em-${window.leadSourceOverride}-${country}`; } + const { locale, language } = getLocaleAndLanguage(); const payload = { + formId: `${locale}/${language}/newsletter`, + pageUrl: window.location.href, email, mobile, - sms_optin: optIn ? '1' : '0', - lead_source: leadSource, - pageUrl: window.location.href, - actionUrl: '/us/en_us/rest/V1/vitamix-api/newslettersubscribe', + smsOptIn: optIn, + emailOptIn: true, + leadSource, }; - const params = new URLSearchParams(payload); try { - const resp = await fetch(`https://www.vitamix.com/bin/vitamix/newslettersubscription?${params.toString()}`); + const url = getFormSubmissionUrl(); + const resp = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }); if (!resp.ok) { // eslint-disable-next-line no-console console.error('Failed to submit newsletter subscription', resp); diff --git a/tests/pdp/integration.spec.js b/tests/pdp/integration.spec.js index 348784a8..3ad561c0 100644 --- a/tests/pdp/integration.spec.js +++ b/tests/pdp/integration.spec.js @@ -626,17 +626,21 @@ test.describe('PDP Integration Tests', () => { pageUrl, } = config; - await page.route('**/bin/vitamix/newslettersubscription**', async (route) => { - const url = route.request().url(); - const urlObj = new URL(url); - - // Check the query parameters - expect(urlObj.searchParams.get('email')).toBe('test@test.com'); - expect(urlObj.searchParams.get('mobile')).toBe('1234567890'); - expect(urlObj.searchParams.get('sms_optin')).toBe(smsOptin ? '1' : '0'); - expect(urlObj.searchParams.get('lead_source')).toBe(leadSource); - expect(urlObj.searchParams.get('pageUrl')).toContain(pageUrl); - expect(urlObj.searchParams.get('actionUrl')).toBe('/us/en_us/rest/V1/vitamix-api/newslettersubscribe'); + await page.route('**/us/en_us/forms**', async (route) => { + expect(route.request().method()).toBe('POST'); + const body = route.request().postDataJSON(); + + expect(body.formId).toBe('us/en_us/newsletter'); + expect(body.email).toBe('test@test.com'); + expect(body.mobile).toBe('1234567890'); + expect(body.emailOptIn).toBe(true); + expect(body.leadSource).toBe(leadSource); + expect(body.pageUrl).toContain(pageUrl); + if (smsOptin) { + expect(body.smsOptIn).toBeTruthy(); + } else { + expect(body.smsOptIn).toBeFalsy(); + } console.log('✓ Newsletter subscription request intercepted with correct parameters'); await route.fulfill({ From 667e034efb4bec16126dd32aec85e754ea3accd3 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 15:03:07 -0400 Subject: [PATCH 55/89] fix: respect free-shipping discounts in `parsePreview` `parsePreview` was reading `shippingMethod.rate` directly from the preview response, ignoring any `freeShipping: true` discount in `preview.discounts`. The API already computed the correct `total` (with effective shipping = $0), but the checkout breakdown displayed the raw shipping rate, making free-shipping rules appear to have no effect. Now checks `discounts` for a free-shipping flag and zeroes `shippingRate` when one is present. --- scripts/commerce-api.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index 82d35331..bf12e1da 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -173,7 +173,9 @@ export async function validateApplePayMerchant(validationUrl, country, locale) { export function parsePreview(preview, cartSubtotal) { const subtotal = parseFloat(preview.subtotal) || cartSubtotal; const taxAmount = parseFloat(preview.taxAmount) || 0; - const shippingRate = preview.shippingMethod?.rate ?? 0; + const rawRate = preview.shippingMethod?.rate ?? 0; + const hasFreeShipping = (preview.discounts ?? []).some((d) => d.freeShipping); + const shippingRate = hasFreeShipping ? 0 : rawRate; const total = parseFloat(preview.total) || (subtotal + taxAmount + shippingRate); return { subtotal, taxAmount, shippingRate, total, From 57875fd8505a63d62778b1cffc4da76b68a1a61e Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 16:23:21 -0400 Subject: [PATCH 56/89] fix: prevent unstyled elements --- blocks/cart-summary/cart-summary.js | 3 +-- blocks/cart/cart.js | 7 ++++--- blocks/checkout-progress/checkout-progress.js | 5 ++--- blocks/checkout/checkout.js | 3 +-- blocks/order-summary/order-summary.js | 5 ++--- 5 files changed, 10 insertions(+), 13 deletions(-) diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js index ee37701e..85c29472 100644 --- a/blocks/cart-summary/cart-summary.js +++ b/blocks/cart-summary/cart-summary.js @@ -6,8 +6,6 @@ import applePay from '../../scripts/payments/apple-pay.js'; import googlePay from '../../scripts/payments/google-pay.js'; import paypal from '../../scripts/payments/paypal.js'; -loadCSS('/styles/commerce-tokens.css'); - const ALL_PROVIDERS = [applePay, googlePay, paypal]; const LOCAL_STRINGS = { @@ -132,6 +130,7 @@ function filterProviders(providers) { * @param {HTMLDivElement} block */ export default async function decorate(block) { + await loadCSS('/styles/commerce-tokens.css'); const config = getConfig(); const s = getStrings(); diff --git a/blocks/cart/cart.js b/blocks/cart/cart.js index 4ea17094..bebf9557 100644 --- a/blocks/cart/cart.js +++ b/blocks/cart/cart.js @@ -3,9 +3,6 @@ import cart from '../../scripts/cart.js'; import { getConfig } from '../../scripts/commerce-config.js'; import buildCartItem from '../../scripts/commerce/cart-item.js'; -loadCSS('/styles/commerce-tokens.css'); -loadCSS('/blocks/cart/cart.css'); - const LOCAL_STRINGS = { 'en-us': { cartEmpty: 'Your cart is empty.', @@ -43,6 +40,10 @@ function buildTemplate(s) { } export default async function decorate(block) { + await Promise.all([ + loadCSS('/styles/commerce-tokens.css'), + loadCSS('/blocks/cart/cart.css'), + ]); block.closest('div.section')?.classList.add('cart-section'); const config = getConfig(); diff --git a/blocks/checkout-progress/checkout-progress.js b/blocks/checkout-progress/checkout-progress.js index 365a3a87..5422335a 100644 --- a/blocks/checkout-progress/checkout-progress.js +++ b/blocks/checkout-progress/checkout-progress.js @@ -1,11 +1,10 @@ import { loadCSS } from '../../scripts/aem.js'; import { getConfig } from '../../scripts/commerce-config.js'; -loadCSS('/styles/commerce-tokens.css'); - const STEP_KEYS = ['cart-new', 'checkout', 'complete']; -export default function decorate(block) { +export default async function decorate(block) { + await loadCSS('/styles/commerce-tokens.css'); const config = getConfig(); const s = config.getStrings(); const currentPath = window.location.pathname; diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 6c0f784b..99c7871a 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -11,8 +11,6 @@ import { initOrder } from './checkout-order.js'; import { initPayment } from './checkout-payment.js'; import { parsePreview } from '../../scripts/commerce-api.js'; -loadCSS('/styles/commerce-tokens.css'); - const ALL_PROVIDERS = [applePay, paypal, affirm]; const LOCAL_STRINGS = { @@ -122,6 +120,7 @@ function getStrings(config) { } export default async function decorate(block) { + await loadCSS('/styles/commerce-tokens.css'); const config = getConfig(); const strings = getStrings(config); diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js index e17ab823..5e3e4f91 100644 --- a/blocks/order-summary/order-summary.js +++ b/blocks/order-summary/order-summary.js @@ -4,8 +4,6 @@ import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; import buildCartItem from '../../scripts/commerce/cart-item.js'; import { parsePreview } from '../../scripts/commerce-api.js'; -loadCSS('/styles/commerce-tokens.css'); - function getStrings() { return getConfig().getStrings(); } @@ -80,7 +78,8 @@ function colocateWithForm(block) { /** * @param {HTMLDivElement} block */ -export default function decorate(block) { +export default async function decorate(block) { + await loadCSS('/styles/commerce-tokens.css'); const s = getStrings(); colocateWithForm(block); block.innerHTML = buildTemplate(s); From 9cd190539e0b7c3dbf0e622955c8e88d4cb95f5a Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 20:56:23 -0400 Subject: [PATCH 57/89] fix: google places autocomplete --- blocks/checkout/checkout-address.js | 128 +++++++++++++++++++++++++++ blocks/checkout/checkout-shipping.js | 19 ++-- blocks/checkout/checkout.css | 53 +++++++++++ 3 files changed, 190 insertions(+), 10 deletions(-) diff --git a/blocks/checkout/checkout-address.js b/blocks/checkout/checkout-address.js index 3afb9d57..c87681bc 100644 --- a/blocks/checkout/checkout-address.js +++ b/blocks/checkout/checkout-address.js @@ -126,6 +126,129 @@ function wireBillingToggle(form) { updateBillingVisibility(); } +function fillAddressFields(section, addressInput, addressComponents) { + const c = {}; + addressComponents.forEach((comp) => { + comp.types.forEach((type) => { c[type] = comp; }); + }); + + const street = [c.street_number?.longText, c.route?.longText].filter(Boolean).join(' '); + addressInput.value = street; + + const address2Input = section.querySelector('[autocomplete="address-line2"]'); + if (address2Input && c.subpremise) { + address2Input.value = c.subpremise.longText; + } + + const cityInput = section.querySelector('[autocomplete="address-level2"]'); + if (cityInput) { + cityInput.value = (c.locality || c.sublocality || c.postal_town)?.longText || ''; + } + + const zipInput = section.querySelector('[autocomplete="postal-code"]'); + if (zipInput) { + zipInput.value = c.postal_code?.longText || ''; + } + + // Set state last so FormData is complete when the change event triggers fetchAndPreview. + // Always dispatch even if the component is absent — the state may already be set from + // a prior interaction, and we still need to re-trigger the shipping/tax estimate. + const stateSelect = section.querySelector('select[name$="-state"]'); + if (stateSelect) { + if (c.administrative_area_level_1) { + stateSelect.value = c.administrative_area_level_1.shortText; + stateSelect.classList.toggle('has-value', !!stateSelect.value); + } + stateSelect.dispatchEvent(new Event('change', { bubbles: true })); + } +} + +function initPlacesAutocomplete(section, config) { + const addressInput = section.querySelector('[autocomplete="address-line1"]'); + if (!addressInput) return; + + const wrapper = document.createElement('div'); + wrapper.className = 'places-autocomplete-wrapper'; + addressInput.parentElement.insertBefore(wrapper, addressInput); + wrapper.append(addressInput); + + // type="search" + autocomplete="off" is the reliable way to suppress Chrome's + // address autofill dropdown — Chrome ignores autocomplete="off" on text inputs + // it heuristically classifies as address fields, but respects it on search inputs. + addressInput.type = 'search'; + addressInput.setAttribute('autocomplete', 'off'); + + let sessionToken = crypto.randomUUID(); + let debounceTimer; + let dropdown; + + function removeDropdown() { + if (dropdown) { dropdown.remove(); dropdown = null; } + } + + function showDropdown(suggestions) { + removeDropdown(); + if (!suggestions.length) return; + + dropdown = document.createElement('ul'); + dropdown.className = 'places-autocomplete-dropdown'; + + suggestions.forEach(({ placePrediction: p }) => { + const li = document.createElement('li'); + const main = document.createElement('span'); + main.className = 'places-main'; + main.textContent = p.structuredFormat.mainText.text; + const secondary = document.createElement('span'); + secondary.className = 'places-secondary'; + secondary.textContent = p.structuredFormat.secondaryText.text; + li.append(main, secondary); + + li.addEventListener('mousedown', async (e) => { + e.preventDefault(); + addressInput.value = p.structuredFormat.mainText.text; + removeDropdown(); + + try { + const params = new URLSearchParams({ place_id: p.placeId, sessiontoken: sessionToken }); + const resp = await fetch(`${config.apiOrigin}/places/details?${params}`); + if (resp.ok) { + const data = await resp.json(); + if (data.addressComponents) { + fillAddressFields(section, addressInput, data.addressComponents); + } + } + } catch { /* silent */ } + + sessionToken = crypto.randomUUID(); + }); + + dropdown.append(li); + }); + + wrapper.append(dropdown); + } + + addressInput.addEventListener('input', () => { + clearTimeout(debounceTimer); + const { value } = addressInput; + if (value.length < 3) { removeDropdown(); return; } + + debounceTimer = setTimeout(async () => { + try { + const params = new URLSearchParams({ input: value, sessiontoken: sessionToken }); + const resp = await fetch(`${config.apiOrigin}/places/autocomplete?${params}`); + if (!resp.ok) return; + const data = await resp.json(); + showDropdown(data.suggestions || []); + } catch { /* silent */ } + }, 300); + }); + + addressInput.addEventListener('blur', () => { + setTimeout(removeDropdown, 200); + }); +} + /** * @param {HTMLFormElement} form * @param {Object} state @@ -138,6 +261,11 @@ export function initAddress(form, state, config, strings) { populateStateSelect(form, 'billing-', isCanada, strings); wireBillingToggle(form); + const shippingSection = form.querySelector('.shipping-address-section'); + const billingSection = form.querySelector('.billing-fields-wrapper'); + if (shippingSection) initPlacesAutocomplete(shippingSection, config); + if (billingSection) initPlacesAutocomplete(billingSection, config); + // Invalidate estimate token when estimate-affecting fields change form.addEventListener('change', (e) => { if (ESTIMATE_FIELDS.has(e.target.name)) { diff --git a/blocks/checkout/checkout-shipping.js b/blocks/checkout/checkout-shipping.js index 3d318ee7..23939ada 100644 --- a/blocks/checkout/checkout-shipping.js +++ b/blocks/checkout/checkout-shipping.js @@ -71,9 +71,6 @@ export async function updatePreview(form, cart, state, config) { const email = data.get('email') || ''; const firstName = data.get('shipping-firstname') || ''; const lastName = data.get('shipping-lastname') || ''; - - // Preview requires customer identity — skip if not yet filled - if (!email || !firstName || !lastName) return; const locale = config.getLocale(); const language = config.getLanguage(); @@ -91,14 +88,7 @@ export async function updatePreview(form, cart, state, config) { }; const orderBody = { - customer: { - firstName, - lastName, - email, - phone: data.get('shipping-telephone') || '', - }, shipping: Object.fromEntries(Object.entries(shippingAddr).filter(([, v]) => v !== '')), - billing: Object.fromEntries(Object.entries(shippingAddr).filter(([, v]) => v !== '')), items: cart.getItemsForAPI(), shippingMethod: { id: state.selectedShippingMethodId }, locale: `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`, @@ -106,6 +96,15 @@ export async function updatePreview(form, cart, state, config) { paymentMethod: data.get('paymentMethod') || null, }; + if (email && firstName && lastName) { + orderBody.customer = { + firstName, + lastName, + email, + phone: data.get('shipping-telephone') || '', + }; + } + document.dispatchEvent(new CustomEvent('checkout:preview-loading')); try { diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 72405ac0..93e0bad3 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -896,6 +896,59 @@ button.checkout-submit-btn { color: var(--commerce-color-text); } +/* ── Places autocomplete ─────────────────────────────────────────── */ +.places-autocomplete-wrapper { + position: relative; +} + +/* Suppress search-input browser chrome added when we set type="search" */ +.places-autocomplete-wrapper input[type="search"]::-webkit-search-decoration, +.places-autocomplete-wrapper input[type="search"]::-webkit-search-cancel-button, +.places-autocomplete-wrapper input[type="search"]::-webkit-search-results-button, +.places-autocomplete-wrapper input[type="search"]::-webkit-search-results-decoration { + display: none; +} + +.places-autocomplete-dropdown { + position: absolute; + top: calc(100% + 4px); + left: 0; + right: 0; + z-index: 100; + margin: 0; + padding: 4px 0; + list-style: none; + background: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + box-shadow: 0 4px 12px rgb(0 0 0 / 10%); + max-height: 240px; + overflow-y: auto; +} + +.places-autocomplete-dropdown li { + display: flex; + flex-direction: column; + gap: 2px; + padding: 10px 14px; + cursor: pointer; + font-size: var(--commerce-font-size-sm); +} + +.places-autocomplete-dropdown li:hover { + background: var(--commerce-color-surface-alt, #f5f5f5); +} + +.places-main { + color: var(--commerce-color-text); + font-weight: var(--commerce-font-weight-medium, 500); +} + +.places-secondary { + color: var(--commerce-color-text-muted); + font-size: 0.75rem; +} + /* ── Mobile ──────────────────────────────────────────────────────── */ @media (width < 480px) { .form-field.field-half, From 87765f66a701bab5ee7eb71869205cb4fe7e0fa4 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 21:26:31 -0400 Subject: [PATCH 58/89] fix: collapse order summary --- blocks/order-summary/order-summary.css | 57 ++++++++++++++- blocks/order-summary/order-summary.js | 96 ++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) diff --git a/blocks/order-summary/order-summary.css b/blocks/order-summary/order-summary.css index 65fb327c..e19079fc 100644 --- a/blocks/order-summary/order-summary.css +++ b/blocks/order-summary/order-summary.css @@ -39,6 +39,42 @@ min-width: 0; } +.order-summary-header-total { + display: none; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); +} + +.order-summary-toggle { + display: none; + background: none; + border: none; + box-shadow: none; + outline: none; + cursor: pointer; + padding: 4px; + color: var(--commerce-color-text); + line-height: 0; + -webkit-tap-highlight-color: transparent; +} + +.order-summary-toggle:hover, +.order-summary-toggle:focus, +.order-summary-toggle:active { + background: none; + border: none; + box-shadow: none; + outline: none; +} + +.order-summary-toggle svg { + transition: transform 0.3s ease; +} + +.order-summary-toggle[aria-expanded="true"] svg { + transform: rotate(180deg); +} + /* Reset the site's per-wrapper padding/margin so flex items size correctly */ .section:has(.order-summary-wrapper) > .cart-wrapper, .section:has(.order-summary-wrapper) > .checkout-wrapper, @@ -79,6 +115,21 @@ position: static; order: -1; } + + .order-summary-toggle { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + } + + .order-summary-header-total { + display: block; + } + + .order-summary.is-collapsed .order-summary-header { + border-bottom-color: transparent; + } } .order-summary { @@ -91,18 +142,22 @@ .order-summary-header { display: flex; align-items: center; + gap: 12px; padding: 20px 24px 12px; border-bottom: 1px solid var(--commerce-color-border); + transition: border-bottom-color 0.35s ease; } .order-summary-header h3 { + flex: 1; margin: 0; font-size: var(--heading-size-l); } .order-summary-content { - display: block; + overflow: hidden; padding: var(--commerce-card-padding); + transition: height 0.35s ease, padding-top 0.35s ease, padding-bottom 0.35s ease; } /* Items list */ diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js index 5e3e4f91..fa1b44ad 100644 --- a/blocks/order-summary/order-summary.js +++ b/blocks/order-summary/order-summary.js @@ -18,6 +18,12 @@ function buildTemplate(s) {

          ${s.orderSummary}

          + +
          @@ -75,6 +81,92 @@ function colocateWithForm(block) { if (!mySection.children.length) mySection.remove(); } +function initMobileCollapse(block) { + const summary = block.querySelector('.order-summary'); + const toggle = block.querySelector('.order-summary-toggle'); + const content = block.querySelector('.order-summary-content'); + if (!summary || !toggle || !content) return; + + const mq = window.matchMedia('(max-width: 999px)'); + + // Measure natural padding once before any inline overrides. + const cs = window.getComputedStyle(content); + const naturalPT = parseFloat(cs.paddingTop); + const naturalPB = parseFloat(cs.paddingBottom); + + const setCollapsed = (instant) => { + if (instant) content.style.transition = 'none'; + content.style.height = '0'; + content.style.paddingTop = '0'; + content.style.paddingBottom = '0'; + if (instant) { + content.getBoundingClientRect(); + content.style.transition = ''; + } + }; + + const setExpanded = (instant) => { + if (instant) content.style.transition = 'none'; + content.style.height = ''; + content.style.paddingTop = ''; + content.style.paddingBottom = ''; + if (instant) { + content.getBoundingClientRect(); + content.style.transition = ''; + } + }; + + const expand = () => { + // paddingTop/Bottom are currently '0' inline; scrollHeight is content-only. + const targetHeight = content.scrollHeight + naturalPT + naturalPB; + content.style.height = `${targetHeight}px`; + content.style.paddingTop = `${naturalPT}px`; + content.style.paddingBottom = `${naturalPB}px`; + content.addEventListener('transitionend', (e) => { + if (e.propertyName === 'height') setExpanded(false); + }, { once: true }); + }; + + toggle.addEventListener('click', () => { + if (summary.classList.contains('is-collapsed')) { + summary.classList.remove('is-collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + expand(); + } else { + // Lock current pixel values so the transition has an explicit start point. + content.style.height = `${content.scrollHeight}px`; + content.style.paddingTop = `${naturalPT}px`; + content.style.paddingBottom = `${naturalPB}px`; + content.getBoundingClientRect(); + summary.classList.add('is-collapsed'); + toggle.setAttribute('aria-expanded', 'false'); + content.getBoundingClientRect(); + setCollapsed(false); + } + }); + + mq.addEventListener('change', (e) => { + if (e.matches) { + summary.classList.add('is-collapsed'); + toggle.setAttribute('aria-expanded', 'false'); + setCollapsed(true); + } else { + summary.classList.remove('is-collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + setExpanded(true); + } + }); + + summary.classList.add('is-collapsed'); + toggle.setAttribute('aria-expanded', 'false'); + setCollapsed(true); + if (!mq.matches) { + summary.classList.remove('is-collapsed'); + toggle.setAttribute('aria-expanded', 'true'); + setExpanded(true); + } +} + /** * @param {HTMLDivElement} block */ @@ -89,6 +181,7 @@ export default async function decorate(block) { const shippingEl = block.querySelector('.order-summary-shipping'); const taxesEl = block.querySelector('.order-summary-taxes'); const grandTotalEl = block.querySelector('.order-summary-grand-total'); + const headerTotalEl = block.querySelector('.order-summary-header-total'); const currencyEl = block.querySelector('.currency'); currencyEl.textContent = getCurrencyCode(); @@ -116,6 +209,7 @@ export default async function decorate(block) { shippingEl.textContent = '--'; taxesEl.textContent = '--'; grandTotalEl.textContent = subtotal; + headerTotalEl.textContent = subtotal; }; renderItems(); @@ -153,7 +247,9 @@ export default async function decorate(block) { : formatPrice(parseFloat(shippingRate), currency); taxesEl.textContent = formatPrice(taxAmount, currency); grandTotalEl.textContent = formatPrice(total, currency); + headerTotalEl.textContent = formatPrice(total, currency); }); syncVisibility(); + initMobileCollapse(block); } From e60dfbb3f448fe6bc899db033f8b2db2a2b10cf2 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 21:39:44 -0400 Subject: [PATCH 59/89] fix: wire Apple Pay express checkout to correct API endpoint --- scripts/commerce-api.js | 2 +- scripts/payments/apple-pay.js | 36 +++++++++++++---------------------- 2 files changed, 14 insertions(+), 24 deletions(-) diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index bf12e1da..673c6e57 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -84,7 +84,7 @@ export async function estimateShipping(country, state, items) { * @throws {CommerceApiError} */ export async function estimateExpressCheckout(country, state, zip, items) { - return post('/estimate/express-checkout', { + return post('/estimate/order', { country, shipping: { country, state, zip }, items, diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js index 433592d3..4873045b 100644 --- a/scripts/payments/apple-pay.js +++ b/scripts/payments/apple-pay.js @@ -29,6 +29,8 @@ function startExpressSession(btn, config, callbacks) { const language = config.getLanguage(); const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; + let lastShippingContact = null; + const request = { countryCode: locale.toUpperCase(), currencyCode: typeof config.currency === 'function' ? config.currency(locale) : config.currency, @@ -50,6 +52,7 @@ function startExpressSession(btn, config, callbacks) { }; session.onshippingcontactselected = async (e) => { + lastShippingContact = e.shippingContact; const contact = e.shippingContact; try { const result = await estimateExpressCheckout( @@ -95,9 +98,18 @@ function startExpressSession(btn, config, callbacks) { session.onshippingmethodselected = async (e) => { try { + const contact = lastShippingContact; const previewResult = await callbacks.previewOrderDirect({ items: cart.getItemsForAPI(), shippingMethod: { id: e.shippingMethod.identifier }, + ...(contact ? { + country: contact.countryCode, + shipping: { + country: contact.countryCode, + state: contact.administrativeArea, + zip: contact.postalCode || '', + }, + } : {}), }); session.completeShippingMethodSelection({ newTotal: { label: config.site || 'Store', amount: String(previewResult.total) }, @@ -277,19 +289,6 @@ export function beginCheckoutSession(config, callbacks) { }); } -function showNotConfiguredDialog() { - const dialog = document.createElement('dialog'); - dialog.className = 'paypal-not-configured-dialog'; - dialog.innerHTML = /* html */` -

          Apple Pay Express Checkout is not configured yet.

          - - `; - dialog.querySelector('button').addEventListener('click', () => dialog.close()); - dialog.addEventListener('close', () => dialog.remove()); - document.body.appendChild(dialog); - dialog.showModal(); -} - /** @type {import('./types').PaymentProvider} */ export default { id: 'apple-pay', @@ -307,16 +306,7 @@ export default { const language = config.getLanguage(); const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; const btn = createApplePayButton(bcp47); - - if (!window.CommerceConfig?.applePay?.merchantId) { - btn.addEventListener('click', (e) => { - e.preventDefault(); - showNotConfiguredDialog(); - }); - } else { - startExpressSession(btn, config, callbacks); - } - + startExpressSession(btn, config, callbacks); container.appendChild(btn); }, From e62dba7ce10cee92f05799b9cdd5cb977314b38f Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 21:50:58 -0400 Subject: [PATCH 60/89] fix: update preview on qty change --- blocks/checkout/checkout-shipping.js | 7 ------- blocks/checkout/checkout.js | 13 +++++++++++++ 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/blocks/checkout/checkout-shipping.js b/blocks/checkout/checkout-shipping.js index 23939ada..3c086fcd 100644 --- a/blocks/checkout/checkout-shipping.js +++ b/blocks/checkout/checkout-shipping.js @@ -184,11 +184,4 @@ export function initShipping(form, shippingContainer, cart, state, config, strin updatePreview(form, cart, state, config); } }); - - // Re-run preview when cart changes - document.addEventListener('cart:change', () => { - if (state.selectedShippingMethodId) { - updatePreview(form, cart, state, config); - } - }); } diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index 99c7871a..ff78463c 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -231,6 +231,19 @@ export default async function decorate(block) { const shippingContainer = form.querySelector('.shipping-methods'); initShipping(form, shippingContainer, cart, state, config, strings); + // When the cart changes mid-checkout, invalidate the stale estimate and + // re-run the preview so totals stay accurate. The empty-cart listener above + // handles the zero-items case via reload; this handles qty changes and + // partial removes while items remain. + document.addEventListener('cart:change', () => { + if (cart.itemCount === 0) return; + state.currentEstimateToken = null; + state.currentPreview = null; + if (state.selectedShippingMethodId) { + updatePreview(form, cart, state, config); + } + }); + // Retry preview when customer identity fields are filled after shipping is already selected. // This handles the case where state/province is chosen before email or name is entered, // causing the first updatePreview call to bail out on the missing-fields guard. From ef80d22b20c83b46e0c2588480ccc68397bab8dd Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 22:07:48 -0400 Subject: [PATCH 61/89] fix(apple-pay): lowercase countryCode before sending to /orders/preview --- scripts/payments/apple-pay.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js index 4873045b..0848c86e 100644 --- a/scripts/payments/apple-pay.js +++ b/scripts/payments/apple-pay.js @@ -99,13 +99,14 @@ function startExpressSession(btn, config, callbacks) { session.onshippingmethodselected = async (e) => { try { const contact = lastShippingContact; + const countryCode = contact?.countryCode?.toLowerCase(); const previewResult = await callbacks.previewOrderDirect({ items: cart.getItemsForAPI(), shippingMethod: { id: e.shippingMethod.identifier }, - ...(contact ? { - country: contact.countryCode, + ...(countryCode ? { + country: countryCode, shipping: { - country: contact.countryCode, + country: countryCode, state: contact.administrativeArea, zip: contact.postalCode || '', }, From abda3d702e293fcb46c11dfba341721328d473eb Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 22:18:58 -0400 Subject: [PATCH 62/89] fix: add locale to express checkout order body and lowercase shipping country --- scripts/payments/apple-pay.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js index 0848c86e..ffd79b76 100644 --- a/scripts/payments/apple-pay.js +++ b/scripts/payments/apple-pay.js @@ -136,7 +136,7 @@ function startExpressSession(btn, config, callbacks) { city: contact.locality, state: contact.administrativeArea, zip: contact.postalCode, - country: contact.countryCode, + country: contact.countryCode?.toLowerCase() || locale, phone: contact.phoneNumber || '', email: contact.emailAddress || '', }; @@ -154,6 +154,7 @@ function startExpressSession(btn, config, callbacks) { shippingMethod: { id: e.payment.shippingMethod?.identifier || '' }, estimateToken: callbacks.getState().currentEstimateToken, country: locale, + locale: bcp47, }; const createdOrder = await callbacks.createOrder(orderBody); From 1ce9d38b600b906add510dfde52f3a0aef9ff15e Mon Sep 17 00:00:00 2001 From: dylandepass Date: Tue, 12 May 2026 23:32:22 -0400 Subject: [PATCH 63/89] fix: fix Apple Pay estimate token hash mismatch in express and form checkout --- blocks/checkout/checkout-order.js | 3 +++ scripts/payments/apple-pay.js | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js index 7abc395b..dd27fb0e 100644 --- a/blocks/checkout/checkout-order.js +++ b/blocks/checkout/checkout-order.js @@ -68,6 +68,9 @@ export function buildOrderJSON(formData, form, cart, state, config) { order.locale = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; order.country = locale; + const paymentMethod = formData.get('paymentMethod'); + if (paymentMethod) order.paymentMethod = paymentMethod; + return order; } diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js index ffd79b76..25fa55f3 100644 --- a/scripts/payments/apple-pay.js +++ b/scripts/payments/apple-pay.js @@ -30,6 +30,7 @@ function startExpressSession(btn, config, callbacks) { const bcp47 = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; let lastShippingContact = null; + let lastShippingMethodId = null; const request = { countryCode: locale.toUpperCase(), @@ -112,6 +113,8 @@ function startExpressSession(btn, config, callbacks) { }, } : {}), }); + lastShippingMethodId = e.shippingMethod.identifier; + callbacks.getState().currentEstimateToken = previewResult.estimateToken; session.completeShippingMethodSelection({ newTotal: { label: config.site || 'Store', amount: String(previewResult.total) }, newLineItems: [ @@ -151,7 +154,7 @@ function startExpressSession(btn, config, callbacks) { shipping: shippingAddr, billing: shippingAddr, items: cart.getItemsForAPI(), - shippingMethod: { id: e.payment.shippingMethod?.identifier || '' }, + shippingMethod: { id: lastShippingMethodId || e.payment.shippingMethod?.identifier || '' }, estimateToken: callbacks.getState().currentEstimateToken, country: locale, locale: bcp47, From 0b655102c40f20f138ada88df35dc46f71f60415 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 13 May 2026 15:06:44 -0400 Subject: [PATCH 64/89] fix: recaptcha error labeling and allow chase disable --- blocks/checkout/checkout-order.js | 6 +- blocks/checkout/checkout-payment.js | 96 +++++++++++++++++------------ blocks/checkout/checkout.js | 3 +- scripts/commerce-api.js | 5 +- scripts/payments/apple-pay.js | 12 ++-- scripts/payments/chase.js | 9 +++ 6 files changed, 84 insertions(+), 47 deletions(-) create mode 100644 scripts/payments/chase.js diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js index dd27fb0e..0128ba6b 100644 --- a/blocks/checkout/checkout-order.js +++ b/blocks/checkout/checkout-order.js @@ -161,6 +161,7 @@ export function initOrder(form, cart, state, config, strings) { let msg = 'Apple Pay payment failed. Please try again.'; if (err.message === 'not-available') msg = 'Apple Pay is not available. Please try a different payment method.'; if (err.message === 'no-preview') msg = 'Please complete your shipping information first.'; + if (err.message === 'recaptcha-blocked') msg = 'Payment was declined for security reasons. Please refresh the page and try again.'; callbacks.showError(msg); } finally { submitBtn.disabled = false; @@ -232,7 +233,10 @@ export function initOrder(form, cart, state, config, strings) { if (submitTextEl) submitTextEl.textContent = strings.continueToPayment; } } catch (err) { - showError(form, err.body?.message || strings.errorGeneric); + const msg = err?.errorHeader === 'recaptcha score too low' + ? 'Payment was declined for security reasons. Please refresh the page and try again.' + : err.body?.message || strings.errorGeneric; + showError(form, msg); submitBtn.disabled = false; if (submitTextEl) submitTextEl.textContent = strings.continueToPayment; } diff --git a/blocks/checkout/checkout-payment.js b/blocks/checkout/checkout-payment.js index 2c3cfcc1..c70d8c7d 100644 --- a/blocks/checkout/checkout-payment.js +++ b/blocks/checkout/checkout-payment.js @@ -57,6 +57,8 @@ function getProviderMeta(strings) { */ export function renderPaymentSection(container, activeProviders, callbacks, strings, config = {}) { const providerMeta = getProviderMeta(strings); + const cardProviderId = config.cardProvider || 'chase'; + const chaseActive = activeProviders.some((p) => p.id === cardProviderId); // Security notice banner const notice = document.createElement('div'); @@ -90,47 +92,49 @@ export function renderPaymentSection(container, activeProviders, callbacks, stri optionsWrapper.className = 'payment-options'; // Credit card option - const cardLabel = document.createElement('label'); - cardLabel.className = 'payment-option-card payment-option-active'; - - const cardRadio = document.createElement('input'); - cardRadio.type = 'radio'; - cardRadio.name = 'paymentMethod'; - cardRadio.value = config.cardProvider || 'chase'; - cardRadio.checked = true; - cardRadio.id = 'payment-credit-card'; - - const cardIcon = document.createElement('span'); - cardIcon.className = 'payment-option-icon'; - cardIcon.innerHTML = CARD_ICON_SVG; - - const cardContent = document.createElement('div'); - cardContent.className = 'payment-option-content'; - - const cardTitle = document.createElement('span'); - cardTitle.className = 'payment-option-label'; - cardTitle.textContent = strings.creditCard; - - const cardSub = document.createElement('span'); - cardSub.className = 'payment-option-sublabel'; - cardSub.textContent = strings.creditCardSub; - - cardContent.append(cardTitle, cardSub); - - const cardBadges = document.createElement('div'); - cardBadges.className = 'payment-option-badges'; - ['VISA', 'MC', 'AMEX'].forEach((name) => { - const badge = document.createElement('span'); - badge.className = 'card-badge'; - badge.textContent = name; - cardBadges.appendChild(badge); - }); + if (chaseActive) { + const cardLabel = document.createElement('label'); + cardLabel.className = 'payment-option-card payment-option-active'; + + const cardRadio = document.createElement('input'); + cardRadio.type = 'radio'; + cardRadio.name = 'paymentMethod'; + cardRadio.value = cardProviderId; + cardRadio.checked = true; + cardRadio.id = 'payment-credit-card'; + + const cardIcon = document.createElement('span'); + cardIcon.className = 'payment-option-icon'; + cardIcon.innerHTML = CARD_ICON_SVG; + + const cardContent = document.createElement('div'); + cardContent.className = 'payment-option-content'; + + const cardTitle = document.createElement('span'); + cardTitle.className = 'payment-option-label'; + cardTitle.textContent = strings.creditCard; + + const cardSub = document.createElement('span'); + cardSub.className = 'payment-option-sublabel'; + cardSub.textContent = strings.creditCardSub; + + cardContent.append(cardTitle, cardSub); + + const cardBadges = document.createElement('div'); + cardBadges.className = 'payment-option-badges'; + ['VISA', 'MC', 'AMEX'].forEach((name) => { + const badge = document.createElement('span'); + badge.className = 'card-badge'; + badge.textContent = name; + cardBadges.appendChild(badge); + }); - cardLabel.append(cardRadio, cardIcon, cardContent, cardBadges); - optionsWrapper.appendChild(cardLabel); + cardLabel.append(cardRadio, cardIcon, cardContent, cardBadges); + optionsWrapper.appendChild(cardLabel); + } - // Provider options - activeProviders.forEach((provider) => { + // Provider options (Chase is rendered above as the card block) + activeProviders.filter((p) => p.id !== cardProviderId).forEach((provider) => { const meta = providerMeta[provider.id] || { label: provider.id, sublabel: '', icon: '' }; const optLabel = document.createElement('label'); @@ -173,6 +177,20 @@ export function renderPaymentSection(container, activeProviders, callbacks, stri container.appendChild(optionsWrapper); + // When Chase is disabled, default-select the first available provider + if (!chaseActive) { + const firstRadio = optionsWrapper.querySelector('input[type="radio"]'); + if (firstRadio) { + firstRadio.checked = true; + firstRadio.closest('.payment-option-card')?.classList.add('payment-option-active'); + const firstProvider = activeProviders.find((p) => p.id !== cardProviderId); + const billingSection = container.closest('form')?.querySelector('.billing-section'); + if (billingSection && firstProvider) { + billingSection.hidden = firstProvider.hidesBilling ?? false; + } + } + } + // Wire radio changes container.addEventListener('change', (e) => { if (e.target.name !== 'paymentMethod') return; diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index ff78463c..b170b003 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -4,6 +4,7 @@ import cart from '../../scripts/cart.js'; import applePay, { beginCheckoutSession } from '../../scripts/payments/apple-pay.js'; import paypal from '../../scripts/payments/paypal.js'; import affirm from '../../scripts/payments/affirm.js'; +import chase from '../../scripts/payments/chase.js'; import buildForm, { initCollapse } from './checkout-form.js'; import { initAddress } from './checkout-address.js'; import { initShipping, updatePreview } from './checkout-shipping.js'; @@ -11,7 +12,7 @@ import { initOrder } from './checkout-order.js'; import { initPayment } from './checkout-payment.js'; import { parsePreview } from '../../scripts/commerce-api.js'; -const ALL_PROVIDERS = [applePay, paypal, affirm]; +const ALL_PROVIDERS = [chase, applePay, paypal, affirm]; const LOCAL_STRINGS = { 'en-us': { diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index 673c6e57..2eb477cb 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -8,10 +8,11 @@ import { mintRecaptchaToken, RECAPTCHA_ACTIONS, RECAPTCHA_HEADER } from './recap * can inspect the error detail without re-parsing the response. */ class CommerceApiError extends Error { - constructor(status, body) { + constructor(status, body, errorHeader) { super(body?.message || `API error ${status}`); this.status = status; this.body = body; + this.errorHeader = errorHeader || null; } } @@ -46,7 +47,7 @@ async function post(path, body, recaptchaAction) { }); const data = await resp.json(); if (!resp.ok) { - throw new CommerceApiError(resp.status, data); + throw new CommerceApiError(resp.status, data, resp.headers.get('x-error')); } return data; } diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js index 25fa55f3..5c528852 100644 --- a/scripts/payments/apple-pay.js +++ b/scripts/payments/apple-pay.js @@ -181,9 +181,12 @@ function startExpressSession(btn, config, callbacks) { session.completePayment(window.ApplePaySession.STATUS_FAILURE); callbacks.showError(result.reason || 'Apple Pay payment failed.'); } - } catch { + } catch (err) { session.completePayment(window.ApplePaySession.STATUS_FAILURE); - callbacks.showError('Apple Pay payment failed. Please try again.'); + const msg = err?.errorHeader === 'recaptcha score too low' + ? 'Payment was declined for security reasons. Please refresh the page and try again.' + : 'Apple Pay payment failed. Please try again.'; + callbacks.showError(msg); } }; @@ -280,9 +283,10 @@ export function beginCheckoutSession(config, callbacks) { session.completePayment(window.ApplePaySession.STATUS_FAILURE); reject(new Error(result.reason || 'payment-failed')); } - } catch { + } catch (err) { session.completePayment(window.ApplePaySession.STATUS_FAILURE); - reject(new Error('payment-failed')); + const reason = err?.errorHeader === 'recaptcha score too low' ? 'recaptcha-blocked' : 'payment-failed'; + reject(new Error(reason)); } }; diff --git a/scripts/payments/chase.js b/scripts/payments/chase.js new file mode 100644 index 00000000..fc302ebc --- /dev/null +++ b/scripts/payments/chase.js @@ -0,0 +1,9 @@ +/** @type {import('./types').PaymentProvider} */ +export default { + id: 'chase', + supportsExpress: false, + load: async () => {}, + isAvailable: () => true, + renderExpressButton: () => {}, + renderCheckoutButton: () => {}, +}; From 12b0fd1bb76aabca2d6baaec9bd0e2e2821ab276 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 13 May 2026 15:33:43 -0400 Subject: [PATCH 65/89] fix: apple pay redirect --- blocks/cart-summary/cart-summary.js | 40 ++++++++++++----------------- blocks/checkout/checkout-order.js | 7 +++-- scripts/payments/apple-pay.js | 4 +++ 3 files changed, 25 insertions(+), 26 deletions(-) diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js index 85c29472..015b05b8 100644 --- a/blocks/cart-summary/cart-summary.js +++ b/blocks/cart-summary/cart-summary.js @@ -1,10 +1,11 @@ -import { loadCSS, getMetadata } from '../../scripts/aem.js'; +import { loadCSS } from '../../scripts/aem.js'; import { getConfig, formatPrice } from '../../scripts/commerce-config.js'; import cart from '../../scripts/cart.js'; import { previewOrder, createOrder, initiatePayment } from '../../scripts/commerce-api.js'; import applePay from '../../scripts/payments/apple-pay.js'; import googlePay from '../../scripts/payments/google-pay.js'; import paypal from '../../scripts/payments/paypal.js'; +import { getActiveProviders } from '../checkout/checkout-payment.js'; const ALL_PROVIDERS = [applePay, googlePay, paypal]; @@ -99,25 +100,6 @@ function buildTemplate(s) { `; } -/** - * Filters ALL_PROVIDERS using the `disabled-providers` page metadata and the - * `?enable-providers=` query param override used for testing. - * @param {Object[]} providers - * @returns {Object[]} - */ -function filterProviders(providers) { - const disabled = (getMetadata('disabled-providers') || '') - .split(',').map((p) => p.trim().toLowerCase()).filter(Boolean); - const reEnabled = (new URLSearchParams(window.location.search).get('enable-providers') || '') - .split(',').map((p) => p.trim().toLowerCase()).filter(Boolean); - - return providers.filter((p) => { - if (reEnabled.includes(p.id)) return true; - if (disabled.includes(p.id)) return false; - return true; - }); -} - /** * Decorates the cart-summary block. * @@ -177,7 +159,7 @@ export default async function decorate(block) { }); // 5. Build express-checkout callbacks, load SDKs, render available wallet buttons - const state = { currentEstimateToken: null }; + const state = { currentEstimateToken: null, currentPreview: null }; const callbacks = { getCart: () => cart, @@ -187,6 +169,7 @@ export default async function decorate(block) { const couponCode = sessionStorage.getItem('checkout_coupon_code') || undefined; const result = await previewOrder({ ...body, ...(couponCode ? { couponCode } : {}) }); if (result.estimateToken) state.currentEstimateToken = result.estimateToken; + state.currentPreview = result; return result; }, createOrder: (orderBody) => createOrder(orderBody), @@ -195,13 +178,22 @@ export default async function decorate(block) { errorEl.textContent = msg; errorEl.hidden = false; }, - onComplete: () => { + onComplete: (createdOrder) => { + const order = createdOrder?.order ?? createdOrder; + const orderId = order?.id; + try { + if (order?.customer?.email) sessionStorage.setItem('checkout_email', order.customer.email); + sessionStorage.setItem('checkout_cart_items', JSON.stringify(cart.items)); + if (state.currentPreview) sessionStorage.setItem('checkout_preview', JSON.stringify(state.currentPreview)); + if (order) sessionStorage.setItem('checkout_order', JSON.stringify(order)); + } catch { /* ignore */ } cart.clear(); - window.location.href = config.getOrderPath('complete'); + const path = config.getOrderPath('complete'); + window.location.href = orderId ? `${path}?orderId=${orderId}` : path; }, }; - const active = filterProviders(ALL_PROVIDERS).filter((p) => p.supportsExpress); + const active = getActiveProviders(ALL_PROVIDERS).filter((p) => p.supportsExpress); await Promise.all(active.map(async (p) => { try { await p.load(config); } catch { /* provider load failure handled by isAvailable check */ } })); diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js index 0128ba6b..3db5886e 100644 --- a/blocks/checkout/checkout-order.js +++ b/blocks/checkout/checkout-order.js @@ -123,9 +123,12 @@ export function initOrder(form, cart, state, config, strings) { initiatePayment: (...args) => initiatePayment(...args), showError: (msg) => showError(form, msg), clearError: () => clearError(form), - onComplete: () => { + onComplete: (createdOrder) => { + const order = createdOrder?.order ?? createdOrder; + const orderId = order?.id; cart.clear(); - window.location.href = config.getOrderPath('complete'); + const path = config.getOrderPath('complete'); + window.location.href = orderId ? `${path}?orderId=${orderId}` : path; }, }; diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js index 5c528852..5b03be0f 100644 --- a/scripts/payments/apple-pay.js +++ b/scripts/payments/apple-pay.js @@ -277,6 +277,10 @@ export function beginCheckoutSession(config, callbacks) { if (result.status === 'completed') { session.completePayment(window.ApplePaySession.STATUS_SUCCESS); + const email = formData.get('email') || ''; + const cart = callbacks.getCart(); + const preview = callbacks.getState().currentPreview; + callbacks.saveCheckoutSession?.(email, cart, preview, createdOrder.order ?? createdOrder); callbacks.onComplete(createdOrder); resolve('success'); } else { From 32b0a39ecca746ba8b96b77e1e97e6cff4138d22 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 13 May 2026 21:13:53 -0400 Subject: [PATCH 66/89] feat: paypal express checkout support --- scripts/commerce-api.js | 76 +++ scripts/payments/paypal.js | 219 ++++++-- tests/scripts/commerce-api-paypal.spec.js | 279 ++++++++++ tests/scripts/paypal-express.spec.js | 651 ++++++++++++++++++++++ 4 files changed, 1186 insertions(+), 39 deletions(-) create mode 100644 tests/scripts/commerce-api-paypal.spec.js create mode 100644 tests/scripts/paypal-express.spec.js diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index 2eb477cb..a3e7c2c5 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -146,6 +146,82 @@ export async function initiatePayment(orderId, idempotencyKey, fraudToken, provi }); } +/** + * Makes an authenticated HTTP request to the Commerce API. + * Attaches a Bearer token from sessionStorage when present. + * No reCAPTCHA support — PayPal session endpoints are Cloudflare-rate-limited. + * + * @param {string} path - API path relative to config.apiOrigin + * @param {object|null} body - Request payload; omitted when null + * @param {string} method - HTTP verb (GET, POST, PATCH, etc.) + * @returns {Promise} Parsed JSON response body + * @throws {CommerceApiError} If the response status is not 2xx + */ +async function request(path, body, method) { + const headers = {}; + if (body !== null) headers['Content-Type'] = 'application/json'; + const token = sessionStorage.getItem(AUTH_TOKEN_KEY); + if (token) headers.Authorization = `Bearer ${token}`; + + const resp = await fetch(`${getConfig().apiOrigin}${path}`, { + method, + headers, + ...(body !== null ? { body: JSON.stringify(body) } : {}), + }); + const data = await resp.json(); + if (!resp.ok) { + throw new CommerceApiError(resp.status, data, resp.headers.get('x-error')); + } + return data; +} + +/** + * Creates a PayPal order via the commerce API session endpoint. + * Called in the PayPal SDK's createOrder callback before a shipping address is known. + * + * @param {Array} items - Cart line items in API format + * @param {object} config - Commerce config with getLocale(), getLanguage(), currency + * @returns {Promise<{paypalOrderId: string}>} + * @throws {CommerceApiError} + */ +export async function createPayPalSession(items, config) { + const currency = typeof config.currency === 'function' + ? config.currency(config.getLocale()) + : config.currency; + return request('/payments/paypal/session', { + items, + currency, + locale: config.getLanguage().replace('-', '_'), + }, 'POST'); +} + +/** + * Patches a PayPal order with updated shipping address or selected option data. + * Called in onShippingAddressChange and onShippingOptionsChange SDK callbacks. + * + * @param {string} paypalOrderId - PayPal order ID from createPayPalSession + * @param {object} data - Patch payload ({ type, address?, items?, + * selectedOptionId?, total?, taxAmount?, shippingRate? }) + * @returns {Promise} API response + * @throws {CommerceApiError} + */ +export async function patchPayPalSession(paypalOrderId, data) { + return request(`/payments/paypal/session/${paypalOrderId}`, data, 'PATCH'); +} + +/** + * Retrieves the normalized payer and shipping details from a completed PayPal order. + * Called in onApprove after the buyer has authenticated. + * + * @param {string} paypalOrderId - PayPal order ID from createPayPalSession + * @returns {Promise<{payer: object, shippingAddress: object, + * selectedOptionId: string|undefined}>} + * @throws {CommerceApiError} + */ +export async function getPayPalSession(paypalOrderId) { + return request(`/payments/paypal/session/${paypalOrderId}`, null, 'GET'); +} + /** * Requests an Apple Pay merchant session from the Commerce API. * Called inside `ApplePaySession.onvalidatemerchant` — the API makes a mutual-TLS diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js index a755c760..32eb3c5c 100644 --- a/scripts/payments/paypal.js +++ b/scripts/payments/paypal.js @@ -1,3 +1,9 @@ +import { + createPayPalSession, + patchPayPalSession, + getPayPalSession, +} from '../commerce-api.js'; + let sdkLoadPromise = null; const PAY_LATER_LABELS = { @@ -25,8 +31,10 @@ function loadSdk(clientId, currency, locale) { const params = new URLSearchParams({ 'client-id': clientId, currency, - components: 'buttons', + components: 'buttons,messages', locale, + commit: 'false', + 'enable-funding': 'paylater', }); script.src = `https://www.paypal.com/sdk/js?${params}`; script.onload = () => resolve(); @@ -70,11 +78,6 @@ function showNotConfiguredDialog() { dialog.showModal(); } -function sdkOnClick(actions) { - showNotConfiguredDialog(); - return actions.reject(); -} - /** @type {import('./types').PaymentProvider} */ export default { id: 'paypal', @@ -96,43 +99,181 @@ export default { isAvailable: () => true, + /** + * Renders the PayPal Express Checkout button (and Pay Later button if + * eligible) into the provided container element. + * + * 1. Guard: if window.paypal is not loaded, render stub dialog buttons + * 2. Declare closure state: lastShippingMethods, lastShippingAddress + * 3. Build buttonConfig with createOrder, onShippingAddressChange, + * onShippingOptionsChange, onApprove, onError, onCancel + * 4. Render primary PayPal button + * 5. Render Pay Later button only if isEligible() + * + * @param {HTMLElement} container + * @param {object} callbacks + */ renderExpressButton(container, callbacks) { - if (window.paypal) { - window.paypal.Buttons({ - style: { - layout: 'horizontal', color: 'gold', shape: 'rect', label: 'paypal', disableMaxHeight: true, - }, - onClick: (data, actions) => sdkOnClick(actions), - }).render(container); - - const payLaterBtn = window.paypal.Buttons({ - fundingSource: window.paypal.FUNDING.PAYLATER, - style: { - layout: 'horizontal', color: 'silver', shape: 'rect', label: 'pay_later', disableMaxHeight: true, - }, - onClick: (data, actions) => sdkOnClick(actions), - }); - if (payLaterBtn.isEligible()) { - const payLaterWrapper = document.createElement('div'); - payLaterWrapper.className = 'paypal-paylater-wrapper'; - container.appendChild(payLaterWrapper); - payLaterBtn.render(payLaterWrapper); - } + if (!window.paypal) { + // No SDK — stub buttons with localized Pay Later label + const label = getPayLaterLabel(callbacks.getConfig().getLanguage()); + const paypalBtn = createButton(PAYPAL_WORDMARK, 'paypal-express-btn'); + paypalBtn.addEventListener('click', showNotConfiguredDialog); + container.appendChild(paypalBtn); + + const payLaterWrapper = document.createElement('div'); + payLaterWrapper.className = 'paypal-paylater-wrapper'; + const payLaterStub = createButton( + payLaterWordmark(label), + 'paypal-express-btn paylater-express-btn', + ); + payLaterStub.addEventListener('click', showNotConfiguredDialog); + payLaterWrapper.appendChild(payLaterStub); + container.appendChild(payLaterWrapper); return; } - // No SDK — stub buttons with localized Pay Later label - const label = getPayLaterLabel(callbacks.getConfig().getLanguage()); - const paypalBtn = createButton(PAYPAL_WORDMARK, 'paypal-express-btn'); - paypalBtn.addEventListener('click', showNotConfiguredDialog); - container.appendChild(paypalBtn); - - const payLaterWrapper = document.createElement('div'); - payLaterWrapper.className = 'paypal-paylater-wrapper'; - const payLaterBtn = createButton(payLaterWordmark(label), 'paypal-express-btn paylater-express-btn'); - payLaterBtn.addEventListener('click', showNotConfiguredDialog); - payLaterWrapper.appendChild(payLaterBtn); - container.appendChild(payLaterWrapper); + let lastShippingMethods = []; + let lastShippingAddress = null; + + const buttonConfig = { + style: { + layout: 'horizontal', + color: 'gold', + shape: 'rect', + label: 'paypal', + disableMaxHeight: true, + }, + + createOrder: async () => { + const config = callbacks.getConfig(); + const cart = callbacks.getCart(); + const state = callbacks.getState(); + const { paypalOrderId } = await createPayPalSession(cart.getItemsForAPI(), config); + state.paypalSessionId = paypalOrderId; + return paypalOrderId; + }, + + onShippingAddressChange: async (data, actions) => { + lastShippingAddress = data.shippingAddress; + const state = callbacks.getState(); + const cart = callbacks.getCart(); + try { + const result = await patchPayPalSession(state.paypalSessionId, { + type: 'address', + address: { + country: data.shippingAddress.countryCode, + state: data.shippingAddress.state, + zip: data.shippingAddress.postalCode, + }, + items: cart.getItemsForAPI(), + }); + if (!result.shippingMethods?.length) { + return actions.reject(data.errors.ADDRESS_ERROR); + } + lastShippingMethods = result.shippingMethods; + } catch { + return actions.reject(data.errors.ADDRESS_ERROR); + } + return undefined; + }, + + onShippingOptionsChange: async (data, actions) => { + const selectedId = data.selectedShippingOption?.id; + const method = lastShippingMethods.find((m) => m.id === selectedId); + if (!method) return actions.reject(data.errors.METHOD_UNAVAILABLE); + const state = callbacks.getState(); + const cart = callbacks.getCart(); + await patchPayPalSession(state.paypalSessionId, { + type: 'option', + selectedOptionId: method.id, + total: method.total, + taxAmount: method.taxAmount, + shippingRate: method.rate, + }); + const countryCode = lastShippingAddress?.countryCode?.toLowerCase(); + const preview = await callbacks.previewOrderDirect({ + items: cart.getItemsForAPI(), + shippingMethod: { id: method.id }, + ...(countryCode ? { + country: countryCode, + shipping: { + country: countryCode, + state: lastShippingAddress.state, + zip: lastShippingAddress.postalCode || '', + }, + } : {}), + }); + state.currentEstimateToken = preview.estimateToken; + return undefined; + }, + + onApprove: async () => { + try { + const state = callbacks.getState(); + const session = await getPayPalSession(state.paypalSessionId); + const cart = callbacks.getCart(); + const config = callbacks.getConfig(); + const orderBody = { + customer: { + firstName: session.payer.firstName, + lastName: session.payer.lastName, + email: session.payer.email, + phone: '', + }, + shipping: session.shippingAddress, + billing: session.shippingAddress, + items: cart.getItemsForAPI(), + shippingMethod: { id: session.selectedOptionId }, + estimateToken: state.currentEstimateToken, + country: session.shippingAddress.country, + locale: config.getLanguage(), + }; + const createdOrder = await callbacks.createOrder(orderBody); + const fraudToken = (() => { + try { return sessionStorage.getItem('forter_token') || undefined; } catch { return undefined; } + })(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}`; + const result = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'paypal-express', + 'paypal', + { paypalOrderId: state.paypalSessionId }, + ); + if (result.status === 'completed') { + callbacks.onComplete(createdOrder); + } else { + callbacks.showError(result.reason || 'PayPal payment failed. Please try again.'); + } + } catch { + callbacks.showError('PayPal payment failed. Please try again.'); + } + }, + + onError: () => { + callbacks.showError('PayPal encountered an error. Please try again.'); + }, + + onCancel: () => { + // User dismissed the PayPal sheet intentionally — no action needed. + }, + }; + + window.paypal.Buttons(buttonConfig).render(container); + + const payLaterBtn = window.paypal.Buttons({ + ...buttonConfig, + fundingSource: window.paypal.FUNDING.PAYLATER, + style: { ...buttonConfig.style, color: 'silver', label: 'pay_later' }, + }); + if (payLaterBtn.isEligible()) { + const wrapper = document.createElement('div'); + wrapper.className = 'paypal-paylater-wrapper'; + container.appendChild(wrapper); + payLaterBtn.render(wrapper); + } }, renderCheckoutButton(container, callbacks) { diff --git a/tests/scripts/commerce-api-paypal.spec.js b/tests/scripts/commerce-api-paypal.spec.js new file mode 100644 index 00000000..ddc9bbb2 --- /dev/null +++ b/tests/scripts/commerce-api-paypal.spec.js @@ -0,0 +1,279 @@ +import { test, expect } from '@playwright/test'; + +/** + * Unit tests for the PayPal session helpers in scripts/commerce-api.js. + * + * The functions are inlined here with injectable dependencies because + * scripts/commerce-api.js imports auth-api.js and recaptcha.js, which in + * turn import aem.js — a browser-only module that sets window.hlx at load + * time and cannot be imported in the Node test runner. This mirrors the + * pattern used in pricing.spec.js and recaptcha.spec.js. + * + * Keep these copies in sync with the real implementations. + */ + +// --------------------------------------------------------------------------- +// Inlined implementations with injectable deps +// --------------------------------------------------------------------------- + +class CommerceApiError extends Error { + constructor(status, body, errorHeader) { + super(body?.message || `API error ${status}`); + this.status = status; + this.body = body; + this.errorHeader = errorHeader || null; + } +} + +/** + * @param {string} path + * @param {object|null} body + * @param {string} method + * @param {{ apiOrigin: string, token: string|null, fetchFn: Function }} deps + */ +async function request(path, body, method, { apiOrigin, token, fetchFn }) { + const headers = {}; + if (body !== null) headers['Content-Type'] = 'application/json'; + if (token) headers.Authorization = `Bearer ${token}`; + const resp = await fetchFn(`${apiOrigin}${path}`, { + method, + headers, + ...(body !== null ? { body: JSON.stringify(body) } : {}), + }); + const data = await resp.json(); + if (!resp.ok) throw new CommerceApiError(resp.status, data, resp.headers.get('x-error')); + return data; +} + +async function createPayPalSession(items, config, deps) { + const currency = typeof config.currency === 'function' + ? config.currency(config.getLocale()) + : config.currency; + return request('/payments/paypal/session', { + items, + currency, + locale: config.getLanguage().replace('-', '_'), + }, 'POST', deps); +} + +async function patchPayPalSession(paypalOrderId, data, deps) { + return request(`/payments/paypal/session/${paypalOrderId}`, data, 'PATCH', deps); +} + +async function getPayPalSession(paypalOrderId, deps) { + return request(`/payments/paypal/session/${paypalOrderId}`, null, 'GET', deps); +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const ORIGIN = 'https://api.example.com'; +const PAYPAL_ORDER_ID = 'PP-ORDER-001'; +const SAMPLE_ITEMS = [ + { sku: 'SKU-001', quantity: 2, price: { final: '49.99', currency: 'USD' } }, +]; + +function makeDeps({ token = null, responseBody = {}, status = 200 } = {}) { + const headers = new Map([['x-error', null]]); + const fetchFn = async () => ({ + ok: status >= 200 && status < 300, + status, + json: async () => responseBody, + headers: { get: (k) => headers.get(k) ?? null }, + }); + return { apiOrigin: ORIGIN, token, fetchFn }; +} + +function makeCapturingDeps({ token = null, responseBody = {}, status = 200 } = {}) { + let captured; + const fetchFn = async (url, init) => { + captured = { url, init }; + return { + ok: status >= 200 && status < 300, + status, + json: async () => responseBody, + headers: { get: () => null }, + }; + }; + return { + deps: { apiOrigin: ORIGIN, token, fetchFn }, + getCapture: () => captured, + }; +} + +// --------------------------------------------------------------------------- +// createPayPalSession +// --------------------------------------------------------------------------- + +test.describe('createPayPalSession', () => { + const CONFIG = { + currency: 'USD', + getLocale: () => 'en-US', + getLanguage: () => 'en-US', + }; + + test('POSTs to /payments/paypal/session', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: { paypalOrderId: 'PP-001' } }); + await createPayPalSession(SAMPLE_ITEMS, CONFIG, deps); + expect(getCapture().url).toBe(`${ORIGIN}/payments/paypal/session`); + expect(getCapture().init.method).toBe('POST'); + }); + + test('sends items, currency, and locale in the request body', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: { paypalOrderId: 'PP-001' } }); + await createPayPalSession(SAMPLE_ITEMS, CONFIG, deps); + const body = JSON.parse(getCapture().init.body); + expect(body.items).toEqual(SAMPLE_ITEMS); + expect(body.currency).toBe('USD'); + expect(body.locale).toBe('en_US'); + }); + + test('converts locale hyphen to underscore', async () => { + const config = { ...CONFIG, getLanguage: () => 'fr-CA' }; + const { deps, getCapture } = makeCapturingDeps({ responseBody: { paypalOrderId: 'PP-001' } }); + await createPayPalSession(SAMPLE_ITEMS, config, deps); + const body = JSON.parse(getCapture().init.body); + expect(body.locale).toBe('fr_CA'); + }); + + test('resolves currency via function when config.currency is a function', async () => { + const config = { + currency: (locale) => (locale === 'en-US' ? 'USD' : 'CAD'), + getLocale: () => 'en-US', + getLanguage: () => 'en-US', + }; + const { deps, getCapture } = makeCapturingDeps({ responseBody: { paypalOrderId: 'PP-001' } }); + await createPayPalSession(SAMPLE_ITEMS, config, deps); + const body = JSON.parse(getCapture().init.body); + expect(body.currency).toBe('USD'); + }); + + test('returns the parsed response body', async () => { + const deps = makeDeps({ responseBody: { paypalOrderId: 'PP-NEW-001' } }); + const result = await createPayPalSession(SAMPLE_ITEMS, CONFIG, deps); + expect(result.paypalOrderId).toBe('PP-NEW-001'); + }); + + test('throws CommerceApiError on non-2xx response', async () => { + const deps = makeDeps({ status: 422, responseBody: { message: 'invalid items' } }); + await expect(createPayPalSession(SAMPLE_ITEMS, CONFIG, deps)) + .rejects.toBeInstanceOf(CommerceApiError); + }); +}); + +// --------------------------------------------------------------------------- +// patchPayPalSession +// --------------------------------------------------------------------------- + +test.describe('patchPayPalSession', () => { + const PATCH_DATA = { type: 'address', currency: 'USD', address: { country: 'us' } }; + + test('makes PATCH request to /payments/paypal/session/:paypalOrderId', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await patchPayPalSession(PAYPAL_ORDER_ID, PATCH_DATA, deps); + expect(getCapture().url).toBe(`${ORIGIN}/payments/paypal/session/${PAYPAL_ORDER_ID}`); + expect(getCapture().init.method).toBe('PATCH'); + }); + + test('sends the data object as the request body', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await patchPayPalSession(PAYPAL_ORDER_ID, PATCH_DATA, deps); + expect(JSON.parse(getCapture().init.body)).toEqual(PATCH_DATA); + }); + + test('returns the parsed response body', async () => { + const deps = makeDeps({ responseBody: { shippingMethods: [], subtotal: '99.98' } }); + const result = await patchPayPalSession(PAYPAL_ORDER_ID, PATCH_DATA, deps); + expect(result.subtotal).toBe('99.98'); + }); + + test('throws CommerceApiError on non-2xx response', async () => { + const deps = makeDeps({ status: 502, responseBody: { message: 'paypal_token_error' } }); + let err; + try { + await patchPayPalSession(PAYPAL_ORDER_ID, PATCH_DATA, deps); + } catch (e) { + err = e; + } + expect(err).toBeInstanceOf(CommerceApiError); + expect(err.status).toBe(502); + }); +}); + +// --------------------------------------------------------------------------- +// getPayPalSession +// --------------------------------------------------------------------------- + +test.describe('getPayPalSession', () => { + test('makes GET request to /payments/paypal/session/:paypalOrderId', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().url).toBe(`${ORIGIN}/payments/paypal/session/${PAYPAL_ORDER_ID}`); + expect(getCapture().init.method).toBe('GET'); + }); + + test('sends no body on GET', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.body).toBeUndefined(); + }); + + test('returns normalized payer and shipping details', async () => { + const responseBody = { + payer: { email_address: 'buyer@example.com' }, + shippingAddress: { country: 'us' }, + selectedOptionId: 'method-standard', + }; + const deps = makeDeps({ responseBody }); + const result = await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(result.payer.email_address).toBe('buyer@example.com'); + expect(result.selectedOptionId).toBe('method-standard'); + }); + + test('throws CommerceApiError on non-2xx response', async () => { + const deps = makeDeps({ status: 404, responseBody: { message: 'not found' } }); + await expect(getPayPalSession(PAYPAL_ORDER_ID, deps)) + .rejects.toBeInstanceOf(CommerceApiError); + }); +}); + +// --------------------------------------------------------------------------- +// request helper behaviour (tested via the wrappers above) +// --------------------------------------------------------------------------- + +test.describe('request helper', () => { + test('attaches Authorization header when token is present', async () => { + const { deps, getCapture } = makeCapturingDeps({ token: 'my-token', responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.headers.Authorization).toBe('Bearer my-token'); + }); + + test('omits Authorization header when token is absent', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.headers.Authorization).toBeUndefined(); + }); + + test('sets Content-Type: application/json when body is non-null', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await patchPayPalSession( + PAYPAL_ORDER_ID, + { type: 'address', currency: 'USD' }, + deps, + ); + expect(getCapture().init.headers['Content-Type']).toBe('application/json'); + }); + + test('omits Content-Type when body is null (GET)', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.headers['Content-Type']).toBeUndefined(); + }); + + test('does not attach a reCAPTCHA token (PayPal endpoints are rate-limited)', async () => { + const { deps, getCapture } = makeCapturingDeps({ responseBody: {} }); + await getPayPalSession(PAYPAL_ORDER_ID, deps); + expect(getCapture().init.headers['X-Recaptcha-Token']).toBeUndefined(); + }); +}); diff --git a/tests/scripts/paypal-express.spec.js b/tests/scripts/paypal-express.spec.js new file mode 100644 index 00000000..c3bc8e65 --- /dev/null +++ b/tests/scripts/paypal-express.spec.js @@ -0,0 +1,651 @@ +import { test, expect } from '@playwright/test'; + +/** + * Unit tests for renderExpressButton callbacks in scripts/payments/paypal.js. + * + * The callbacks are inlined here with injectable dependencies because paypal.js + * imports commerce-api.js → auth-api.js → aem.js, a browser-only module that + * cannot be loaded in the Node test runner. + * + * Keep these copies in sync with the real implementations in paypal.js. + */ + +// --------------------------------------------------------------------------- +// Inlined callback logic with injectable deps +// --------------------------------------------------------------------------- + +/** @param {{ createPayPalSessionFn: Function }} deps */ +async function handleCreateOrder(callbacks, deps) { + const config = callbacks.getConfig(); + const state = callbacks.getState(); + const cart = callbacks.getCart(); + const { paypalOrderId } = await deps.createPayPalSessionFn(cart.getItemsForAPI(), config); + state.paypalSessionId = paypalOrderId; + return paypalOrderId; +} + +/** + * @param {object} data + * @param {object} actions + * @param {{ lastShippingMethods: Array, lastShippingAddress: object|null }} closureState + * @param {object} callbacks + * @param {{ patchPayPalSessionFn: Function }} deps + */ +async function handleShippingAddressChange(data, actions, closureState, callbacks, deps) { + closureState.lastShippingAddress = data.shippingAddress; + const state = callbacks.getState(); + const cart = callbacks.getCart(); + try { + const result = await deps.patchPayPalSessionFn(state.paypalSessionId, { + type: 'address', + address: { + country: data.shippingAddress.countryCode, + state: data.shippingAddress.state, + zip: data.shippingAddress.postalCode, + }, + items: cart.getItemsForAPI(), + }); + if (!result.shippingMethods?.length) { + return actions.reject(data.errors.ADDRESS_ERROR); + } + closureState.lastShippingMethods = result.shippingMethods; + } catch { + return actions.reject(data.errors.ADDRESS_ERROR); + } + return undefined; +} + +/** + * @param {object} data + * @param {object} actions + * @param {{ lastShippingMethods: Array, lastShippingAddress: object|null }} closureState + * @param {object} callbacks + * @param {{ patchPayPalSessionFn: Function }} deps + */ +async function handleShippingOptionsChange(data, actions, closureState, callbacks, deps) { + const selectedId = data.selectedShippingOption?.id; + const method = closureState.lastShippingMethods.find((m) => m.id === selectedId); + if (!method) return actions.reject(data.errors.METHOD_UNAVAILABLE); + const state = callbacks.getState(); + const cart = callbacks.getCart(); + await deps.patchPayPalSessionFn(state.paypalSessionId, { + type: 'option', + selectedOptionId: method.id, + total: method.total, + taxAmount: method.taxAmount, + shippingRate: method.rate, + }); + const countryCode = closureState.lastShippingAddress?.countryCode?.toLowerCase(); + const preview = await callbacks.previewOrderDirect({ + items: cart.getItemsForAPI(), + shippingMethod: { id: method.id }, + ...(countryCode ? { + country: countryCode, + shipping: { + country: countryCode, + state: closureState.lastShippingAddress.state, + zip: closureState.lastShippingAddress.postalCode || '', + }, + } : {}), + }); + state.currentEstimateToken = preview.estimateToken; + return undefined; +} + +/** @param {object} callbacks @param {{ getPayPalSessionFn: Function }} deps */ +async function handleApprove(callbacks, deps) { + try { + const state = callbacks.getState(); + const session = await deps.getPayPalSessionFn(state.paypalSessionId); + const cart = callbacks.getCart(); + const config = callbacks.getConfig(); + const orderBody = { + customer: { + firstName: session.payer.firstName, + lastName: session.payer.lastName, + email: session.payer.email, + phone: '', + }, + shipping: session.shippingAddress, + billing: session.shippingAddress, + items: cart.getItemsForAPI(), + shippingMethod: { id: session.selectedOptionId }, + estimateToken: state.currentEstimateToken, + country: session.shippingAddress.country, + locale: config.getLanguage(), + }; + const createdOrder = await callbacks.createOrder(orderBody); + const fraudToken = (() => { + try { + // eslint-disable-next-line no-undef + return (typeof sessionStorage !== 'undefined' && sessionStorage.getItem('forter_token')) + || undefined; + } catch { return undefined; } + })(); + const idempotencyKey = crypto.randomUUID?.() || `${Date.now()}`; + const result = await callbacks.initiatePayment( + createdOrder.order?.id ?? createdOrder.id, + idempotencyKey, + fraudToken, + 'paypal-express', + 'paypal', + { paypalOrderId: state.paypalSessionId }, + ); + if (result.status === 'completed') { + callbacks.onComplete(createdOrder); + } else { + callbacks.showError(result.reason || 'PayPal payment failed. Please try again.'); + } + } catch { + callbacks.showError('PayPal payment failed. Please try again.'); + } +} + +/** + * Mirrors the URLSearchParams construction in loadSdk() for parameter tests. + * + * @param {string} clientId + * @param {string} currency + * @param {string} locale + * @returns {string} + */ +function buildSdkUrl(clientId, currency, locale) { + const params = new URLSearchParams({ + 'client-id': clientId, + currency, + components: 'buttons,messages', + locale, + commit: 'false', + 'enable-funding': 'paylater', + }); + return `https://www.paypal.com/sdk/js?${params}`; +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +const ITEMS = [{ sku: 'SKU-001', quantity: 1, price: { final: '99.00', currency: 'USD' } }]; +const SESSION_ID = 'PP-SESSION-001'; + +function makeState(overrides = {}) { + return { + paypalSessionId: SESSION_ID, + currentEstimateToken: 'est-token-123', + ...overrides, + }; +} + +function makeCallbacks(state = makeState(), overrides = {}) { + return { + getConfig: () => ({ + getLanguage: () => 'en-US', + currency: 'USD', + getLocale: () => 'en-US', + }), + getState: () => state, + getCart: () => ({ getItemsForAPI: () => ITEMS }), + createOrder: async () => ({ order: { id: 'ORD-001' } }), + initiatePayment: async () => ({ status: 'completed' }), + onComplete: () => {}, + showError: () => {}, + previewOrderDirect: async () => ({ estimateToken: 'est-new-456' }), + ...overrides, + }; +} + +function makeActions() { + let rejected = null; + return { + reject: (reason) => { rejected = reason; return undefined; }, + getRejected: () => rejected, + }; +} + +// --------------------------------------------------------------------------- +// createOrder callback +// --------------------------------------------------------------------------- + +test.describe('createOrder callback', () => { + test('calls createPayPalSession with cart items and config', async () => { + const state = makeState({ paypalSessionId: undefined }); + const callbacks = makeCallbacks(state); + let capturedItems; + let capturedConfig; + const deps = { + createPayPalSessionFn: async (items, config) => { + capturedItems = items; + capturedConfig = config; + return { paypalOrderId: 'PP-NEW-001' }; + }, + }; + await handleCreateOrder(callbacks, deps); + expect(capturedItems).toEqual(ITEMS); + expect(capturedConfig.currency).toBe('USD'); + }); + + test('stores paypalOrderId in state.paypalSessionId', async () => { + const state = makeState({ paypalSessionId: undefined }); + const callbacks = makeCallbacks(state); + const deps = { + createPayPalSessionFn: async () => ({ paypalOrderId: 'PP-NEW-001' }), + }; + await handleCreateOrder(callbacks, deps); + expect(state.paypalSessionId).toBe('PP-NEW-001'); + }); + + test('returns the paypalOrderId string to the caller', async () => { + const state = makeState({}); + const callbacks = makeCallbacks(state); + const deps = { + createPayPalSessionFn: async () => ({ paypalOrderId: 'PP-RETURN-001' }), + }; + const result = await handleCreateOrder(callbacks, deps); + expect(result).toBe('PP-RETURN-001'); + }); +}); + +// --------------------------------------------------------------------------- +// onShippingAddressChange callback +// --------------------------------------------------------------------------- + +test.describe('onShippingAddressChange callback', () => { + const ADDR_DATA = { + shippingAddress: { countryCode: 'US', state: 'CA', postalCode: '90210' }, + errors: { ADDRESS_ERROR: 'ADDRESS_ERROR' }, + }; + + test('patches with type=address and normalized field names', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [], lastShippingAddress: null }; + let patchBody; + const deps = { + patchPayPalSessionFn: async (id, body) => { + patchBody = body; + return { shippingMethods: [{ id: 'std' }] }; + }, + }; + await handleShippingAddressChange(ADDR_DATA, makeActions(), closureState, callbacks, deps); + expect(patchBody.type).toBe('address'); + expect(patchBody.address.country).toBe('US'); + expect(patchBody.address.state).toBe('CA'); + expect(patchBody.address.zip).toBe('90210'); + expect(patchBody.items).toEqual(ITEMS); + }); + + test('stores shippingMethods and shippingAddress in closure state on success', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [], lastShippingAddress: null }; + const methods = [{ id: 'std', label: 'Standard' }]; + const deps = { + patchPayPalSessionFn: async () => ({ shippingMethods: methods }), + }; + await handleShippingAddressChange(ADDR_DATA, makeActions(), closureState, callbacks, deps); + expect(closureState.lastShippingMethods).toEqual(methods); + expect(closureState.lastShippingAddress).toEqual(ADDR_DATA.shippingAddress); + }); + + test('calls actions.reject with ADDRESS_ERROR when shippingMethods is empty', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [], lastShippingAddress: null }; + const actions = makeActions(); + const deps = { + patchPayPalSessionFn: async () => ({ shippingMethods: [] }), + }; + await handleShippingAddressChange(ADDR_DATA, actions, closureState, callbacks, deps); + expect(actions.getRejected()).toBe('ADDRESS_ERROR'); + }); + + test('calls actions.reject with ADDRESS_ERROR when patchPayPalSession throws', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [], lastShippingAddress: null }; + const actions = makeActions(); + const deps = { + patchPayPalSessionFn: async () => { throw new Error('network error'); }, + }; + await handleShippingAddressChange(ADDR_DATA, actions, closureState, callbacks, deps); + expect(actions.getRejected()).toBe('ADDRESS_ERROR'); + }); +}); + +// --------------------------------------------------------------------------- +// onShippingOptionsChange callback +// --------------------------------------------------------------------------- + +test.describe('onShippingOptionsChange callback', () => { + const METHOD = { + id: 'std', + label: 'Standard', + total: '14.99', + taxAmount: '1.20', + rate: '5.99', + }; + const OPT_DATA = { + selectedShippingOption: { id: 'std' }, + errors: { METHOD_UNAVAILABLE: 'METHOD_UNAVAILABLE' }, + }; + + test('patches with type=option and amounts from the selected method', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { + lastShippingMethods: [METHOD], + lastShippingAddress: { countryCode: 'US', state: 'CA', postalCode: '90210' }, + }; + let patchBody; + const deps = { + patchPayPalSessionFn: async (id, body) => { patchBody = body; return {}; }, + }; + await handleShippingOptionsChange(OPT_DATA, makeActions(), closureState, callbacks, deps); + expect(patchBody.type).toBe('option'); + expect(patchBody.selectedOptionId).toBe('std'); + expect(patchBody.total).toBe('14.99'); + expect(patchBody.taxAmount).toBe('1.20'); + expect(patchBody.shippingRate).toBe('5.99'); + }); + + test('calls previewOrderDirect with lowercased country and shipping from closure address', async () => { + const state = makeState(); + let previewArg; + const callbacks = makeCallbacks(state, { + previewOrderDirect: async (arg) => { previewArg = arg; return { estimateToken: 'tok' }; }, + }); + const closureState = { + lastShippingMethods: [METHOD], + lastShippingAddress: { countryCode: 'US', state: 'CA', postalCode: '90210' }, + }; + const deps = { patchPayPalSessionFn: async () => ({}) }; + await handleShippingOptionsChange(OPT_DATA, makeActions(), closureState, callbacks, deps); + expect(previewArg.country).toBe('us'); + expect(previewArg.shipping.country).toBe('us'); + expect(previewArg.shipping.state).toBe('CA'); + expect(previewArg.shipping.zip).toBe('90210'); + expect(previewArg.shippingMethod.id).toBe('std'); + }); + + test('calls previewOrderDirect without country/shipping when lastShippingAddress is null', async () => { + const state = makeState(); + let previewArg; + const callbacks = makeCallbacks(state, { + previewOrderDirect: async (arg) => { previewArg = arg; return { estimateToken: 'tok' }; }, + }); + const closureState = { lastShippingMethods: [METHOD], lastShippingAddress: null }; + const deps = { patchPayPalSessionFn: async () => ({}) }; + await handleShippingOptionsChange(OPT_DATA, makeActions(), closureState, callbacks, deps); + expect(previewArg.country).toBeUndefined(); + expect(previewArg.shipping).toBeUndefined(); + expect(previewArg.shippingMethod.id).toBe('std'); + }); + + test('stores preview.estimateToken in state.currentEstimateToken', async () => { + const state = makeState({ currentEstimateToken: null }); + const callbacks = makeCallbacks(state, { + previewOrderDirect: async () => ({ estimateToken: 'new-tok' }), + }); + const closureState = { + lastShippingMethods: [METHOD], + lastShippingAddress: { countryCode: 'US', state: 'CA', postalCode: '90210' }, + }; + const deps = { patchPayPalSessionFn: async () => ({}) }; + await handleShippingOptionsChange(OPT_DATA, makeActions(), closureState, callbacks, deps); + expect(state.currentEstimateToken).toBe('new-tok'); + }); + + test('calls actions.reject with METHOD_UNAVAILABLE when method is not in lastShippingMethods', async () => { + const state = makeState(); + const callbacks = makeCallbacks(state); + const closureState = { lastShippingMethods: [{ id: 'express' }], lastShippingAddress: null }; + const actions = makeActions(); + const deps = { patchPayPalSessionFn: async () => ({}) }; + await handleShippingOptionsChange( + { selectedShippingOption: { id: 'std' }, errors: { METHOD_UNAVAILABLE: 'METHOD_UNAVAILABLE' } }, + actions, + closureState, + callbacks, + deps, + ); + expect(actions.getRejected()).toBe('METHOD_UNAVAILABLE'); + }); +}); + +// --------------------------------------------------------------------------- +// onApprove callback +// --------------------------------------------------------------------------- + +test.describe('onApprove callback', () => { + const SESSION = { + payer: { firstName: 'John', lastName: 'Doe', email: 'john@example.com' }, + shippingAddress: { + address1: '123 Main St', city: 'Los Angeles', state: 'CA', zip: '90210', country: 'us', + }, + selectedOptionId: 'std', + }; + + test('calls getPayPalSession with state.paypalSessionId', async () => { + const state = makeState({ paypalSessionId: 'PP-TEST-001' }); + let capturedId; + const callbacks = makeCallbacks(state); + const deps = { + getPayPalSessionFn: async (id) => { capturedId = id; return SESSION; }, + }; + await handleApprove(callbacks, deps); + expect(capturedId).toBe('PP-TEST-001'); + }); + + test('builds orderBody from session payer, shippingAddress, and estimateToken', async () => { + const state = makeState({ paypalSessionId: 'PP-TEST-001', currentEstimateToken: 'est-tok' }); + let orderBody; + const callbacks = makeCallbacks(state, { + createOrder: async (body) => { orderBody = body; return { order: { id: 'ORD-001' } }; }, + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(orderBody.customer.firstName).toBe('John'); + expect(orderBody.customer.lastName).toBe('Doe'); + expect(orderBody.customer.email).toBe('john@example.com'); + expect(orderBody.customer.phone).toBe(''); + expect(orderBody.shipping).toEqual(SESSION.shippingAddress); + expect(orderBody.billing).toEqual(SESSION.shippingAddress); + expect(orderBody.shippingMethod.id).toBe('std'); + expect(orderBody.estimateToken).toBe('est-tok'); + expect(orderBody.country).toBe('us'); + }); + + test('calls initiatePayment with provider=paypal-express, paymentMethod=paypal, paypalOrderId', async () => { + const state = makeState({ paypalSessionId: 'PP-SESSION-XYZ' }); + let capturedArgs; + const callbacks = makeCallbacks(state, { + initiatePayment: async (...args) => { capturedArgs = args; return { status: 'completed' }; }, + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(capturedArgs[3]).toBe('paypal-express'); + expect(capturedArgs[4]).toBe('paypal'); + expect(capturedArgs[5]).toEqual({ paypalOrderId: 'PP-SESSION-XYZ' }); + }); + + test('calls onComplete with createdOrder when payment status is completed', async () => { + const state = makeState(); + let completedOrder; + const callbacks = makeCallbacks(state, { + onComplete: (order) => { completedOrder = order; }, + initiatePayment: async () => ({ status: 'completed' }), + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(completedOrder).toBeDefined(); + expect(completedOrder.order.id).toBe('ORD-001'); + }); + + test('calls showError with result.reason when payment status is not completed', async () => { + const state = makeState(); + let errorMsg; + const callbacks = makeCallbacks(state, { + showError: (msg) => { errorMsg = msg; }, + initiatePayment: async () => ({ status: 'failed', reason: 'payment_declined' }), + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(errorMsg).toBe('payment_declined'); + }); + + test('calls showError with fallback message when result.reason is absent', async () => { + const state = makeState(); + let errorMsg; + const callbacks = makeCallbacks(state, { + showError: (msg) => { errorMsg = msg; }, + initiatePayment: async () => ({ status: 'failed' }), + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(errorMsg).toBe('PayPal payment failed. Please try again.'); + }); + + test('calls showError when getPayPalSession throws', async () => { + const state = makeState(); + let errorMsg; + const callbacks = makeCallbacks(state, { + showError: (msg) => { errorMsg = msg; }, + }); + const deps = { + getPayPalSessionFn: async () => { throw new Error('network error'); }, + }; + await handleApprove(callbacks, deps); + expect(errorMsg).toBe('PayPal payment failed. Please try again.'); + }); + + test('calls showError when createOrder throws', async () => { + const state = makeState(); + let errorMsg; + const callbacks = makeCallbacks(state, { + createOrder: async () => { throw new Error('order failed'); }, + showError: (msg) => { errorMsg = msg; }, + }); + const deps = { getPayPalSessionFn: async () => SESSION }; + await handleApprove(callbacks, deps); + expect(errorMsg).toBe('PayPal payment failed. Please try again.'); + }); +}); + +// --------------------------------------------------------------------------- +// SDK load parameters +// --------------------------------------------------------------------------- + +test.describe('SDK load parameters', () => { + test('includes commit=false in the SDK URL', () => { + const url = buildSdkUrl('test-client-id', 'USD', 'en_US'); + expect(url).toContain('commit=false'); + }); + + test('includes messages in the components parameter', () => { + const url = buildSdkUrl('test-client-id', 'USD', 'en_US'); + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('components')).toContain('messages'); + }); + + test('includes paylater in the enable-funding parameter', () => { + const url = buildSdkUrl('test-client-id', 'USD', 'en_US'); + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('enable-funding')).toContain('paylater'); + }); + + test('does NOT include venmo in the enable-funding parameter', () => { + const url = buildSdkUrl('test-client-id', 'USD', 'en_US'); + const params = new URLSearchParams(url.split('?')[1]); + expect(params.get('enable-funding')).not.toContain('venmo'); + }); +}); + +// --------------------------------------------------------------------------- +// Button style config +// --------------------------------------------------------------------------- + +test.describe('button style config', () => { + const BASE_STYLE = { + layout: 'horizontal', color: 'gold', shape: 'rect', label: 'paypal', disableMaxHeight: true, + }; + + test('primary button has gold color, paypal label, and horizontal layout', () => { + expect(BASE_STYLE.color).toBe('gold'); + expect(BASE_STYLE.label).toBe('paypal'); + expect(BASE_STYLE.layout).toBe('horizontal'); + }); + + test('Pay Later button overrides color to silver and label to pay_later', () => { + const payLaterStyle = { ...BASE_STYLE, color: 'silver', label: 'pay_later' }; + expect(payLaterStyle.color).toBe('silver'); + expect(payLaterStyle.label).toBe('pay_later'); + expect(payLaterStyle.layout).toBe('horizontal'); + }); +}); + +// --------------------------------------------------------------------------- +// Pay Later eligibility +// --------------------------------------------------------------------------- + +test.describe('Pay Later eligibility', () => { + function makePaypalMock(isEligible) { + return { + Buttons: () => ({ isEligible: () => isEligible, render: () => {} }), + FUNDING: { PAYLATER: 'paylater' }, + }; + } + + test('renders Pay Later button when isEligible() returns true', () => { + const paypal = makePaypalMock(true); + const btn = paypal.Buttons({ fundingSource: paypal.FUNDING.PAYLATER }); + expect(btn.isEligible()).toBe(true); + }); + + test('does not render Pay Later button when isEligible() returns false', () => { + const paypal = makePaypalMock(false); + const btn = paypal.Buttons({ fundingSource: paypal.FUNDING.PAYLATER }); + expect(btn.isEligible()).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// Stub fallback (no window.paypal) +// --------------------------------------------------------------------------- + +test.describe('stub fallback', () => { + function dispatchRender(windowPaypal, onSdk, onStub) { + if (!windowPaypal) { + onStub(); + return 'stub'; + } + onSdk(); + return 'sdk'; + } + + test('renders stub buttons and skips SDK when window.paypal is undefined', () => { + let stubCalled = false; + let sdkCalled = false; + const result = dispatchRender( + undefined, + () => { sdkCalled = true; }, + () => { stubCalled = true; }, + ); + expect(result).toBe('stub'); + expect(stubCalled).toBe(true); + expect(sdkCalled).toBe(false); + }); + + test('renders SDK buttons when window.paypal is defined', () => { + let stubCalled = false; + let sdkCalled = false; + const mockPaypal = { Buttons: () => ({ render: () => {}, isEligible: () => false }) }; + const result = dispatchRender( + mockPaypal, + () => { sdkCalled = true; }, + () => { stubCalled = true; }, + ); + expect(result).toBe('sdk'); + expect(sdkCalled).toBe(true); + expect(stubCalled).toBe(false); + }); +}); From 5bcf2b1c73e1891a75187b5442b65eb4c0846a8d Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 13 May 2026 21:29:20 -0400 Subject: [PATCH 67/89] fix: add paypal clientId --- scripts/payments/paypal.js | 2 +- scripts/scripts.js | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js index 32eb3c5c..4216695d 100644 --- a/scripts/payments/paypal.js +++ b/scripts/payments/paypal.js @@ -97,7 +97,7 @@ export default { } catch { /* fall back to stub buttons */ } }, - isAvailable: () => true, + isAvailable: () => !!window.paypal, /** * Renders the PayPal Express Checkout button (and Pay Later button if diff --git a/scripts/scripts.js b/scripts/scripts.js index 0e092d97..dc4cd335 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -37,6 +37,9 @@ export const FORMS_ENDPOINT = isProdHost window.CommerceConfig = { org: 'aemsites', site: 'vitamix', + paypal: { + clientId: 'AdWjsTBIELzBwnT08zwFuxDeEW89L8bTcBnE_d4C8lwZHpqMjCszTRh4lrYsUx0TGnjFffeC_UXiIBgJ', + }, }; /** From 9be9fc4062d6d164f908b7143ddee291a1e3b564 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 13 May 2026 21:39:59 -0400 Subject: [PATCH 68/89] fix: normalize SDK locale to language_COUNTRY format --- scripts/payments/paypal.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js index 4216695d..729672ef 100644 --- a/scripts/payments/paypal.js +++ b/scripts/payments/paypal.js @@ -28,11 +28,13 @@ function loadSdk(clientId, currency, locale) { sdkLoadPromise = new Promise((resolve, reject) => { if (window.paypal) { resolve(); return; } const script = document.createElement('script'); + const [lang, country] = locale.split('_'); + const normalizedLocale = country ? `${lang}_${country.toUpperCase()}` : locale; const params = new URLSearchParams({ 'client-id': clientId, currency, components: 'buttons,messages', - locale, + locale: normalizedLocale, commit: 'false', 'enable-funding': 'paylater', }); From 164ff8a2f265dbde52fb4f3d2d6478258b416a06 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 13 May 2026 21:46:45 -0400 Subject: [PATCH 69/89] fix: remove disableMaxHeight --- scripts/payments/paypal.js | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js index 729672ef..33cf6204 100644 --- a/scripts/payments/paypal.js +++ b/scripts/payments/paypal.js @@ -144,7 +144,6 @@ export default { color: 'gold', shape: 'rect', label: 'paypal', - disableMaxHeight: true, }, createOrder: async () => { From c12676cf034459c9968390f70dc8ec83e5004384 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 13 May 2026 22:43:01 -0400 Subject: [PATCH 70/89] fix: send country and locale --- blocks/checkout/checkout.css | 2 +- scripts/commerce-api.js | 15 ++++++++++++--- scripts/payments/paypal.js | 15 ++++++++++++++- 3 files changed, 27 insertions(+), 5 deletions(-) diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 93e0bad3..776b1b62 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -587,7 +587,7 @@ main > .section:has(.checkout-wrapper) { flex: 1; min-width: 0; width: 100%; - height: 48px; + height: 58px; overflow: hidden; background: #ffc439; border: none; diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index a3e7c2c5..c3f9a818 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -1,6 +1,7 @@ import { getConfig } from './commerce-config.js'; import { AUTH_TOKEN_KEY } from './auth-api.js'; import { mintRecaptchaToken, RECAPTCHA_ACTIONS, RECAPTCHA_HEADER } from './recaptcha.js'; +import { getLocaleAndLanguage } from './scripts.js'; /** * Error thrown when the Commerce API returns a non-2xx response. @@ -188,10 +189,12 @@ export async function createPayPalSession(items, config) { const currency = typeof config.currency === 'function' ? config.currency(config.getLocale()) : config.currency; + const { locale: country, language: locale } = getLocaleAndLanguage(false, true); return request('/payments/paypal/session', { items, currency, - locale: config.getLanguage().replace('-', '_'), + country, + locale, }, 'POST'); } @@ -214,12 +217,18 @@ export async function patchPayPalSession(paypalOrderId, data) { * Called in onApprove after the buyer has authenticated. * * @param {string} paypalOrderId - PayPal order ID from createPayPalSession + * @param {string} [country] - Store country code for provider config resolution + * @param {string} [locale] - Store locale for provider config resolution * @returns {Promise<{payer: object, shippingAddress: object, * selectedOptionId: string|undefined}>} * @throws {CommerceApiError} */ -export async function getPayPalSession(paypalOrderId) { - return request(`/payments/paypal/session/${paypalOrderId}`, null, 'GET'); +export async function getPayPalSession(paypalOrderId, country, locale) { + const params = new URLSearchParams(); + if (country) params.set('country', country); + if (locale) params.set('locale', locale); + const qs = params.size ? `?${params}` : ''; + return request(`/payments/paypal/session/${paypalOrderId}${qs}`, null, 'GET'); } /** diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js index 33cf6204..c170885c 100644 --- a/scripts/payments/paypal.js +++ b/scripts/payments/paypal.js @@ -3,6 +3,7 @@ import { patchPayPalSession, getPayPalSession, } from '../commerce-api.js'; +import { getLocaleAndLanguage } from '../scripts.js'; let sdkLoadPromise = null; @@ -144,6 +145,8 @@ export default { color: 'gold', shape: 'rect', label: 'paypal', + tagline: false, + height: 58, }, createOrder: async () => { @@ -159,9 +162,12 @@ export default { lastShippingAddress = data.shippingAddress; const state = callbacks.getState(); const cart = callbacks.getCart(); + const config = callbacks.getConfig(); try { const result = await patchPayPalSession(state.paypalSessionId, { type: 'address', + country: config.getLocale(), + locale: getLocaleAndLanguage(false, true).language, address: { country: data.shippingAddress.countryCode, state: data.shippingAddress.state, @@ -185,8 +191,11 @@ export default { if (!method) return actions.reject(data.errors.METHOD_UNAVAILABLE); const state = callbacks.getState(); const cart = callbacks.getCart(); + const config = callbacks.getConfig(); await patchPayPalSession(state.paypalSessionId, { type: 'option', + country: config.getLocale(), + locale: getLocaleAndLanguage(false, true).language, selectedOptionId: method.id, total: method.total, taxAmount: method.taxAmount, @@ -212,9 +221,13 @@ export default { onApprove: async () => { try { const state = callbacks.getState(); - const session = await getPayPalSession(state.paypalSessionId); const cart = callbacks.getCart(); const config = callbacks.getConfig(); + const session = await getPayPalSession( + state.paypalSessionId, + config.getLocale(), + getLocaleAndLanguage(false, true).language, + ); const orderBody = { customer: { firstName: session.payer.firstName, From 361320619c7abca0be679cf2430ef2b338bb0801 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Wed, 13 May 2026 22:48:27 -0400 Subject: [PATCH 71/89] fix: express checkout buttons --- blocks/cart-summary/cart-summary.css | 4 ++-- blocks/checkout/checkout.css | 8 ++++---- scripts/payments/paypal.js | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/blocks/cart-summary/cart-summary.css b/blocks/cart-summary/cart-summary.css index 36daff22..6194cff2 100644 --- a/blocks/cart-summary/cart-summary.css +++ b/blocks/cart-summary/cart-summary.css @@ -127,7 +127,7 @@ div.section.cart-section:has(.cart-summary-wrapper) > .cart-wrapper { .cart-summary-express-buttons apple-pay-button { --apple-pay-button-width: 100%; - --apple-pay-button-height: 48px; + --apple-pay-button-height: 55px; --apple-pay-button-border-radius: var(--commerce-button-radius); --apple-pay-button-padding: 0; @@ -157,7 +157,7 @@ div.section.cart-section:has(.cart-summary-wrapper) > .cart-wrapper { align-items: center; justify-content: center; width: 100%; - height: 48px; + height: 55px; background: #ffc439; border: none; border-radius: var(--commerce-button-radius); diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 776b1b62..25dafa79 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -107,12 +107,12 @@ main > .section:has(.checkout-wrapper) { flex: 1; min-width: 0; display: flex; - height: 58px; + height: 55px; } /* Stub Pay Later button fills its wrapper in the default (wide) state */ .paypal-paylater-wrapper .paypal-express-btn { - height: 58px; + height: 55px; } /* Pay Later drops to a full-width second row when space is tight */ @@ -587,7 +587,7 @@ main > .section:has(.checkout-wrapper) { flex: 1; min-width: 0; width: 100%; - height: 58px; + height: 55px; overflow: hidden; background: #ffc439; border: none; @@ -661,7 +661,7 @@ main > .section:has(.checkout-wrapper) { /* Apple Pay */ apple-pay-button { --apple-pay-button-width: 100%; - --apple-pay-button-height: 58px; + --apple-pay-button-height: 55px; --apple-pay-button-border-radius: var(--commerce-button-radius); --apple-pay-button-padding: 0; diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js index c170885c..81d35aba 100644 --- a/scripts/payments/paypal.js +++ b/scripts/payments/paypal.js @@ -146,7 +146,7 @@ export default { shape: 'rect', label: 'paypal', tagline: false, - height: 58, + height: 55, }, createOrder: async () => { From e50a4d7174349fe464a536c3b16ba82e119bdb19 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Thu, 14 May 2026 09:04:15 -0400 Subject: [PATCH 72/89] fix: express checkout paypal fixes --- blocks/checkout/checkout-order.js | 9 ++++++- scripts/payments/paypal.js | 41 +++++++++++++++++++++++++------ scripts/scripts.js | 1 + 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js index 3db5886e..8e413e40 100644 --- a/blocks/checkout/checkout-order.js +++ b/blocks/checkout/checkout-order.js @@ -114,7 +114,12 @@ export function initOrder(form, cart, state, config, strings) { getFormData: () => new FormData(form), getState: () => state, updatePreview: () => updatePreview(form, cart, state, config), - previewOrderDirect: (body) => previewOrder(body), + previewOrderDirect: async (body) => { + const result = await previewOrder(body); + if (result.estimateToken) state.currentEstimateToken = result.estimateToken; + state.currentPreview = result; + return result; + }, buildOrderJSON: (formData) => buildOrderJSON(formData, form, cart, state, config), saveCheckoutSession: (email, c, preview, order) => ( saveCheckoutSession(email, c, preview, order) @@ -126,6 +131,8 @@ export function initOrder(form, cart, state, config, strings) { onComplete: (createdOrder) => { const order = createdOrder?.order ?? createdOrder; const orderId = order?.id; + const email = order?.customer?.email || ''; + saveCheckoutSession(email, cart, state.currentPreview, order); cart.clear(); const path = config.getOrderPath('complete'); window.location.href = orderId ? `${path}?orderId=${orderId}` : path; diff --git a/scripts/payments/paypal.js b/scripts/payments/paypal.js index 81d35aba..a1d9cb9f 100644 --- a/scripts/payments/paypal.js +++ b/scripts/payments/paypal.js @@ -24,7 +24,7 @@ function getPayLaterLabel(language) { return PAY_LATER_LABELS[lang] || 'Pay Later'; } -function loadSdk(clientId, currency, locale) { +function loadSdk(clientId, currency, locale, intent = 'capture') { if (sdkLoadPromise) return sdkLoadPromise; sdkLoadPromise = new Promise((resolve, reject) => { if (window.paypal) { resolve(); return; } @@ -36,6 +36,7 @@ function loadSdk(clientId, currency, locale) { currency, components: 'buttons,messages', locale: normalizedLocale, + intent, commit: 'false', 'enable-funding': 'paylater', }); @@ -95,8 +96,9 @@ export default { ? config.currency(config.getLocale()) : (config.currency || 'USD'); const locale = config.getLanguage().replace('-', '_'); + const intent = (window.CommerceConfig?.paypal?.intent || 'capture').toLowerCase(); try { - await loadSdk(clientId, currency, locale); + await loadSdk(clientId, currency, locale, intent); } catch { /* fall back to stub buttons */ } }, @@ -139,6 +141,11 @@ export default { let lastShippingMethods = []; let lastShippingAddress = null; + const expressConfig = callbacks.getConfig(); + const currency = typeof expressConfig.currency === 'function' + ? expressConfig.currency(expressConfig.getLocale()) + : (expressConfig.currency || 'USD'); + const buttonConfig = { style: { layout: 'horizontal', @@ -168,6 +175,7 @@ export default { type: 'address', country: config.getLocale(), locale: getLocaleAndLanguage(false, true).language, + currency, address: { country: data.shippingAddress.countryCode, state: data.shippingAddress.state, @@ -179,6 +187,24 @@ export default { return actions.reject(data.errors.ADDRESS_ERROR); } lastShippingMethods = result.shippingMethods; + // Preview with the default (first) method so estimateToken is always set + // even when the user never changes the shipping option (onShippingOptionsChange + // only fires on an explicit option change, not on initial address selection). + const countryCode = data.shippingAddress.countryCode?.toLowerCase(); + const [defaultMethod] = lastShippingMethods; + const preview = await callbacks.previewOrderDirect({ + items: cart.getItemsForAPI(), + shippingMethod: { id: String(defaultMethod.id) }, + ...(countryCode ? { + country: countryCode, + shipping: { + country: countryCode, + state: data.shippingAddress.state, + zip: data.shippingAddress.postalCode || '', + }, + } : {}), + }); + state.currentEstimateToken = preview.estimateToken; } catch { return actions.reject(data.errors.ADDRESS_ERROR); } @@ -196,6 +222,7 @@ export default { type: 'option', country: config.getLocale(), locale: getLocaleAndLanguage(false, true).language, + currency, selectedOptionId: method.id, total: method.total, taxAmount: method.taxAmount, @@ -204,7 +231,7 @@ export default { const countryCode = lastShippingAddress?.countryCode?.toLowerCase(); const preview = await callbacks.previewOrderDirect({ items: cart.getItemsForAPI(), - shippingMethod: { id: method.id }, + shippingMethod: { id: String(method.id) }, ...(countryCode ? { country: countryCode, shipping: { @@ -235,13 +262,13 @@ export default { email: session.payer.email, phone: '', }, - shipping: session.shippingAddress, - billing: session.shippingAddress, + shipping: { ...session.shippingAddress, email: session.payer.email }, + billing: { ...session.shippingAddress, email: session.payer.email }, items: cart.getItemsForAPI(), shippingMethod: { id: session.selectedOptionId }, estimateToken: state.currentEstimateToken, country: session.shippingAddress.country, - locale: config.getLanguage(), + locale: getLocaleAndLanguage(false, true).language, }; const createdOrder = await callbacks.createOrder(orderBody); const fraudToken = (() => { @@ -280,7 +307,7 @@ export default { const payLaterBtn = window.paypal.Buttons({ ...buttonConfig, fundingSource: window.paypal.FUNDING.PAYLATER, - style: { ...buttonConfig.style, color: 'silver', label: 'pay_later' }, + style: { ...buttonConfig.style, color: 'silver' }, }); if (payLaterBtn.isEligible()) { const wrapper = document.createElement('div'); diff --git a/scripts/scripts.js b/scripts/scripts.js index dc4cd335..bb1d88b3 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -39,6 +39,7 @@ window.CommerceConfig = { site: 'vitamix', paypal: { clientId: 'AdWjsTBIELzBwnT08zwFuxDeEW89L8bTcBnE_d4C8lwZHpqMjCszTRh4lrYsUx0TGnjFffeC_UXiIBgJ', + intent: 'authorize', }, }; From 9c277f50f272307513e4a0969c36f75088cbde19 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Thu, 14 May 2026 09:39:58 -0400 Subject: [PATCH 73/89] fix: include couponCode in preview and order --- blocks/checkout/checkout-order.js | 3 +++ blocks/checkout/checkout-shipping.js | 3 +++ 2 files changed, 6 insertions(+) diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js index 8e413e40..d1f77347 100644 --- a/blocks/checkout/checkout-order.js +++ b/blocks/checkout/checkout-order.js @@ -65,6 +65,9 @@ export function buildOrderJSON(formData, form, cart, state, config) { order.giftMessage = giftMessage.trim(); } + const couponCode = sessionStorage.getItem('checkout_coupon_code') || undefined; + if (couponCode) order.couponCode = couponCode; + order.locale = `${language.split('_')[0]}-${(language.split('_')[1] || locale).toUpperCase()}`; order.country = locale; diff --git a/blocks/checkout/checkout-shipping.js b/blocks/checkout/checkout-shipping.js index 3c086fcd..4a827c78 100644 --- a/blocks/checkout/checkout-shipping.js +++ b/blocks/checkout/checkout-shipping.js @@ -96,6 +96,9 @@ export async function updatePreview(form, cart, state, config) { paymentMethod: data.get('paymentMethod') || null, }; + const couponCode = sessionStorage.getItem('checkout_coupon_code') || undefined; + if (couponCode) orderBody.couponCode = couponCode; + if (email && firstName && lastName) { orderBody.customer = { firstName, From 8f01151a54159987f8c4ce3026ba9e4e3e957f92 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Thu, 14 May 2026 11:20:22 -0600 Subject: [PATCH 74/89] chore: initial account UX --- blocks/header/header.css | 22 ++- blocks/header/header.js | 150 ++++++++++++--- scripts/account-api.js | 351 ++++++++++++++++++++++++++++++++++ widgets/account/account.css | 354 +++++++++++++++++++++++++++++++++++ widgets/account/account.html | 103 ++++++++++ widgets/account/account.js | 249 ++++++++++++++++++++++++ widgets/account/account.json | 203 ++++++++++++++++++++ 7 files changed, 1408 insertions(+), 24 deletions(-) create mode 100644 scripts/account-api.js create mode 100644 widgets/account/account.css create mode 100644 widgets/account/account.html create mode 100644 widgets/account/account.js create mode 100644 widgets/account/account.json diff --git a/blocks/header/header.css b/blocks/header/header.css index 28f430c4..902e25d1 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -51,7 +51,7 @@ header .icon-wrapper .icon { text-align: center; } -header section { +header section#nav { display: grid; grid-template: 'cart title hamburger' var(--header-height) @@ -66,7 +66,7 @@ header section { box-shadow: 0 -2px 8px rgb(0 0 0 / 10%); } -header section[data-expanded='true'] { +header section#nav[data-expanded='true'] { height: 100dvh; max-height: 100dvh; padding: 0; @@ -77,7 +77,7 @@ header [aria-hidden='true'] { } @media (width >= 1000px) { - header section[data-expanded='true'] { + header section#nav[data-expanded='true'] { grid-template: 'title sections tools cart' var(--header-height) / auto 1fr max-content auto; gap: 0; @@ -355,6 +355,10 @@ header .nav-tools li a.icon-wrapper { flex-direction: column; } +header .nav-tools li a.icon-wrapper.is-logged-in { + font-weight: 600; +} + @media (width >= 1000px) { header .nav-tools ul { gap: var(--spacing-40); @@ -598,6 +602,18 @@ header .minicart > div:not(.slide-panel-header) { margin-top: 1em; } +/* Account management drawer: wider than minicart; flat (no drawer drop shadow) */ +header .minicart.account-management { + width: min(90vw, 1200px); + box-shadow: none; +} + +header .minicart.account-management > div:not(.slide-panel-header) { + text-align: left; + width: 100%; + box-sizing: border-box; +} + /* hamburger */ header .nav-hamburger { grid-area: hamburger; diff --git a/blocks/header/header.js b/blocks/header/header.js index c5c42f77..5e1cd052 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -1,7 +1,19 @@ import { getMetadata, toClassName } from '../../scripts/aem.js'; import { swapIcons, getCookies, getOrderPath } from '../../scripts/scripts.js'; import { loadFragment } from '../fragment/fragment.js'; -import { AUTH_TOKEN_KEY } from '../../scripts/auth-api.js'; +import { AUTH_EVENT, getUser, isLoggedIn } from '../../scripts/auth-api.js'; +import { + addMagentoCacheListener, getLoggedInFromLocalStorage, getMagentoCache, +} from '../../scripts/storage/util.js'; + +/** True when OTP JWT or legacy Magento customer cache indicates signed in. */ +function isHeaderAuthSessionActive() { + try { + return isLoggedIn() || getLoggedInFromLocalStorage(); + } catch { + return false; + } +} const EDGE_CART_PATH = () => getOrderPath('cart'); @@ -439,29 +451,48 @@ export default async function decorate(block) { } const accountLink = block.querySelector('.icon-account').parentElement; + const defaultAccountLabel = (accountLink?.lastChild?.textContent ?? '').trim(); + + /** + * Account link label: fragment default when signed out; cookie name, OTP email local-part, + * or Magento customer firstname when signed in (same "'s Account" pattern as legacy cookie). + */ + const resolveAccountLinkLabel = (signedIn) => { + const cookieName = getCookies().vitamix_customer; + if (!signedIn) { + if (cookieName) return `${cookieName}'s Account`; + return defaultAccountLabel; + } + if (cookieName) return `${cookieName}'s Account`; + if (isLoggedIn()) { + const email = getUser()?.email; + if (email) { + const local = email.split('@')[0]; + return `${local}'s Account`; + } + } + if (getLoggedInFromLocalStorage()) { + const first = getMagentoCache().customer?.firstname; + if (first) return `${first}'s Account`; + } + return defaultAccountLabel; + }; - const customer = cookies.vitamix_customer; - if (customer) { - accountLink.lastChild.textContent = `${customer}'s Account`; - } - - // --- Auth state display (all pages) --- + // --- Auth state display (all pages): OTP session and/or legacy Magento customer cache --- - const updateAuthUI = (loggedIn) => { - accountLink.classList.toggle('is-logged-in', !!loggedIn); + const syncHeaderAuthDisplay = () => { + const combined = isHeaderAuthSessionActive(); + accountLink.classList.toggle('is-logged-in', combined); + if (accountLink.lastChild) { + accountLink.lastChild.textContent = resolveAccountLinkLabel(combined); + } }; - document.addEventListener('commerce:auth-state-changed', (ev) => { - updateAuthUI(ev.detail.loggedIn); - }); + document.addEventListener(AUTH_EVENT, syncHeaderAuthDisplay); + addMagentoCacheListener(syncHeaderAuthDisplay); + syncHeaderAuthDisplay(); - // restore auth UI on page load - try { - const hasToken = !!sessionStorage.getItem(AUTH_TOKEN_KEY); - if (hasToken) updateAuthUI(true); - } catch { /* ignore */ } - - // --- Auth panel (edge checkout mode only) --- + // --- Auth panel + OTP account drawer (edge checkout mode only) --- if (window.useEdgeCheckout) { let authPanel = null; const ensureAuthPanel = async () => { @@ -472,14 +503,91 @@ export default async function decorate(block) { return authPanel; }; + /** @type {HTMLDialogElement | null} */ + let accountMgmtDialog = null; + const ensureAccountManagementModal = async () => { + if (accountMgmtDialog) return accountMgmtDialog; + const base = window.hlx?.codeBasePath || ''; + const htmlResp = await fetch(`${base}/widgets/account/account.html`); + const html = await htmlResp.text(); + + accountMgmtDialog = document.createElement('dialog'); + accountMgmtDialog.id = 'account-management'; + accountMgmtDialog.className = 'minicart account-management'; + accountMgmtDialog.setAttribute('aria-expanded', 'false'); + block.append(accountMgmtDialog); + + const headerRow = document.createElement('div'); + headerRow.className = 'slide-panel-header'; + const accountTitle = document.createElement('h2'); + accountTitle.textContent = 'Account'; + const accountClose = document.createElement('button'); + accountClose.className = 'slide-panel-close'; + accountClose.textContent = '\u00D7'; + accountClose.setAttribute('aria-label', 'Close'); + headerRow.append(accountTitle, accountClose); + + const bodyHost = document.createElement('div'); + bodyHost.innerHTML = html.trim(); + const widgetRoot = bodyHost.firstElementChild; + if (!widgetRoot) { + accountMgmtDialog.remove(); + accountMgmtDialog = null; + throw new Error('account widget HTML missing root'); + } + accountMgmtDialog.append(headerRow, widgetRoot); + + accountClose.addEventListener('click', () => { + accountMgmtDialog.closeModal(); + }); + + accountMgmtDialog.addEventListener('click', (event) => { + const rect = accountMgmtDialog.getBoundingClientRect(); + const inside = rect.top <= event.clientY + && event.clientY <= rect.top + rect.height + && rect.left <= event.clientX + && event.clientX <= rect.left + rect.width; + if (!inside) accountMgmtDialog.closeModal(); + }); + + const open = () => { + accountMgmtDialog.showModal(); + accountMgmtDialog.setAttribute('aria-expanded', 'true'); + }; + accountMgmtDialog.openModal = open; + + const close = () => { + accountMgmtDialog.setAttribute('aria-expanded', 'false'); + setTimeout(() => { + accountMgmtDialog.close(); + }, 300); + }; + accountMgmtDialog.closeModal = close; + + const { default: decorateAccount } = await import(`${base}/widgets/account/account.js`); + await decorateAccount(widgetRoot); + + return accountMgmtDialog; + }; + accountLink.addEventListener('click', async (e) => { - const hasToken = !!sessionStorage.getItem(AUTH_TOKEN_KEY); - if (!hasToken) { + if (!isHeaderAuthSessionActive()) { e.preventDefault(); const panel = await ensureAuthPanel(); panel.showEmailStep(); panel.open(); + return; + } + if (isLoggedIn()) { + e.preventDefault(); + try { + const dlg = await ensureAccountManagementModal(); + dlg.openModal(); + } catch { + /* fall through to default navigation */ + } } + /* Legacy Magento session only: follow account href */ }); } diff --git a/scripts/account-api.js b/scripts/account-api.js new file mode 100644 index 00000000..0a50c80d --- /dev/null +++ b/scripts/account-api.js @@ -0,0 +1,351 @@ +import { authFetch } from './auth-api.js'; +import { getConfig } from './commerce-config.js'; + +/* eslint-disable no-console -- VITAMIX_ACCOUNT_API_* payload logs for copy/paste integration */ + +/** + * Base URL for customer-scoped APIs: + * `{apiOrigin}/customers/{email}` (apiOrigin is e.g. …/aemsites/sites/vitamix). + * + * @param {string} customerEmail + * @returns {string} + */ +export function getCustomerApiBase(customerEmail) { + const origin = getConfig().apiOrigin.replace(/\/$/, ''); + return `${origin}/customers/${encodeURIComponent(customerEmail)}`; +} + +/** + * Logs a fixed tag line then the raw response body string for copy/paste. + * + * @param {string} tag + * @param {Response} resp + * @returns {Promise} Parsed JSON or null + */ +async function readResponseAndLog(tag, resp) { + const text = await resp.text(); + console.log(tag); + console.log(`HTTP_${resp.status}`); + console.log(text); + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * GET logged-in customer record. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getLoggedInCustomer(customerEmail) { + const url = getCustomerApiBase(customerEmail); + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_CUSTOMER', resp); +} + +/** + * GET customer addresses list. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getCustomerAddresses(customerEmail) { + const url = `${getCustomerApiBase(customerEmail)}/addresses`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESSES', resp); +} + +/** + * GET single address (stub for future UI). + * @param {string} customerEmail + * @param {string} addressId + * @returns {Promise} + */ +export async function getCustomerAddressById(customerEmail, addressId) { + const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_BY_ID', resp); +} + +/** + * DELETE address (stub for future UI). + * @param {string} customerEmail + * @param {string} addressId + * @returns {Promise} + */ +export async function deleteCustomerAddress(customerEmail, addressId) { + const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; + const resp = await authFetch(url, { method: 'DELETE' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_DELETE', resp); +} + +/** + * POST new address (stub for future UI). + * @param {string} customerEmail + * @param {Record} body + * @returns {Promise} + */ +export async function createCustomerAddress(customerEmail, body) { + const url = `${getCustomerApiBase(customerEmail)}/addresses`; + const resp = await authFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_CREATE', resp); +} + +/** + * GET customer orders list. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getCustomerOrders(customerEmail) { + const url = `${getCustomerApiBase(customerEmail)}/orders`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ORDERS', resp); +} + +/** + * GET single order (stub; id may be numeric or friendly per API). + * @param {string} customerEmail + * @param {string} orderId + * @returns {Promise} + */ +export async function getCustomerOrderById(customerEmail, orderId) { + const url = `${getCustomerApiBase(customerEmail)}/orders/${encodeURIComponent(orderId)}`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ORDER_BY_ID', resp); +} + +/** + * Fetches customer, addresses, and orders in parallel for the account drawer. + * + * @param {string} customerEmail + * @returns {Promise<{ customer: unknown, addresses: unknown, orders: unknown }>} + */ +export async function fetchAccountBundle(customerEmail) { + const [customer, addresses, orders] = await Promise.all([ + getLoggedInCustomer(customerEmail), + getCustomerAddresses(customerEmail), + getCustomerOrders(customerEmail), + ]); + return { customer, addresses, orders }; +} + +/** @param {unknown} payload */ +function normalizeAddressArray(payload) { + if (Array.isArray(payload)) return payload; + if (payload && typeof payload === 'object') { + const o = /** @type {Record} */ (payload); + if (Array.isArray(o.addresses)) return o.addresses; + if (Array.isArray(o.items)) return o.items; + if (Array.isArray(o.data)) return o.data; + } + return []; +} + +/** @param {unknown} payload */ +function normalizeOrderArray(payload) { + if (Array.isArray(payload)) return payload; + if (payload && typeof payload === 'object') { + const o = /** @type {Record} */ (payload); + if (Array.isArray(o.orders)) return o.orders; + if (Array.isArray(o.items)) return o.items; + if (Array.isArray(o.data)) return o.data; + } + return []; +} + +/** + * @param {Record} addr + * @returns {{ badge: string, lines: string[] }} + */ +function mapAddressToDisplay(addr) { + const id = addr.id != null ? String(addr.id) : ''; + const badgeParts = []; + if (addr.default === true || addr.isDefault === true) badgeParts.push('Default'); + if (id) badgeParts.push(id); + const badge = badgeParts.join(' · ') || 'Address'; + const name = typeof addr.name === 'string' ? addr.name : ''; + const line1 = typeof addr.address1 === 'string' ? addr.address1 : ''; + const line2 = [addr.city, addr.state, addr.zip].filter((x) => x != null && String(x).length).join(', '); + const country = typeof addr.country === 'string' ? addr.country : ''; + const lines = [name, line1, line2, country].filter((x) => String(x).length); + return { badge, lines: lines.length ? lines : [JSON.stringify(addr)] }; +} + +function pickOrderTotal(order) { + if (order.total != null) return String(order.total); + if (order.grandTotal != null) return String(order.grandTotal); + if (order.totalDue != null) return String(order.totalDue); + return '—'; +} + +/** + * @param {Record} order + * @param {{ placed?: string, total?: string }} orderLabels + */ +function mapOrderToDisplay(order, orderLabels) { + const id = order.friendlyId || order.orderId || order.id || order.number || '—'; + const date = order.createdAt || order.created_at || order.date || order.placedAt || '—'; + const total = pickOrderTotal(order); + return { + id: String(id), + date: String(date), + total: String(total), + placedLabel: orderLabels.placed || 'Placed', + totalLabel: orderLabels.total || 'Total', + }; +} + +/** + * Builds label/value rows from a customer object (best-effort; API shape may vary). + * + * @param {unknown} customer + * @param {string} fallbackEmail + * @returns {Array<{ label: string, value: string }>} + */ +function buildInfoRowsFromCustomer(customer, fallbackEmail) { + if (!customer || typeof customer !== 'object') return []; + const c = /** @type {Record} */ (customer); + const rows = []; + const push = (label, val) => { + if (val != null && String(val).length) rows.push({ label, value: String(val) }); + }; + push('Email', c.email ?? fallbackEmail); + const name = typeof c.name === 'string' ? c.name + : [c.firstName, c.lastName].filter((x) => x != null && String(x).length).join(' '); + push('Name', name); + push('Phone', c.phone ?? c.telephone); + push('Customer ID', c.id ?? c.customerId ?? c.uid); + push('Preferred language', c.locale ?? c.language); + push('Account status', c.status ?? c.state); + return rows.filter((r) => r.value.length); +} + +/** + * Updates overview + information panels when customer JSON is available. + * + * @param {HTMLElement} widget + * @param {unknown} customer + * @param {string} email + */ +function applyCustomerToWidget(widget, customer, email) { + const information = widget.querySelector('.account-panel[data-section="information"]'); + if (!information) return; + const rows = buildInfoRowsFromCustomer(customer, email); + if (!rows.length) return; + const container = information.querySelector('.account-mock-rows'); + if (!container) return; + container.innerHTML = ''; + rows.forEach((row) => { + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = row.label; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = row.value; + wrap.append(lab, val); + container.append(wrap); + }); +} + +/** + * @param {HTMLElement} widget + * @param {unknown} addressesPayload + */ +function applyAddressesToWidget(widget, addressesPayload) { + const listEl = widget.querySelector('.account-address-list'); + if (!listEl) return; + const raw = normalizeAddressArray(addressesPayload); + if (!raw.length) return; + const mapped = raw + .filter((x) => x && typeof x === 'object') + .map((x) => mapAddressToDisplay(/** @type {Record} */ (x))); + listEl.innerHTML = ''; + mapped.forEach((addr) => { + const li = document.createElement('li'); + li.className = 'account-address-item'; + const badge = document.createElement('div'); + badge.className = 'account-address-badge'; + badge.textContent = addr.badge; + const lines = document.createElement('p'); + lines.className = 'account-address-lines'; + lines.textContent = addr.lines.join('\n'); + li.append(badge, lines); + listEl.append(li); + }); +} + +/** + * @param {HTMLElement} widget + * @param {unknown} ordersPayload + * @param {{ placed?: string, total?: string }} orderMockLabels + */ +function applyOrdersToWidget(widget, ordersPayload, orderMockLabels) { + const list = widget.querySelector('.account-order-mock-list'); + if (!list) return; + const raw = normalizeOrderArray(ordersPayload); + if (!raw.length) return; + list.innerHTML = ''; + raw.forEach((item) => { + if (!item || typeof item !== 'object') return; + const o = mapOrderToDisplay(/** @type {Record} */ (item), orderMockLabels); + const li = document.createElement('li'); + li.className = 'account-order-mock-item'; + const idEl = document.createElement('span'); + idEl.className = 'account-order-mock-id'; + idEl.textContent = o.id; + const meta = document.createElement('div'); + meta.className = 'account-order-mock-meta'; + const s1 = document.createElement('span'); + s1.textContent = `${o.placedLabel}: ${o.date}`; + const s2 = document.createElement('span'); + s2.textContent = `${o.totalLabel}: ${o.total}`; + meta.append(s1, s2); + li.append(idEl, meta); + list.append(li); + }); +} + +/** If API wraps payload in `{ data: ... }`, unwrap one level. */ +function unwrapPayload(payload) { + if (payload == null) return payload; + if (typeof payload === 'object' && 'data' in payload && payload.data !== undefined) { + return /** @type {Record} */ (payload).data; + } + return payload; +} + +/** + * After fetchAccountBundle, maps API payloads onto the account widget DOM. + * + * @param {HTMLElement} widget + * @param {{ customer: unknown, addresses: unknown, orders: unknown }} data + * @param {{ orderMock?: { placed?: string, total?: string } }} [copySlice] + */ +export function applyAccountDataToWidget(widget, data, copySlice = {}) { + const email = /** @type {HTMLParagraphElement | null} */ (widget.querySelector('.account-email-muted'))?.textContent?.trim() || ''; + + let customer = unwrapPayload(data.customer); + if (Array.isArray(customer) && customer.length === 1) { + [customer] = customer; + } + + const addresses = unwrapPayload(data.addresses); + const orders = unwrapPayload(data.orders); + + if (customer && typeof customer === 'object') { + applyCustomerToWidget(widget, customer, email); + } + if (addresses != null) { + applyAddressesToWidget(widget, addresses); + } + if (orders != null) { + applyOrdersToWidget(widget, orders, copySlice.orderMock || {}); + } +} diff --git a/widgets/account/account.css b/widgets/account/account.css new file mode 100644 index 00000000..2ec4c2c3 --- /dev/null +++ b/widgets/account/account.css @@ -0,0 +1,354 @@ +/* OTP account drawer — left nav + full-width main; minimal main content */ + +.account-widget { + font-family: var(--commerce-font-family); + color: var(--commerce-color-text); + margin-top: 1em; + text-align: left; +} + +.account-layout { + display: flex; + flex-direction: column; + gap: var(--commerce-gap); +} + +.account-sidebar { + min-width: 0; +} + +.account-summary-card { + display: flex; + flex-direction: column; + background-color: var(--commerce-color-background); + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + overflow: hidden; + min-height: 0; + box-shadow: none; +} + +.account-summary-header { + padding: 20px 24px 12px; + border-bottom: 1px solid var(--commerce-color-border); +} + +.account-summary-header h3 { + margin: 0; + font-size: var(--heading-size-l, 1.25rem); + font-weight: var(--commerce-font-weight-bold); +} + +.account-email-muted { + margin: 8px 0 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.account-nav-shell { + position: relative; + flex: 1; + min-height: 0; +} + +.account-nav-mobile { + display: none; + padding: 12px 16px; + border-bottom: 1px solid var(--commerce-color-border); + background: var(--commerce-color-background); +} + +.account-widget--mobile-nav-select .account-nav-mobile { + display: block; +} + +.account-widget--mobile-nav-select .account-nav { + display: none; +} + +/* On mobile compact nav, sign out is only in the select — hide duplicate footer button */ +.account-widget--mobile-nav-select .account-sidebar-footer { + display: none; +} + +.account-nav-select { + width: 100%; + box-sizing: border-box; + min-height: var(--commerce-input-height, 48px); + padding: 12px 16px; + font-size: var(--commerce-font-size-sm); + font-family: inherit; + color: var(--commerce-color-text); + background: var(--commerce-input-background); + border: 1px solid var(--commerce-input-border); + border-radius: var(--commerce-input-radius); + cursor: pointer; + appearance: auto; +} + +.account-nav-select:focus { + outline: none; + border-color: var(--commerce-input-border-focus); +} + +.account-nav-list { + list-style: none; + margin: 0; + padding: 0; +} + +.account-nav-list li { + border-bottom: 1px solid var(--commerce-color-border); +} + +.account-nav-list li:last-child { + border-bottom: none; +} + +.account-nav-item { + display: block; + width: 100%; + padding: 14px 24px; + text-align: left; + font-size: var(--commerce-font-size-sm); + font-weight: 500; + font-family: inherit; + color: var(--commerce-color-text); + background: var(--commerce-color-background); + border: none; + cursor: pointer; + transition: background 0.15s, color 0.15s; + box-sizing: border-box; +} + +.account-nav-item:hover { + background: var(--commerce-color-surface); +} + +.account-nav-item.is-active { + background: var(--commerce-color-surface); + font-weight: var(--commerce-font-weight-bold); + border-left: 3px solid var(--commerce-color-primary); + padding-left: calc(24px - 3px); +} + +.account-sidebar-footer { + padding: 12px 16px 16px; + margin-top: auto; + border-top: 1px solid var(--commerce-color-border); +} + +.account-logout { + width: 100%; + box-sizing: border-box; + min-height: var(--commerce-button-height); + padding: 10px 16px; + font-size: var(--commerce-font-size-sm); + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-error); + background: transparent; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-button-radius); + cursor: pointer; + font-family: inherit; + text-align: left; + transition: background 0.15s, border-color 0.15s; +} + +.account-logout:hover { + background: var(--commerce-color-error-bg); + border-color: var(--commerce-color-error-border); +} + +.account-logout:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.account-main { + display: flex; + flex-direction: column; + gap: 0; + min-width: 0; + flex: 1; + text-align: left; +} + +.account-panels { + flex: 1; + min-height: 0; + min-width: 0; +} + +.account-panel { + display: none; + text-align: left; +} + +.account-panel.is-visible { + display: block; +} + +/* Main column: flat, left-aligned, uses drawer width — no popover card */ +.account-panel-body { + padding: 0 8px 24px 0; + margin: 0; + text-align: left; + background: transparent; + border: none; + border-radius: 0; + max-width: 100%; +} + +.account-panel-title { + margin: 0 0 16px; + padding-bottom: 8px; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); + border-bottom: 1px solid var(--commerce-color-border); +} + +.account-panel-intro { + margin: 0 0 20px; + max-width: 52rem; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + line-height: 1.55; +} + +.account-mock-rows { + margin: 0; + max-width: 40rem; + display: flex; + flex-direction: column; + gap: 0; +} + +.account-mock-row { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; + padding: 14px 0; + border-bottom: 1px solid var(--commerce-color-border); + font-size: var(--commerce-font-size-sm); +} + +.account-mock-row:last-child { + border-bottom: none; +} + +.account-mock-label { + color: var(--commerce-color-text-muted); + font-weight: 400; + font-size: var(--commerce-font-size-xs); +} + +.account-mock-value { + text-align: left; + font-weight: 500; + color: var(--commerce-color-text); +} + +.account-address-list { + list-style: none; + margin: 0; + padding: 0; + max-width: 42rem; +} + +.account-address-item { + margin: 0 0 20px; + padding-bottom: 20px; + border-bottom: 1px solid var(--commerce-color-border); +} + +.account-address-item:last-child { + margin-bottom: 0; + padding-bottom: 0; + border-bottom: none; +} + +.account-address-badge { + margin: 0 0 8px; + font-size: var(--commerce-font-size-xs); + font-weight: var(--commerce-font-weight-bold); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--commerce-color-text-muted); +} + +.account-address-lines { + margin: 0; + font-size: var(--commerce-font-size-sm); + line-height: 1.65; + color: var(--commerce-color-text); + white-space: pre-line; +} + +.account-order-mock-list { + list-style: none; + margin: 0; + padding: 0; + max-width: 42rem; + display: flex; + flex-direction: column; + gap: 0; +} + +.account-order-mock-item { + display: flex; + flex-direction: column; + gap: 6px; + padding: 14px 0; + border-bottom: 1px solid var(--commerce-color-border); + background: transparent; +} + +.account-order-mock-item:last-child { + border-bottom: none; +} + +.account-order-mock-id { + font-weight: var(--commerce-font-weight-bold); + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text); +} + +.account-order-mock-meta { + display: flex; + flex-wrap: wrap; + gap: 12px 24px; + font-size: var(--commerce-font-size-xs); + color: var(--commerce-color-text-muted); +} + +@media (width >= 768px) { + .account-layout { + display: grid; + grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); + gap: clamp(1rem, 3vw, 2.5rem); + align-items: start; + } + + .account-sidebar { + position: sticky; + top: 0; + } + + .account-panel-body { + padding-right: clamp(8px, 2vw, 32px); + } + + .account-widget--mobile-nav-select .account-nav-mobile { + display: none; + } + + .account-widget--mobile-nav-select .account-nav { + display: block; + } + + .account-widget--mobile-nav-select .account-sidebar-footer { + display: block; + } +} diff --git a/widgets/account/account.html b/widgets/account/account.html new file mode 100644 index 00000000..4273c920 --- /dev/null +++ b/widgets/account/account.html @@ -0,0 +1,103 @@ + diff --git a/widgets/account/account.js b/widgets/account/account.js new file mode 100644 index 00000000..97964a5e --- /dev/null +++ b/widgets/account/account.js @@ -0,0 +1,249 @@ +import { loadCSS } from '../../scripts/aem.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getUser, logout } from '../../scripts/auth-api.js'; + +/** Select option value for sign out (not a content section). */ +const LOGOUT_SELECT_VALUE = '__logout__'; + +/** + * @param {string} lang + * @returns {Promise>} + */ +async function loadCopy(lang) { + const jsonPath = new URL('./account.json', import.meta.url).pathname; + const url = `${window.hlx?.codeBasePath || ''}${jsonPath}`; + const resp = await fetch(url); + const data = await resp.json(); + const key = data[lang] ? lang : 'en'; + return data[key]; +} + +/** + * @param {HTMLElement} section + * @param {Array<{ label?: string, value?: string, valueKey?: string }>} rows + * @param {string} [email] + */ +function fillMockRows(section, rows, email) { + if (!section || !rows?.length) return; + const rowEls = section.querySelectorAll('.account-mock-row'); + rows.forEach((row, i) => { + const el = rowEls[i]; + if (!el) return; + const label = el.querySelector('.account-mock-label'); + const value = el.querySelector('.account-mock-value'); + if (label) label.textContent = row.label ?? ''; + if (value) { + const v = row.valueKey === 'email' ? (email || '—') : (row.value ?? '—'); + value.textContent = v; + } + }); +} + +/** + * @param {HTMLElement} listEl + * @param {Array<{ badge?: string, lines?: string[] }>} addresses + */ +function fillAddressList(listEl, addresses) { + if (!listEl || !addresses?.length) return; + listEl.innerHTML = ''; + addresses.forEach((addr) => { + const li = document.createElement('li'); + li.className = 'account-address-item'; + const badge = document.createElement('div'); + badge.className = 'account-address-badge'; + badge.textContent = addr.badge || ''; + const lines = document.createElement('p'); + lines.className = 'account-address-lines'; + lines.textContent = (addr.lines || []).join('\n'); + li.append(badge, lines); + listEl.append(li); + }); +} + +/** + * @param {HTMLElement} widget + */ +export default async function decorate(widget) { + const base = window.hlx?.codeBasePath || ''; + await Promise.all([ + loadCSS(`${base}/styles/commerce-tokens.css`), + loadCSS(`${base}/widgets/account/account.css`), + ]); + + const { language } = getLocaleAndLanguage(); + const lang = (language || 'en_us').split('_')[0]; + const copy = await loadCopy(lang); + const email = getUser()?.email || ''; + const dialog = widget.closest('dialog'); + const titleEl = dialog?.querySelector('.slide-panel-header h2'); + if (titleEl && copy.modalTitle) titleEl.textContent = copy.modalTitle; + + const nav = copy.nav || {}; + const navButtons = widget.querySelectorAll('.account-nav-item'); + const panelEls = widget.querySelectorAll('.account-panel'); + const navSelect = widget.querySelector('.account-nav-select'); + + navButtons.forEach((btn) => { + const key = btn.dataset.section; + if (key && nav[key]) btn.textContent = nav[key]; + }); + + const buildNavSelectOptions = () => { + if (!navSelect) return; + navSelect.setAttribute('aria-label', copy.sectionSelectAria || 'Choose section'); + navSelect.innerHTML = ''; + navButtons.forEach((btn) => { + const { section } = btn.dataset; + if (!section) return; + const opt = document.createElement('option'); + opt.value = section; + opt.textContent = btn.textContent || section; + navSelect.append(opt); + }); + const logoutOpt = document.createElement('option'); + logoutOpt.value = LOGOUT_SELECT_VALUE; + logoutOpt.textContent = copy.logout || 'Log out'; + navSelect.append(logoutOpt); + }; + buildNavSelectOptions(); + + const greetingEl = widget.querySelector('.account-greeting'); + const emailEl = widget.querySelector('.account-email-muted'); + if (greetingEl) { + const local = email ? email.split('@')[0] : ''; + greetingEl.textContent = local ? `${copy.greeting}, ${local}` : copy.greeting; + } + if (emailEl) emailEl.textContent = email || ''; + + const panels = copy.panels || {}; + const overview = widget.querySelector('.account-panel[data-section="overview"]'); + if (overview) { + const p = panels.overview || {}; + const t = overview.querySelector('.account-panel-title'); + const intro = overview.querySelector('.account-panel-intro'); + if (t) t.textContent = p.title || ''; + if (intro) intro.textContent = p.intro || ''; + fillMockRows(overview, p.rows, email); + } + + const information = widget.querySelector('.account-panel[data-section="information"]'); + if (information) { + const p = panels.information || {}; + const t = information.querySelector('.account-panel-title'); + if (t) t.textContent = p.title || ''; + fillMockRows(information, p.rows, email); + } + + const address = widget.querySelector('.account-panel[data-section="address"]'); + if (address) { + const p = panels.address || {}; + const t = address.querySelector('.account-panel-title'); + const listEl = address.querySelector('.account-address-list'); + if (t) t.textContent = p.title || ''; + fillAddressList(listEl, p.mockAddresses); + } + + const orders = widget.querySelector('.account-panel[data-section="orders"]'); + if (orders) { + const p = panels.orders || {}; + const t = orders.querySelector('.account-panel-title'); + const list = orders.querySelector('.account-order-mock-list'); + if (t) t.textContent = p.title || ''; + if (list && Array.isArray(p.mockOrders)) { + const om = copy.orderMock || {}; + list.innerHTML = p.mockOrders.map((o) => ` + + `).join(''); + } + } + + const mq = window.matchMedia('(min-width: 768px)'); + let activeSection = 'overview'; + + const syncMobileNavMode = () => { + if (mq.matches) { + widget.classList.remove('account-widget--mobile-nav-select'); + return; + } + if (activeSection !== 'overview') { + widget.classList.add('account-widget--mobile-nav-select'); + } else { + widget.classList.remove('account-widget--mobile-nav-select'); + } + }; + + const logoutBtn = widget.querySelector('.account-logout'); + const doLogout = async () => { + if (!logoutBtn) return; + logoutBtn.disabled = true; + try { + await logout(); + } catch { + /* best-effort */ + } finally { + logoutBtn.disabled = false; + } + if (dialog?.closeModal) dialog.closeModal(); + else dialog?.close(); + }; + + const selectSection = (section) => { + if (!section || section === LOGOUT_SELECT_VALUE) return; + activeSection = section; + navButtons.forEach((b) => { + const on = b.dataset.section === section; + b.classList.toggle('is-active', on); + if (on) b.setAttribute('aria-current', 'page'); + else b.removeAttribute('aria-current'); + }); + if (navSelect) navSelect.value = section; + panelEls.forEach((panel) => { + const show = panel.dataset.section === section; + panel.classList.toggle('is-visible', show); + panel.hidden = !show; + }); + syncMobileNavMode(); + }; + + navButtons.forEach((btn) => { + btn.addEventListener('click', () => selectSection(btn.dataset.section)); + }); + if (navSelect) { + navSelect.addEventListener('change', async () => { + if (navSelect.value === LOGOUT_SELECT_VALUE) { + navSelect.value = activeSection; + await doLogout(); + return; + } + selectSection(navSelect.value); + }); + } + mq.addEventListener('change', syncMobileNavMode); + syncMobileNavMode(); + + if (logoutBtn) { + logoutBtn.textContent = copy.logout || 'Log out'; + logoutBtn.addEventListener('click', async () => { + await doLogout(); + }); + } + + if (email) { + try { + const { fetchAccountBundle, applyAccountDataToWidget } = await import('../../scripts/account-api.js'); + const data = await fetchAccountBundle(email); + applyAccountDataToWidget(widget, data, copy); + } catch (err) { + // eslint-disable-next-line no-console -- integration: copy API bundle errors + console.log('VITAMIX_ACCOUNT_API_BUNDLE_EXCEPTION'); + // eslint-disable-next-line no-console + console.log(err instanceof Error ? err.message : String(err)); + } + } +} diff --git a/widgets/account/account.json b/widgets/account/account.json new file mode 100644 index 00000000..5c970671 --- /dev/null +++ b/widgets/account/account.json @@ -0,0 +1,203 @@ +{ + "en": { + "modalTitle": "Account", + "nav": { + "overview": "My Account", + "information": "Account Information", + "address": "Addresses", + "orders": "Order History" + }, + "greeting": "Welcome back", + "panels": { + "overview": { + "title": "My Account", + "intro": "Overview of your Vitamix account. Detailed data will load from the account API.", + "rows": [ + { "label": "Member since", "value": "March 2024" }, + { "label": "Account status", "value": "Active" }, + { "label": "Preferred store", "value": "United States" } + ] + }, + "information": { + "title": "Account Information", + "rows": [ + { "label": "Email", "valueKey": "email" }, + { "label": "Name", "value": "David Nuescheler" }, + { "label": "Phone", "value": "+1 (216) 555-0142" }, + { "label": "Customer ID", "value": "VX-1000192" }, + { "label": "Preferred language", "value": "English (US)" } + ] + }, + "address": { + "title": "Addresses", + "mockAddresses": [ + { + "badge": "Default billing", + "lines": [ + "David Nuescheler", + "1234 Mockingbird Lane", + "Cleveland, OH 44114", + "United States" + ] + }, + { + "badge": "Shipping", + "lines": [ + "David Nuescheler", + "88 Example Parkway, Suite 200", + "Toronto, ON M5V 2T6", + "Canada" + ] + } + ] + }, + "orders": { + "title": "Order History", + "mockOrders": [ + { "id": "VM-482910", "date": "Nov 2, 2025", "total": "$429.00" }, + { "id": "VM-471205", "date": "Aug 18, 2025", "total": "$89.95" } + ] + } + }, + "logout": "Log out", + "sectionSelectAria": "Account menu", + "orderMock": { + "placed": "Placed", + "total": "Total" + } + }, + "fr": { + "modalTitle": "Compte", + "nav": { + "overview": "Mon compte", + "information": "Informations du compte", + "address": "Adresses", + "orders": "Historique des commandes" + }, + "greeting": "Bon retour", + "panels": { + "overview": { + "title": "Mon compte", + "intro": "Aperçu de votre compte Vitamix. Les données détaillées proviendront de l'API.", + "rows": [ + { "label": "Membre depuis", "value": "mars 2024" }, + { "label": "Statut du compte", "value": "Actif" }, + { "label": "Boutique préférée", "value": "Canada" } + ] + }, + "information": { + "title": "Informations du compte", + "rows": [ + { "label": "Courriel", "valueKey": "email" }, + { "label": "Nom", "value": "David Nuescheler" }, + { "label": "Téléphone", "value": "+1 (416) 555-0188" }, + { "label": "ID client", "value": "VX-CA-90012" }, + { "label": "Langue préférée", "value": "Français (CA)" } + ] + }, + "address": { + "title": "Adresses", + "mockAddresses": [ + { + "badge": "Facturation par défaut", + "lines": [ + "David Nuescheler", + "1234 rue du Faux", + "Montréal, QC H2Y 1C6", + "Canada" + ] + }, + { + "badge": "Livraison", + "lines": [ + "David Nuescheler", + "88 avenue Exemple, bureau 200", + "Toronto, ON M5V 2T6", + "Canada" + ] + } + ] + }, + "orders": { + "title": "Historique des commandes", + "mockOrders": [ + { "id": "VM-482910", "date": "2 nov. 2025", "total": "429,00 $ CA" }, + { "id": "VM-471205", "date": "18 août 2025", "total": "89,95 $ CA" } + ] + } + }, + "logout": "Se déconnecter", + "sectionSelectAria": "Menu du compte", + "orderMock": { + "placed": "Passée le", + "total": "Total" + } + }, + "es": { + "modalTitle": "Cuenta", + "nav": { + "overview": "Mi cuenta", + "information": "Información de la cuenta", + "address": "Direcciones", + "orders": "Historial de pedidos" + }, + "greeting": "Bienvenido de nuevo", + "panels": { + "overview": { + "title": "Mi cuenta", + "intro": "Resumen de su cuenta Vitamix. Los datos detallados vendrán de la API.", + "rows": [ + { "label": "Miembro desde", "value": "marzo de 2024" }, + { "label": "Estado de la cuenta", "value": "Activa" }, + { "label": "Tienda preferida", "value": "Estados Unidos" } + ] + }, + "information": { + "title": "Información de la cuenta", + "rows": [ + { "label": "Correo", "valueKey": "email" }, + { "label": "Nombre", "value": "David Nuescheler" }, + { "label": "Teléfono", "value": "+1 (305) 555-0160" }, + { "label": "ID de cliente", "value": "VX-US-44021" }, + { "label": "Idioma preferido", "value": "Español (US)" } + ] + }, + "address": { + "title": "Direcciones", + "mockAddresses": [ + { + "badge": "Facturación predeterminada", + "lines": [ + "David Nuescheler", + "1234 Calle Ejemplo", + "Miami, FL 33101", + "Estados Unidos" + ] + }, + { + "badge": "Envío", + "lines": [ + "David Nuescheler", + "88 Parkway de Muestra, Oficina 200", + "Toronto, ON M5V 2T6", + "Canadá" + ] + } + ] + }, + "orders": { + "title": "Historial de pedidos", + "mockOrders": [ + { "id": "VM-482910", "date": "2 nov 2025", "total": "US$429.00" }, + { "id": "VM-471205", "date": "18 ago 2025", "total": "US$89.95" } + ] + } + }, + "logout": "Cerrar sesión", + "sectionSelectAria": "Menú de la cuenta", + "orderMock": { + "placed": "Realizado", + "total": "Total" + } + } +} From 87a105fc99dfae933a0b941851807ade63c49b0f Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Thu, 14 May 2026 12:00:21 -0600 Subject: [PATCH 75/89] chore: add origin --- scripts/scripts.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index bb1d88b3..7ab08498 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -23,7 +23,7 @@ const { hostname } = window.location; // Format: '/' (e.g., 'ca/fr_ca'). Add pairs as each region goes live. const EDGE_CHECKOUT_LOCALES = ['ca/fr_ca', 'ca/en_us', 'us/en_us']; -const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-orders') || hostname.includes('integration.vitamix.com') || hostname.includes('uat.vitamix.com'); +const isEdgeHost = hostname.includes('localhost') || hostname.includes('edge-accounts') || hostname.includes('edge-orders') || hostname.includes('integration.vitamix.com') || hostname.includes('uat.vitamix.com'); const isProdHost = hostname.includes('vitamix.com'); // Affirm public API key — safe to expose client-side (used for PDP promo widgets). From e164f6f235aa49a9d6590f54a55c3512c50fd731 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Thu, 14 May 2026 12:04:15 -0600 Subject: [PATCH 76/89] chore: add to commerce-config --- scripts/commerce-config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/commerce-config.js b/scripts/commerce-config.js index 7cbf9f6d..87ca8217 100644 --- a/scripts/commerce-config.js +++ b/scripts/commerce-config.js @@ -3,6 +3,7 @@ const { hostname } = window.location; function resolveApiOrigin(org, site) { const isProduction = !hostname.endsWith('.aem.page') && !hostname.endsWith('.aem.live') + && !hostname.endsWith('.aem.network') && hostname !== 'localhost' && !hostname.startsWith('127.') && !hostname.startsWith('integration.'); From 15fbff23480cc456f539a23a0910126787b16447 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Thu, 14 May 2026 13:17:19 -0600 Subject: [PATCH 77/89] chore: wired --- .gitignore | 2 + blocks/header/header.js | 11 + scripts/account-api.js | 351 ------- scripts/body-scroll-lock.js | 37 + scripts/slide-panel.js | 7 + widgets/account/account-address-book.js | 311 +++++++ widgets/account/account-api.js | 1118 +++++++++++++++++++++++ widgets/account/account.css | 237 ++++- widgets/account/account.html | 122 ++- widgets/account/account.js | 96 +- widgets/account/account.json | 345 ++++--- 11 files changed, 2048 insertions(+), 589 deletions(-) delete mode 100644 scripts/account-api.js create mode 100644 scripts/body-scroll-lock.js create mode 100644 widgets/account/account-address-book.js create mode 100644 widgets/account/account-api.js diff --git a/.gitignore b/.gitignore index a9d7efa1..efad076a 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ helix-importer-ui .claude content + +.hlx/.da-token.json diff --git a/blocks/header/header.js b/blocks/header/header.js index 5e1cd052..75316805 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -5,6 +5,7 @@ import { AUTH_EVENT, getUser, isLoggedIn } from '../../scripts/auth-api.js'; import { addMagentoCacheListener, getLoggedInFromLocalStorage, getMagentoCache, } from '../../scripts/storage/util.js'; +import { lockBodyScroll, unlockBodyScroll } from '../../scripts/body-scroll-lock.js'; /** True when OTP JWT or legacy Magento customer cache indicates signed in. */ function isHeaderAuthSessionActive() { @@ -550,7 +551,12 @@ export default async function decorate(block) { if (!inside) accountMgmtDialog.closeModal(); }); + accountMgmtDialog.addEventListener('close', () => { + unlockBodyScroll(); + }); + const open = () => { + lockBodyScroll(); accountMgmtDialog.showModal(); accountMgmtDialog.setAttribute('aria-expanded', 'true'); }; @@ -702,6 +708,10 @@ export default async function decorate(block) { } }); + minicart.addEventListener('close', () => { + unlockBodyScroll(); + }); + const closeOnEmpty = (ev) => { if (ev.detail.action === 'empty') { minicart.closeModal(); @@ -710,6 +720,7 @@ export default async function decorate(block) { // open/close methods to account for transitions const open = () => { + lockBodyScroll(); minicart.showModal(); minicart.setAttribute('aria-expanded', true); document.addEventListener('cart:change', closeOnEmpty); diff --git a/scripts/account-api.js b/scripts/account-api.js deleted file mode 100644 index 0a50c80d..00000000 --- a/scripts/account-api.js +++ /dev/null @@ -1,351 +0,0 @@ -import { authFetch } from './auth-api.js'; -import { getConfig } from './commerce-config.js'; - -/* eslint-disable no-console -- VITAMIX_ACCOUNT_API_* payload logs for copy/paste integration */ - -/** - * Base URL for customer-scoped APIs: - * `{apiOrigin}/customers/{email}` (apiOrigin is e.g. …/aemsites/sites/vitamix). - * - * @param {string} customerEmail - * @returns {string} - */ -export function getCustomerApiBase(customerEmail) { - const origin = getConfig().apiOrigin.replace(/\/$/, ''); - return `${origin}/customers/${encodeURIComponent(customerEmail)}`; -} - -/** - * Logs a fixed tag line then the raw response body string for copy/paste. - * - * @param {string} tag - * @param {Response} resp - * @returns {Promise} Parsed JSON or null - */ -async function readResponseAndLog(tag, resp) { - const text = await resp.text(); - console.log(tag); - console.log(`HTTP_${resp.status}`); - console.log(text); - try { - return JSON.parse(text); - } catch { - return null; - } -} - -/** - * GET logged-in customer record. - * @param {string} customerEmail - * @returns {Promise} - */ -export async function getLoggedInCustomer(customerEmail) { - const url = getCustomerApiBase(customerEmail); - const resp = await authFetch(url, { method: 'GET' }); - return readResponseAndLog('VITAMIX_ACCOUNT_API_CUSTOMER', resp); -} - -/** - * GET customer addresses list. - * @param {string} customerEmail - * @returns {Promise} - */ -export async function getCustomerAddresses(customerEmail) { - const url = `${getCustomerApiBase(customerEmail)}/addresses`; - const resp = await authFetch(url, { method: 'GET' }); - return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESSES', resp); -} - -/** - * GET single address (stub for future UI). - * @param {string} customerEmail - * @param {string} addressId - * @returns {Promise} - */ -export async function getCustomerAddressById(customerEmail, addressId) { - const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; - const resp = await authFetch(url, { method: 'GET' }); - return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_BY_ID', resp); -} - -/** - * DELETE address (stub for future UI). - * @param {string} customerEmail - * @param {string} addressId - * @returns {Promise} - */ -export async function deleteCustomerAddress(customerEmail, addressId) { - const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; - const resp = await authFetch(url, { method: 'DELETE' }); - return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_DELETE', resp); -} - -/** - * POST new address (stub for future UI). - * @param {string} customerEmail - * @param {Record} body - * @returns {Promise} - */ -export async function createCustomerAddress(customerEmail, body) { - const url = `${getCustomerApiBase(customerEmail)}/addresses`; - const resp = await authFetch(url, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_CREATE', resp); -} - -/** - * GET customer orders list. - * @param {string} customerEmail - * @returns {Promise} - */ -export async function getCustomerOrders(customerEmail) { - const url = `${getCustomerApiBase(customerEmail)}/orders`; - const resp = await authFetch(url, { method: 'GET' }); - return readResponseAndLog('VITAMIX_ACCOUNT_API_ORDERS', resp); -} - -/** - * GET single order (stub; id may be numeric or friendly per API). - * @param {string} customerEmail - * @param {string} orderId - * @returns {Promise} - */ -export async function getCustomerOrderById(customerEmail, orderId) { - const url = `${getCustomerApiBase(customerEmail)}/orders/${encodeURIComponent(orderId)}`; - const resp = await authFetch(url, { method: 'GET' }); - return readResponseAndLog('VITAMIX_ACCOUNT_API_ORDER_BY_ID', resp); -} - -/** - * Fetches customer, addresses, and orders in parallel for the account drawer. - * - * @param {string} customerEmail - * @returns {Promise<{ customer: unknown, addresses: unknown, orders: unknown }>} - */ -export async function fetchAccountBundle(customerEmail) { - const [customer, addresses, orders] = await Promise.all([ - getLoggedInCustomer(customerEmail), - getCustomerAddresses(customerEmail), - getCustomerOrders(customerEmail), - ]); - return { customer, addresses, orders }; -} - -/** @param {unknown} payload */ -function normalizeAddressArray(payload) { - if (Array.isArray(payload)) return payload; - if (payload && typeof payload === 'object') { - const o = /** @type {Record} */ (payload); - if (Array.isArray(o.addresses)) return o.addresses; - if (Array.isArray(o.items)) return o.items; - if (Array.isArray(o.data)) return o.data; - } - return []; -} - -/** @param {unknown} payload */ -function normalizeOrderArray(payload) { - if (Array.isArray(payload)) return payload; - if (payload && typeof payload === 'object') { - const o = /** @type {Record} */ (payload); - if (Array.isArray(o.orders)) return o.orders; - if (Array.isArray(o.items)) return o.items; - if (Array.isArray(o.data)) return o.data; - } - return []; -} - -/** - * @param {Record} addr - * @returns {{ badge: string, lines: string[] }} - */ -function mapAddressToDisplay(addr) { - const id = addr.id != null ? String(addr.id) : ''; - const badgeParts = []; - if (addr.default === true || addr.isDefault === true) badgeParts.push('Default'); - if (id) badgeParts.push(id); - const badge = badgeParts.join(' · ') || 'Address'; - const name = typeof addr.name === 'string' ? addr.name : ''; - const line1 = typeof addr.address1 === 'string' ? addr.address1 : ''; - const line2 = [addr.city, addr.state, addr.zip].filter((x) => x != null && String(x).length).join(', '); - const country = typeof addr.country === 'string' ? addr.country : ''; - const lines = [name, line1, line2, country].filter((x) => String(x).length); - return { badge, lines: lines.length ? lines : [JSON.stringify(addr)] }; -} - -function pickOrderTotal(order) { - if (order.total != null) return String(order.total); - if (order.grandTotal != null) return String(order.grandTotal); - if (order.totalDue != null) return String(order.totalDue); - return '—'; -} - -/** - * @param {Record} order - * @param {{ placed?: string, total?: string }} orderLabels - */ -function mapOrderToDisplay(order, orderLabels) { - const id = order.friendlyId || order.orderId || order.id || order.number || '—'; - const date = order.createdAt || order.created_at || order.date || order.placedAt || '—'; - const total = pickOrderTotal(order); - return { - id: String(id), - date: String(date), - total: String(total), - placedLabel: orderLabels.placed || 'Placed', - totalLabel: orderLabels.total || 'Total', - }; -} - -/** - * Builds label/value rows from a customer object (best-effort; API shape may vary). - * - * @param {unknown} customer - * @param {string} fallbackEmail - * @returns {Array<{ label: string, value: string }>} - */ -function buildInfoRowsFromCustomer(customer, fallbackEmail) { - if (!customer || typeof customer !== 'object') return []; - const c = /** @type {Record} */ (customer); - const rows = []; - const push = (label, val) => { - if (val != null && String(val).length) rows.push({ label, value: String(val) }); - }; - push('Email', c.email ?? fallbackEmail); - const name = typeof c.name === 'string' ? c.name - : [c.firstName, c.lastName].filter((x) => x != null && String(x).length).join(' '); - push('Name', name); - push('Phone', c.phone ?? c.telephone); - push('Customer ID', c.id ?? c.customerId ?? c.uid); - push('Preferred language', c.locale ?? c.language); - push('Account status', c.status ?? c.state); - return rows.filter((r) => r.value.length); -} - -/** - * Updates overview + information panels when customer JSON is available. - * - * @param {HTMLElement} widget - * @param {unknown} customer - * @param {string} email - */ -function applyCustomerToWidget(widget, customer, email) { - const information = widget.querySelector('.account-panel[data-section="information"]'); - if (!information) return; - const rows = buildInfoRowsFromCustomer(customer, email); - if (!rows.length) return; - const container = information.querySelector('.account-mock-rows'); - if (!container) return; - container.innerHTML = ''; - rows.forEach((row) => { - const wrap = document.createElement('div'); - wrap.className = 'account-mock-row'; - const lab = document.createElement('span'); - lab.className = 'account-mock-label'; - lab.textContent = row.label; - const val = document.createElement('span'); - val.className = 'account-mock-value'; - val.textContent = row.value; - wrap.append(lab, val); - container.append(wrap); - }); -} - -/** - * @param {HTMLElement} widget - * @param {unknown} addressesPayload - */ -function applyAddressesToWidget(widget, addressesPayload) { - const listEl = widget.querySelector('.account-address-list'); - if (!listEl) return; - const raw = normalizeAddressArray(addressesPayload); - if (!raw.length) return; - const mapped = raw - .filter((x) => x && typeof x === 'object') - .map((x) => mapAddressToDisplay(/** @type {Record} */ (x))); - listEl.innerHTML = ''; - mapped.forEach((addr) => { - const li = document.createElement('li'); - li.className = 'account-address-item'; - const badge = document.createElement('div'); - badge.className = 'account-address-badge'; - badge.textContent = addr.badge; - const lines = document.createElement('p'); - lines.className = 'account-address-lines'; - lines.textContent = addr.lines.join('\n'); - li.append(badge, lines); - listEl.append(li); - }); -} - -/** - * @param {HTMLElement} widget - * @param {unknown} ordersPayload - * @param {{ placed?: string, total?: string }} orderMockLabels - */ -function applyOrdersToWidget(widget, ordersPayload, orderMockLabels) { - const list = widget.querySelector('.account-order-mock-list'); - if (!list) return; - const raw = normalizeOrderArray(ordersPayload); - if (!raw.length) return; - list.innerHTML = ''; - raw.forEach((item) => { - if (!item || typeof item !== 'object') return; - const o = mapOrderToDisplay(/** @type {Record} */ (item), orderMockLabels); - const li = document.createElement('li'); - li.className = 'account-order-mock-item'; - const idEl = document.createElement('span'); - idEl.className = 'account-order-mock-id'; - idEl.textContent = o.id; - const meta = document.createElement('div'); - meta.className = 'account-order-mock-meta'; - const s1 = document.createElement('span'); - s1.textContent = `${o.placedLabel}: ${o.date}`; - const s2 = document.createElement('span'); - s2.textContent = `${o.totalLabel}: ${o.total}`; - meta.append(s1, s2); - li.append(idEl, meta); - list.append(li); - }); -} - -/** If API wraps payload in `{ data: ... }`, unwrap one level. */ -function unwrapPayload(payload) { - if (payload == null) return payload; - if (typeof payload === 'object' && 'data' in payload && payload.data !== undefined) { - return /** @type {Record} */ (payload).data; - } - return payload; -} - -/** - * After fetchAccountBundle, maps API payloads onto the account widget DOM. - * - * @param {HTMLElement} widget - * @param {{ customer: unknown, addresses: unknown, orders: unknown }} data - * @param {{ orderMock?: { placed?: string, total?: string } }} [copySlice] - */ -export function applyAccountDataToWidget(widget, data, copySlice = {}) { - const email = /** @type {HTMLParagraphElement | null} */ (widget.querySelector('.account-email-muted'))?.textContent?.trim() || ''; - - let customer = unwrapPayload(data.customer); - if (Array.isArray(customer) && customer.length === 1) { - [customer] = customer; - } - - const addresses = unwrapPayload(data.addresses); - const orders = unwrapPayload(data.orders); - - if (customer && typeof customer === 'object') { - applyCustomerToWidget(widget, customer, email); - } - if (addresses != null) { - applyAddressesToWidget(widget, addresses); - } - if (orders != null) { - applyOrdersToWidget(widget, orders, copySlice.orderMock || {}); - } -} diff --git a/scripts/body-scroll-lock.js b/scripts/body-scroll-lock.js new file mode 100644 index 00000000..cdf3eb41 --- /dev/null +++ b/scripts/body-scroll-lock.js @@ -0,0 +1,37 @@ +let lockCount = 0; +/** @type {number} */ +let scrollY = 0; + +/** + * Freezes document scroll behind overlays (e.g. ``). Ref-counted so nested + * overlays do not restore scroll until the last one closes. + */ +export function lockBodyScroll() { + if (typeof document === 'undefined') return; + if (lockCount === 0) { + scrollY = window.scrollY; + const { body } = document; + body.style.overflow = 'hidden'; + body.style.position = 'fixed'; + body.style.top = `-${scrollY}px`; + body.style.left = '0'; + body.style.right = '0'; + body.style.width = '100%'; + } + lockCount += 1; +} + +/** Call when an overlay is fully closed; pairs with {@link lockBodyScroll}. */ +export function unlockBodyScroll() { + if (typeof document === 'undefined') return; + lockCount = Math.max(0, lockCount - 1); + if (lockCount !== 0) return; + const { body } = document; + body.style.removeProperty('overflow'); + body.style.removeProperty('position'); + body.style.removeProperty('top'); + body.style.removeProperty('left'); + body.style.removeProperty('right'); + body.style.removeProperty('width'); + window.scrollTo(0, scrollY); +} diff --git a/scripts/slide-panel.js b/scripts/slide-panel.js index 220f393d..0e8cd3e3 100644 --- a/scripts/slide-panel.js +++ b/scripts/slide-panel.js @@ -1,3 +1,5 @@ +import { lockBodyScroll, unlockBodyScroll } from './body-scroll-lock.js'; + /** * Creates a reusable slide-out dialog panel. * @param {string} id - Element ID for the dialog @@ -29,7 +31,12 @@ export default function createSlidePanel(id, title, className) { dialog.append(headerRow, content); + dialog.addEventListener('close', () => { + unlockBodyScroll(); + }); + function open() { + lockBodyScroll(); dialog.showModal(); dialog.setAttribute('aria-expanded', 'true'); } diff --git a/widgets/account/account-address-book.js b/widgets/account/account-address-book.js new file mode 100644 index 00000000..2053c9ad --- /dev/null +++ b/widgets/account/account-address-book.js @@ -0,0 +1,311 @@ +/* eslint-disable import/prefer-default-export -- module is address-book wiring only */ +/* eslint-disable no-alert -- minimal user feedback for address errors */ +import getStatesProvincesOptions from '../forms/states-provinces.js'; +import { + createCustomerAddress, + deleteCustomerAddress, + getCustomerAddressById, + getCustomerAddresses, + renderAccountAddressList, + unwrapAddressDetail, + unwrapPayload, + updateCustomerAddress, +} from './account-api.js'; + +/** + * @param {HTMLSelectElement} select + * @param {{ value: string, label: string }[]} options + * @param {string} emptyLabel + */ +function fillRegionSelect(select, options, emptyLabel) { + if (!select) return; + select.innerHTML = ''; + const opt0 = document.createElement('option'); + opt0.value = ''; + opt0.textContent = emptyLabel; + select.append(opt0); + options.forEach((o) => { + const opt = document.createElement('option'); + opt.value = o.value; + opt.textContent = o.label; + select.append(opt); + }); +} + +/** + * @param {string} name "John Q Public" + * @returns {{ first: string, last: string }} + */ +function splitFullName(name) { + const s = String(name || '').trim(); + if (!s) return { first: '', last: '' }; + const i = s.indexOf(' '); + if (i === -1) return { first: s, last: '' }; + return { first: s.slice(0, i).trim(), last: s.slice(i + 1).trim() }; +} + +/** + * @param {HTMLFormElement} form + * @param {string} accountEmail + * @returns {Record} + */ +function readAddressForm(form, accountEmail) { + const fd = new FormData(form); + const first = String(fd.get('firstName') || '').trim(); + const last = String(fd.get('lastName') || '').trim(); + const phone = String(fd.get('phone') || '').replace(/\D/g, ''); + const country = String(fd.get('country') || 'us').toLowerCase(); + const body = { + name: `${first} ${last}`.trim() || (accountEmail || '').split('@')[0] || '—', + address1: String(fd.get('address1') || '').trim(), + address2: String(fd.get('address2') || '').trim(), + city: String(fd.get('city') || '').trim(), + state: String(fd.get('state') || '').trim(), + zip: String(fd.get('zip') || '').trim(), + country, + phone, + email: String(fd.get('email') || '').trim() || accountEmail, + isDefault: fd.get('isDefault') === 'on', + default: fd.get('isDefault') === 'on', + }; + return body; +} + +/** + * @param {HTMLFormElement} form + * @param {Record} addr + * @param {string} accountEmail + */ +function fillAddressForm(form, addr, accountEmail) { + const { first, last } = splitFullName(typeof addr.name === 'string' ? addr.name : ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="firstName"]')).value = first; + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="lastName"]')).value = last; + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="address1"]')).value = String(addr.address1 ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="address2"]')).value = String(addr.address2 ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="city"]')).value = String(addr.city ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="zip"]')).value = String(addr.zip ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="phone"]')).value = String(addr.phone ?? ''); + /** @type {HTMLInputElement | null} */ + (form.querySelector('[name="email"]')).value = String(addr.email ?? accountEmail ?? ''); + /** @type {HTMLInputElement | null} */ + const defCb = form.querySelector('input[name="isDefault"]'); + if (defCb) defCb.checked = addr.isDefault === true || addr.default === true; + const countrySel = form.querySelector('[name="country"]'); + if (countrySel) { + const c = String(addr.country || 'us').toLowerCase(); + countrySel.value = ['us', 'ca', 'mx'].includes(c) ? c : 'us'; + } +} + +/** + * @param {HTMLElement} widget + * @param {string} customerEmail + * @param {string} lang - `en` | `fr` | `es` + * @param {string} marketLocale - path locale `us` | `ca` | `mx` + * @param {Record} copy + */ +export function wireAccountAddressBook(widget, customerEmail, lang, marketLocale, copy) { + if (widget.dataset.addressBookWired === '1') return; + widget.dataset.addressBookWired = '1'; + + const ab = /** @type {Record} */ (copy.addressBook || {}); + const dialog = widget.querySelector('.account-address-dialog'); + const form = widget.querySelector('.account-address-form'); + const addBtn = widget.querySelector('.account-address-add'); + const titleEl = widget.querySelector('.account-address-dialog-title'); + const errEl = widget.querySelector('.account-address-form-error'); + const saveBtn = widget.querySelector('.account-address-save'); + const cancelBtn = widget.querySelector('.account-address-cancel'); + const countrySelect = form?.querySelector('[name="country"]'); + const stateSelect = form?.querySelector('[name="state"]'); + const zipLabel = form?.querySelector('[for="account-addr-zip"] .label-text'); + const stateLabel = form?.querySelector('[for="account-addr-state"] .label-text'); + + if (!dialog || !form) return; + + const setFormCopy = () => { + const countries = /** @type {Record} */ (copy.countries || {}); + const setLab = (forId, text) => { + const lab = form.querySelector(`[for="${forId}"] .label-text`); + if (lab) lab.textContent = text; + }; + setLab('account-addr-first', ab.firstName || 'First name'); + setLab('account-addr-last', ab.lastName || 'Last name'); + setLab('account-addr-email', ab.email || 'Email'); + setLab('account-addr-line1', ab.address1 || 'Street address'); + setLab('account-addr-line2', ab.address2 || 'Address line 2'); + setLab('account-addr-city', ab.city || 'City'); + setLab('account-addr-country', ab.country || 'Country'); + setLab('account-addr-phone', ab.phone || 'Phone'); + setLab('account-addr-zip', ab.zipUs || 'ZIP code'); + setLab('account-addr-state', ab.stateUs || 'State'); + const defSpan = form.querySelector('.account-addr-default-label'); + if (defSpan) defSpan.textContent = ab.defaultAddress || 'Default address'; + if (saveBtn) saveBtn.textContent = ab.save || 'Save'; + if (cancelBtn) cancelBtn.textContent = ab.cancel || 'Cancel'; + if (addBtn) addBtn.textContent = ab.add || 'Add address'; + ['us', 'ca', 'mx'].forEach((code) => { + const opt = countrySelect?.querySelector(`option[value="${code}"]`); + if (opt) opt.textContent = countries[code] || code.toUpperCase(); + }); + }; + setFormCopy(); + + if (!customerEmail) return; + + /** @type {string | null} */ + let editingId = null; + + const defaultCountry = () => { + const m = String(marketLocale || 'us').toLowerCase(); + return ['us', 'ca', 'mx'].includes(m) ? m : 'us'; + }; + + const setZipStateLabels = (country) => { + const c = country.toLowerCase(); + if (zipLabel) { + if (c === 'us') zipLabel.textContent = ab.zipUs || 'ZIP code'; + else zipLabel.textContent = ab.postalOther || 'Postal code'; + } + if (stateLabel) { + if (c === 'us') stateLabel.textContent = ab.stateUs || 'State'; + else if (c === 'ca') stateLabel.textContent = ab.stateCa || 'Province'; + else stateLabel.textContent = ab.stateMx || 'State'; + } + }; + + const loadRegions = async () => { + if (!stateSelect || !countrySelect) return; + const country = String(countrySelect.value || 'us').toUpperCase(); + setZipStateLabels(country.toLowerCase()); + const opts = await getStatesProvincesOptions(country, lang).catch(() => []); + fillRegionSelect(stateSelect, opts, ab.regionPlaceholder || 'Select…'); + }; + + const closeDialog = () => { + dialog.close(); + editingId = null; + }; + + const showAddDialog = () => { + editingId = null; + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + form.reset(); + if (countrySelect) countrySelect.value = defaultCountry(); + const em = /** @type {HTMLInputElement | null} */ (form.querySelector('[name="email"]')); + if (em) em.value = customerEmail; + if (titleEl) titleEl.textContent = ab.dialogAddTitle || 'New address'; + loadRegions().then(() => dialog.showModal()); + }; + + const showEditDialog = (raw) => { + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + fillAddressForm(form, raw, customerEmail); + const pendingState = String(raw.state ?? ''); + if (titleEl) titleEl.textContent = ab.dialogEditTitle || 'Edit address'; + loadRegions().then(() => { + if (stateSelect && pendingState) { + stateSelect.value = pendingState; + } + dialog.showModal(); + }); + }; + + addBtn?.addEventListener('click', () => { + showAddDialog(); + }); + + cancelBtn?.addEventListener('click', () => closeDialog()); + + const reloadList = async () => { + const payload = await getCustomerAddresses(customerEmail); + const list = unwrapPayload(payload); + await renderAccountAddressList(widget, list ?? payload, copy); + }; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + const body = readAddressForm(form, customerEmail); + if (!body.address1 || !body.city || !body.state || !body.zip) { + if (errEl) { + errEl.hidden = false; + errEl.textContent = ab.validationRequired || 'Please fill all required fields.'; + } + return; + } + if (saveBtn) saveBtn.disabled = true; + try { + if (editingId) { + await updateCustomerAddress(customerEmail, editingId, body); + } else { + await createCustomerAddress(customerEmail, body); + } + closeDialog(); + await reloadList(); + } catch (err) { + if (errEl) { + errEl.hidden = false; + errEl.textContent = ab.saveError || (err instanceof Error ? err.message : String(err)); + } + } finally { + if (saveBtn) saveBtn.disabled = false; + } + }); + + widget.addEventListener('click', async (e) => { + const editBtn = e.target.closest('.account-address-edit'); + const delBtn = e.target.closest('.account-address-delete'); + const li = editBtn?.closest('.account-address-item') || delBtn?.closest('.account-address-item'); + const id = li?.dataset.addressId; + if (!id || !customerEmail) return; + + if (editBtn) { + editingId = id; + const liRaw = /** @type {unknown} */ (li).accountAddressRaw; + let raw = /** @type {Record | undefined} */ (liRaw); + const street = raw && typeof raw.address1 === 'string' ? raw.address1.trim() : ''; + const minimal = !raw || !street; + if (minimal) { + try { + const fetched = await getCustomerAddressById(customerEmail, id); + raw = /** @type {Record | undefined} */ (unwrapAddressDetail(fetched)); + } catch { + raw = undefined; + } + } + if (!raw || typeof raw !== 'object') { + window.alert(ab.loadDetailError || 'Could not load address.'); + return; + } + showEditDialog(raw); + } + + if (delBtn) { + const ok = window.confirm(ab.deleteConfirm || 'Remove this address?'); + if (!ok) return; + try { + await deleteCustomerAddress(customerEmail, id); + await reloadList(); + } catch (err) { + window.alert(ab.deleteError || (err instanceof Error ? err.message : String(err))); + } + } + }); +} diff --git a/widgets/account/account-api.js b/widgets/account/account-api.js new file mode 100644 index 00000000..29a41038 --- /dev/null +++ b/widgets/account/account-api.js @@ -0,0 +1,1118 @@ +import { createOptimizedPicture } from '../../scripts/aem.js'; +import { authFetch } from '../../scripts/auth-api.js'; +import { formatPrice, getConfig } from '../../scripts/commerce-config.js'; +import { getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/* eslint-disable no-console -- VITAMIX_ACCOUNT_API_* payload logs for copy/paste integration */ + +/** + * Base URL for customer-scoped APIs: + * `{apiOrigin}/customers/{email}` (apiOrigin is e.g. …/aemsites/sites/vitamix). + * Email is appended unencoded so `@` stays literal — temporary until the service accepts + * RFC 3986 path encoding (`%40` for `@`). + * + * @param {string} customerEmail + * @returns {string} + */ +export function getCustomerApiBase(customerEmail) { + const origin = getConfig().apiOrigin.replace(/\/$/, ''); + return `${origin}/customers/${customerEmail}`; +} + +/** + * Logs a fixed tag line then the raw response body string for copy/paste. + * + * @param {string} tag + * @param {Response} resp + * @param {{ throwIfNotOk?: boolean }} [options] + * @returns {Promise} Parsed JSON or null + */ +async function readResponseAndLog(tag, resp, options = {}) { + const text = await resp.text(); + console.log(tag); + console.log(`HTTP_${resp.status}`); + console.log(text); + if (options.throwIfNotOk && !resp.ok) { + throw new Error(`Request failed (${resp.status})`); + } + if (!text.trim()) { + return null; + } + try { + return JSON.parse(text); + } catch { + return null; + } +} + +/** + * GET logged-in customer record. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getLoggedInCustomer(customerEmail) { + const url = getCustomerApiBase(customerEmail); + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_CUSTOMER', resp); +} + +/** + * GET customer addresses list. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getCustomerAddresses(customerEmail) { + const url = `${getCustomerApiBase(customerEmail)}/addresses`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESSES', resp); +} + +/** + * GET single address by id. + * @param {string} customerEmail + * @param {string} addressId + * @returns {Promise} + */ +export async function getCustomerAddressById(customerEmail, addressId) { + const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_BY_ID', resp); +} + +/** + * DELETE address. + * @param {string} customerEmail + * @param {string} addressId + * @returns {Promise} + */ +export async function deleteCustomerAddress(customerEmail, addressId) { + const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; + const resp = await authFetch(url, { method: 'DELETE' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_DELETE', resp, { throwIfNotOk: true }); +} + +/** + * POST new address (stub for future UI). + * @param {string} customerEmail + * @param {Record} body + * @returns {Promise} + */ +export async function createCustomerAddress(customerEmail, body) { + const url = `${getCustomerApiBase(customerEmail)}/addresses`; + const resp = await authFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_CREATE', resp, { throwIfNotOk: true }); +} + +/** + * PUT replace customer address. + * @param {string} customerEmail + * @param {string} addressId + * @param {Record} body + * @returns {Promise} + */ +export async function updateCustomerAddress(customerEmail, addressId, body) { + const url = `${getCustomerApiBase(customerEmail)}/addresses/${encodeURIComponent(addressId)}`; + const resp = await authFetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ADDRESS_UPDATE', resp, { throwIfNotOk: true }); +} + +/** + * GET customer orders list. + * @param {string} customerEmail + * @returns {Promise} + */ +export async function getCustomerOrders(customerEmail) { + const url = `${getCustomerApiBase(customerEmail)}/orders`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ORDERS', resp); +} + +/** + * GET single order (stub; id may be numeric or friendly per API). + * @param {string} customerEmail + * @param {string} orderId + * @returns {Promise} + */ +export async function getCustomerOrderById(customerEmail, orderId) { + const url = `${getCustomerApiBase(customerEmail)}/orders/${encodeURIComponent(orderId)}`; + const resp = await authFetch(url, { method: 'GET' }); + return readResponseAndLog('VITAMIX_ACCOUNT_API_ORDER_BY_ID', resp, { throwIfNotOk: true }); +} + +/** + * Fetches customer, addresses, and orders in parallel for the account drawer. + * + * @param {string} customerEmail + * @returns {Promise<{ customer: unknown, addresses: unknown, orders: unknown }>} + */ +export async function fetchAccountBundle(customerEmail) { + const [customer, addresses, orders] = await Promise.all([ + getLoggedInCustomer(customerEmail), + getCustomerAddresses(customerEmail), + getCustomerOrders(customerEmail), + ]); + return { customer, addresses, orders }; +} + +/** @param {unknown} iso */ +function formatIsoForUi(iso) { + if (iso == null || iso === '') return '—'; + const s = typeof iso === 'string' ? iso : String(iso); + const d = new Date(s); + return Number.isNaN(d.getTime()) ? s : d.toLocaleString(); +} + +/** + * API returns `{ "customer": { ... } }` at the root of the customer GET response. + * + * @param {unknown} payload + * @returns {unknown} + */ +function unwrapCustomerResponse(payload) { + const afterData = (payload && typeof payload === 'object' && 'data' in payload + && /** @type {Record} */ (payload).data !== undefined) + ? /** @type {Record} */ (payload).data + : payload; + if (afterData && typeof afterData === 'object' && 'customer' in afterData + && /** @type {Record} */ (afterData).customer != null) { + return /** @type {Record} */ (afterData).customer; + } + return afterData; +} + +/** @param {unknown} payload */ +function normalizeAddressArray(payload) { + if (Array.isArray(payload)) return payload; + if (payload && typeof payload === 'object') { + const o = /** @type {Record} */ (payload); + if (Array.isArray(o.addresses)) return o.addresses; + if (Array.isArray(o.items)) return o.items; + if (Array.isArray(o.data)) return o.data; + } + return []; +} + +/** + * Turns ISO 3166-1 alpha-2 codes (e.g. `us`, `CA`) into a region name in the store language + * (from URL path, same as {@link getLocaleAndLanguage}). + * + * @param {unknown} raw + * @returns {string} + */ +function formatCountryLabel(raw) { + if (raw == null) return ''; + const s = String(raw).trim(); + if (!s) return ''; + const iso = s.length === 2 && /^[a-zA-Z]{2}$/.test(s) ? s.toUpperCase() : null; + if (!iso) return s; + + let uiLocale = 'en-US'; + try { + const { language } = getLocaleAndLanguage(true, true); + if (language && typeof language === 'string') uiLocale = language; + } catch { + /* no window */ + } + + if (typeof Intl !== 'undefined' && typeof Intl.DisplayNames === 'function') { + try { + const label = new Intl.DisplayNames([uiLocale], { type: 'region' }).of(iso); + if (label && typeof label === 'string') return label; + } catch { + /* bad locale tag */ + } + } + return iso; +} + +/** @param {unknown} payload */ +function normalizeOrderArray(payload) { + if (Array.isArray(payload)) return payload; + if (payload && typeof payload === 'object') { + const o = /** @type {Record} */ (payload); + if (Array.isArray(o.orders)) return o.orders; + if (Array.isArray(o.items)) return o.items; + if (Array.isArray(o.data)) return o.data; + } + return []; +} + +/** + * @param {Record} addr + * @param {Record} [addressBookCopy] + * @returns {{ badge: string, lines: string[] }} + */ +function mapAddressToDisplay(addr, addressBookCopy = {}) { + const defBadge = addressBookCopy.defaultBadge || 'Default'; + const addrBadge = addressBookCopy.addressBadge || 'Address'; + const id = addr.id != null ? String(addr.id) : ''; + const hasStreet = typeof addr.address1 === 'string' && addr.address1.length > 0; + + /* List endpoint: { id, email, isDefault } only — hydrate before display when possible */ + if (!hasStreet && id) { + const isDef = addr.default === true || addr.isDefault === true; + const badge = isDef ? defBadge : addrBadge; + const emailLine = typeof addr.email === 'string' ? addr.email : ''; + const lines = [emailLine].filter((x) => String(x).length); + return { + badge, + lines: lines.length ? lines : [addressBookCopy.listSummaryFallback || 'Address'], + }; + } + + const badgeParts = []; + if (addr.default === true || addr.isDefault === true) badgeParts.push(defBadge); + const badge = badgeParts.join(' · ') || addrBadge; + const name = typeof addr.name === 'string' ? addr.name : ''; + const line1 = typeof addr.address1 === 'string' ? addr.address1 : ''; + const line2 = [addr.city, addr.state, addr.zip].filter((x) => x != null && String(x).length).join(', '); + const countryRaw = typeof addr.country === 'string' ? addr.country : ''; + const country = countryRaw ? formatCountryLabel(countryRaw) : ''; + const lines = [name, line1, line2, country].filter((x) => String(x).length); + return { badge, lines: lines.length ? lines : [JSON.stringify(addr)] }; +} + +/** + * @param {HTMLElement} widget + * @param {unknown} customer + * @param {string} email + * @param {Record} copy + */ +function applyOverviewPanel(widget, customer, email, copy) { + const overview = widget.querySelector('.account-panel[data-section="overview"]'); + if (!overview) return; + const container = overview.querySelector('.account-overview-rows'); + if (!container) return; + container.innerHTML = ''; + const ov = /** @type {Record} */ (copy.overviewLabels || {}); + const countries = /** @type {Record} */ (copy.countries || {}); + const lc = String( + /** @type {{ accountLocale?: string }} */ (copy).accountLocale + || (typeof window !== 'undefined' + ? (window.location.pathname.split('/').filter(Boolean)[0] || 'us') + : 'us'), + ).toLowerCase(); + + const pushRow = (label, value) => { + if (value == null || String(value).length === 0) return; + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = label; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = String(value); + wrap.append(lab, val); + container.append(wrap); + }; + + if (customer && typeof customer === 'object') { + const c = /** @type {Record} */ (customer); + const name = typeof c.name === 'string' ? c.name + : [c.firstName, c.lastName].filter((x) => x != null && String(x).length).join(' '); + if (name) pushRow(ov.displayName || 'Name', name); + if (email) pushRow(ov.email || 'Email', email); + if (c.createdAt) pushRow(ov.memberSince || 'Member since', formatIsoForUi(c.createdAt)); + if (c.status != null || c.state != null) { + pushRow(ov.accountStatus || 'Account status', String(c.status ?? c.state)); + } + } + const regionLabel = countries[lc] || lc.toUpperCase(); + pushRow(ov.storeRegion || 'Store region', regionLabel); +} + +function pickOrderTotal(order) { + if (order.total != null) return String(order.total); + if (order.grandTotal != null) return String(order.grandTotal); + if (order.totalDue != null) return String(order.totalDue); + return '—'; +} + +/** + * Last segment after the final `-` (e.g. `…661Z-K1770KLK` → `K1770KLK`). If there is no hyphen, + * returns the trimmed string unchanged. + * + * @param {string} raw + * @returns {string} + */ +export function getOrderNumberTailSegment(raw) { + const s = String(raw ?? '').trim(); + if (!s) return ''; + const i = s.lastIndexOf('-'); + if (i === -1) return s; + const tail = s.slice(i + 1).trim(); + return tail || s; +} + +/** + * Short order number for UI: tail segment, uppercased, hyphen after the first 4 characters + * when longer (e.g. `ydm3csej` → `YDM3-CSEJ`). + * + * @param {string} raw + * @returns {string} + */ +export function formatOrderNumberForDisplay(raw) { + const tail = getOrderNumberTailSegment(raw); + const u = tail.toUpperCase(); + if (u.length <= 4) return u; + return `${u.slice(0, 4)}-${u.slice(4)}`; +} + +/** + * @param {Record} order + * @param {{ placed?: string, total?: string, state?: string }} orderLabels + */ +function mapOrderToDisplay(order, orderLabels) { + const fetchId = order.friendlyId || order.orderId || order.id || order.number || '—'; + const displaySource = order.id || order.friendlyId || order.orderId || order.number || fetchId; + const dateRaw = order.createdAt || order.created_at || order.date || order.placedAt; + const dateDisplay = formatIsoForUi(dateRaw); + const total = pickOrderTotal(order); + const state = order.state != null ? String(order.state) : ''; + const totalLabel = orderLabels.total || 'Total'; + const stateLabel = orderLabels.state || 'Status'; + const metaSecond = total !== '—' + ? `${totalLabel}: ${total}` + : `${stateLabel}: ${state || '—'}`; + const orderId = String(fetchId); + return { + orderId, + displayOrderNumber: formatOrderNumberForDisplay(String(displaySource)), + metaFirst: `${orderLabels.placed || 'Placed'}: ${dateDisplay}`, + metaSecond, + }; +} + +/** + * Builds label/value rows from a customer object (best-effort; API shape may vary). + * + * @param {unknown} customer + * @param {string} fallbackEmail + * @param {Record} [labels] + * @returns {Array<{ label: string, value: string }>} + */ +function buildInfoRowsFromCustomer(customer, fallbackEmail, labels = {}) { + if (!customer || typeof customer !== 'object') return []; + const c = /** @type {Record} */ (customer); + const rows = []; + const push = (key, val) => { + const label = labels[key]; + if (!label || val == null || !String(val).length) return; + rows.push({ label, value: String(val) }); + }; + push('email', c.email ?? fallbackEmail); + const name = typeof c.name === 'string' ? c.name + : [c.firstName, c.lastName].filter((x) => x != null && String(x).length).join(' '); + push('name', name); + push('phone', c.phone ?? c.telephone); + push('customerId', c.id ?? c.customerId ?? c.uid); + push('preferredLanguage', c.locale ?? c.language); + push('accountStatus', c.status ?? c.state); + if (c.createdAt) push('created', formatIsoForUi(c.createdAt)); + if (c.updatedAt) push('updated', formatIsoForUi(c.updatedAt)); + return rows.filter((r) => r.value.length); +} + +/** + * Updates the information panel when customer JSON is available. + * + * @param {HTMLElement} widget + * @param {unknown} customer + * @param {string} email + * @param {Record} copy + */ +function applyCustomerToWidget(widget, customer, email, copy) { + const information = widget.querySelector('.account-panel[data-section="information"]'); + if (!information) return; + const labels = /** @type {Record} */ (copy.customerInfo || {}); + const rows = buildInfoRowsFromCustomer(customer, email, labels); + const container = information.querySelector('.account-mock-rows'); + if (!container) return; + container.innerHTML = ''; + if (!rows.length) return; + rows.forEach((row) => { + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = row.label; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = row.value; + wrap.append(lab, val); + container.append(wrap); + }); +} + +/** + * @param {HTMLElement} widget + * @param {unknown} ordersPayload + * @param {{ placed?: string, total?: string, state?: string }} orderMockLabels + * @param {Record} copy + */ +function applyOrdersToWidget(widget, ordersPayload, orderMockLabels, copy = {}) { + const list = widget.querySelector('.account-order-mock-list'); + const emptyEl = widget.querySelector('.account-orders-empty'); + if (!list) return; + list.innerHTML = ''; + const raw = normalizeOrderArray(ordersPayload); + if (!raw.length) { + if (emptyEl) { + emptyEl.hidden = false; + emptyEl.textContent = String(copy.ordersEmpty || 'No orders yet.'); + } + return; + } + if (emptyEl) emptyEl.hidden = true; + raw.forEach((item) => { + if (!item || typeof item !== 'object') return; + const o = mapOrderToDisplay(/** @type {Record} */ (item), orderMockLabels); + const li = document.createElement('li'); + li.className = 'account-order-mock-row'; + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'account-order-mock-item'; + btn.dataset.orderId = o.orderId; + const idEl = document.createElement('span'); + idEl.className = 'account-order-mock-id'; + idEl.textContent = o.displayOrderNumber; + idEl.title = o.orderId; + const meta = document.createElement('div'); + meta.className = 'account-order-mock-meta'; + const s1 = document.createElement('span'); + s1.textContent = o.metaFirst; + const s2 = document.createElement('span'); + s2.textContent = o.metaSecond; + meta.append(s1, s2); + btn.append(idEl, meta); + li.append(btn); + list.append(li); + }); +} + +/** If API wraps payload in `{ data: ... }`, unwrap one level. */ +export function unwrapPayload(payload) { + if (payload == null) return payload; + if (typeof payload === 'object' && 'data' in payload && payload.data !== undefined) { + return /** @type {Record} */ (payload).data; + } + return payload; +} + +/** + * GET single-address response may be `{ "address": { ... } }`. + * + * @param {unknown} payload + * @returns {unknown} + */ +export function unwrapAddressDetail(payload) { + const d = unwrapPayload(payload); + if (d && typeof d === 'object' && 'address' in d + && /** @type {Record} */ (d).address != null) { + return /** @type {Record} */ (d).address; + } + return d; +} + +/** + * When the addresses list API returns stubs only (`id`, `email`, `isDefault`), fetch each + * full record so the list can show a normal postal summary. + * + * @param {unknown[]} items + * @param {string} customerEmail + * @returns {Promise} + */ +async function hydrateAccountAddressListItems(items, customerEmail) { + if (!customerEmail || !Array.isArray(items)) return items; + return Promise.all( + items.map(async (item) => { + if (!item || typeof item !== 'object') return item; + const stub = /** @type {Record} */ (item); + const id = stub.id != null ? String(stub.id) : ''; + const street = typeof stub.address1 === 'string' ? stub.address1.trim() : ''; + if (!id || street) return stub; + try { + const payload = await getCustomerAddressById(customerEmail, id); + const detail = unwrapAddressDetail(payload); + if (detail && typeof detail === 'object') { + const d = /** @type {Record} */ (detail); + return { ...d, ...stub, id: stub.id ?? d.id }; + } + } catch { + /* keep stub for minimal display */ + } + return stub; + }), + ); +} + +/** + * @param {HTMLElement} widget + * @param {unknown} addressesPayload + * @param {Record} [copy] + * @returns {Promise} + */ +export async function renderAccountAddressList(widget, addressesPayload, copy = {}) { + const listEl = widget.querySelector('.account-address-list'); + const emptyEl = widget.querySelector('.account-address-empty'); + const ab = /** @type {Record} */ (copy.addressBook || {}); + if (!listEl) return; + listEl.innerHTML = ''; + let raw = normalizeAddressArray(addressesPayload); + if (!raw.length) { + if (emptyEl) { + emptyEl.hidden = false; + emptyEl.textContent = ab.emptyList || 'No saved addresses yet.'; + } + return; + } + if (emptyEl) emptyEl.hidden = true; + + const emailEl = widget.querySelector('.account-email-muted'); + const customerEmail = (emailEl?.textContent || '').trim(); + const needsHydration = raw.some((item) => { + if (!item || typeof item !== 'object') return false; + const a = /** @type {Record} */ (item); + const id = a.id != null ? String(a.id) : ''; + const street = typeof a.address1 === 'string' ? a.address1.trim() : ''; + return Boolean(id && !street); + }); + if (customerEmail && needsHydration) { + raw = await hydrateAccountAddressListItems(raw, customerEmail); + } + + raw + .filter((x) => x && typeof x === 'object') + .forEach((item) => { + const addr = /** @type {Record} */ (item); + const mapped = mapAddressToDisplay(addr, ab); + const li = document.createElement('li'); + li.className = 'account-address-item'; + const aid = addr.id != null ? String(addr.id) : ''; + li.dataset.addressId = aid; + /** @type {any} */ (li).accountAddressRaw = addr; + + const head = document.createElement('div'); + head.className = 'account-address-item-head'; + const badge = document.createElement('div'); + badge.className = 'account-address-badge'; + badge.textContent = mapped.badge; + const actions = document.createElement('div'); + actions.className = 'account-address-actions'; + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'button link-style account-address-edit'; + editBtn.textContent = ab.edit || 'Edit'; + const delBtn = document.createElement('button'); + delBtn.type = 'button'; + delBtn.className = 'button link-style account-address-delete'; + delBtn.textContent = ab.remove || 'Remove'; + actions.append(editBtn, delBtn); + head.append(badge, actions); + + const lines = document.createElement('p'); + lines.className = 'account-address-lines'; + lines.textContent = mapped.lines.join('\n'); + li.append(head, lines); + listEl.append(li); + }); +} + +/** + * @param {HTMLElement} widget + * @param {unknown} addressesPayload + * @param {Record} [copy] + */ +async function applyAddressesToWidget(widget, addressesPayload, copy = {}) { + await renderAccountAddressList(widget, addressesPayload, copy); +} + +/** + * Single-order GET may return `{ "order": { ... } }` or a bare object. + * + * @param {unknown} payload + * @returns {unknown} + */ +function unwrapOrderDetail(payload) { + const afterData = unwrapPayload(payload); + if (afterData && typeof afterData === 'object' && 'order' in afterData + && /** @type {Record} */ (afterData).order != null) { + return /** @type {Record} */ (afterData).order; + } + return afterData; +} + +/** + * @param {Record} addr + * @returns {string} + */ +function formatOrderAddressBlock(addr) { + if (!addr || typeof addr !== 'object') return ''; + const a = /** @type {Record} */ (addr); + const lines = [ + typeof a.name === 'string' ? a.name : '', + typeof a.address1 === 'string' ? a.address1 : '', + typeof a.address2 === 'string' ? a.address2 : '', + ].filter(Boolean); + const cityLine = [a.city, a.state, a.zip] + .filter((x) => x != null && String(x).length) + .join(', '); + if (cityLine) lines.push(cityLine); + if (a.country != null && String(a.country).length) { + lines.push(formatCountryLabel(String(a.country))); + } + return lines.join('\n'); +} + +/** + * @param {Record} order + * @param {Record} od + * @returns {string} + */ +function buildOrderPaymentLine(order, od) { + const rootMethod = order.paymentMethod != null ? String(order.paymentMethod) : ''; + const pay = order.payment; + if (!pay || typeof pay !== 'object') { + return rootMethod; + } + const p = /** @type {Record} */ (pay); + const method = p.method != null ? String(p.method) : ''; + const last4 = p.cardLastFour != null ? String(p.cardLastFour) : ''; + let cardPart = ''; + if (last4) { + const tpl = od.paymentCard; + cardPart = tpl && tpl.includes('{{lastFour}}') + ? tpl.replace('{{lastFour}}', last4) + : `Card ending ${last4}`; + } + const tail = [method, cardPart].filter(Boolean).join(' · '); + if (!tail) return rootMethod; + return rootMethod ? `${rootMethod} · ${tail}` : tail; +} + +/** + * @param {Record} item + * @param {string} currencyCode + * @param {string} qtyLabel + * @returns {HTMLElement} + */ +function buildReadOnlyOrderLineItem(item, currencyCode, qtyLabel) { + const qty = Number(item.quantity) || 0; + const priceObj = item.price; + const finalStr = priceObj && typeof priceObj === 'object' && priceObj != null && 'final' in priceObj + ? String(/** @type {{ final?: unknown }} */ (priceObj).final) + : String(item.price ?? '0'); + const unit = parseFloat(finalStr) || 0; + const lineTotal = unit * qty; + + const el = document.createElement('div'); + el.className = 'cart-item'; + + const imgWrap = document.createElement('div'); + imgWrap.className = 'cart-item-image'; + const imageUrl = typeof item.imageUrl === 'string' ? item.imageUrl : ''; + if (imageUrl) { + const name = typeof item.name === 'string' ? item.name : ''; + imgWrap.appendChild(createOptimizedPicture(imageUrl, name, true)); + } + + const details = document.createElement('div'); + details.className = 'cart-item-details'; + const nameP = document.createElement('p'); + nameP.className = 'cart-item-name'; + const productUrl = typeof item.productUrl === 'string' ? item.productUrl : ''; + const displayName = typeof item.name === 'string' ? item.name : String(item.sku || ''); + if (productUrl) { + const a = document.createElement('a'); + a.textContent = displayName; + try { + a.href = new URL(productUrl).pathname; + } catch { + a.href = productUrl; + } + nameP.appendChild(a); + } else { + nameP.textContent = displayName; + } + const qtyP = document.createElement('p'); + qtyP.className = 'cart-item-variant'; + const sku = item.sku != null ? String(item.sku) : ''; + qtyP.textContent = sku ? `${qtyLabel} ${qty} · SKU ${sku}` : `${qtyLabel} ${qty}`; + details.append(nameP, qtyP); + + const right = document.createElement('div'); + right.className = 'cart-item-right'; + const priceEl = document.createElement('div'); + priceEl.className = 'cart-item-price'; + priceEl.textContent = formatPrice(lineTotal, currencyCode); + right.appendChild(priceEl); + if (qty > 1) { + const each = document.createElement('div'); + each.className = 'cart-item-per-unit'; + each.textContent = `${formatPrice(unit, currencyCode)} each`; + right.appendChild(each); + } + + el.append(imgWrap, details, right); + return el; +} + +/** + * Renders checkout-style order summary (shared `order-summary` + `cart-item` classes). + * + * @param {HTMLElement} container + * @param {Record} order + * @param {{ + * orderMock?: { placed?: string, state?: string }, + * orderDetail?: Record, + * }} [copySlice] + */ +function renderOrderDetailReadout(container, order, copySlice = {}) { + container.innerHTML = ''; + if (!order || typeof order !== 'object') return; + + const o = /** @type {Record} */ (order); + const od = /** @type {Record} */ (copySlice.orderDetail || {}); + const om = /** @type {{ placed?: string, state?: string }} */ (copySlice.orderMock || {}); + const s = getConfig().getStrings(); + + const meta = document.createElement('div'); + meta.className = 'account-order-detail-meta account-mock-rows'; + + const pushMetaRow = (label, value) => { + if (value == null || String(value).length === 0) return; + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = label; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = String(value); + wrap.append(lab, val); + meta.append(wrap); + }; + + const placedRaw = o.createdAt ?? o.placedAt; + if (placedRaw) { + pushMetaRow(om.placed || od.placedOn || 'Placed', formatIsoForUi(placedRaw)); + } + if (o.state != null) { + const stateStr = String(o.state).replaceAll('_', ' '); + pushMetaRow(om.state || od.status || 'Status', stateStr); + } + + const items = Array.isArray(o.items) ? o.items.filter((x) => x && typeof x === 'object') : []; + const firstPrice = items[0] && /** @type {Record} */ (items[0]).price; + const currency = firstPrice && typeof firstPrice === 'object' && firstPrice != null && 'currency' in firstPrice + ? String(/** @type {{ currency?: unknown }} */ (firstPrice).currency || 'USD') + : 'USD'; + + let subtotalNum = 0; + items.forEach((raw) => { + const it = /** @type {Record} */ (raw); + const q = Number(it.quantity) || 0; + const pObj = it.price; + const finalStr = pObj && typeof pObj === 'object' && pObj != null && 'final' in pObj + ? String(/** @type {{ final?: unknown }} */ (pObj).final) + : String(it.price ?? '0'); + const unit = parseFloat(finalStr) || 0; + subtotalNum += unit * q; + }); + + const estimates = o.estimates && typeof o.estimates === 'object' + ? /** @type {Record} */ (o.estimates) + : {}; + const discounts = Array.isArray(estimates.discounts) ? estimates.discounts : []; + const freeShip = discounts.some((d) => d && typeof d === 'object' + && /** @type {Record} */ (d).freeShipping === true); + const shipMeta = estimates.shippingMethod && typeof estimates.shippingMethod === 'object' + ? /** @type {Record} */ (estimates.shippingMethod) + : null; + const rateRaw = shipMeta?.rate; + const rateNum = typeof rateRaw === 'number' ? rateRaw : parseFloat(String(rateRaw ?? NaN)); + let shipAmount = 0; + let shipText = '—'; + if (freeShip) { + shipText = s.free; + shipAmount = 0; + } else if (Number.isFinite(rateNum)) { + shipAmount = rateNum; + shipText = formatPrice(rateNum, currency); + } + + const taxObj = estimates.tax && typeof estimates.tax === 'object' + ? /** @type {Record} */ (estimates.tax) + : null; + const taxNum = taxObj != null + ? parseFloat(String(taxObj.totalTax ?? taxObj.amount ?? NaN)) + : NaN; + const taxText = Number.isFinite(taxNum) ? formatPrice(taxNum, currency) : '—'; + const taxAmount = Number.isFinite(taxNum) ? taxNum : 0; + + const totalNum = subtotalNum + shipAmount + taxAmount; + + const summaryRoot = document.createElement('div'); + summaryRoot.className = 'order-summary'; + summaryRoot.innerHTML = ` +
          +

          +
          +
          +
          +
          +
          + + +
          +
          + + +
          +
          + + +
          +
          + +
          + + +
          +
          +
          +
          + `; + + const h3 = summaryRoot.querySelector('h3'); + if (h3) h3.textContent = s.orderSummary; + + const lblSub = summaryRoot.querySelector('.od-lbl-sub'); + const valSub = summaryRoot.querySelector('.od-val-sub'); + const lblShip = summaryRoot.querySelector('.od-lbl-ship'); + const valShip = summaryRoot.querySelector('.od-val-ship'); + const lblTax = summaryRoot.querySelector('.od-lbl-tax'); + const valTax = summaryRoot.querySelector('.od-val-tax'); + const lblTotal = summaryRoot.querySelector('.od-lbl-total'); + const valTotal = summaryRoot.querySelector('.od-val-total'); + const curEl = summaryRoot.querySelector('.currency'); + + if (lblSub) lblSub.textContent = s.subtotal; + if (valSub) valSub.textContent = formatPrice(subtotalNum, currency); + if (lblShip) lblShip.textContent = s.shipping; + if (valShip) valShip.textContent = shipText; + if (lblTax) lblTax.textContent = s.estimatedTaxes; + if (valTax) valTax.textContent = taxText; + if (lblTotal) lblTotal.textContent = s.total; + if (valTotal) valTotal.textContent = formatPrice(totalNum, currency); + if (curEl) curEl.textContent = currency; + + const itemsRoot = summaryRoot.querySelector('.order-summary-items'); + const qtyLabel = `${od.qty || 'Qty.'} `; + if (itemsRoot) { + items.forEach((raw) => { + const lineItem = /** @type {Record} */ (raw); + const row = buildReadOnlyOrderLineItem(lineItem, currency, qtyLabel.trim()); + itemsRoot.appendChild(row); + }); + } + + const addrWrap = document.createElement('div'); + addrWrap.className = 'account-order-detail-addresses'; + + const shipBody = formatOrderAddressBlock( + o.shipping && typeof o.shipping === 'object' ? /** @type {Record} */ (o.shipping) : {}, + ); + const billBody = formatOrderAddressBlock( + o.billing && typeof o.billing === 'object' ? /** @type {Record} */ (o.billing) : {}, + ); + + if (shipBody) { + const sec = document.createElement('div'); + sec.className = 'account-order-detail-address-block'; + const h = document.createElement('h6'); + h.className = 'account-order-detail-address-title'; + h.textContent = od.shippingAddress || 'Shipping address'; + const p = document.createElement('p'); + p.className = 'account-address-lines'; + p.textContent = shipBody; + sec.append(h, p); + addrWrap.append(sec); + } + if (billBody) { + const sec = document.createElement('div'); + sec.className = 'account-order-detail-address-block'; + const h = document.createElement('h6'); + h.className = 'account-order-detail-address-title'; + h.textContent = od.billingAddress || 'Billing address'; + const p = document.createElement('p'); + p.className = 'account-address-lines'; + p.textContent = billBody; + sec.append(h, p); + addrWrap.append(sec); + } + + const payLine = buildOrderPaymentLine(o, od); + + container.append(meta, summaryRoot); + if (addrWrap.childElementCount) { + container.append(addrWrap); + } + if (payLine) { + const foot = document.createElement('div'); + foot.className = 'account-order-detail-foot account-mock-rows'; + const wrap = document.createElement('div'); + wrap.className = 'account-mock-row'; + const lab = document.createElement('span'); + lab.className = 'account-mock-label'; + lab.textContent = od.payment || 'Payment'; + const val = document.createElement('span'); + val.className = 'account-mock-value'; + val.textContent = payLine; + wrap.append(lab, val); + foot.append(wrap); + container.append(foot); + } +} + +/** + * One-time: order row opens detail panel, fetches GET …/orders/:id, logs payloads, shows readout. + * Customer email is read from `.account-email-muted` on each click so it stays in sync with the UI. + * + * @param {HTMLElement} widget + * @param {{ orderMock?: object, orderDetail?: Record }} [copySlice] + */ +export function wireOrderDetailInteractions(widget, copySlice = {}) { + if (widget.dataset.orderDetailWired === '1') return; + widget.dataset.orderDetailWired = '1'; + + const ordersPanel = widget.querySelector('.account-panel[data-section="orders"]'); + if (!ordersPanel) return; + + const listPanel = ordersPanel.querySelector('.account-orders-list-panel'); + const detailPanel = ordersPanel.querySelector('.account-order-detail-panel'); + const list = ordersPanel.querySelector('.account-order-mock-list'); + const back = ordersPanel.querySelector('.account-order-detail-back'); + const heading = ordersPanel.querySelector('.account-order-detail-heading'); + const readout = ordersPanel.querySelector('.account-order-detail-readout'); + const errEl = ordersPanel.querySelector('.account-order-detail-error'); + const od = copySlice.orderDetail || {}; + + if (back) back.textContent = od.back || '← All orders'; + + const showList = () => { + if (listPanel) listPanel.hidden = false; + if (detailPanel) detailPanel.hidden = true; + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + }; + + const showDetail = () => { + if (listPanel) listPanel.hidden = true; + if (detailPanel) detailPanel.hidden = false; + }; + + back?.addEventListener('click', showList); + + const mo = new MutationObserver(() => { + if (ordersPanel.hidden) showList(); + }); + mo.observe(ordersPanel, { attributes: true, attributeFilter: ['hidden'] }); + + list?.addEventListener('click', async (e) => { + const btn = e.target.closest('button.account-order-mock-item'); + if (!btn) return; + const { orderId } = btn.dataset; + const emailEl = widget.querySelector('.account-email-muted'); + const customerEmail = (emailEl?.textContent || '').trim(); + if (!orderId || !customerEmail) return; + + showDetail(); + if (heading) { + heading.textContent = formatOrderNumberForDisplay(orderId); + heading.title = orderId; + } + if (readout) { + readout.innerHTML = ''; + const loadP = document.createElement('p'); + loadP.className = 'account-order-detail-loading'; + loadP.textContent = od.loading || 'Loading…'; + readout.append(loadP); + } + if (errEl) { + errEl.hidden = true; + errEl.textContent = ''; + } + + try { + const payload = await getCustomerOrderById(customerEmail, orderId); + const display = unwrapOrderDetail(payload); + console.log('VITAMIX_ACCOUNT_API_ORDER_DETAIL_PARSED'); + console.log(JSON.stringify(display)); + if (readout && display && typeof display === 'object') { + const orderRecord = /** @type {Record} */ (display); + renderOrderDetailReadout(readout, orderRecord, copySlice); + } else if (readout) { + readout.innerHTML = ''; + } + } catch (err) { + if (readout) readout.innerHTML = ''; + if (errEl) { + errEl.hidden = false; + errEl.textContent = od.error || (err instanceof Error ? err.message : String(err)); + } + console.log('VITAMIX_ACCOUNT_API_ORDER_DETAIL_EXCEPTION'); + console.log(err instanceof Error ? err.message : String(err)); + } + }); +} + +/** + * After fetchAccountBundle, maps API payloads onto the account widget DOM. + * + * @param {HTMLElement} widget + * @param {{ customer: unknown, addresses: unknown, orders: unknown }} data + * @param {{ + * orderMock?: { placed?: string, total?: string, state?: string }, + * orderDetail?: Record, + * }} [copySlice] + */ +export async function applyAccountDataToWidget(widget, data, copySlice = {}) { + const email = /** @type {HTMLParagraphElement | null} */ (widget.querySelector('.account-email-muted'))?.textContent?.trim() || ''; + + let customer = unwrapCustomerResponse(data.customer); + if (Array.isArray(customer) && customer.length === 1) { + [customer] = customer; + } + + const addresses = unwrapPayload(data.addresses); + const orders = unwrapPayload(data.orders); + + if (customer && typeof customer === 'object') { + applyOverviewPanel(widget, customer, email, copySlice); + applyCustomerToWidget(widget, customer, email, copySlice); + } else { + applyOverviewPanel(widget, null, email, copySlice); + } + if (addresses != null) { + await applyAddressesToWidget(widget, addresses, copySlice); + } else { + await renderAccountAddressList(widget, [], copySlice); + } + if (orders != null) { + applyOrdersToWidget(widget, orders, copySlice.orderMock || {}, copySlice); + } else { + applyOrdersToWidget(widget, [], copySlice.orderMock || {}, copySlice); + } +} diff --git a/widgets/account/account.css b/widgets/account/account.css index 2ec4c2c3..48c144df 100644 --- a/widgets/account/account.css +++ b/widgets/account/account.css @@ -58,16 +58,22 @@ background: var(--commerce-color-background); } -.account-widget--mobile-nav-select .account-nav-mobile { +.account-sidebar-footer { + padding: 12px 16px 16px; + margin-top: auto; + border-top: 1px solid var(--commerce-color-border); +} + +.account-widget-mobile-nav-select .account-nav-mobile { display: block; } -.account-widget--mobile-nav-select .account-nav { +.account-widget-mobile-nav-select .account-nav { display: none; } /* On mobile compact nav, sign out is only in the select — hide duplicate footer button */ -.account-widget--mobile-nav-select .account-sidebar-footer { +.account-widget-mobile-nav-select .account-sidebar-footer { display: none; } @@ -132,12 +138,6 @@ padding-left: calc(24px - 3px); } -.account-sidebar-footer { - padding: 12px 16px 16px; - margin-top: auto; - border-top: 1px solid var(--commerce-color-border); -} - .account-logout { width: 100%; box-sizing: border-box; @@ -257,6 +257,124 @@ max-width: 42rem; } +.account-address-toolbar { + margin: 0 0 16px; +} + +.account-address-item-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} + +.account-address-actions { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; + flex-shrink: 0; +} + +.account-address-dialog { + max-width: min(100vw - 32px, 560px); + width: 100%; + padding: 0; + border: 1px solid var(--commerce-color-border); + border-radius: var(--commerce-radius-card); + background: var(--commerce-color-background); +} + +.account-address-dialog::backdrop { + background: rgb(0 0 0 / 45%); +} + +.account-address-dialog-inner { + padding: 20px 24px 24px; +} + +.account-address-dialog-title { + margin: 0 0 16px; + font-size: var(--commerce-font-size-lg); +} + +.account-address-form-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 14px 16px; +} + +.account-addr-field-full { + grid-column: 1 / -1; +} + +.account-addr-field label { + display: block; + margin-bottom: 4px; + font-size: var(--commerce-font-size-xs); + color: var(--commerce-color-text-muted); +} + +.account-addr-field .required { + color: var(--commerce-color-error-text, #b00020); +} + +.account-addr-field input:not([type='checkbox'], [type='radio']), +.account-addr-field select { + width: 100%; + box-sizing: border-box; + padding: 10px 12px; + border: 1px solid var(--commerce-input-border); + border-radius: var(--commerce-input-radius); + font-size: var(--commerce-font-size-sm); + background: var(--commerce-input-background); +} + +.account-addr-field-checkbox label { + display: flex; + flex-flow: row nowrap; + align-items: flex-start; + justify-content: flex-start; + gap: 10px; + margin: 0; + cursor: pointer; + color: var(--commerce-color-text); + font-size: var(--commerce-font-size-sm); +} + +.account-addr-field-checkbox input[type='checkbox'] { + width: auto; + min-width: 1rem; + height: auto; + margin: 0.2em 0 0; + flex-shrink: 0; + cursor: pointer; +} + +.account-addr-field-checkbox .account-addr-default-label { + flex: 1 1 auto; + min-width: 0; + line-height: 1.35; +} + +.account-address-form-error { + margin: 12px 0 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-error-text, #b00020); +} + +.account-address-form-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 20px; +} + +.account-orders-empty { + margin: 0 0 12px; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + .account-address-item { margin: 0 0 20px; padding-bottom: 20px; @@ -286,26 +404,49 @@ white-space: pre-line; } +.account-orders-stack { + display: flex; + flex-direction: column; + gap: 1rem; + max-width: 42rem; +} + .account-order-mock-list { list-style: none; margin: 0; padding: 0; - max-width: 42rem; display: flex; flex-direction: column; gap: 0; } +.account-order-mock-row { + margin: 0; + padding: 0; +} + .account-order-mock-item { display: flex; flex-direction: column; gap: 6px; + width: 100%; + margin: 0; padding: 14px 0; + border: none; border-bottom: 1px solid var(--commerce-color-border); + border-radius: 0; background: transparent; + font: inherit; + text-align: left; + color: inherit; + cursor: pointer; } -.account-order-mock-item:last-child { +.account-order-mock-item:hover { + background: color-mix(in srgb, var(--commerce-color-text) 4%, transparent); +} + +.account-order-mock-row:last-child .account-order-mock-item { border-bottom: none; } @@ -323,6 +464,74 @@ color: var(--commerce-color-text-muted); } +.account-order-detail-back { + margin: 0 0 12px; + padding: 0; + border: none; + background: none; + font: inherit; + color: var(--commerce-color-primary); + cursor: pointer; + text-decoration: underline; +} + +.account-order-detail-heading { + margin: 0 0 12px; + font-size: var(--commerce-font-size-md); + font-weight: var(--commerce-font-weight-bold); +} + +.account-order-detail-readout { + max-width: 42rem; +} + +.account-order-detail-readout .order-summary { + margin-top: 0; +} + +.account-order-detail-meta { + margin-bottom: 20px; +} + +.account-order-detail-loading { + margin: 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); +} + +.account-order-detail-addresses { + display: grid; + gap: 20px; + margin-top: 24px; + max-width: 42rem; +} + +@media (width >= 640px) { + .account-order-detail-addresses { + grid-template-columns: 1fr 1fr; + } +} + +.account-order-detail-address-title { + margin: 0 0 8px; + font-size: var(--commerce-font-size-xs); + font-weight: var(--commerce-font-weight-bold); + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--commerce-color-text-muted); +} + +.account-order-detail-foot { + margin-top: 20px; + max-width: 42rem; +} + +.account-order-detail-error { + margin: 12px 0 0; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-error-text, #b00020); +} + @media (width >= 768px) { .account-layout { display: grid; @@ -340,15 +549,15 @@ padding-right: clamp(8px, 2vw, 32px); } - .account-widget--mobile-nav-select .account-nav-mobile { + .account-widget-mobile-nav-select .account-nav-mobile { display: none; } - .account-widget--mobile-nav-select .account-nav { + .account-widget-mobile-nav-select .account-nav { display: block; } - .account-widget--mobile-nav-select .account-sidebar-footer { + .account-widget-mobile-nav-select .account-sidebar-footer { display: block; } } diff --git a/widgets/account/account.html b/widgets/account/account.html index 4273c920..26914e96 100644 --- a/widgets/account/account.html +++ b/widgets/account/account.html @@ -39,54 +39,24 @@ @@ -94,10 +64,88 @@ + + diff --git a/widgets/account/account.js b/widgets/account/account.js index 97964a5e..4829a8ee 100644 --- a/widgets/account/account.js +++ b/widgets/account/account.js @@ -1,4 +1,5 @@ import { loadCSS } from '../../scripts/aem.js'; +import { renderAccountAddressList } from './account-api.js'; import { getLocaleAndLanguage } from '../../scripts/scripts.js'; import { getUser, logout } from '../../scripts/auth-api.js'; @@ -18,48 +19,6 @@ async function loadCopy(lang) { return data[key]; } -/** - * @param {HTMLElement} section - * @param {Array<{ label?: string, value?: string, valueKey?: string }>} rows - * @param {string} [email] - */ -function fillMockRows(section, rows, email) { - if (!section || !rows?.length) return; - const rowEls = section.querySelectorAll('.account-mock-row'); - rows.forEach((row, i) => { - const el = rowEls[i]; - if (!el) return; - const label = el.querySelector('.account-mock-label'); - const value = el.querySelector('.account-mock-value'); - if (label) label.textContent = row.label ?? ''; - if (value) { - const v = row.valueKey === 'email' ? (email || '—') : (row.value ?? '—'); - value.textContent = v; - } - }); -} - -/** - * @param {HTMLElement} listEl - * @param {Array<{ badge?: string, lines?: string[] }>} addresses - */ -function fillAddressList(listEl, addresses) { - if (!listEl || !addresses?.length) return; - listEl.innerHTML = ''; - addresses.forEach((addr) => { - const li = document.createElement('li'); - li.className = 'account-address-item'; - const badge = document.createElement('div'); - badge.className = 'account-address-badge'; - badge.textContent = addr.badge || ''; - const lines = document.createElement('p'); - lines.className = 'account-address-lines'; - lines.textContent = (addr.lines || []).join('\n'); - li.append(badge, lines); - listEl.append(li); - }); -} - /** * @param {HTMLElement} widget */ @@ -68,9 +27,11 @@ export default async function decorate(widget) { await Promise.all([ loadCSS(`${base}/styles/commerce-tokens.css`), loadCSS(`${base}/widgets/account/account.css`), + loadCSS(`${base}/blocks/order-summary/order-summary.css`), + loadCSS(`${base}/scripts/commerce/cart-item.css`), ]); - const { language } = getLocaleAndLanguage(); + const { locale, language } = getLocaleAndLanguage(); const lang = (language || 'en_us').split('_')[0]; const copy = await loadCopy(lang); const email = getUser()?.email || ''; @@ -123,7 +84,6 @@ export default async function decorate(widget) { const intro = overview.querySelector('.account-panel-intro'); if (t) t.textContent = p.title || ''; if (intro) intro.textContent = p.intro || ''; - fillMockRows(overview, p.rows, email); } const information = widget.querySelector('.account-panel[data-section="information"]'); @@ -131,35 +91,32 @@ export default async function decorate(widget) { const p = panels.information || {}; const t = information.querySelector('.account-panel-title'); if (t) t.textContent = p.title || ''; - fillMockRows(information, p.rows, email); } const address = widget.querySelector('.account-panel[data-section="address"]'); if (address) { const p = panels.address || {}; const t = address.querySelector('.account-panel-title'); - const listEl = address.querySelector('.account-address-list'); + const addBtn = address.querySelector('.account-address-add'); if (t) t.textContent = p.title || ''; - fillAddressList(listEl, p.mockAddresses); + const ab = /** @type {Record} */ (copy.addressBook || {}); + if (addBtn) { + addBtn.textContent = ab.add || 'Add address'; + addBtn.hidden = !email; + addBtn.disabled = !email; + } + await renderAccountAddressList(widget, [], copy); } const orders = widget.querySelector('.account-panel[data-section="orders"]'); if (orders) { const p = panels.orders || {}; const t = orders.querySelector('.account-panel-title'); - const list = orders.querySelector('.account-order-mock-list'); + const emptyEl = orders.querySelector('.account-orders-empty'); if (t) t.textContent = p.title || ''; - if (list && Array.isArray(p.mockOrders)) { - const om = copy.orderMock || {}; - list.innerHTML = p.mockOrders.map((o) => ` - - `).join(''); + if (emptyEl) { + emptyEl.hidden = false; + emptyEl.textContent = String(copy.ordersEmpty || ''); } } @@ -168,13 +125,13 @@ export default async function decorate(widget) { const syncMobileNavMode = () => { if (mq.matches) { - widget.classList.remove('account-widget--mobile-nav-select'); + widget.classList.remove('account-widget-mobile-nav-select'); return; } if (activeSection !== 'overview') { - widget.classList.add('account-widget--mobile-nav-select'); + widget.classList.add('account-widget-mobile-nav-select'); } else { - widget.classList.remove('account-widget--mobile-nav-select'); + widget.classList.remove('account-widget-mobile-nav-select'); } }; @@ -234,16 +191,27 @@ export default async function decorate(widget) { }); } + const { + fetchAccountBundle, + applyAccountDataToWidget, + wireOrderDetailInteractions, + } = await import('./account-api.js'); + const { wireAccountAddressBook } = await import('./account-address-book.js'); + + const copyWithLocale = { ...copy, accountLocale: locale || 'us' }; + if (email) { try { - const { fetchAccountBundle, applyAccountDataToWidget } = await import('../../scripts/account-api.js'); const data = await fetchAccountBundle(email); - applyAccountDataToWidget(widget, data, copy); + await applyAccountDataToWidget(widget, data, copyWithLocale); } catch (err) { // eslint-disable-next-line no-console -- integration: copy API bundle errors console.log('VITAMIX_ACCOUNT_API_BUNDLE_EXCEPTION'); // eslint-disable-next-line no-console console.log(err instanceof Error ? err.message : String(err)); } + wireAccountAddressBook(widget, email, lang, String(locale || 'us').toLowerCase(), copy); } + + wireOrderDetailInteractions(widget, copy); } diff --git a/widgets/account/account.json b/widgets/account/account.json index 5c970671..81797595 100644 --- a/widgets/account/account.json +++ b/widgets/account/account.json @@ -11,59 +11,92 @@ "panels": { "overview": { "title": "My Account", - "intro": "Overview of your Vitamix account. Detailed data will load from the account API.", - "rows": [ - { "label": "Member since", "value": "March 2024" }, - { "label": "Account status", "value": "Active" }, - { "label": "Preferred store", "value": "United States" } - ] + "intro": "Your account summary loads from the account service when you are signed in." }, "information": { - "title": "Account Information", - "rows": [ - { "label": "Email", "valueKey": "email" }, - { "label": "Name", "value": "David Nuescheler" }, - { "label": "Phone", "value": "+1 (216) 555-0142" }, - { "label": "Customer ID", "value": "VX-1000192" }, - { "label": "Preferred language", "value": "English (US)" } - ] + "title": "Account Information" }, "address": { - "title": "Addresses", - "mockAddresses": [ - { - "badge": "Default billing", - "lines": [ - "David Nuescheler", - "1234 Mockingbird Lane", - "Cleveland, OH 44114", - "United States" - ] - }, - { - "badge": "Shipping", - "lines": [ - "David Nuescheler", - "88 Example Parkway, Suite 200", - "Toronto, ON M5V 2T6", - "Canada" - ] - } - ] + "title": "Addresses" }, "orders": { - "title": "Order History", - "mockOrders": [ - { "id": "VM-482910", "date": "Nov 2, 2025", "total": "$429.00" }, - { "id": "VM-471205", "date": "Aug 18, 2025", "total": "$89.95" } - ] + "title": "Order History" } }, + "overviewLabels": { + "displayName": "Name", + "email": "Email", + "memberSince": "Member since", + "accountStatus": "Account status", + "storeRegion": "Store region" + }, + "customerInfo": { + "email": "Email", + "name": "Name", + "phone": "Phone", + "customerId": "Customer ID", + "preferredLanguage": "Preferred language", + "accountStatus": "Account status", + "created": "Member since", + "updated": "Last updated" + }, + "countries": { + "us": "United States", + "ca": "Canada", + "mx": "Mexico" + }, + "addressBook": { + "add": "Add address", + "edit": "Edit", + "remove": "Remove", + "save": "Save", + "cancel": "Cancel", + "deleteConfirm": "Remove this address?", + "dialogAddTitle": "New address", + "dialogEditTitle": "Edit address", + "firstName": "First name", + "lastName": "Last name", + "email": "Email", + "address1": "Street address", + "address2": "Apt, suite, etc. (optional)", + "city": "City", + "country": "Country", + "phone": "Phone", + "defaultAddress": "Default address", + "zipUs": "ZIP code", + "postalOther": "Postal code", + "stateUs": "State", + "stateCa": "Province", + "stateMx": "State", + "regionPlaceholder": "Select state or province", + "emptyList": "No saved addresses yet.", + "listSummaryFallback": "Address", + "validationRequired": "Please fill all required fields.", + "saveError": "Could not save address.", + "deleteError": "Could not remove address.", + "loadDetailError": "Could not load address.", + "defaultBadge": "Default", + "addressBadge": "Address" + }, + "ordersEmpty": "No orders yet.", "logout": "Log out", "sectionSelectAria": "Account menu", "orderMock": { "placed": "Placed", - "total": "Total" + "total": "Total", + "state": "Status" + }, + "orderDetail": { + "back": "← All orders", + "loading": "Loading order…", + "error": "Could not load this order.", + "placedOn": "Placed", + "status": "Status", + "qty": "Qty.", + "shippingAddress": "Shipping address", + "billingAddress": "Billing address", + "payment": "Payment", + "paymentCard": "Card ending {{lastFour}}" } }, "fr": { @@ -78,59 +111,92 @@ "panels": { "overview": { "title": "Mon compte", - "intro": "Aperçu de votre compte Vitamix. Les données détaillées proviendront de l'API.", - "rows": [ - { "label": "Membre depuis", "value": "mars 2024" }, - { "label": "Statut du compte", "value": "Actif" }, - { "label": "Boutique préférée", "value": "Canada" } - ] + "intro": "Le résumé de votre compte est chargé depuis le service compte lorsque vous êtes connecté." }, "information": { - "title": "Informations du compte", - "rows": [ - { "label": "Courriel", "valueKey": "email" }, - { "label": "Nom", "value": "David Nuescheler" }, - { "label": "Téléphone", "value": "+1 (416) 555-0188" }, - { "label": "ID client", "value": "VX-CA-90012" }, - { "label": "Langue préférée", "value": "Français (CA)" } - ] + "title": "Informations du compte" }, "address": { - "title": "Adresses", - "mockAddresses": [ - { - "badge": "Facturation par défaut", - "lines": [ - "David Nuescheler", - "1234 rue du Faux", - "Montréal, QC H2Y 1C6", - "Canada" - ] - }, - { - "badge": "Livraison", - "lines": [ - "David Nuescheler", - "88 avenue Exemple, bureau 200", - "Toronto, ON M5V 2T6", - "Canada" - ] - } - ] + "title": "Adresses" }, "orders": { - "title": "Historique des commandes", - "mockOrders": [ - { "id": "VM-482910", "date": "2 nov. 2025", "total": "429,00 $ CA" }, - { "id": "VM-471205", "date": "18 août 2025", "total": "89,95 $ CA" } - ] + "title": "Historique des commandes" } }, + "overviewLabels": { + "displayName": "Nom", + "email": "Courriel", + "memberSince": "Membre depuis", + "accountStatus": "Statut du compte", + "storeRegion": "Boutique" + }, + "customerInfo": { + "email": "Courriel", + "name": "Nom", + "phone": "Téléphone", + "customerId": "ID client", + "preferredLanguage": "Langue préférée", + "accountStatus": "Statut du compte", + "created": "Membre depuis", + "updated": "Dernière mise à jour" + }, + "countries": { + "us": "États-Unis", + "ca": "Canada", + "mx": "Mexique" + }, + "addressBook": { + "add": "Ajouter une adresse", + "edit": "Modifier", + "remove": "Supprimer", + "save": "Enregistrer", + "cancel": "Annuler", + "deleteConfirm": "Supprimer cette adresse ?", + "dialogAddTitle": "Nouvelle adresse", + "dialogEditTitle": "Modifier l'adresse", + "firstName": "Prénom", + "lastName": "Nom", + "email": "Courriel", + "address1": "Adresse", + "address2": "Appartement, bureau, etc. (facultatif)", + "city": "Ville", + "country": "Pays", + "phone": "Téléphone", + "defaultAddress": "Adresse par défaut", + "zipUs": "Code ZIP", + "postalOther": "Code postal", + "stateUs": "État", + "stateCa": "Province", + "stateMx": "État", + "regionPlaceholder": "Sélectionnez l'état ou la province", + "emptyList": "Aucune adresse enregistrée.", + "listSummaryFallback": "Adresse", + "validationRequired": "Veuillez remplir tous les champs obligatoires.", + "saveError": "Impossible d'enregistrer l'adresse.", + "deleteError": "Impossible de supprimer l'adresse.", + "loadDetailError": "Impossible de charger l'adresse.", + "defaultBadge": "Par défaut", + "addressBadge": "Adresse" + }, + "ordersEmpty": "Aucune commande pour le moment.", "logout": "Se déconnecter", "sectionSelectAria": "Menu du compte", "orderMock": { "placed": "Passée le", - "total": "Total" + "total": "Total", + "state": "Statut" + }, + "orderDetail": { + "back": "← Toutes les commandes", + "loading": "Chargement de la commande…", + "error": "Impossible de charger cette commande.", + "placedOn": "Passée le", + "status": "Statut", + "qty": "Qté", + "shippingAddress": "Adresse de livraison", + "billingAddress": "Adresse de facturation", + "payment": "Paiement", + "paymentCard": "Carte se terminant par {{lastFour}}" } }, "es": { @@ -145,59 +211,92 @@ "panels": { "overview": { "title": "Mi cuenta", - "intro": "Resumen de su cuenta Vitamix. Los datos detallados vendrán de la API.", - "rows": [ - { "label": "Miembro desde", "value": "marzo de 2024" }, - { "label": "Estado de la cuenta", "value": "Activa" }, - { "label": "Tienda preferida", "value": "Estados Unidos" } - ] + "intro": "El resumen de su cuenta se carga desde el servicio de cuenta cuando ha iniciado sesión." }, "information": { - "title": "Información de la cuenta", - "rows": [ - { "label": "Correo", "valueKey": "email" }, - { "label": "Nombre", "value": "David Nuescheler" }, - { "label": "Teléfono", "value": "+1 (305) 555-0160" }, - { "label": "ID de cliente", "value": "VX-US-44021" }, - { "label": "Idioma preferido", "value": "Español (US)" } - ] + "title": "Información de la cuenta" }, "address": { - "title": "Direcciones", - "mockAddresses": [ - { - "badge": "Facturación predeterminada", - "lines": [ - "David Nuescheler", - "1234 Calle Ejemplo", - "Miami, FL 33101", - "Estados Unidos" - ] - }, - { - "badge": "Envío", - "lines": [ - "David Nuescheler", - "88 Parkway de Muestra, Oficina 200", - "Toronto, ON M5V 2T6", - "Canadá" - ] - } - ] + "title": "Direcciones" }, "orders": { - "title": "Historial de pedidos", - "mockOrders": [ - { "id": "VM-482910", "date": "2 nov 2025", "total": "US$429.00" }, - { "id": "VM-471205", "date": "18 ago 2025", "total": "US$89.95" } - ] + "title": "Historial de pedidos" } }, + "overviewLabels": { + "displayName": "Nombre", + "email": "Correo", + "memberSince": "Miembro desde", + "accountStatus": "Estado de la cuenta", + "storeRegion": "Tienda" + }, + "customerInfo": { + "email": "Correo", + "name": "Nombre", + "phone": "Teléfono", + "customerId": "ID de cliente", + "preferredLanguage": "Idioma preferido", + "accountStatus": "Estado de la cuenta", + "created": "Miembro desde", + "updated": "Última actualización" + }, + "countries": { + "us": "Estados Unidos", + "ca": "Canadá", + "mx": "México" + }, + "addressBook": { + "add": "Añadir dirección", + "edit": "Editar", + "remove": "Eliminar", + "save": "Guardar", + "cancel": "Cancelar", + "deleteConfirm": "¿Eliminar esta dirección?", + "dialogAddTitle": "Nueva dirección", + "dialogEditTitle": "Editar dirección", + "firstName": "Nombre", + "lastName": "Apellido", + "email": "Correo", + "address1": "Dirección", + "address2": "Apartamento, suite, etc. (opcional)", + "city": "Ciudad", + "country": "País", + "phone": "Teléfono", + "defaultAddress": "Dirección predeterminada", + "zipUs": "Código ZIP", + "postalOther": "Código postal", + "stateUs": "Estado", + "stateCa": "Provincia", + "stateMx": "Estado", + "regionPlaceholder": "Seleccione estado o provincia", + "emptyList": "No hay direcciones guardadas.", + "listSummaryFallback": "Dirección", + "validationRequired": "Complete todos los campos obligatorios.", + "saveError": "No se pudo guardar la dirección.", + "deleteError": "No se pudo eliminar la dirección.", + "loadDetailError": "No se pudo cargar la dirección.", + "defaultBadge": "Predeterminada", + "addressBadge": "Dirección" + }, + "ordersEmpty": "Aún no hay pedidos.", "logout": "Cerrar sesión", "sectionSelectAria": "Menú de la cuenta", "orderMock": { "placed": "Realizado", - "total": "Total" + "total": "Total", + "state": "Estado" + }, + "orderDetail": { + "back": "← Todos los pedidos", + "loading": "Cargando pedido…", + "error": "No se pudo cargar este pedido.", + "placedOn": "Realizado", + "status": "Estado", + "qty": "Cant.", + "shippingAddress": "Dirección de envío", + "billingAddress": "Dirección de facturación", + "payment": "Pago", + "paymentCard": "Tarjeta terminada en {{lastFour}}" } } } From b05590d50b8e9f5f8ab8d6ae531d408afb52ac9c Mon Sep 17 00:00:00 2001 From: dylandepass Date: Thu, 14 May 2026 17:08:53 -0400 Subject: [PATCH 78/89] fix: show checkmark on completed steps on the order complete page --- blocks/checkout-progress/checkout-progress.css | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/blocks/checkout-progress/checkout-progress.css b/blocks/checkout-progress/checkout-progress.css index 59ac72c4..2416a80a 100644 --- a/blocks/checkout-progress/checkout-progress.css +++ b/blocks/checkout-progress/checkout-progress.css @@ -72,7 +72,8 @@ } /* Complete steps: dark fill with checkmark */ -.checkout-progress .step-complete a::before { +.checkout-progress .step-complete a::before, +.checkout-progress .step-complete span::before { content: '✓'; background: var(--commerce-color-text); border-color: var(--commerce-color-text); @@ -87,7 +88,8 @@ color: #fff; } -.checkout-progress .step-complete a { +.checkout-progress .step-complete a, +.checkout-progress .step-complete span { color: var(--commerce-color-text); } From cf87bf93c47a2b481289be33fa51d17f3bab97f2 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Thu, 14 May 2026 19:03:00 -0400 Subject: [PATCH 79/89] fix: affirm checkout on /ca/en_us --- scripts/payments/affirm.js | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/scripts/payments/affirm.js b/scripts/payments/affirm.js index f3eabf7e..24dc3c6d 100644 --- a/scripts/payments/affirm.js +++ b/scripts/payments/affirm.js @@ -1,3 +1,5 @@ +import { getLocaleAndLanguage } from '../scripts.js'; + /** @type {import('./types').PaymentProvider} */ export default { id: 'affirm', @@ -77,12 +79,8 @@ export default { return; } - const config = callbacks.getConfig(); - const locale = config.getLocale(); - const language = config.getLanguage(); + const { locale, language: affirmLocale } = getLocaleAndLanguage(true, true); const affirmCountryCode = locale === 'ca' ? 'CAN' : 'USA'; - const [lang, region] = language.split('_'); - const affirmLocale = region ? `${lang}_${region.toUpperCase()}` : language; // eslint-disable-next-line no-underscore-dangle window._affirm_config = { From 8a20a4e9180b63629d46c97355a8652120e79d87 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 15 May 2026 11:04:51 -0400 Subject: [PATCH 80/89] fix: contain checkout stacking context to prevent PayPal buttons overlapping header --- blocks/checkout/checkout.css | 1 + 1 file changed, 1 insertion(+) diff --git a/blocks/checkout/checkout.css b/blocks/checkout/checkout.css index 25dafa79..66d08de0 100644 --- a/blocks/checkout/checkout.css +++ b/blocks/checkout/checkout.css @@ -4,6 +4,7 @@ main > .section:has(.checkout-wrapper) { margin-right: auto; margin-left: auto; gap: 24px; + isolation: isolate; } /* Stack section cards with spacing */ From 9fc9e6c9eca0b87f135aed6364c6d7dcfe9525de Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Fri, 15 May 2026 11:36:28 -0600 Subject: [PATCH 81/89] chore: escape @ --- widgets/account/account-api.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/widgets/account/account-api.js b/widgets/account/account-api.js index 29a41038..17027357 100644 --- a/widgets/account/account-api.js +++ b/widgets/account/account-api.js @@ -7,16 +7,15 @@ import { getLocaleAndLanguage } from '../../scripts/scripts.js'; /** * Base URL for customer-scoped APIs: - * `{apiOrigin}/customers/{email}` (apiOrigin is e.g. …/aemsites/sites/vitamix). - * Email is appended unencoded so `@` stays literal — temporary until the service accepts - * RFC 3986 path encoding (`%40` for `@`). + * `{apiOrigin}/customers/{encodedEmail}` (apiOrigin is e.g. …/aemsites/sites/vitamix). + * The email path segment is URL-encoded (e.g. `@` → `%40`) per RFC 3986. * * @param {string} customerEmail * @returns {string} */ export function getCustomerApiBase(customerEmail) { const origin = getConfig().apiOrigin.replace(/\/$/, ''); - return `${origin}/customers/${customerEmail}`; + return `${origin}/customers/${encodeURIComponent(customerEmail)}`; } /** From 18db7bece4135b29c6b6f95e0330ed810956c232 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 15 May 2026 10:48:12 -0700 Subject: [PATCH 82/89] fix: update cj coupon handling (#446) * fix: update cj coupon handling * fix: reenable php fetch * fix: add back cjdata * fix: use EDGE_CHECKOUT_LOCALES for php cj switch --- scripts/scripts.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index 6c36fbec..5c468906 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -208,15 +208,23 @@ function setAffiliateCoupon() { const urlParams = new URLSearchParams(window.location.search); const { cjdata, cjevent, COUPON } = Object.fromEntries(urlParams); - if (!cjdata || !cjevent || !COUPON) return; - - const { locale, language } = getLocaleAndLanguage(); - const loginUrl = new URL(`https://www.vitamix.com/${locale}/${language}/checkout/cart`); - Object.entries({ cjdata, cjevent, COUPON }).forEach(([key, value]) => { - loginUrl.searchParams.set(key, value); - }); + if (cjevent) { + localStorage.setItem('cjevent', JSON.stringify({ value: cjevent, ts: Date.now() })); + } - fetch(loginUrl.toString()); + if (COUPON) { + sessionStorage.setItem('checkout_coupon_code', COUPON); + + // TODO: remove once all locales migrate off Magento — applies the coupon to the PHP cart + const { locale, language } = getLocaleAndLanguage(); + if (!EDGE_CHECKOUT_LOCALES.includes(`${locale}/${language}`)) { + const cartUrl = new URL(`https://www.vitamix.com/${locale}/${language}/checkout/cart`); + if (cjdata) cartUrl.searchParams.set('cjdata', cjdata); + if (cjevent) cartUrl.searchParams.set('cjevent', cjevent); + cartUrl.searchParams.set('COUPON', COUPON); + fetch(cartUrl.toString()); + } + } } /** From d82bd23a1c3bc70cb605d450b85ba46902bf20bc Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 15 May 2026 15:32:26 -0400 Subject: [PATCH 83/89] fix: use country specific paypal client ids --- scripts/scripts.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/scripts/scripts.js b/scripts/scripts.js index a888ac36..2c88a4b9 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -35,11 +35,17 @@ export const FORMS_ENDPOINT = isProdHost ? '' : 'https://main--vitamix--aemsites.aem.network'; +const PAYPAL_CLIENT_IDS = { + us: 'AaBQdCVqIp15uFQaHrJmTUDBZ-xJrOYPs99NtZ-iLN5oij-ustZq304ikTHJKwqqSL4yN0v9GLireQLN', + ca: 'AVlGkhsI0_EFNFx8jnRsv0dSROcLzzLYtTdMoNieyjZeAWlIXdFpocB6eyhHvuDOZ3F2YFmQDkLE03Rp', +}; +const siteLocale = window.location.pathname.split('/').filter(Boolean)[0] || 'us'; + window.CommerceConfig = { org: 'aemsites', site: 'vitamix', paypal: { - clientId: 'AdWjsTBIELzBwnT08zwFuxDeEW89L8bTcBnE_d4C8lwZHpqMjCszTRh4lrYsUx0TGnjFffeC_UXiIBgJ', + clientId: PAYPAL_CLIENT_IDS[siteLocale] ?? PAYPAL_CLIENT_IDS.us, intent: 'authorize', }, }; From 38aa2d58441f42e8e3d297a5dfe4eb7e5faf8959 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 15 May 2026 16:32:15 -0400 Subject: [PATCH 84/89] fix: Apple Pay express checkout dismisses in Chrome QR-code flow --- scripts/payments/apple-pay.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/scripts/payments/apple-pay.js b/scripts/payments/apple-pay.js index 5b03be0f..faeb0ed5 100644 --- a/scripts/payments/apple-pay.js +++ b/scripts/payments/apple-pay.js @@ -75,7 +75,7 @@ function startExpressSession(btn, config, callbacks) { const defaultMethod = methods[0]; session.completeShippingContactSelection({ newShippingMethods: methods.map((m) => ({ - identifier: m.id, + identifier: String(m.id), label: m.label, detail: m.eta || '', amount: String(m.rate), @@ -104,6 +104,7 @@ function startExpressSession(btn, config, callbacks) { const previewResult = await callbacks.previewOrderDirect({ items: cart.getItemsForAPI(), shippingMethod: { id: e.shippingMethod.identifier }, + locale: bcp47, ...(countryCode ? { country: countryCode, shipping: { From 9ed07d3e1d27ec3f3c5aedd9030e6b5ae7edea32 Mon Sep 17 00:00:00 2001 From: Max Date: Fri, 15 May 2026 13:37:22 -0700 Subject: [PATCH 85/89] fix: subscribe to newsletter in checkout (#449) --- blocks/checkout/checkout-order.js | 39 ++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/blocks/checkout/checkout-order.js b/blocks/checkout/checkout-order.js index d1f77347..564d4a87 100644 --- a/blocks/checkout/checkout-order.js +++ b/blocks/checkout/checkout-order.js @@ -1,6 +1,7 @@ import { createOrder, initiatePayment, previewOrder } from '../../scripts/commerce-api.js'; import { collectAddress } from './checkout-address.js'; import { updatePreview } from './checkout-shipping.js'; +import { FORMS_ENDPOINT, getLocaleAndLanguage } from '../../scripts/scripts.js'; /** * Writes checkout state to sessionStorage before a payment redirect. @@ -99,6 +100,37 @@ function clearError(form) { if (el) { el.hidden = true; el.textContent = ''; } } +/** + * Fire-and-forget newsletter subscription after order creation. + * Reads the newsletter checkbox from formData; does nothing if unchecked. + * @param {FormData} formData + */ +function subscribeNewsletter(formData) { + if (!formData.get('newsletter')) return; + + const { locale, language } = getLocaleAndLanguage(); + const email = formData.get('email') || ''; + const firstName = formData.get('shipping-firstname') || ''; + const lastName = formData.get('shipping-lastname') || ''; + + const url = `${FORMS_ENDPOINT}/${locale}/${language}/forms`; + fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + formId: `${locale}/${language}/newsletter`, + email, + emailOptIn: true, + firstName, + lastName, + country: locale, + }), + }).catch((err) => { + // eslint-disable-next-line no-console + console.error('newsletter subscription failed:', err); + }); +} + /** * Wires the Chase (credit card) submit button and provides a shared callbacks object * that payment providers can use. @@ -127,7 +159,11 @@ export function initOrder(form, cart, state, config, strings) { saveCheckoutSession: (email, c, preview, order) => ( saveCheckoutSession(email, c, preview, order) ), - createOrder: (orderBody) => createOrder(orderBody), + createOrder: async (orderBody) => { + const result = await createOrder(orderBody); + subscribeNewsletter(new FormData(form)); + return result; + }, initiatePayment: (...args) => initiatePayment(...args), showError: (msg) => showError(form, msg), clearError: () => clearError(form), @@ -224,6 +260,7 @@ export function initOrder(form, cart, state, config, strings) { try { const createdOrder = await createOrder(orderBody); + subscribeNewsletter(formData); saveCheckoutSession(email, cart, state.currentPreview, createdOrder.order ?? createdOrder); const fraudToken = config.getFraudToken?.(); From 0262a71cb161c2191b8efec3c3d926dc7213ed42 Mon Sep 17 00:00:00 2001 From: dylandepass Date: Fri, 15 May 2026 18:07:58 -0400 Subject: [PATCH 86/89] fix: apply coupons --- blocks/cart-summary/cart-summary.css | 5 +++ blocks/cart-summary/cart-summary.js | 25 +++++++++++ blocks/checkout/checkout.js | 5 +++ blocks/order-summary/order-summary.css | 4 ++ blocks/order-summary/order-summary.js | 57 +++++++++++++++++++++++++- scripts/commerce-api.js | 5 ++- scripts/commerce-config.js | 4 ++ 7 files changed, 102 insertions(+), 3 deletions(-) diff --git a/blocks/cart-summary/cart-summary.css b/blocks/cart-summary/cart-summary.css index 6194cff2..912828e1 100644 --- a/blocks/cart-summary/cart-summary.css +++ b/blocks/cart-summary/cart-summary.css @@ -336,6 +336,11 @@ dialog.paypal-not-configured-dialog::backdrop { gap: 8px; } +.cart-summary-discount-pending { + color: var(--commerce-color-text-muted); + font-style: italic; +} + .cart-summary-final-amount .currency { font-size: var(--commerce-font-size-xs); font-weight: 400; diff --git a/blocks/cart-summary/cart-summary.js b/blocks/cart-summary/cart-summary.js index 015b05b8..ac7b2b05 100644 --- a/blocks/cart-summary/cart-summary.js +++ b/blocks/cart-summary/cart-summary.js @@ -14,11 +14,15 @@ const LOCAL_STRINGS = { havePromoCode: 'Have a promo code?', shippingPlaceholder: 'Calculated at checkout', checkoutSecurely: 'Checkout securely', + applied: 'Code saved', + discountPending: 'Applied at checkout', }, 'fr-ca': { havePromoCode: 'Vous avez un code promo?', shippingPlaceholder: 'Calculée à la caisse', checkoutSecurely: 'Payer en toute sécurité', + applied: 'Code sauvegardé', + discountPending: 'Appliqué à la caisse', }, }; @@ -77,6 +81,10 @@ function buildTemplate(s) { ${s.subtotal} +
          ${s.shipping} ${s.shippingPlaceholder} @@ -127,6 +135,8 @@ export default async function decorate(block) { const expressContainer = block.querySelector('.cart-summary-express-buttons'); const discountInput = block.querySelector('.discount-input'); const discountApply = block.querySelector('.discount-apply'); + const discountRow = block.querySelector('.cart-summary-discount-row'); + const discountRowLabel = block.querySelector('.cart-summary-discount-label'); const errorEl = block.querySelector('.cart-summary-error'); const checkoutBtn = block.querySelector('.cart-summary-checkout-btn'); @@ -144,17 +154,32 @@ export default async function decorate(block) { document.addEventListener('cart:change', updateTotals); // 4. Restore saved promo code; persist to sessionStorage on apply + const showDiscountRow = (code) => { + discountRowLabel.textContent = `${s.discount} (${code})`; + discountRow.hidden = false; + }; + const hideDiscountRow = () => { discountRow.hidden = true; }; + const savedCoupon = sessionStorage.getItem('checkout_coupon_code') || ''; if (savedCoupon) { discountInput.value = savedCoupon; block.querySelector('.cart-summary-promo').open = true; + showDiscountRow(savedCoupon); } discountApply.addEventListener('click', () => { const code = discountInput.value.trim(); if (code) { sessionStorage.setItem('checkout_coupon_code', code); + showDiscountRow(code); + discountApply.textContent = s.applied; + discountApply.disabled = true; + setTimeout(() => { + discountApply.textContent = s.apply; + discountApply.disabled = false; + }, 2000); } else { sessionStorage.removeItem('checkout_coupon_code'); + hideDiscountRow(); } }); diff --git a/blocks/checkout/checkout.js b/blocks/checkout/checkout.js index b170b003..0f5f12fc 100644 --- a/blocks/checkout/checkout.js +++ b/blocks/checkout/checkout.js @@ -258,6 +258,11 @@ export default async function decorate(block) { }); }); + // Re-run preview when a coupon code is applied from the order summary sidebar. + document.addEventListener('checkout:coupon-apply', () => { + if (state.selectedShippingMethodId) updatePreview(form, cart, state, config); + }); + // Wire Chase submit and get shared callbacks for providers const callbacks = initOrder(form, cart, state, config, strings); diff --git a/blocks/order-summary/order-summary.css b/blocks/order-summary/order-summary.css index e19079fc..8990dad9 100644 --- a/blocks/order-summary/order-summary.css +++ b/blocks/order-summary/order-summary.css @@ -238,6 +238,10 @@ gap: 8px; } +.order-summary-discount-amount { + color: var(--commerce-color-success, #2d7a3a); +} + .order-summary-final-amount .currency { font-size: var(--commerce-font-size-xs); font-weight: 400; diff --git a/blocks/order-summary/order-summary.js b/blocks/order-summary/order-summary.js index fa1b44ad..6f404c52 100644 --- a/blocks/order-summary/order-summary.js +++ b/blocks/order-summary/order-summary.js @@ -36,6 +36,7 @@ function buildTemplate(s) { ${s.subtotal}
          +
          ${s.shipping} @@ -183,8 +184,48 @@ export default async function decorate(block) { const grandTotalEl = block.querySelector('.order-summary-grand-total'); const headerTotalEl = block.querySelector('.order-summary-header-total'); const currencyEl = block.querySelector('.currency'); + const discountInput = block.querySelector('.discount-input'); + const discountApply = block.querySelector('.discount-apply'); + const discountsEl = block.querySelector('.order-summary-discounts'); currencyEl.textContent = getCurrencyCode(); + const showPendingDiscount = (code) => { + discountsEl.innerHTML = ''; + const row = document.createElement('div'); + row.className = 'order-summary-row order-summary-discount-item order-summary-discount-pending'; + const label = document.createElement('span'); + label.textContent = `${s.discount} (${code})`; + const amount = document.createElement('span'); + amount.className = 'order-summary-discount-amount'; + amount.textContent = '--'; + row.append(label, amount); + discountsEl.appendChild(row); + }; + + const savedCoupon = sessionStorage.getItem('checkout_coupon_code') || ''; + if (savedCoupon) { + discountInput.value = savedCoupon; + showPendingDiscount(savedCoupon); + } + + discountApply.addEventListener('click', () => { + const code = discountInput.value.trim(); + if (code) { + sessionStorage.setItem('checkout_coupon_code', code); + showPendingDiscount(code); + discountApply.textContent = s.applied; + discountApply.disabled = true; + setTimeout(() => { + discountApply.textContent = s.apply; + discountApply.disabled = false; + }, 2000); + } else { + sessionStorage.removeItem('checkout_coupon_code'); + discountsEl.innerHTML = ''; + } + document.dispatchEvent(new CustomEvent('checkout:coupon-apply')); + }); + const renderItems = () => { itemsList.innerHTML = ''; @@ -237,11 +278,25 @@ export default async function decorate(block) { if (!preview) return; const { - subtotal, taxAmount, shippingRate, total, + subtotal, taxAmount, shippingRate, total, discounts, } = parsePreview(preview, cart.subtotal); const currency = getCurrencyCode(); subtotalEl.textContent = formatPrice(subtotal, currency); + + discountsEl.innerHTML = ''; + discounts.filter((d) => !d.freeShipping && d.amount > 0).forEach((d) => { + const row = document.createElement('div'); + row.className = 'order-summary-row order-summary-discount-item'; + const label = document.createElement('span'); + label.textContent = d.name || s.discount; + const amount = document.createElement('span'); + amount.className = 'order-summary-discount-amount'; + amount.textContent = `-${formatPrice(d.amount, currency)}`; + row.append(label, amount); + discountsEl.appendChild(row); + }); + shippingEl.textContent = shippingRate === 0 ? s.free : formatPrice(parseFloat(shippingRate), currency); diff --git a/scripts/commerce-api.js b/scripts/commerce-api.js index c3f9a818..64e44982 100644 --- a/scripts/commerce-api.js +++ b/scripts/commerce-api.js @@ -260,10 +260,11 @@ export function parsePreview(preview, cartSubtotal) { const subtotal = parseFloat(preview.subtotal) || cartSubtotal; const taxAmount = parseFloat(preview.taxAmount) || 0; const rawRate = preview.shippingMethod?.rate ?? 0; - const hasFreeShipping = (preview.discounts ?? []).some((d) => d.freeShipping); + const discounts = preview.discounts ?? []; + const hasFreeShipping = discounts.some((d) => d.freeShipping); const shippingRate = hasFreeShipping ? 0 : rawRate; const total = parseFloat(preview.total) || (subtotal + taxAmount + shippingRate); return { - subtotal, taxAmount, shippingRate, total, + subtotal, taxAmount, shippingRate, total, discounts, }; } diff --git a/scripts/commerce-config.js b/scripts/commerce-config.js index 87ca8217..beaa706c 100644 --- a/scripts/commerce-config.js +++ b/scripts/commerce-config.js @@ -40,6 +40,8 @@ const defaults = { removeItem: 'Remove item', continueShopping: 'Continue shopping', apply: 'Apply', + applied: 'Applied', + discount: 'Discount', subtotal: 'Subtotal', shipping: 'Shipping', estimatedTaxes: 'Estimated taxes', @@ -58,6 +60,8 @@ const defaults = { removeItem: "Retirer l'article", continueShopping: 'Continuer vos achats', apply: 'Appliquer', + applied: 'Appliqué', + discount: 'Rabais', subtotal: 'Sous-total', shipping: 'Livraison', estimatedTaxes: 'Taxes estimées', From e76a9b719fb808e120dccbe6ea1aa40f33ba81d9 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Fri, 15 May 2026 17:55:18 -0600 Subject: [PATCH 87/89] chore: sub / unsub --- widgets/account/account.css | 44 +++++++++++++++ widgets/account/account.html | 10 ++++ widgets/account/account.js | 102 ++++++++++++++++++++++++++++++++++- widgets/account/account.json | 27 ++++++++++ 4 files changed, 182 insertions(+), 1 deletion(-) diff --git a/widgets/account/account.css b/widgets/account/account.css index 48c144df..d232aab8 100644 --- a/widgets/account/account.css +++ b/widgets/account/account.css @@ -250,6 +250,50 @@ color: var(--commerce-color-text); } +.account-communications { + margin-top: 28px; + padding-top: 20px; + border-top: 1px solid var(--commerce-color-border); + max-width: 40rem; +} + +.account-communications-title { + margin: 0 0 8px; + font-size: var(--commerce-font-size-base); + font-weight: var(--commerce-font-weight-bold); + color: var(--commerce-color-text); +} + +.account-communications-question { + margin: 0 0 16px; + font-size: var(--commerce-font-size-sm); + color: var(--commerce-color-text-muted); + line-height: 1.55; +} + +.account-communications-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: center; +} + +.account-communications-success, +.account-communications-error { + margin: 0; + font-size: var(--commerce-font-size-sm); + line-height: 1.5; +} + +.account-communications-success { + color: var(--commerce-color-text); + font-weight: 500; +} + +.account-communications-error { + color: var(--commerce-color-danger, #b00020); +} + .account-address-list { list-style: none; margin: 0; diff --git a/widgets/account/account.html b/widgets/account/account.html index 26914e96..103ac896 100644 --- a/widgets/account/account.html +++ b/widgets/account/account.html @@ -47,6 +47,16 @@ diff --git a/widgets/account/account.js b/widgets/account/account.js index 4829a8ee..35d265d0 100644 --- a/widgets/account/account.js +++ b/widgets/account/account.js @@ -1,6 +1,6 @@ import { loadCSS } from '../../scripts/aem.js'; import { renderAccountAddressList } from './account-api.js'; -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { getFormSubmissionUrl, getLocaleAndLanguage } from '../../scripts/scripts.js'; import { getUser, logout } from '../../scripts/auth-api.js'; /** Select option value for sign out (not a content section). */ @@ -91,6 +91,106 @@ export default async function decorate(widget) { const p = panels.information || {}; const t = information.querySelector('.account-panel-title'); if (t) t.textContent = p.title || ''; + const comm = /** @type {Record} */ (copy.communications || {}); + const commRoot = information.querySelector('.account-communications'); + const commTitle = information.querySelector('.account-communications-title'); + const commQuestion = information.querySelector('.account-communications-question'); + const commActions = information.querySelector('.account-communications-actions'); + const commSubscribe = information.querySelector('.account-communications-subscribe'); + const commUnsubscribe = information.querySelector('.account-communications-unsubscribe'); + const commSuccess = information.querySelector('.account-communications-success'); + const commError = information.querySelector('.account-communications-error'); + if (commTitle) commTitle.textContent = comm.title || 'Communications'; + if (commQuestion) { + commQuestion.textContent = comm.question + || 'Would you like us to send you periodic emails and newsletters from Vitamix?'; + } + if (commSubscribe) commSubscribe.textContent = comm.subscribe || 'Subscribe'; + if (commUnsubscribe) commUnsubscribe.textContent = comm.unsubscribe || 'Unsubscribe'; + + const setCommLoading = (loading) => { + [commSubscribe, commUnsubscribe].forEach((btn) => { + if (btn) { + btn.disabled = loading; + } + }); + }; + + const showCommSuccess = (message) => { + if (commActions) commActions.hidden = true; + if (commError) { + commError.hidden = true; + commError.textContent = ''; + } + if (commSuccess) { + commSuccess.textContent = message; + commSuccess.hidden = false; + } + }; + + const showCommError = (message) => { + if (!commError) return; + commError.textContent = message; + commError.hidden = false; + }; + + const submitNewsletterPreference = async (emailOptIn) => { + const trimmed = (email || '').trim(); + if (!trimmed) return; + const country = window.location.pathname.split('/')[1] || 'us'; + const leadSource = `sub-em-account-${country}`; + const payload = { + formId: `${locale}/${language}/newsletter`, + pageUrl: window.location.href, + email: trimmed, + mobile: '', + smsOptIn: false, + emailOptIn, + leadSource, + }; + setCommLoading(true); + if (commError) { + commError.hidden = true; + commError.textContent = ''; + } + try { + const url = getFormSubmissionUrl(); + const resp = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + let apiMessage = ''; + try { + const body = await resp.json(); + apiMessage = body?.data?.message != null ? String(body.data.message) : ''; + } catch { + /* non-JSON */ + } + if (!resp.ok) { + showCommError(comm.error || 'Something went wrong. Please try again.'); + return; + } + const fallback = emailOptIn + ? (comm.successSubscribe || 'You are subscribed to Vitamix emails.') + : (comm.successUnsubscribe || 'You are unsubscribed from Vitamix emails.'); + showCommSuccess(apiMessage.trim() || fallback); + } catch { + showCommError(comm.error || 'Something went wrong. Please try again.'); + } finally { + setCommLoading(false); + } + }; + + if (commRoot && email) { + commRoot.hidden = false; + commSubscribe?.addEventListener('click', () => { + submitNewsletterPreference(true); + }); + commUnsubscribe?.addEventListener('click', () => { + submitNewsletterPreference(false); + }); + } } const address = widget.querySelector('.account-panel[data-section="address"]'); diff --git a/widgets/account/account.json b/widgets/account/account.json index 81797595..c690e38d 100644 --- a/widgets/account/account.json +++ b/widgets/account/account.json @@ -86,6 +86,15 @@ "total": "Total", "state": "Status" }, + "communications": { + "title": "Communications", + "question": "Would you like us to send you periodic emails and newsletters from Vitamix?", + "subscribe": "Subscribe", + "unsubscribe": "Unsubscribe", + "successSubscribe": "Your email preferences were updated. Thank you for subscribing.", + "successUnsubscribe": "Your email preferences were updated. You are unsubscribed from marketing emails.", + "error": "Something went wrong. Please try again." + }, "orderDetail": { "back": "← All orders", "loading": "Loading order…", @@ -186,6 +195,15 @@ "total": "Total", "state": "Statut" }, + "communications": { + "title": "Communications", + "question": "Souhaitez-vous recevoir de temps à autre des courriels et bulletins Vitamix ?", + "subscribe": "S'abonner", + "unsubscribe": "Se désabonner", + "successSubscribe": "Vos préférences de courriel ont été mises à jour. Merci de votre inscription.", + "successUnsubscribe": "Vos préférences de courriel ont été mises à jour. Vous êtes désabonné des courriels marketing.", + "error": "Une erreur s'est produite. Veuillez réessayer." + }, "orderDetail": { "back": "← Toutes les commandes", "loading": "Chargement de la commande…", @@ -286,6 +304,15 @@ "total": "Total", "state": "Estado" }, + "communications": { + "title": "Comunicaciones", + "question": "¿Desea que le enviemos correos periódicos y boletines de Vitamix?", + "subscribe": "Suscribirse", + "unsubscribe": "Cancelar suscripción", + "successSubscribe": "Se actualizaron sus preferencias de correo. Gracias por suscribirse.", + "successUnsubscribe": "Se actualizaron sus preferencias de correo. Ya no recibirá correos de marketing.", + "error": "Algo salió mal. Inténtelo de nuevo." + }, "orderDetail": { "back": "← Todos los pedidos", "loading": "Cargando pedido…", From 640863f40f08d221e2419c1d93845cca26b96e30 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Sat, 16 May 2026 11:32:44 -0600 Subject: [PATCH 88/89] chore: add subscription UI --- widgets/account/account-api.js | 43 ++++++++++++++- widgets/account/account.css | 64 +++++++++++++++++++--- widgets/account/account.html | 7 ++- widgets/account/account.js | 98 +++++++++++++++++++++------------- widgets/account/account.json | 9 ++-- 5 files changed, 168 insertions(+), 53 deletions(-) diff --git a/widgets/account/account-api.js b/widgets/account/account-api.js index 17027357..2148a99d 100644 --- a/widgets/account/account-api.js +++ b/widgets/account/account-api.js @@ -1,10 +1,51 @@ import { createOptimizedPicture } from '../../scripts/aem.js'; import { authFetch } from '../../scripts/auth-api.js'; import { formatPrice, getConfig } from '../../scripts/commerce-config.js'; -import { getLocaleAndLanguage } from '../../scripts/scripts.js'; +import { FORMS_ENDPOINT, getLocaleAndLanguage } from '../../scripts/scripts.js'; + +/** + * GET URL for the signed-in user's forms profile (customer + newsletter opt-in status). + * @returns {string} + */ +function getFormsProfileUrl() { + const { locale, language } = getLocaleAndLanguage(); + return `${FORMS_ENDPOINT}/${locale}/${language}/forms/profile`; +} /* eslint-disable no-console -- VITAMIX_ACCOUNT_API_* payload logs for copy/paste integration */ +/** + * GET forms profile: customer record + newsletter opt-in status (requires Bearer token). + * + * @returns {Promise<{ customer: unknown, profile: { emailOptInStatus?: boolean, smsOptInStatus?: boolean, emailAddress?: string, mobile?: string | null } | null }>} + */ +export async function fetchFormsProfile() { + const url = getFormsProfileUrl(); + const resp = await authFetch(url, { method: 'GET' }); + const text = await resp.text(); + console.log('VITAMIX_ACCOUNT_API_FORMS_PROFILE'); + console.log(`HTTP_${resp.status}`); + console.log(text); + if (!resp.ok) { + throw new Error(`Profile request failed (${resp.status})`); + } + if (!text.trim()) { + return { customer: null, profile: null }; + } + let payload = JSON.parse(text); + if (payload && typeof payload === 'object' && 'data' in payload && payload.data !== undefined) { + payload = payload.data; + } + const root = payload && typeof payload === 'object' ? /** @type {Record} */ (payload) : {}; + const profile = root.profile && typeof root.profile === 'object' + ? /** @type {{ emailOptInStatus?: boolean, smsOptInStatus?: boolean, emailAddress?: string, mobile?: string | null }} */ (root.profile) + : null; + return { + customer: root.customer ?? null, + profile, + }; +} + /** * Base URL for customer-scoped APIs: * `{apiOrigin}/customers/{encodedEmail}` (apiOrigin is e.g. …/aemsites/sites/vitamix). diff --git a/widgets/account/account.css b/widgets/account/account.css index d232aab8..13f63318 100644 --- a/widgets/account/account.css +++ b/widgets/account/account.css @@ -269,6 +269,33 @@ font-size: var(--commerce-font-size-sm); color: var(--commerce-color-text-muted); line-height: 1.55; + min-height: 1.55em; +} + +.account-communications-question-copy:not(.is-visible) { + display: none; +} + +.account-shimmer { + display: none; + border-radius: var(--commerce-input-radius, 4px); + background: linear-gradient( + 90deg, + var(--commerce-color-border, #e0e0e0) 0%, + var(--commerce-color-background, #f5f5f5) 45%, + var(--commerce-color-border, #e0e0e0) 90% + ); + background-size: 200% 100%; + animation: account-shimmer 1.2s ease-in-out infinite; +} + +.account-shimmer.is-visible { + display: block; +} + +.account-communications-question-shimmer { + width: min(100%, 28rem); + height: 1.1em; } .account-communications-actions { @@ -276,21 +303,42 @@ flex-wrap: wrap; gap: 12px; align-items: center; + min-height: 48px; } -.account-communications-success, -.account-communications-error { - margin: 0; - font-size: var(--commerce-font-size-sm); - line-height: 1.5; +.account-communications-btn-shimmer { + width: 140px; + height: var(--commerce-button-height, 48px); } -.account-communications-success { - color: var(--commerce-color-text); - font-weight: 500; +@keyframes account-shimmer { + 0% { + background-position: 200% 0; + } + + 100% { + background-position: -200% 0; + } +} + +/* `button.button { display: inline-block }` in styles.css overrides the [hidden] attribute */ +.account-communications-actions > .account-communications-btn-shimmer, +.account-communications-actions > .button { + display: none !important; +} + +.account-communications-actions > .account-communications-btn-shimmer.is-visible { + display: block !important; +} + +.account-communications-actions > .button.is-visible { + display: inline-block !important; } .account-communications-error { + margin: 12px 0 0; + font-size: var(--commerce-font-size-sm); + line-height: 1.5; color: var(--commerce-color-danger, #b00020); } diff --git a/widgets/account/account.html b/widgets/account/account.html index 103ac896..f8a2316a 100644 --- a/widgets/account/account.html +++ b/widgets/account/account.html @@ -49,12 +49,15 @@
          diff --git a/widgets/account/account.js b/widgets/account/account.js index 35d265d0..9a3a7255 100644 --- a/widgets/account/account.js +++ b/widgets/account/account.js @@ -1,5 +1,5 @@ import { loadCSS } from '../../scripts/aem.js'; -import { renderAccountAddressList } from './account-api.js'; +import { fetchFormsProfile, renderAccountAddressList } from './account-api.js'; import { getFormSubmissionUrl, getLocaleAndLanguage } from '../../scripts/scripts.js'; import { getUser, logout } from '../../scripts/auth-api.js'; @@ -94,38 +94,48 @@ export default async function decorate(widget) { const comm = /** @type {Record} */ (copy.communications || {}); const commRoot = information.querySelector('.account-communications'); const commTitle = information.querySelector('.account-communications-title'); - const commQuestion = information.querySelector('.account-communications-question'); + const commQuestionCopy = information.querySelector('.account-communications-question-copy'); + const commQuestionShimmer = information.querySelector('.account-communications-question-shimmer'); const commActions = information.querySelector('.account-communications-actions'); + const commBtnShimmer = information.querySelector('.account-communications-btn-shimmer'); const commSubscribe = information.querySelector('.account-communications-subscribe'); const commUnsubscribe = information.querySelector('.account-communications-unsubscribe'); - const commSuccess = information.querySelector('.account-communications-success'); const commError = information.querySelector('.account-communications-error'); if (commTitle) commTitle.textContent = comm.title || 'Communications'; - if (commQuestion) { - commQuestion.textContent = comm.question - || 'Would you like us to send you periodic emails and newsletters from Vitamix?'; - } if (commSubscribe) commSubscribe.textContent = comm.subscribe || 'Subscribe'; if (commUnsubscribe) commUnsubscribe.textContent = comm.unsubscribe || 'Unsubscribe'; - const setCommLoading = (loading) => { - [commSubscribe, commUnsubscribe].forEach((btn) => { - if (btn) { - btn.disabled = loading; - } - }); + /** @type {boolean} */ + let emailOptInStatus = false; + + /** Buttons use a class for visibility — site `button.button { display: inline-block }` overrides `[hidden]`. */ + const COMM_VISIBLE = 'is-visible'; + + const applyCommCopy = () => { + if (!commQuestionCopy) return; + const subscribed = emailOptInStatus === true; + commQuestionCopy.textContent = subscribed + ? (comm.questionSubscribed + || 'You are currently subscribed to periodic emails and newsletters from Vitamix.') + : (comm.question + || 'Would you like us to send you periodic emails and newsletters from Vitamix?'); }; - const showCommSuccess = (message) => { - if (commActions) commActions.hidden = true; + const setCommBusy = (busy) => { + commQuestionShimmer?.classList.toggle(COMM_VISIBLE, busy); + commBtnShimmer?.classList.toggle(COMM_VISIBLE, busy); + commQuestionCopy?.classList.toggle(COMM_VISIBLE, !busy); + if (busy) { + commSubscribe?.classList.remove(COMM_VISIBLE); + commUnsubscribe?.classList.remove(COMM_VISIBLE); + } + }; + + const hideCommError = () => { if (commError) { commError.hidden = true; commError.textContent = ''; } - if (commSuccess) { - commSuccess.textContent = message; - commSuccess.hidden = false; - } }; const showCommError = (message) => { @@ -134,6 +144,15 @@ export default async function decorate(widget) { commError.hidden = false; }; + const applyCommOptInUi = () => { + applyCommCopy(); + const subscribed = emailOptInStatus === true; + commSubscribe?.classList.toggle(COMM_VISIBLE, !subscribed); + commUnsubscribe?.classList.toggle(COMM_VISIBLE, subscribed); + if (commSubscribe) commSubscribe.disabled = false; + if (commUnsubscribe) commUnsubscribe.disabled = false; + }; + const submitNewsletterPreference = async (emailOptIn) => { const trimmed = (email || '').trim(); if (!trimmed) return; @@ -148,11 +167,8 @@ export default async function decorate(widget) { emailOptIn, leadSource, }; - setCommLoading(true); - if (commError) { - commError.hidden = true; - commError.textContent = ''; - } + hideCommError(); + setCommBusy(true); try { const url = getFormSubmissionUrl(); const resp = await fetch(url, { @@ -160,25 +176,34 @@ export default async function decorate(widget) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }); - let apiMessage = ''; - try { - const body = await resp.json(); - apiMessage = body?.data?.message != null ? String(body.data.message) : ''; - } catch { - /* non-JSON */ - } if (!resp.ok) { showCommError(comm.error || 'Something went wrong. Please try again.'); + setCommBusy(false); + applyCommOptInUi(); return; } - const fallback = emailOptIn - ? (comm.successSubscribe || 'You are subscribed to Vitamix emails.') - : (comm.successUnsubscribe || 'You are unsubscribed from Vitamix emails.'); - showCommSuccess(apiMessage.trim() || fallback); + emailOptInStatus = emailOptIn; + setCommBusy(false); + applyCommOptInUi(); } catch { showCommError(comm.error || 'Something went wrong. Please try again.'); + setCommBusy(false); + applyCommOptInUi(); + } + }; + + const loadCommunicationsProfile = async () => { + setCommBusy(true); + try { + const { profile } = await fetchFormsProfile(); + if (profile && typeof profile.emailOptInStatus === 'boolean') { + emailOptInStatus = profile.emailOptInStatus; + } + } catch { + /* keep default not subscribed */ } finally { - setCommLoading(false); + setCommBusy(false); + applyCommOptInUi(); } }; @@ -190,6 +215,7 @@ export default async function decorate(widget) { commUnsubscribe?.addEventListener('click', () => { submitNewsletterPreference(false); }); + loadCommunicationsProfile(); } } diff --git a/widgets/account/account.json b/widgets/account/account.json index c690e38d..3b2b6753 100644 --- a/widgets/account/account.json +++ b/widgets/account/account.json @@ -89,10 +89,9 @@ "communications": { "title": "Communications", "question": "Would you like us to send you periodic emails and newsletters from Vitamix?", + "questionSubscribed": "You are currently subscribed to periodic emails and newsletters from Vitamix.", "subscribe": "Subscribe", "unsubscribe": "Unsubscribe", - "successSubscribe": "Your email preferences were updated. Thank you for subscribing.", - "successUnsubscribe": "Your email preferences were updated. You are unsubscribed from marketing emails.", "error": "Something went wrong. Please try again." }, "orderDetail": { @@ -198,10 +197,9 @@ "communications": { "title": "Communications", "question": "Souhaitez-vous recevoir de temps à autre des courriels et bulletins Vitamix ?", + "questionSubscribed": "Vous êtes actuellement abonné aux courriels et bulletins périodiques de Vitamix.", "subscribe": "S'abonner", "unsubscribe": "Se désabonner", - "successSubscribe": "Vos préférences de courriel ont été mises à jour. Merci de votre inscription.", - "successUnsubscribe": "Vos préférences de courriel ont été mises à jour. Vous êtes désabonné des courriels marketing.", "error": "Une erreur s'est produite. Veuillez réessayer." }, "orderDetail": { @@ -307,10 +305,9 @@ "communications": { "title": "Comunicaciones", "question": "¿Desea que le enviemos correos periódicos y boletines de Vitamix?", + "questionSubscribed": "Actualmente está suscrito a correos y boletines periódicos de Vitamix.", "subscribe": "Suscribirse", "unsubscribe": "Cancelar suscripción", - "successSubscribe": "Se actualizaron sus preferencias de correo. Gracias por suscribirse.", - "successUnsubscribe": "Se actualizaron sus preferencias de correo. Ya no recibirá correos de marketing.", "error": "Algo salió mal. Inténtelo de nuevo." }, "orderDetail": { From f34d1a22e7bf992254ec75ab271af2c23783a152 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Sat, 16 May 2026 14:53:19 -0600 Subject: [PATCH 89/89] chore: lint --- widgets/account/account-api.js | 20 ++++++++++++++++++-- widgets/account/account.js | 6 ++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/widgets/account/account-api.js b/widgets/account/account-api.js index 2148a99d..f8b000cf 100644 --- a/widgets/account/account-api.js +++ b/widgets/account/account-api.js @@ -17,7 +17,15 @@ function getFormsProfileUrl() { /** * GET forms profile: customer record + newsletter opt-in status (requires Bearer token). * - * @returns {Promise<{ customer: unknown, profile: { emailOptInStatus?: boolean, smsOptInStatus?: boolean, emailAddress?: string, mobile?: string | null } | null }>} + * @returns {Promise<{ + * customer: unknown, + * profile: { + * emailOptInStatus?: boolean, + * smsOptInStatus?: boolean, + * emailAddress?: string, + * mobile?: string | null, + * } | null, + * }>} */ export async function fetchFormsProfile() { const url = getFormsProfileUrl(); @@ -37,8 +45,16 @@ export async function fetchFormsProfile() { payload = payload.data; } const root = payload && typeof payload === 'object' ? /** @type {Record} */ (payload) : {}; + /** + * @type {{ + * emailOptInStatus?: boolean, + * smsOptInStatus?: boolean, + * emailAddress?: string, + * mobile?: string | null, + * } | null} + */ const profile = root.profile && typeof root.profile === 'object' - ? /** @type {{ emailOptInStatus?: boolean, smsOptInStatus?: boolean, emailAddress?: string, mobile?: string | null }} */ (root.profile) + ? root.profile : null; return { customer: root.customer ?? null, diff --git a/widgets/account/account.js b/widgets/account/account.js index 9a3a7255..b4118cff 100644 --- a/widgets/account/account.js +++ b/widgets/account/account.js @@ -96,7 +96,6 @@ export default async function decorate(widget) { const commTitle = information.querySelector('.account-communications-title'); const commQuestionCopy = information.querySelector('.account-communications-question-copy'); const commQuestionShimmer = information.querySelector('.account-communications-question-shimmer'); - const commActions = information.querySelector('.account-communications-actions'); const commBtnShimmer = information.querySelector('.account-communications-btn-shimmer'); const commSubscribe = information.querySelector('.account-communications-subscribe'); const commUnsubscribe = information.querySelector('.account-communications-unsubscribe'); @@ -108,7 +107,10 @@ export default async function decorate(widget) { /** @type {boolean} */ let emailOptInStatus = false; - /** Buttons use a class for visibility — site `button.button { display: inline-block }` overrides `[hidden]`. */ + /** + * Buttons use a class for visibility — site `button.button { display: inline-block }` + * overrides `[hidden]`. + */ const COMM_VISIBLE = 'is-visible'; const applyCommCopy = () => {