diff --git a/widgets/locator/locator.css b/widgets/locator/locator.css index 4853a670..be7d97a7 100644 --- a/widgets/locator/locator.css +++ b/widgets/locator/locator.css @@ -134,7 +134,7 @@ main > .locator-container.section { position: relative; display: flex; flex-direction: column; - gap: var(--spacing-60); + gap: var(--spacing-100); margin-bottom: var(--spacing-200); padding: var(--spacing-200); background-color: var(--color-white); @@ -207,3 +207,126 @@ main > .locator-container.section { .locator .locator-results[aria-hidden='true'] { display: none; } + +.locator-events-layout{ + display: flex; + gap: 24px; + align-items: flex-start; + + max-width: 1000px; + margin: 0 auto; + padding: var(--spacing-300); + background: var(--color-white); +} + +.locator-events-layout .locator-tabpanels{ + max-width: none; + margin: 0; + padding: 0; + background: transparent; + flex: 1 1 auto; + min-width: 0; +} + +.locator-events-calendar{ + flex: 0 0 360px; + max-width: 360px; + background: var(--color-white); + padding: var(--spacing-200); + border-radius: 8px; +} + +.locator-cal-header{ + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.locator-cal-title{ + font-weight: 600; +} + +.locator-cal-grid{ + display: grid; + grid-template-columns: repeat(7, minmax(0, 1fr)); + gap: 6px; +} + +.locator-cal-dow{ + font-size: 12px; + opacity: 0.8; + text-align: center; + padding: 6px 0; +} + +.locator-cal-cell{ + height: 40px; + border: 1px solid rgba(0,0,0,.15); + border-radius: 6px; + background: transparent; + cursor: pointer; +} + +.locator-cal-cell.is-blank{ + border: none; + background: transparent; + cursor: default; +} + +.locator-cal-cell.has-event{ + outline: 2px solid rgba(180,0,60,.6); +} + +.locator-cal-cell.is-selected{ + outline: 2px solid rgba(180,0,60,1); +} +.locator-hh-layout, +.locator-comm-layout{ + display: flex; + gap: 24px; + align-items: flex-start; + + max-width: 1000px; + margin: 0 auto; + padding: var(--spacing-300); + background-color: var(--color-white); +} + +/* IMPORTANT: override global .locator-tabpanels styles INSIDE these layouts */ +.locator-hh-layout .locator-tabpanels, +.locator-comm-layout .locator-tabpanels{ + max-width: none; + margin: 0; + padding: 0; + background: transparent; + flex: 1 1 auto; + min-width: 0; +} + + +/* Mobile */ +@media (max-width: 900px){ + .locator-events-layout{ + flex-direction: column; + } + .locator-events-calendar{ + flex: 1 1 auto; + max-width: 100%; + width: 100%; + } + .locator-hh-layout, + .locator-comm-layout{ + flex-direction: column; + } + + .locator-hh-side, + .locator-comm-side{ + max-width: 100%; + width: 100%; + } +} + + + diff --git a/widgets/locator/locator.html b/widgets/locator/locator.html index 59c262b9..7b8230d9 100644 --- a/widgets/locator/locator.html +++ b/widgets/locator/locator.html @@ -38,10 +38,12 @@

Find Locally

-
-
- - +
+
+
+ + +
@@ -51,9 +53,11 @@

Find Locally

-
-
- +
+
+
+ +
@@ -63,11 +67,17 @@

Find Locally

-
-
- + +
+
+
+ +
+ +
+ diff --git a/widgets/locator/locator.js b/widgets/locator/locator.js index f6de3053..ac6096df 100644 --- a/widgets/locator/locator.js +++ b/widgets/locator/locator.js @@ -16,7 +16,10 @@ async function loadWidgetCopy(lang) { return data[key] || {}; } -const MAX_DISTANCE = 100; +const MAX_DISTANCE = 200; +const EVENTS_MAX_DISTANCE = 100; +const MAX_DISTANCE_COMM = 1000; +const maxDistanceHhDistributors = 1500; const hhRetailersResults = document.querySelector('#locator-hh-retailers-tabpanel'); const hhDistributorsResults = document.querySelector('#locator-hh-distributors-tabpanel'); @@ -44,9 +47,9 @@ async function fetchData(form) { 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'); + window.locatorData.HH = await fetchSheet('https://main--vitamix--aemsites.aem.live/us/en_us/where-to-buy/storelocations-hh.json'); + window.locatorData.COMM = await fetchSheet('https://main--vitamix--aemsites.aem.live/us/en_us/where-to-buy/storelocations-comm.json'); + window.locatorData.EVENTS = await fetchSheet('https://main--vitamix--aemsites.aem.live/us/en_us/where-to-buy/storelocations-events.json'); form.dataset.status = 'loaded'; return window.locatorData; } @@ -67,92 +70,290 @@ const haversineDistance = (lat1, lon1, lat2, lon2) => { }; async function geoCode(address) { - const resp = await fetch(`https://helix-geocode.adobeaem.workers.dev/?address=${encodeURIComponent(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 results = json?.results; + + if (!results || !results.length) { + return null; + } + + const result = results[0]; + const components = result?.address_components || []; + + const getComponent = (type) => components.find((c) => c.types?.includes(type)); + + let countryComponent = getComponent('country'); + const stateComponent = getComponent('administrative_area_level_1'); + + if (!countryComponent && components.length === 1) { + [countryComponent] = components; + } - 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, + // Geo Location + location: result?.geometry?.location || null, + + // Country + countryShort: countryComponent?.short_name || null, + countryLong: countryComponent?.long_name || null, + + // State + stateShort: stateComponent?.short_name || null, + stateLong: stateComponent?.long_name || 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), - ); +// Common helpers +function norm(v) { + return (v ?? '').toString().trim(); +} +function normLower(v) { + return norm(v).toLowerCase(); +} +function isEnabled(v) { + return ['true', '1', 'yes', 'y'].includes(normLower(v)); +} +function countryMatches(itemCountry, countryShort, countryLong) { + const c = normLower(itemCountry); + return c && (c === normLower(countryShort) || c === normLower(countryLong)); +} +function recordKey(item) { + return [ + item.TYPE, + item.PRODUCT_TYPE, + item.NAME, + item.ADDRESS_1, + item.CITY, + item.STATE_PROVINCE, + item.POSTAL_CODE, + item.COUNTRY, + ].map(normLower).join('|'); +} - // 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), - ); +function applyAemRules(rows, { + countryShort, countryLong, productType, allowedTypes, +}) { + const map = new Map(); + + (rows || []).forEach((r) => { + if (!isEnabled(r.ENABLED)) return; + + if (productType && normLower(r.PRODUCT_TYPE) !== normLower(productType)) return; - return { hhEvents, commEvents }; + if (allowedTypes && !allowedTypes.includes(norm(r.TYPE))) return; + + if (!countryMatches(r.COUNTRY, countryShort, countryLong)) return; + + const action = normLower(r.ACTION); + const key = recordKey(r); + + if (action === 'remove') { + map.delete(key); + return; + } + + if (action === '' || action === 'add' || action === 'update') { + map.set(key, r); + } + }); + + return Array.from(map.values()); } -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, +// EVENTS helpers +function excelSerialToDate(serial) { + const excelEpoch = new Date(1900, 0, 1); + const msPerDay = 24 * 60 * 60 * 1000; + const adjusted = serial > 59 ? serial - 1 : serial; // Excel leap-year bug + return new Date(excelEpoch.getTime() + (adjusted - 1) * msPerDay); +} + +function parseAnyDate(v) { + if (v == null || v === '') return null; + + if (!Number.isNaN(Number(v)) && String(v).trim() !== '') { + return excelSerialToDate(Number(v)); + } + + const d = new Date(v); + return Number.isNaN(d.getTime()) ? null : d; +} + +function ymdKey(d) { + const yyyy = d.getFullYear(); + const mm = String(d.getMonth() + 1).padStart(2, '0'); + const dd = String(d.getDate()).padStart(2, '0'); + return `${yyyy}-${mm}-${dd}`; +} + +function formatMDYFromKey(key) { + const [yyyy, mm, dd] = key.split('-'); + return `${mm}/${dd}/${yyyy}`; +} + +function eventKey(e) { + const s = parseAnyDate(e.START_DATE); + const startKey = s ? ymdKey(s) : ''; + return [ + normLower(e.PRODUCT_TYPE), + normLower(e.NAME), + normLower(e.ADDRESS_1), + normLower(e.CITY), + normLower(e.STATE_PROVINCE), + normLower(e.POSTAL_CODE), + normLower(e.COUNTRY), + startKey, + ].join('|'); +} + +function applyAemRulesEvents(rows, { productType }) { + const map = new Map(); + + (rows || []).forEach((r) => { + if (!isEnabled(r.ENABLED)) return; + if (productType && normLower(r.PRODUCT_TYPE) !== normLower(productType)) return; + + const action = normLower(r.ACTION); + const key = eventKey(r); + + if (action === 'remove') { + map.delete(key); + return; + } + if (action === '' || action === 'add' || action === 'update') { + map.set(key, r); + } + }); + + return Array.from(map.values()); +} + +function filterFutureEvents(rows) { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + return (rows || []).filter((e) => { + const start = parseAnyDate(e.START_DATE); + const end = parseAnyDate(e.END_DATE); + const compare = (end || start); + if (!compare) return false; + + compare.setHours(0, 0, 0, 0); + return compare >= today; + }); +} + +function groupByStartDate(rows) { + const groups = new Map(); + + (rows || []).forEach((e) => { + const start = parseAnyDate(e.START_DATE); + if (!start) return; + start.setHours(0, 0, 0, 0); + + const key = ymdKey(start); + if (!groups.has(key)) groups.set(key, []); + groups.get(key).push(e); + }); + + return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0])); +} + +function findEventsResults(data, location) { + const hhClean = applyAemRulesEvents(data, { productType: 'HH' }); + const commClean = applyAemRulesEvents(data, { productType: 'COMM' }); + + const hhFuture = filterFutureEvents(hhClean); + const commFuture = filterFutureEvents(commClean); + + const hhNearby = (hhFuture || []).filter( + (e) => haversineDistance(location.lat, location.lng, e.lat, e.lng) <= EVENTS_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), + const commNearby = (commFuture || []).filter( + (e) => haversineDistance(location.lat, location.lng, e.lat, e.lng) <= EVENTS_MAX_DISTANCE, ); - // Local Representatives (country-based) - const wantCountry = (countryShort || '').toUpperCase(); - const wantRegion = (regionShort || '').toUpperCase(); + const sortEvents = (arr) => { + arr.sort((a, b) => { + const ad = parseAnyDate(a.START_DATE)?.getTime() ?? 0; + const bd = parseAnyDate(b.START_DATE)?.getTime() ?? 0; + if (ad !== bd) return ad - bd; + return haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng); + }); + }; + sortEvents(hhNearby); + sortEvents(commNearby); + + return { + hhGrouped: groupByStartDate(hhNearby), + commGrouped: groupByStartDate(commNearby), + }; +} - // 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; +function findCommResults( + data, + location, + countryShort, + countryLong, + stateShort, + stateLong, +) { + const allowedTypes = ['DEALER/DISTRIBUTOR', 'LOCAL REP']; + + const cleaned = applyAemRules(data, { + countryShort, + countryLong, + stateShort, + stateLong, + productType: 'COMM', + allowedTypes, }); + const distributors = cleaned + .filter((i) => i.TYPE === 'DEALER/DISTRIBUTOR' + && haversineDistance(location.lat, location.lng, i.lat, i.lng) <= MAX_DISTANCE_COMM) + .sort((a, b) => haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng)); + + const localRep = cleaned + .filter((i) => i.TYPE === 'LOCAL REP' + && (i.STATE_PROVINCE === stateShort || i.STATE_NAME === stateLong)); + 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), - ); +function findHHResults(data, location, countryShort, countryLong) { + const allowedTypes = ['ONLINE', 'RETAILERS', 'DEALER/DISTRIBUTOR']; - // 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), - ); + const cleaned = applyAemRules(data, { + countryShort, + countryLong, + productType: 'HH', + allowedTypes, + }); - // Online - const online = data.filter((item) => item.TYPE === 'ONLINE' && item.COUNTRY === country); + const retailers = cleaned + .filter((i) => i.TYPE === 'RETAILERS' + && haversineDistance(location.lat, location.lng, i.lat, i.lng) <= MAX_DISTANCE) + .sort((a, b) => haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng)); + + const distributors = cleaned + .filter((i) => i.TYPE === 'DEALER/DISTRIBUTOR' + && haversineDistance(location.lat, location.lng, i.lat, i.lng) + <= maxDistanceHhDistributors) + .sort((a, b) => haversineDistance(location.lat, location.lng, a.lat, a.lng) + - haversineDistance(location.lat, location.lng, b.lat, b.lng)); + + const online = cleaned + .filter((i) => i.TYPE === 'ONLINE') + .sort((a, b) => normLower(a.NAME).localeCompare(normLower(b.NAME))); return { retailers, distributors, online }; } @@ -166,28 +367,40 @@ function displayCommResults(results, location, labels = {}) { 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); + if (result.ADDRESS_1) { + const addressWrapper = document.createElement('span'); + addressWrapper.classList.add('locator-address'); - 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); + const addressLabel = document.createElement('strong'); + addressLabel.textContent = 'Address: '; + addressWrapper.append(addressLabel); + + const addressLink = document.createElement('a'); + const addressQuery = `${result.ADDRESS_1}, ${result.CITY}, ${result.STATE_PROVINCE} ${result.POSTAL_CODE}`; + addressLink.href = `https://maps.google.com/?q=${encodeURIComponent(addressQuery)}`; + addressLink.target = '_blank'; + addressLink.rel = 'noopener noreferrer'; + addressLink.textContent = addressQuery; + + addressWrapper.append(addressLink); + li.append(addressWrapper); + } // Phone number if (result.PHONE_NUMBER) { const phoneWrapper = document.createElement('span'); phoneWrapper.classList.add('locator-phone'); + + const phoneLabel = document.createElement('strong'); + phoneLabel.textContent = 'Phone: '; + phoneWrapper.append(phoneLabel); + const phoneLink = document.createElement('a'); phoneLink.href = `tel:${result.PHONE_NUMBER}`; phoneLink.textContent = result.PHONE_NUMBER; + phoneLink.target = '_blank'; + phoneLink.rel = 'noopener noreferrer'; + phoneWrapper.append(phoneLink); li.append(phoneWrapper); } @@ -195,17 +408,41 @@ function displayCommResults(results, location, labels = {}) { if (result.WEB_ADDRESS) { const webWrapper = document.createElement('span'); webWrapper.classList.add('locator-web'); - const webLink = document.createElement('a'); + const webLabel = document.createElement('strong'); + webLabel.textContent = '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.rel = 'noopener noreferrer'; webLink.textContent = result.WEB_ADDRESS_LINK_TEXT || result.WEB_ADDRESS; + webWrapper.append(webLink); li.append(webWrapper); + } + // Email + if (result.EMAIL) { + const emailWrapper = document.createElement('span'); + emailWrapper.classList.add('locator-email'); + + const emailLabel = document.createElement('strong'); + emailLabel.textContent = 'Email: '; + emailWrapper.append(emailLabel); + + const emailLink = document.createElement('a'); + emailLink.href = `mailto:${result.EMAIL}`; + emailLink.textContent = result.EMAIL; + emailLink.target = '_blank'; + emailLink.rel = 'noopener noreferrer'; + + emailWrapper.append(emailLink); + li.append(emailWrapper); } return li; }; @@ -215,13 +452,7 @@ function displayCommResults(results, location, labels = {}) { 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'); @@ -286,203 +517,338 @@ function displayCommResults(results, location, labels = {}) { } } -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' }); - }; +function displayEventsResults(results, location) { + const { hhGrouped, commGrouped } = results; - const createHHEventResult = (result) => { - const li = document.createElement('li'); - const title = document.createElement('h3'); - title.textContent = result.NAME; - li.append(title); + const renderGroupedList = (container, grouped) => { + container.textContent = ''; - const date = document.createElement('span'); - date.textContent = `${formatDate(result.START_DATE)} - ${formatDate(result.END_DATE)}`; - date.classList.add('locator-date'); - li.append(date); + if (!grouped || grouped.length === 0) { + container.innerHTML = '

No events found

'; + return; + } - 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); + grouped.forEach(([dateKey, items]) => { + const heading = document.createElement('h4'); + heading.classList.add('locator-events-dateheading'); + heading.textContent = formatMDYFromKey(dateKey); + container.append(heading); + + const ol = document.createElement('ol'); + ol.classList.add('locator-events-list'); + + items.forEach((e) => { + const li = document.createElement('li'); + li.classList.add('locator-event-card'); + + const title = document.createElement('h3'); + title.textContent = e.NAME; + li.append(title); + + const start = parseAnyDate(e.START_DATE); + const end = parseAnyDate(e.END_DATE); + if (start) { + const dateLine = document.createElement('span'); + dateLine.classList.add('locator-date'); + const startTxt = start.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }); + const endTxt = end + ? end.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' }) + : ''; + dateLine.textContent = endTxt ? `${startTxt} - ${endTxt}` : startTxt; + li.append(dateLine); + } + + const dist = document.createElement('span'); + dist.classList.add('locator-distance'); + dist.textContent = `${haversineDistance(location.lat, location.lng, e.lat, e.lng).toFixed(1)} miles away`; + li.append(dist); + + if (e.ADDRESS_1) { + const addressWrapper = document.createElement('span'); + addressWrapper.classList.add('locator-address'); + + const addressLabel = document.createElement('strong'); + addressLabel.textContent = 'Address: '; + addressWrapper.append(addressLabel); + + const addressLink = document.createElement('a'); + const addressQuery = `${e.ADDRESS_1}, ${e.CITY}, ${e.STATE_PROVINCE} ${e.POSTAL_CODE}`.replace(/\s+/g, ' ').trim(); + addressLink.href = `https://maps.google.com/?q=${encodeURIComponent(addressQuery)}`; + addressLink.target = '_blank'; + addressLink.rel = 'noopener noreferrer'; + addressLink.textContent = addressQuery; + + addressWrapper.append(addressLink); + li.append(addressWrapper); + } + + ol.append(li); + }); + + container.append(ol); + }); + }; - 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); + // render both tab panels + renderGroupedList(eventsHHResults, hhGrouped); + renderGroupedList(eventsCommResults, commGrouped); - return li; - }; + // calendar (optional) + const calendarEl = document.querySelector('#locator-events-calendar'); + if (!calendarEl) return; - const createCommEventResult = (result) => { - const li = document.createElement('li'); - const title = document.createElement('h3'); - title.textContent = result.NAME; - li.append(title); + const eventDates = new Set(); + const addDatesFromGrouped = (grouped) => (grouped || []).forEach(([k]) => eventDates.add(k)); + addDatesFromGrouped(hhGrouped); + addDatesFromGrouped(commGrouped); - const date = document.createElement('span'); - date.textContent = `${formatDate(result.START_DATE)} - ${formatDate(result.END_DATE)}`; - date.classList.add('locator-date'); - li.append(date); + const today = new Date(); + today.setHours(0, 0, 0, 0); - 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); + let viewYear = today.getFullYear(); + let viewMonth = today.getMonth(); + let activeDateKey = null; - 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); + const renderCalendar = () => { + calendarEl.textContent = ''; - return li; - }; + const header = document.createElement('div'); + header.classList.add('locator-cal-header'); - if (hhEvents && hhEvents.length > 0) { - const hhEventList = document.createElement('ol'); - hhEvents.forEach((event) => { - hhEventList.appendChild(createHHEventResult(event)); + const prevBtn = document.createElement('button'); + prevBtn.type = 'button'; + prevBtn.textContent = '‹'; + prevBtn.addEventListener('click', () => { + viewMonth -= 1; + if (viewMonth < 0) { viewMonth = 11; viewYear -= 1; } + renderCalendar(); }); - 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)); + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.textContent = '›'; + nextBtn.addEventListener('click', () => { + viewMonth += 1; + if (viewMonth > 11) { viewMonth = 0; viewYear += 1; } + renderCalendar(); }); - eventsCommResults.textContent = ''; - eventsCommResults.appendChild(commEventList); - } else { - eventsCommResults.innerHTML = `

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

