// NSBH CMS Map — open-source Jetboost equivalent.
// Loaded from the Webflow page footer as a classic script:
//
//
// Expects on the page (data-map-element hooks, set in Webflow Designer):
// [data-map-element="viewport"] The map container div
// [data-map-element="item"] Collection List items (inside the list)
// data-id, data-lat, data-lng bound to CMS Slug / Latitude / Longitude fields
// [data-map-element="search-input"] Address / zip input (optional)
// [data-map-element="search-radius"] of miles (optional; values 5/10/25/50/100)
// [data-map-element="search-submit"] Submit button (optional)
// [data-map-element="search-clear"] Clear button (optional)
// [data-map-element="search-status"] Text container for result summary (optional)
//
// Design decisions:
// - Popups reuse the corresponding list item's own HTML (no duplicate templating).
// - Two-way sync: list hover/click <-> marker state, via a shared .is-active class.
// - Zero build step: dependencies are loaded dynamically from esm.sh.
let maplibregl;
let distance;
const MAP_STYLE = 'https://tiles.openfreemap.org/styles/liberty';
const DEFAULT_CENTER = [-70.95, 42.55];
const DEFAULT_ZOOM = 9;
const FIT_PADDING = 48;
const NOMINATIM_URL = 'https://nominatim.openstreetmap.org/search';
const NOMINATIM_UA = 'nsbh-map/1.0 (webflow-embed)';
const NOMINATIM_VIEWBOX = '-73.5,44.5,-68.5,41.0'; // New England bias — covers 100-mile radius from North Shore MA
async function loadDependencies() {
if (maplibregl && distance) return;
const [{ default: maplibreDefault }, { default: distanceDefault }] = await Promise.all([
import('https://esm.sh/maplibre-gl@4.7.1'),
import('https://esm.sh/@turf/distance@7.1.0'),
]);
maplibregl = maplibreDefault;
distance = distanceDefault;
}
function boot() {
const mapEl = document.querySelector('[data-map-element="viewport"]');
if (!mapEl) return;
const items = collectItems();
if (!items.length) {
console.warn('[cms-map] No [data-map-element="item"] nodes with data-lat/data-lng found.');
}
const map = new maplibregl.Map({
container: mapEl,
style: MAP_STYLE,
center: DEFAULT_CENTER,
zoom: DEFAULT_ZOOM,
attributionControl: { compact: true },
});
map.addControl(new maplibregl.NavigationControl({ showCompass: false }), 'top-right');
map.on('movestart', () => mapEl.classList.add('is-moving'));
map.on('moveend', () => mapEl.classList.remove('is-moving'));
map.on('load', () => {
const markers = addMarkers(map, items);
wireListSync(items, markers, map);
wireGeoSearch(items, markers, map);
if (items.length) fitToMarkers(map, items);
});
}
function collectItems() {
const nodes = document.querySelectorAll('[data-map-element="item"]');
const items = [];
const seen = new Set();
for (const el of nodes) {
const lat = parseFloat(el.dataset.lat);
const lng = parseFloat(el.dataset.lng);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) continue;
const id = el.dataset.id || `item-${items.length}`;
if (seen.has(id)) continue;
seen.add(id);
items.push({ id, lat, lng, el });
}
return items;
}
function fitToMarkers(map, items) {
if (items.length === 1) {
map.easeTo({ center: [items[0].lng, items[0].lat], zoom: 12, duration: 0 });
return;
}
const bounds = new maplibregl.LngLatBounds();
for (const it of items) bounds.extend([it.lng, it.lat]);
map.fitBounds(bounds, { padding: FIT_PADDING, duration: 0, maxZoom: 13 });
}
function addMarkers(map, items) {
const byId = new Map();
for (const it of items) {
const markerEl = document.createElement('button');
markerEl.type = 'button';
markerEl.className = 'cms-map-marker';
markerEl.setAttribute('aria-label', it.el.textContent.trim().split('\n')[0] || 'Location');
const popup = new maplibregl.Popup({ offset: 18, closeButton: true, maxWidth: '320px' })
.setHTML(buildPopupHtml(it.el));
const marker = new maplibregl.Marker({ element: markerEl, anchor: 'bottom' })
.setLngLat([it.lng, it.lat])
.setPopup(popup)
.addTo(map);
byId.set(it.id, { marker, markerEl, popup, item: it });
}
for (const entry of byId.values()) {
entry.markerEl.addEventListener('click', (e) => {
e.stopPropagation();
setActive({ items, markers: byId, activeId: entry.item.id, scrollList: true });
openExclusive(byId, entry.marker);
});
}
return byId;
}
function openExclusive(markers, targetMarker) {
const isAlreadyOpen = targetMarker.getPopup().isOpen();
for (const { marker } of markers.values()) {
if (marker.getPopup().isOpen()) marker.getPopup().remove();
}
if (!isAlreadyOpen) targetMarker.togglePopup();
}
function buildPopupHtml(listItemEl) {
const clone = listItemEl.cloneNode(true);
clone.classList.add('cms-map-popup-card');
// Strip webflow-only wrapper artifacts so the card reads cleanly in the popup.
clone.removeAttribute('role');
clone.removeAttribute('data-id');
return clone.outerHTML;
}
function setActive({ items, markers, activeId, scrollList }) {
for (const { markerEl, item } of markers.values()) {
const on = item.id === activeId;
markerEl.classList.toggle('is-active', on);
item.el.classList.toggle('is-active', on);
}
if (scrollList && activeId) {
const entry = markers.get(activeId);
if (entry) entry.item.el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function wireListSync(items, markers, map) {
for (const { markerEl, item, marker } of markers.values()) {
const onEnter = () => setActive({ items, markers, activeId: item.id, scrollList: false });
const onLeave = () => setActive({ items, markers, activeId: null, scrollList: false });
item.el.addEventListener('mouseenter', onEnter);
item.el.addEventListener('mouseleave', onLeave);
item.el.addEventListener('focusin', onEnter);
item.el.addEventListener('click', (e) => {
if (e.target.closest('a, button')) return; // let real links/buttons through
setActive({ items, markers, activeId: item.id, scrollList: false });
map.flyTo({ center: marker.getLngLat(), zoom: Math.max(map.getZoom(), 12), speed: 1.1 });
openExclusive(markers, marker);
});
}
}
// --- geo search ----------------------------------------------------------
function wireGeoSearch(items, markers, map) {
const input = document.querySelector('[data-map-element="search-input"]');
const submit = document.querySelector('[data-map-element="search-submit"]');
const clear = document.querySelector('[data-map-element="search-clear"]');
const radius = document.querySelector('[data-map-element="search-radius"]');
const status = document.querySelector('[data-map-element="search-status"]');
if (!input && !submit) return; // no search UI on page; skip.
const runSearch = async () => {
const query = (input?.value || '').trim();
if (!query) return;
setStatus(status, 'Searching…');
try {
const hit = await geocodeQuery(query);
if (!hit) { setStatus(status, `No match for “${query}”.`); return; }
const miles = radius ? parseFloat(radius.value) : 25;
applyRadiusFilter({ items, markers, map, from: hit, miles, status, label: query });
} catch (err) {
console.error('[cms-map] geo-search failed', err);
setStatus(status, 'Search failed. Try again.');
}
};
submit?.addEventListener('click', (e) => { e.preventDefault(); runSearch(); });
input?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); runSearch(); } });
radius?.addEventListener('change', () => { if ((input?.value || '').trim()) runSearch(); });
clear?.addEventListener('click', (e) => {
e.preventDefault();
if (input) input.value = '';
for (const { marker } of markers.values()) {
if (marker.getPopup().isOpen()) marker.getPopup().remove();
}
resetFilter({ items, markers, map, status });
});
}
async function geocodeQuery(q) {
const url = `${NOMINATIM_URL}?q=${encodeURIComponent(q)}&format=json&limit=1&countrycodes=us&viewbox=${NOMINATIM_VIEWBOX}`;
const res = await fetch(url, { headers: { 'Accept': 'application/json', 'User-Agent': NOMINATIM_UA } });
if (!res.ok) throw new Error(`Nominatim HTTP ${res.status}`);
const json = await res.json();
if (!Array.isArray(json) || !json.length) return null;
return { lat: parseFloat(json[0].lat), lng: parseFloat(json[0].lon), label: json[0].display_name };
}
function applyRadiusFilter({ items, markers, map, from, miles, status, label }) {
const origin = [from.lng, from.lat];
const keep = new Set();
for (const it of items) {
const d = distance(origin, [it.lng, it.lat], { units: 'miles' });
if (d <= miles) keep.add(it.id);
}
setStatus(status, keep.size
? `${keep.size} shop${keep.size === 1 ? '' : 's'} within ${miles} mi of ${label.split(',')[0]}.`
: `No Beefs here. 😞`);
if (keep.size) {
const bounds = new maplibregl.LngLatBounds();
bounds.extend(origin);
for (const it of items) if (keep.has(it.id)) bounds.extend([it.lng, it.lat]);
map.fitBounds(bounds, { padding: FIT_PADDING, maxZoom: 13 });
} else {
map.flyTo({ center: origin, zoom: 10 });
}
}
function resetFilter({ items, markers, map, status }) {
setStatus(status, '');
fitToMarkers(map, items);
}
function setStatus(el, text) { if (el) el.textContent = text; }
// --- bootstrap -----------------------------------------------------------
async function start() {
try {
await loadDependencies();
boot();
} catch (err) {
console.error('[cms-map] failed to load dependencies', err);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', start, { once: true });
} else {
start();
}