From 30f14242cf38502f595a0e203415767a26e88194 Mon Sep 17 00:00:00 2001 From: Ben Peter Date: Sat, 25 Jul 2026 11:00:02 +0200 Subject: [PATCH] Wire the tire finder to a product catalogue and add product pages support Add products.json, a 13-product catalogue captured from the live site with name, category, season, vehicle types, image, description, features, and tire sizes. It is served from the code bus at /products.json. Rewrite the perfect-fit tire finder to search that catalogue. By Tire Size offers cascading width, aspect, and rim menus built from the real sizes, and returns every product available in the chosen size. By Vehicle maps a curated make and model set to a vehicle class and returns the tires that fit it. By Plate recommends the all-weather range. Results render as product cards that link to /tires/. Add a size-list block that renders authored tire sizes as chips, used by the product pages. Tested: npm test covers size parsing, the cascading option builder, size and vehicle-class matching, the modal, and the size-list block. Live-tested on a local aem up server. By Tire Size 225/35R19 returns ExtremeContact Sport 02, By Vehicle Ford Explorer returns 9 SUV-fitting tires, and a product page renders hero, overview, features, and size chips. --- blocks/perfect-fit/perfect-fit.css | 74 +++ blocks/perfect-fit/perfect-fit.js | 309 ++++++--- blocks/size-list/size-list.css | 22 + blocks/size-list/size-list.js | 20 + products.json | 685 ++++++++++++++++++++ test/blocks/perfect-fit/perfect-fit.test.js | 132 ++-- test/blocks/size-list/size-list.test.js | 22 + 7 files changed, 1133 insertions(+), 131 deletions(-) create mode 100644 blocks/size-list/size-list.css create mode 100644 blocks/size-list/size-list.js create mode 100644 products.json create mode 100644 test/blocks/size-list/size-list.test.js diff --git a/blocks/perfect-fit/perfect-fit.css b/blocks/perfect-fit/perfect-fit.css index 82aaeb4..ce913c3 100644 --- a/blocks/perfect-fit/perfect-fit.css +++ b/blocks/perfect-fit/perfect-fit.css @@ -216,3 +216,77 @@ body.perfect-fit-modal-open { grid-column: 1 / -1; } } + +/* search results */ +.perfect-fit-results:empty { + display: none; +} + +.perfect-fit-result-count { + margin: 24px 0 12px; + font-family: var(--heading-font-family); + font-size: 12px; + font-weight: 700; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--conti-darkest-grey); +} + +.perfect-fit-results-list { + display: grid; + grid-template-columns: 1fr; + gap: 12px; +} + +@media (width >= 600px) { + .perfect-fit-results-list { + grid-template-columns: repeat(2, 1fr); + } +} + +.perfect-fit-result { + display: flex; + align-items: center; + gap: 14px; + padding: 10px; + border: 1px solid var(--conti-lightest-grey); + border-radius: 8px; + color: inherit; + text-decoration: none; + transition: border-color 0.2s ease, background-color 0.2s ease; +} + +.perfect-fit-result:hover, +.perfect-fit-result:focus-visible { + border-color: var(--conti-yellow); + background-color: var(--conti-lightest-grey); +} + +.perfect-fit-result-media { + flex: none; + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; +} + +.perfect-fit-result .perfect-fit-result-media img { + max-width: 100%; + max-height: 100%; + object-fit: contain; +} + +.perfect-fit-result-body h4 { + margin: 0; + font-family: var(--body-font-family); + font-size: 15px; + font-weight: 700; + line-height: 1.2; +} + +.perfect-fit-result-body p { + margin: 4px 0 0; + font-size: 12px; + color: var(--conti-darkest-grey); +} diff --git a/blocks/perfect-fit/perfect-fit.js b/blocks/perfect-fit/perfect-fit.js index a3def8e..22cebd7 100644 --- a/blocks/perfect-fit/perfect-fit.js +++ b/blocks/perfect-fit/perfect-fit.js @@ -1,121 +1,256 @@ +import { createOptimizedPicture } from '../../scripts/aem.js'; + /** - * "Find your perfect fit:" bar plus its tire-finder modal. The bar's three - * items open the modal on a matching tab (By Vehicle / By Tire Size / By - * Plate); each tab is a simple, non-functional sample search form. + * "Find your perfect fit:" bar plus its tire-finder modal. The three items + * open the modal on a matching tab (By Vehicle / By Tire Size / By Plate). + * Each tab searches the real product catalogue in /products.json and lists + * the matching tires. */ +const PRODUCTS_URL = '/products.json'; + const TABS = [ { id: 'vehicle', label: 'By Vehicle' }, { id: 'tire-size', label: 'By Tire Size' }, { id: 'plate', label: 'By Plate' }, ]; -function range(start, end, step = 1) { - const values = []; - for (let n = start; n <= end; n += step) values.push(n); - return values; -} +// A small curated vehicle set. Each model maps to a coarse class that lines +// up with the vehicleTypes recorded in products.json. +const VEHICLES = { + Chevrolet: { 'Silverado 1500': 'truck', Equinox: 'crossover', Malibu: 'car' }, + Ford: { 'F-150': 'truck', Explorer: 'suv', Escape: 'crossover' }, + Toyota: { RAV4: 'crossover', Camry: 'car', Tacoma: 'truck' }, + BMW: { X5: 'suv', '3 Series': 'car', X3: 'crossover' }, + Honda: { 'CR-V': 'crossover', Civic: 'car', Pilot: 'suv' }, + Tesla: { 'Model 3': 'car', 'Model Y': 'crossover' }, +}; +const STATES = ['California', 'Florida', 'Illinois', 'New York', 'Ohio', 'Texas']; +function range(start, end) { + const out = []; + for (let n = start; n <= end; n += 1) out.push(n); + return out; +} const YEARS = range(2015, 2026).reverse(); -const MAKES = ['Chevrolet', 'Ford', 'Toyota', 'BMW', 'Honda']; -const WIDTHS = range(185, 315, 10); -const ASPECT_RATIOS = range(40, 75, 5); -const RIM_DIAMETERS = range(15, 22); -const STATES = ['California', 'Florida', 'Illinois', 'New York', 'Ohio', 'Texas']; -/** - * Builds a labelled `` field. - * @returns {{ wrapper: Element, field: Element }} - */ -function createField(tag, id, labelText) { +// --- pure data helpers (exported for tests) --- + +/** Parses "225/45ZR17" into its width, aspect and rim, or null. */ +export function parseSize(str) { + const m = String(str).toUpperCase().match(/^(\d{3})\/(\d{2})Z?R(\d{2})$/); + return m ? { width: m[1], aspect: m[2], rim: m[3] } : null; +} + +/** Builds cascading width / aspect / rim option lists from every size. */ +export function sizeOptions(products) { + const widths = new Set(); + const aspectsByWidth = {}; + const rimsByWidthAspect = {}; + products.forEach((product) => (product.sizes || []).forEach((size) => { + const parsed = parseSize(size); + if (!parsed) return; + widths.add(parsed.width); + if (!aspectsByWidth[parsed.width]) aspectsByWidth[parsed.width] = new Set(); + aspectsByWidth[parsed.width].add(parsed.aspect); + const key = `${parsed.width}/${parsed.aspect}`; + if (!rimsByWidthAspect[key]) rimsByWidthAspect[key] = new Set(); + rimsByWidthAspect[key].add(parsed.rim); + })); + const num = (a, b) => Number(a) - Number(b); + const sortEntries = (obj) => Object.fromEntries( + Object.entries(obj).map(([k, v]) => [k, [...v].sort(num)]), + ); + return { + widths: [...widths].sort(num), + aspectsByWidth: sortEntries(aspectsByWidth), + rimsByWidthAspect: sortEntries(rimsByWidthAspect), + }; +} + +/** All products available in the exact width / aspect / rim size. */ +export function findBySize(products, { width, aspect, rim }) { + return products.filter((product) => (product.sizes || []).some((size) => { + const parsed = parseSize(size); + return parsed && parsed.width === width && parsed.aspect === aspect && parsed.rim === rim; + })); +} + +/** All products whose vehicleTypes match a coarse vehicle class keyword. */ +export function findByVehicleClass(products, vehicleClass) { + if (!vehicleClass) return []; + return products.filter((product) => (product.vehicleTypes || []).some( + (type) => type.toLowerCase().includes(vehicleClass), + )); +} + +// --- DOM helpers --- + +function createField(tag, name, labelText) { const wrapper = document.createElement('div'); wrapper.className = 'perfect-fit-field'; + const id = `perfect-fit-${name}`; const label = document.createElement('label'); label.setAttribute('for', id); label.textContent = labelText; const field = document.createElement(tag); field.id = id; - field.name = id; + field.name = name; wrapper.append(label, field); return { wrapper, field }; } -/** Fills a `