`; - } + + const monthTitle = document.createElement('div'); + monthTitle.classList.add('locator-cal-title'); + monthTitle.textContent = new Date(viewYear, viewMonth, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }); + + header.append(prevBtn, monthTitle, nextBtn); + calendarEl.append(header); + + const grid = document.createElement('div'); + grid.classList.add('locator-cal-grid'); + + ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].forEach((d) => { + const cell = document.createElement('div'); + cell.classList.add('locator-cal-dow'); + cell.textContent = d; + grid.append(cell); + }); + + const firstDay = new Date(viewYear, viewMonth, 1); + const startWeekday = firstDay.getDay(); + const daysInMonth = new Date(viewYear, viewMonth + 1, 0).getDate(); + + for (let i = 0; i < startWeekday; i += 1) { + const blank = document.createElement('div'); + blank.classList.add('locator-cal-cell', 'is-blank'); + grid.append(blank); + } + + const createCellClickHandler = (cellKey) => () => { + activeDateKey = activeDateKey === cellKey ? null : cellKey; + + if (activeDateKey) { + const hhFiltered = (hhGrouped || []).filter(([k]) => k === activeDateKey); + const commFiltered = (commGrouped || []).filter(([k]) => k === activeDateKey); + + renderGroupedList(eventsHHResults, hhFiltered); + renderGroupedList(eventsCommResults, commFiltered); + } else { + renderGroupedList(eventsHHResults, hhGrouped); + renderGroupedList(eventsCommResults, commGrouped); + } + + renderCalendar(); + }; + + for (let day = 1; day <= daysInMonth; day += 1) { + const d = new Date(viewYear, viewMonth, day); + d.setHours(0, 0, 0, 0); + const key = ymdKey(d); + + const cell = document.createElement('button'); + cell.type = 'button'; + cell.classList.add('locator-cal-cell'); + cell.textContent = String(day); + + if (eventDates.has(key)) cell.classList.add('has-event'); + if (activeDateKey === key) cell.classList.add('is-active'); + + cell.addEventListener('click', createCellClickHandler(key)); + + grid.append(cell); + } + + calendarEl.append(grid); + }; + + renderCalendar(); } function displayHHResults(results, location, labels = {}) { const { retailers, distributors, online } = results; + const cleanTel = (v) => (v || '').toString().replace(/[^\d+]/g, ''); + + const appendAddress = (li, result) => { + if (!result.ADDRESS_1) return; + + const addressWrapper = document.createElement('span'); + addressWrapper.classList.add('locator-address'); + + const addressLabel = document.createElement('strong'); + addressLabel.textContent = 'Address: '; + addressWrapper.append(addressLabel); + + const addressLink = document.createElement('a'); + const addressQuery = `${result.ADDRESS_1}, ${result.CITY}, ${result.STATE_PROVINCE} ${result.POSTAL_CODE}`.replace(/\s+/g, ' ').trim(); + addressLink.href = `https://maps.google.com/?q=${encodeURIComponent(addressQuery)}`; + addressLink.target = '_blank'; + addressLink.rel = 'noopener noreferrer'; + addressLink.textContent = addressQuery; + + addressWrapper.append(addressLink); + li.append(addressWrapper); + }; + + const appendWebsite = (li, result) => { + if (!result.WEB_ADDRESS) return; + + const webWrapper = document.createElement('span'); + webWrapper.classList.add('locator-web'); + + const webLabel = document.createElement('strong'); + webLabel.textContent = '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.rel = 'noopener noreferrer'; + webLink.textContent = result.WEB_ADDRESS_LINK_TEXT || result.WEB_ADDRESS; + + webWrapper.append(webLink); + li.append(webWrapper); + }; + + const appendPhone = (li, result) => { + if (!result.PHONE_NUMBER) return; + + const phoneWrapper = document.createElement('span'); + phoneWrapper.classList.add('locator-phone'); + + const phoneLabel = document.createElement('strong'); + phoneLabel.textContent = 'Phone: '; + phoneWrapper.append(phoneLabel); + + const phoneLink = document.createElement('a'); + phoneLink.href = `tel:${cleanTel(result.PHONE_NUMBER)}`; + phoneLink.textContent = result.PHONE_NUMBER; + phoneLink.target = '_blank'; + phoneLink.rel = 'noopener noreferrer'; + + phoneWrapper.append(phoneLink); + li.append(phoneWrapper); + }; + + const appendEmail = (li, result) => { + if (!result.EMAIL) return; + + const emailWrapper = document.createElement('span'); + emailWrapper.classList.add('locator-email'); + + const emailLabel = document.createElement('strong'); + emailLabel.textContent = 'Email: '; + emailWrapper.append(emailLabel); + + const emailLink = document.createElement('a'); + emailLink.href = `mailto:${result.EMAIL}`; + emailLink.textContent = result.EMAIL; + emailLink.target = '_blank'; + emailLink.rel = 'noopener noreferrer'; + + emailWrapper.append(emailLink); + li.append(emailWrapper); + }; + + const appendDistance = (li, result) => { + if (!location?.lat || !location?.lng || result.lat == null || result.lng == null) return; + + 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 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); - } + + appendAddress(li, result); + appendWebsite(li, result); + appendPhone(li, result); + appendEmail(li, result); + 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); + appendDistance(li, result); + appendAddress(li, result); + appendWebsite(li, result); + appendPhone(li, result); + appendEmail(li, result); 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); - } + appendDistance(li, result); + appendAddress(li, result); + appendWebsite(li, result); + appendPhone(li, result); + appendEmail(li, result); - 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; }; + // Retailers if (retailers && retailers.length > 0) { const retailerList = document.createElement('ol'); retailers.forEach((retailer) => { @@ -494,6 +860,7 @@ function displayHHResults(results, location, labels = {}) { hhRetailersResults.innerHTML = `

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

