From 01746f836f3d5cd32738d8f732e6091c72d43fd1 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Fri, 13 Feb 2026 13:58:47 -0700 Subject: [PATCH 1/9] chore: initial compare products code WIP --- widgets/compare-products/compare-products.css | 208 +++++++ .../compare-products/compare-products.html | 7 + widgets/compare-products/compare-products.js | 512 ++++++++++++++++++ 3 files changed, 727 insertions(+) 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 diff --git a/widgets/compare-products/compare-products.css b/widgets/compare-products/compare-products.css new file mode 100644 index 00000000..792abc31 --- /dev/null +++ b/widgets/compare-products/compare-products.css @@ -0,0 +1,208 @@ +/* Compare Products Widget */ +.compare-products { + max-width: var(--site-width); + margin: 0 auto; + padding: var(--spacing-400) var(--spacing-600); +} + +.compare-products-empty { + margin: var(--spacing-200) 0; + color: var(--color-gray-700); + font-size: var(--body-size-m); +} + +/* Product cards row */ +.compare-products-products { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 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-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); + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + padding: 0; + background-color: var(--color-gray-200); + color: var(--color-charcoal); + font-size: 1.25rem; + line-height: 1; + cursor: pointer; + transition: background-color 0.2s, color 0.2s; +} + +.compare-products-product-remove:hover { + background-color: var(--color-gray-300); + color: var(--color-gray-800); +} + +.compare-products-product-image-wrap { + width: 100%; + max-width: 280px; + margin-bottom: var(--spacing-200); + 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-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-product-name { + margin: 0 0 var(--spacing-100); + color: var(--color-charcoal); + font-family: var(--heading-font-family); + font-size: var(--font-size-300); + font-weight: var(--weight-regular); + text-align: center; +} + +.compare-products-product-price { + margin-bottom: var(--spacing-100); + text-align: center; + font-size: var(--body-size-m); +} + +.compare-products-product-price-now { + font-weight: var(--weight-medium); + color: var(--color-charcoal); +} + +.compare-products-product-price-save { + display: block; + font-size: var(--body-size-s); + color: var(--color-gray-700); +} + +.compare-products-product-cta { + display: inline-block; + margin-top: var(--spacing-80); + padding: var(--spacing-100) var(--spacing-300); + background-color: var(--color-red); + color: var(--color-white); + font-size: var(--body-size-m); + font-weight: var(--weight-medium); + text-align: center; + text-decoration: none; + border: none; + border-radius: var(--rounding-s); + cursor: pointer; + transition: background-color 0.2s, opacity 0.2s; +} + +.compare-products-product-cta:hover { + background-color: var(--color-red-dark, #b71c1c); + color: var(--color-white); +} + +/* Features section */ +.compare-products-features { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-300); + align-items: start; +} + +.compare-products-features-label { + padding-top: var(--spacing-100); + color: var(--color-charcoal); + font-family: var(--heading-font-family); + font-size: var(--body-size-m); + font-weight: var(--weight-medium); +} + +.compare-products-features-table { + display: flex; + flex-direction: column; + min-width: 0; +} + +.compare-products-features-row { + display: grid; + grid-template-columns: subgrid; + align-items: stretch; +} + +.compare-products-products[data-columns] { + grid-template-columns: repeat(var(--compare-cols, 2), 1fr); +} + +.compare-products-features-table { + grid-column: 2; + display: grid; + grid-template-columns: minmax(120px, auto) repeat(var(--compare-cols, 2), minmax(0, 1fr)); + gap: 0; +} + +.compare-products-features-row { + display: contents; +} + +.compare-products-features-cell { + padding: var(--spacing-100) var(--spacing-200); + border-bottom: var(--border-s) solid var(--color-gray-200); + font-size: var(--body-size-s); + color: var(--color-charcoal); +} + +.compare-products-features-cell.feature-name { + font-weight: var(--weight-medium); + color: var(--color-gray-700); +} + +@media (width >= 600px) { + .compare-products-products { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (width >= 900px) { + .compare-products-products { + grid-template-columns: repeat(var(--compare-cols, 2), 1fr); + } +} diff --git a/widgets/compare-products/compare-products.html b/widgets/compare-products/compare-products.html new file mode 100644 index 00000000..a60311db --- /dev/null +++ b/widgets/compare-products/compare-products.html @@ -0,0 +1,7 @@ +
+
+
+
Features
+
+
+
diff --git a/widgets/compare-products/compare-products.js b/widgets/compare-products/compare-products.js new file mode 100644 index 00000000..4cda5f57 --- /dev/null +++ b/widgets/compare-products/compare-products.js @@ -0,0 +1,512 @@ +import { loadCSS } from '../../scripts/aem.js'; + +const FEATURE_KEYS = [ + 'Series', + 'Container Capacity', + 'Programs', + 'Touch Screen', + 'Timer', + 'Pulse', + 'Self-Detect Technology', + 'Dishwasher Safe', + 'Dimensions', + 'HP', + 'Warranty', +]; + +/** Map page spec labels to our feature keys */ +const SPEC_LABEL_MAP = { + Series: 'Series', + Capacity: 'Container Capacity', + Dimensions: 'Dimensions', + HP: 'HP', + 'Dishwasher Safe': 'Dishwasher Safe', + Warranty: 'Warranty', +}; + +/** + * 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; + } + }); +} + +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'); +} + +/** + * 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 or null + */ +async function fetchProductPage(path) { + const pathOnly = path.startsWith('http') ? new URL(path).pathname : path; + const fullUrl = path.startsWith('http') ? path : `${AEM_NETWORK_ORIGIN}${pathOnly}`; + + 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); + } + if (!resp.ok) return null; + return resp.text(); +} + +/** + * 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 headings = [...doc.querySelectorAll('main h3')]; + const h = headings.find((el) => /warranty/i.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) return null; + let ld; + try { + ld = JSON.parse(jsonLdScript.textContent); + } catch { + return null; + } + if (ld['@type'] !== 'Product' || !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} Normalized product object or null + */ +async function fetchProduct(path) { + const html = await fetchProductPage(path); + if (!html) return null; + return parseProductFromPage(html, path); +} + +/** + * Get feature value for a product (page specs, then custom fallbacks). + * @param {Object} product - Normalized product (has .specs from page) + * @param {string} key - Feature label (e.g. 'Series', 'Warranty') + * @returns {string} Display value + */ +function getFeatureValue(product, key) { + const specs = product?.specs || {}; + const custom = product?.custom || {}; + + const mapKey = SPEC_LABEL_MAP[key] || key; + const direct = specs[key] ?? specs[mapKey]; + if (direct) return direct; + + if (key === 'Container Capacity' && specs.Capacity) return specs.Capacity; + if (key === 'Series') return custom.series || custom.collection || '—'; + if (key === 'Warranty') { + if (specs.Warranty) return specs.Warranty; + const opts = custom.options; + if (Array.isArray(opts) && opts.length > 0) { + const name = opts[0].name || ''; + const match = name.match(/(\d+)\s*yr|(\d+)\s*year/i); + if (match) return `${match[1] || match[2]} Years`; + return name; + } + return '—'; + } + return '—'; +} + +/** + * Format price for display. + * @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 now = `$${parseFloat(price.final).toFixed(2)}`; + const regular = parseFloat(price.regular); + const finalVal = parseFloat(price.final); + const save = regular > finalVal + ? `Save $${(regular - finalVal).toFixed(2)} | Was $${regular.toFixed(2)}` + : null; + return { now, save }; +} + +/** + * 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 ''; +} + +/** + * Simple color name to hex for swatches (optional). + * @param {string} name - Color name + * @returns {string} CSS color + */ +function getColorHex(name) { + const map = { + black: '#1a1a1a', + 'shadow black': '#1a1a1a', + white: '#f5f5f5', + 'polar white': '#f5f5f5', + gray: '#6b6b6b', + 'nano gray': '#6b6b6b', + grey: '#6b6b6b', + blue: '#2c5282', + 'midnight blue': '#1e3a5f', + }; + const key = (name || '').toLowerCase().trim(); + return map[key] || 'var(--color-gray-300)'; +} + +/** + * 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 = 'compare-products-product-remove'; + removeBtn.setAttribute('aria-label', `Remove ${product.name} 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; + btn.style.backgroundColor = getColorHex(opt.value); + 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 = `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 = 'compare-products-product-cta'; + const { path: productPath, url: productUrl } = product || {}; + cta.href = productPath || productUrl || '#'; + cta.textContent = 'VIEW DETAILS'; + + col.append(removeBtn, imgWrap, colorsWrap, nameEl, priceEl, cta); + return col; +} + +/** + * Build features table body (feature rows with one cell per product). + * @param {Object[]} products - Array of product JSON + * @param {HTMLElement} tableEl - Table container + * @param {number} columnCount - Number of product columns + */ +function buildFeaturesTable(products, tableEl, columnCount) { + tableEl.style.setProperty('--compare-cols', String(columnCount)); + tableEl.innerHTML = ''; + + FEATURE_KEYS.forEach((key) => { + const row = document.createElement('div'); + row.className = 'compare-products-features-row'; + const nameCell = document.createElement('div'); + nameCell.className = 'compare-products-features-cell feature-name'; + nameCell.textContent = key; + row.appendChild(nameCell); + products.forEach((product) => { + const cell = document.createElement('div'); + cell.className = 'compare-products-features-cell'; + cell.textContent = getFeatureValue(product, key); + row.appendChild(cell); + }); + tableEl.appendChild(row); + }); +} + +/** + * Render the full compare view: product cards + features table. + * @param {HTMLElement} widget - Widget root + * @param {Object[]} products - Array of product JSON + */ +function render(widget, products) { + const productsContainer = widget.querySelector('.compare-products-products'); + const featuresTable = widget.querySelector('.compare-products-features-table'); + if (!productsContainer || !featuresTable) return; + + productsContainer.style.setProperty('--compare-cols', String(products.length)); + productsContainer.innerHTML = ''; + + const removeProduct = (index) => { + const next = products.filter((_, i) => i !== index); + if (next.length === 0) { + widget.dispatchEvent(new CustomEvent('compare-products-empty')); + return; + } + render(widget, next); + }; + + products.forEach((product, index) => { + const card = buildProductCard(product, index, removeProduct); + productsContainer.appendChild(card); + }); + + buildFeaturesTable(products, featuresTable, products.length); +} + +/** + * Initialize compare-products widget: read config, fetch products, render. + * @param {HTMLElement} widget - Widget root + */ +export default async function decorate(widget) { + const paths = getProductComparisonPaths(); + if (paths.length === 0) { + widget.querySelector('.compare-products-products')?.appendChild( + Object.assign(document.createElement('p'), { + textContent: 'Add product paths to compare (productComparison).', + className: 'compare-products-empty', + }), + ); + return; + } + + const products = await Promise.all(paths.map(fetchProduct)); + const valid = products.filter(Boolean); + if (valid.length === 0) { + widget.querySelector('.compare-products-products')?.appendChild( + Object.assign(document.createElement('p'), { + textContent: 'Could not load product data.', + className: 'compare-products-empty', + }), + ); + return; + } + + render(widget, valid); +} + +// Load CSS +const start = () => { + loadCSS('/widgets/compare-products/compare-products.css'); +}; + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', start); +} else { + start(); +} From 6521646812ac4604f18b6f90be7e4b6432b9c055 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Mon, 16 Feb 2026 16:56:44 -0700 Subject: [PATCH 2/9] chore: rev2 --- widgets/compare-products/compare-products.css | 222 +++++++++------ .../compare-products/compare-products.html | 9 +- widgets/compare-products/compare-products.js | 263 +++++++++++++----- 3 files changed, 329 insertions(+), 165 deletions(-) diff --git a/widgets/compare-products/compare-products.css b/widgets/compare-products/compare-products.css index 792abc31..a5a15a90 100644 --- a/widgets/compare-products/compare-products.css +++ b/widgets/compare-products/compare-products.css @@ -1,20 +1,111 @@ -/* Compare Products Widget */ +/* Compare Products Widget – layout only; typography, buttons, colors inherit from site */ +/* Swatch colors aligned with blocks/pdp/color-swatches.css */ +.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; - color: var(--color-gray-700); - font-size: var(--body-size-m); } -/* Product cards row */ +/* Product cards row – start after feature-label column so cards align with table data columns */ .compare-products-products { + grid-column: 2 / -1; display: grid; - grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + 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); @@ -33,22 +124,6 @@ position: absolute; top: var(--spacing-80); right: var(--spacing-80); - width: 32px; - height: 32px; - border: none; - border-radius: 50%; - padding: 0; - background-color: var(--color-gray-200); - color: var(--color-charcoal); - font-size: 1.25rem; - line-height: 1; - cursor: pointer; - transition: background-color 0.2s, color 0.2s; -} - -.compare-products-product-remove:hover { - background-color: var(--color-gray-300); - color: var(--color-gray-800); } .compare-products-product-image-wrap { @@ -95,114 +170,79 @@ .compare-products-product-name { margin: 0 0 var(--spacing-100); - color: var(--color-charcoal); - font-family: var(--heading-font-family); - font-size: var(--font-size-300); - font-weight: var(--weight-regular); text-align: center; } .compare-products-product-price { margin-bottom: var(--spacing-100); text-align: center; - font-size: var(--body-size-m); } .compare-products-product-price-now { - font-weight: var(--weight-medium); - color: var(--color-charcoal); -} - -.compare-products-product-price-save { display: block; - font-size: var(--body-size-s); - color: var(--color-gray-700); } -.compare-products-product-cta { - display: inline-block; - margin-top: var(--spacing-80); - padding: var(--spacing-100) var(--spacing-300); - background-color: var(--color-red); - color: var(--color-white); - font-size: var(--body-size-m); - font-weight: var(--weight-medium); - text-align: center; - text-decoration: none; - border: none; - border-radius: var(--rounding-s); - cursor: pointer; - transition: background-color 0.2s, opacity 0.2s; +.compare-products-product-placeholder { + justify-content: center; + min-height: 200px; } -.compare-products-product-cta:hover { - background-color: var(--color-red-dark, #b71c1c); - color: var(--color-white); +.compare-products-product-placeholder-msg { + margin: var(--spacing-200) 0; + text-align: center; } /* Features section */ .compare-products-features { - display: grid; - grid-template-columns: auto 1fr; - gap: var(--spacing-300); - align-items: start; -} - -.compare-products-features-label { - padding-top: var(--spacing-100); - color: var(--color-charcoal); - font-family: var(--heading-font-family); - font-size: var(--body-size-m); - font-weight: var(--weight-medium); + grid-column: 1 / -1; } .compare-products-features-table { - display: flex; - flex-direction: column; + 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: grid; - grid-template-columns: subgrid; - align-items: stretch; + display: contents; } -.compare-products-products[data-columns] { - grid-template-columns: repeat(var(--compare-cols, 2), 1fr); +.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-table { - grid-column: 2; - display: grid; - grid-template-columns: minmax(120px, auto) repeat(var(--compare-cols, 2), minmax(0, 1fr)); - gap: 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-row { - display: contents; +.compare-products-features-cell.row-even { + background-color: var(--color-background); } -.compare-products-features-cell { - padding: var(--spacing-100) var(--spacing-200); - border-bottom: var(--border-s) solid var(--color-gray-200); - font-size: var(--body-size-s); - color: var(--color-charcoal); +.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); - color: var(--color-gray-700); } -@media (width >= 600px) { - .compare-products-products { - grid-template-columns: repeat(2, 1fr); - } +.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; } -@media (width >= 900px) { - .compare-products-products { - grid-template-columns: repeat(var(--compare-cols, 2), 1fr); - } -} diff --git a/widgets/compare-products/compare-products.html b/widgets/compare-products/compare-products.html index a60311db..3fea5ed1 100644 --- a/widgets/compare-products/compare-products.html +++ b/widgets/compare-products/compare-products.html @@ -1,7 +1,8 @@
-
-
-
Features
-
+
+
+
+
+
diff --git a/widgets/compare-products/compare-products.js b/widgets/compare-products/compare-products.js index 4cda5f57..3dd76c7c 100644 --- a/widgets/compare-products/compare-products.js +++ b/widgets/compare-products/compare-products.js @@ -1,29 +1,88 @@ -import { loadCSS } from '../../scripts/aem.js'; +import { loadCSS, toClassName } from '../../scripts/aem.js'; const FEATURE_KEYS = [ 'Series', - 'Container Capacity', - 'Programs', - 'Touch Screen', - 'Timer', + 'Blending Programs', + 'Variable Speed Control', + 'Touch Buttons', 'Pulse', + 'Digital Timer', 'Self-Detect Technology', - 'Dishwasher Safe', - 'Dimensions', - 'HP', + 'Tamper Indicator', + 'Plus 15 Second Button', 'Warranty', + 'Dimensions (L × W × H)', ]; -/** Map page spec labels to our feature keys */ +/** Map page spec labels to our feature keys (for product specs lookup) */ const SPEC_LABEL_MAP = { - Series: 'Series', - Capacity: 'Container Capacity', - Dimensions: 'Dimensions', - HP: 'HP', - 'Dishwasher Safe': 'Dishwasher Safe', + Dimensions: 'Dimensions (L × W × H)', Warranty: 'Warranty', }; +const FEATURES_BY_SERIES_PATH = '/us/en_us/products/config/features-by-series.json'; + +/** + * Fetch features-by-series config (same-origin only; no fcors). + * @returns {Promise} Parsed JSON or null + */ +async function fetchFeaturesBySeries() { + const url = new URL(FEATURES_BY_SERIES_PATH, window.location.origin).href; + const resp = await fetch(url); + if (!resp.ok) return null; + try { + return await resp.json(); + } catch { + return null; + } +} + +/** + * Get series name from product (for features-by-series lookup). + * @param {Object} product - Product with .custom, .name + * @returns {string} Series name or '' + */ +function getProductSeries(product) { + if (!product) return ''; + const c = product.custom || {}; + return (c.series || c.collection || product.name || '').trim(); +} + +/** + * Normalize series name for matching: remove "Vitamix", trim, strip ®™. + * @param {string} s - Series or product name + * @returns {string} Normalized string for comparison + */ +function normalizeSeriesForMatch(s) { + if (!s || typeof s !== 'string') return ''; + let t = s + .replace(/\s*®\s*|\s*™\s*/gi, ' ') + .replace(/\bVitamix\b/gi, '') + .replace(/\s+/g, ' ') + .trim(); + return t; +} + +/** + * Find features row for a series in features-by-series.data. + * @param {Object} featuresBySeries - { data: Array<{ Series: string, ... }> } + * @param {string} series - Series name from product + * @returns {Object|null} Row object or null + */ +function getSeriesFeaturesRow(featuresBySeries, series) { + if (!featuresBySeries?.data?.length || !series) return null; + const data = featuresBySeries.data; + const norm = normalizeSeriesForMatch(series); + if (!norm) return null; + const normLower = norm.toLowerCase(); + const exact = data.find((row) => normalizeSeriesForMatch(row.Series).toLowerCase() === normLower); + if (exact) return exact; + return data.find((row) => { + const rowNorm = normalizeSeriesForMatch(row.Series).toLowerCase(); + return rowNorm === normLower || rowNorm.includes(normLower) || normLower.includes(rowNorm); + }) || null; +} + /** * Resolve product comparison paths from window.location query * (e.g. ?productComparison=/path1,/path2). @@ -64,7 +123,7 @@ function useFcors() { * 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 or null + * @returns {Promise<{ html: string|null, status: number }>} */ async function fetchProductPage(path) { const pathOnly = path.startsWith('http') ? new URL(path).pathname : path; @@ -80,8 +139,8 @@ async function fetchProductPage(path) { const url = path.startsWith('http') ? path : new URL(path, window.location.origin).href; resp = await fetch(url); } - if (!resp.ok) return null; - return resp.text(); + if (!resp.ok) return { html: null, status: resp.status }; + return { html: await resp.text(), status: resp.status }; } /** @@ -241,41 +300,63 @@ function parseProductFromPage(html, path) { /** * Fetch product by loading the live page (no .json) and parsing HTML. * @param {string} path - Product path - * @returns {Promise} Normalized product object or null + * @returns {Promise<{ product: Object|null, errorStatus?: number }>} */ async function fetchProduct(path) { - const html = await fetchProductPage(path); - if (!html) return null; - return parseProductFromPage(html, path); + const { html, status } = await fetchProductPage(path); + if (!html) return { product: null, errorStatus: status }; + const product = parseProductFromPage(html, path); + return { product, errorStatus: product ? undefined : status }; } /** - * Get feature value for a product (page specs, then custom fallbacks). + * Get feature value for a product (page specs, then custom fallbacks, then features-by-series). * @param {Object} product - Normalized product (has .specs from page) * @param {string} key - Feature label (e.g. 'Series', 'Warranty') + * @param {Object} [featuresBySeries] - Optional { data } from features-by-series.json * @returns {string} Display value */ -function getFeatureValue(product, key) { +function getFeatureValue(product, key, featuresBySeries) { const specs = product?.specs || {}; const custom = product?.custom || {}; const mapKey = SPEC_LABEL_MAP[key] || key; const direct = specs[key] ?? specs[mapKey]; + + if (key === 'Dimensions (L × W × H)') { + const seriesRow = getSeriesFeaturesRow(featuresBySeries, getProductSeries(product)); + if (seriesRow) { + const sheetVal = seriesRow[key]; + if (sheetVal != null && String(sheetVal).trim() !== '') return String(sheetVal).trim(); + } + if (direct) return direct; + return '—'; + } + + if (key === 'Series') { + const seriesRow = getSeriesFeaturesRow(featuresBySeries, getProductSeries(product)); + if (seriesRow?.Series) return String(seriesRow.Series).trim(); + return custom.series || custom.collection || '—'; + } + if (direct) return direct; - if (key === 'Container Capacity' && specs.Capacity) return specs.Capacity; - if (key === 'Series') return custom.series || custom.collection || '—'; if (key === 'Warranty') { - if (specs.Warranty) return specs.Warranty; const opts = custom.options; if (Array.isArray(opts) && opts.length > 0) { const name = opts[0].name || ''; const match = name.match(/(\d+)\s*yr|(\d+)\s*year/i); if (match) return `${match[1] || match[2]} Years`; - return name; + if (name) return name; } - return '—'; } + + const seriesRow = getSeriesFeaturesRow(featuresBySeries, getProductSeries(product)); + if (seriesRow) { + const sheetVal = seriesRow[key]; + if (sheetVal != null && String(sheetVal).trim() !== '') return String(sheetVal).trim(); + } + return '—'; } @@ -316,24 +397,38 @@ function getProductImageUrl(product, variantIndex = 0) { } /** - * Simple color name to hex for swatches (optional). - * @param {string} name - Color name - * @returns {string} CSS color + * Build a placeholder 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 getColorHex(name) { - const map = { - black: '#1a1a1a', - 'shadow black': '#1a1a1a', - white: '#f5f5f5', - 'polar white': '#f5f5f5', - gray: '#6b6b6b', - 'nano gray': '#6b6b6b', - grey: '#6b6b6b', - blue: '#2c5282', - 'midnight blue': '#1e3a5f', - }; - const key = (name || '').toLowerCase().trim(); - return map[key] || 'var(--color-gray-300)'; +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', '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 + ? 'Product not found (404).' + : '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 = 'Try opening the product page'; + link.className = 'button link'; + + col.append(removeBtn, msg, link); + return col; } /** @@ -350,7 +445,7 @@ function buildProductCard(product, index, onRemove) { const removeBtn = document.createElement('button'); removeBtn.type = 'button'; - removeBtn.className = 'compare-products-product-remove'; + removeBtn.className = 'button button close compare-products-product-remove'; removeBtn.setAttribute('aria-label', `Remove ${product.name} from comparison`); removeBtn.textContent = '×'; removeBtn.addEventListener('click', () => onRemove(index)); @@ -374,7 +469,10 @@ function buildProductCard(product, index, onRemove) { btn.className = `compare-products-product-color ${i === 0 ? 'selected' : ''}`; btn.setAttribute('aria-label', opt.value); btn.title = opt.value; - btn.style.backgroundColor = getColorHex(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')); @@ -403,7 +501,7 @@ function buildProductCard(product, index, onRemove) { } const cta = document.createElement('a'); - cta.className = 'compare-products-product-cta'; + cta.className = 'button emphasis'; const { path: productPath, url: productUrl } = product || {}; cta.href = productPath || productUrl || '#'; cta.textContent = 'VIEW DETAILS'; @@ -413,26 +511,38 @@ function buildProductCard(product, index, onRemove) { } /** - * Build features table body (feature rows with one cell per product). - * @param {Object[]} products - Array of product JSON + * Build features table body (feature rows with one cell per product/slot). + * @param {{ path: string, product: Object|null }[]} slots - One slot per requested path * @param {HTMLElement} tableEl - Table container - * @param {number} columnCount - Number of product columns + * @param {Object} [featuresBySeries] - Optional { data } from features-by-series.json for fallbacks */ -function buildFeaturesTable(products, tableEl, columnCount) { +function buildFeaturesTable(slots, tableEl, featuresBySeries) { + const columnCount = slots.length; tableEl.style.setProperty('--compare-cols', String(columnCount)); tableEl.innerHTML = ''; - FEATURE_KEYS.forEach((key) => { + FEATURE_KEYS.forEach((key, rowIndex) => { const row = document.createElement('div'); - row.className = 'compare-products-features-row'; + 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'; + nameCell.className = `compare-products-features-cell feature-name ${rowIndex % 2 ? 'row-odd' : 'row-even'}`; nameCell.textContent = key; row.appendChild(nameCell); - products.forEach((product) => { + slots.forEach((slot) => { const cell = document.createElement('div'); - cell.className = 'compare-products-features-cell'; - cell.textContent = getFeatureValue(product, key); + cell.className = `compare-products-features-cell ${rowIndex % 2 ? 'row-odd' : 'row-even'}`; + const value = slot.product + ? getFeatureValue(slot.product, key, featuresBySeries) + : '—'; + if (value === 'Yes') { + 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); @@ -442,31 +552,34 @@ function buildFeaturesTable(products, tableEl, columnCount) { /** * Render the full compare view: product cards + features table. * @param {HTMLElement} widget - Widget root - * @param {Object[]} products - Array of product JSON + * @param {{ path: string, product: Object|null }[]} slots - One slot per requested path + * @param {Object} [featuresBySeries] - Optional { data } from features-by-series.json for fallbacks */ -function render(widget, products) { +function render(widget, slots, featuresBySeries) { const productsContainer = widget.querySelector('.compare-products-products'); const featuresTable = widget.querySelector('.compare-products-features-table'); if (!productsContainer || !featuresTable) return; - productsContainer.style.setProperty('--compare-cols', String(products.length)); + widget.style.setProperty('--compare-cols', String(slots.length)); productsContainer.innerHTML = ''; const removeProduct = (index) => { - const next = products.filter((_, i) => i !== index); + const next = slots.filter((_, i) => i !== index); if (next.length === 0) { widget.dispatchEvent(new CustomEvent('compare-products-empty')); return; } - render(widget, next); + render(widget, next, featuresBySeries); }; - products.forEach((product, index) => { - const card = buildProductCard(product, index, removeProduct); + slots.forEach((slot, index) => { + const card = slot.product + ? buildProductCard(slot.product, index, removeProduct) + : buildPlaceholderCard(slot.path, index, removeProduct, slot.errorStatus); productsContainer.appendChild(card); }); - buildFeaturesTable(products, featuresTable, products.length); + buildFeaturesTable(slots, featuresTable, featuresBySeries); } /** @@ -485,9 +598,19 @@ export default async function decorate(widget) { return; } - const products = await Promise.all(paths.map(fetchProduct)); - const valid = products.filter(Boolean); - if (valid.length === 0) { + const [featuresBySeries, ...slotResults] = await Promise.all([ + fetchFeaturesBySeries(), + ...paths.map((path) => fetchProduct(path)), + ]); + + const slots = slotResults.map(({ product, errorStatus }, i) => ({ + path: paths[i], + product, + errorStatus, + })); + + const allFailed = slots.every((slot) => !slot.product); + if (allFailed) { widget.querySelector('.compare-products-products')?.appendChild( Object.assign(document.createElement('p'), { textContent: 'Could not load product data.', @@ -497,7 +620,7 @@ export default async function decorate(widget) { return; } - render(widget, valid); + render(widget, slots, featuresBySeries || undefined); } // Load CSS From a62d60b9108d5bb5636c8e909298f661953d65f3 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Mon, 16 Feb 2026 17:01:40 -0700 Subject: [PATCH 3/9] chore: lint --- widgets/compare-products/compare-products.css | 41 ++++++++++--------- widgets/compare-products/compare-products.js | 6 +-- 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/widgets/compare-products/compare-products.css b/widgets/compare-products/compare-products.css index a5a15a90..5593135b 100644 --- a/widgets/compare-products/compare-products.css +++ b/widgets/compare-products/compare-products.css @@ -1,5 +1,26 @@ /* 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; @@ -148,26 +169,6 @@ margin-bottom: var(--spacing-200); } -.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-product-name { margin: 0 0 var(--spacing-100); text-align: center; diff --git a/widgets/compare-products/compare-products.js b/widgets/compare-products/compare-products.js index 3dd76c7c..ed7b4e40 100644 --- a/widgets/compare-products/compare-products.js +++ b/widgets/compare-products/compare-products.js @@ -55,7 +55,7 @@ function getProductSeries(product) { */ function normalizeSeriesForMatch(s) { if (!s || typeof s !== 'string') return ''; - let t = s + const t = s .replace(/\s*®\s*|\s*™\s*/gi, ' ') .replace(/\bVitamix\b/gi, '') .replace(/\s+/g, ' ') @@ -70,8 +70,8 @@ function normalizeSeriesForMatch(s) { * @returns {Object|null} Row object or null */ function getSeriesFeaturesRow(featuresBySeries, series) { - if (!featuresBySeries?.data?.length || !series) return null; - const data = featuresBySeries.data; + const { data } = featuresBySeries || {}; + if (!data?.length || !series) return null; const norm = normalizeSeriesForMatch(series); if (!norm) return null; const normLower = norm.toLowerCase(); From e60aeb026946b12f99bc0034c22bf183d2875b39 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Wed, 18 Feb 2026 11:43:27 -0700 Subject: [PATCH 4/9] chore: comparison table --- blocks/table/table.css | 81 ++++++++++++++++++++++++++++++++++++++++++ blocks/table/table.js | 61 +++++++++++++++++++++++++++++-- scripts/scripts.js | 1 + 3 files changed, 140 insertions(+), 3 deletions(-) diff --git a/blocks/table/table.css b/blocks/table/table.css index de1df9a5..4cd4b142 100644 --- a/blocks/table/table.css +++ b/blocks/table/table.css @@ -33,3 +33,84 @@ .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: small image left, text right, vertically centered */ +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content { + display: flex; + align-items: center; + gap: 12px; +} + +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content picture, +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content img { + flex-shrink: 0; + width: 48px; + height: 48px; + margin: 0; +} + +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content img { + width: 48px; + height: 48px; + object-fit: contain; +} + +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content a { + color: #06c; + text-decoration: none; + flex: 1; + min-width: 0; +} + +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content a:hover { + text-decoration: underline; +} + +.table.comparison .table-comparison-scroll tbody td { + background-color: #fff; + text-align: center; + color: #333; +} diff --git a/blocks/table/table.js b/blocks/table/table.js index afe00f81..02ad8c4e 100644 --- a/blocks/table/table.js +++ b/blocks/table/table.js @@ -20,18 +20,74 @@ function buildRowWithRowHeaders(row) { return tr; } +function buildComparisonTable(rows) { + const table = document.createElement('table'); + const colCount = rows[0]?.children?.length || 0; + + // colgroup: first column 2x width of others (2/(n+1) vs 1/(n+1)) + if (colCount > 0) { + const colgroup = document.createElement('colgroup'); + const firstPct = (200 / (1 + colCount)).toFixed(2); + const otherPct = (100 / (1 + colCount)).toFixed(2); + const firstCol = document.createElement('col'); + firstCol.style.width = `${firstPct}%`; + colgroup.appendChild(firstCol); + for (let i = 1; i < colCount; i += 1) { + const col = document.createElement('col'); + col.style.width = `${otherPct}%`; + colgroup.appendChild(col); + } + table.appendChild(colgroup); + } + + const thead = document.createElement('thead'); + if (rows.length > 0) { + thead.appendChild(buildRow(rows[0], 'th')); + } + + const tbody = document.createElement('tbody'); + rows.slice(1).forEach((row) => { + const tr = document.createElement('tr'); + [...row.children].forEach((col, index) => { + const cell = document.createElement(index === 0 ? 'th' : 'td'); + if (index === 0) { + cell.setAttribute('scope', 'row'); + const wrap = document.createElement('div'); + wrap.className = 'table-comparison-cell-content'; + wrap.innerHTML = col.innerHTML; + cell.appendChild(wrap); + } else { + cell.innerHTML = col.innerHTML; + } + tr.appendChild(cell); + }); + tbody.appendChild(tr); + }); + + table.append(thead, tbody); + return table; +} + export default function decorate(block) { const table = document.createElement('table'); const rows = [...block.children]; const hasRowHeaders = block.classList.contains('row-headers'); + const isComparison = block.classList.contains('comparison'); - if (hasRowHeaders) { + if (isComparison) { + const comparisonTable = buildComparisonTable(rows); + const scrollWrapper = document.createElement('div'); + scrollWrapper.className = 'table-comparison-scroll'; + scrollWrapper.appendChild(comparisonTable); + block.replaceChildren(scrollWrapper); + } else if (hasRowHeaders) { // build table with row headers (first column is header) const tbody = document.createElement('tbody'); rows.forEach((row) => { tbody.appendChild(buildRowWithRowHeaders(row)); }); table.appendChild(tbody); + block.replaceChildren(table); } else { // build table head const thead = document.createElement('thead'); @@ -46,7 +102,6 @@ export default function decorate(block) { }); table.append(thead, tbody); + block.replaceChildren(table); } - - block.replaceChildren(table); } diff --git a/scripts/scripts.js b/scripts/scripts.js index d121f91e..ab097c68 100644 --- a/scripts/scripts.js +++ b/scripts/scripts.js @@ -621,6 +621,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(); From 15905f56418616d85f777da97fed9e14a64f3876 Mon Sep 17 00:00:00 2001 From: David Nuescheler Date: Fri, 20 Feb 2026 07:57:51 -0700 Subject: [PATCH 5/9] chore: refactor --- blocks/table/table.css | 54 ++- blocks/table/table.js | 91 +++- widgets/compare-products/compare-products.css | 11 + widgets/compare-products/compare-products.js | 428 +++++++++++++++++- 4 files changed, 552 insertions(+), 32 deletions(-) diff --git a/blocks/table/table.css b/blocks/table/table.css index 4cd4b142..1dcac7cf 100644 --- a/blocks/table/table.css +++ b/blocks/table/table.css @@ -77,35 +77,36 @@ font-weight: 600; } -/* Heading column: small image left, text right, vertically centered */ -.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content { +/* 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 picture, -.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content img { +.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 img { +.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 a { +.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 a:hover { +.table.comparison .table-comparison-scroll tbody th .table-comparison-cell-content p a:hover { text-decoration: underline; } @@ -114,3 +115,42 @@ 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 index 02ad8c4e..74decde2 100644 --- a/blocks/table/table.js +++ b/blocks/table/table.js @@ -1,3 +1,40 @@ +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 + *