`; } + // Distributors if (distributors && distributors.length > 0) { const distributorList = document.createElement('ol'); distributors.forEach((distributor) => { @@ -505,6 +872,7 @@ function displayHHResults(results, location, labels = {}) { hhDistributorsResults.innerHTML = `

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

`; } + // Online if (online && online.length > 0) { const onlineList = document.createElement('ol'); online.forEach((item) => { @@ -591,12 +959,18 @@ export default async function decorate(widget) { e.preventDefault(); const formData = new FormData(form); const data = Object.fromEntries(formData); - const { location, country, region } = await geoCode(data.address); + const { + location, + countryShort, + countryLong, + stateShort, + stateLong, + } = await geoCode(data.address); if (data.productType === 'HH') { if (location) { - const results = findHHResults(window.locatorData.HH, location, country?.short); - displayHHResults(results, location, labels); + const results = findHHResults(window.locatorData.HH, location, countryShort, countryLong); + displayHHResults(results, location); } else { displayHHResults({}, null, labels); } @@ -608,8 +982,10 @@ export default async function decorate(widget) { const results = findCommResults( window.locatorData.COMM, location, - country?.short, - region?.short, + countryShort, + countryLong, + stateShort, + stateLong, ); displayCommResults(results, location, labels); } else { @@ -623,7 +999,7 @@ export default async function decorate(widget) { const results = findEventsResults(window.locatorData.EVENTS, location); displayEventsResults(results, location, labels); } else { - displayEventsResults({}, null, labels); + displayEventsResults({ hhGrouped: [], commGrouped: [] }, location); } showType('events'); }