import { Controller } from '@hotwired/stimulus' import L from 'leaflet' import 'leaflet/dist/leaflet.css' import { csrfToken } from './csrf' // Box Now's own brand green, used for the pin instead of Leaflet's default // blue teardrop — a small SVG data URI rather than another bundled asset. const PIN_COLOR = '#00c389' const PIN_COLOR_SELECTED = '#0a7a52' function pinIcon(color) { const svg = ` ` return L.divIcon({ className: 'bbk-box-now-pin', html: svg, iconSize: [34, 46], iconAnchor: [17, 46], popupAnchor: [0, -40], }) } const ICON = pinIcon(PIN_COLOR) const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED) // A self-hosted Leaflet map standing in for Box Now's own Destination Map // JS widget — that widget only talks to Box Now's Production API (see // their Partner API manual §4.1), so it can't be used while developing // against Stage credentials. Same underlying /destinations data, rendered // with OpenStreetMap tiles instead of Box Now's map. // // Visibility is toggled by bbk-checkout-form (see its own // toggleBoxNowLocker()) whenever the "box-now" shipping option becomes // selected/deselected — this controller only owns loading the locker list // once visible, rendering pins, and autosaving the chosen one. export default class extends Controller { static targets = ['map', 'search', 'status', 'chosen'] static values = { lockersUrl: String, selectUrl: String, loading: String, selectLabel: String, selectedLabel: String, noResults: String, } // Athens — a reasonable default center before any locker is loaded. static DEFAULT_CENTER = [37.9838, 23.7275] connect() { this.map = null this.markers = new Map() this.selectedId = null this.loaded = false if (!this.element.hidden) this.show() } disconnect() { this.map?.remove() this.map = null } // Called by bbk-checkout-form right after it un-hides this element. show() { this.element.hidden = false // Leaflet measures its container's size on init — doing that while // the element (or an ancestor) is still `hidden` produces a // collapsed/blank map, so this is deferred to the same tick `hidden` // is cleared, then Leaflet is nudged once more via invalidateSize(). requestAnimationFrame(() => { if (!this.map) this.initMap() this.map.invalidateSize() if (!this.loaded) this.loadLockers() }) } hide() { this.element.hidden = true } initMap() { this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10) L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 19, }).addTo(this.map) // Delegated: popup content is re-inserted by Leaflet on every open, // so a listener bound once on the map's container beats binding (and // losing) one on the button each time a popup renders. this.map.getContainer().addEventListener('click', (event) => { const button = event.target.closest('[data-locker-id]') if (button) this.select(button.dataset.lockerId) }) } async loadLockers() { this.setStatus(this.loadingValue) try { const response = await fetch(this.lockersUrlValue, { headers: { Accept: 'application/json' }, }) if (!response.ok) return const { lockers } = await response.json() this.loaded = true this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker])) this.renderMarkers(lockers) this.setStatus('') } catch { this.setStatus('') } } renderMarkers(lockers) { this.markers.forEach((marker) => marker.remove()) this.markers = new Map(lockers.map((locker) => { const marker = L.marker([locker.lat, locker.lng], { icon: ICON }) .addTo(this.map) .bindPopup(this.popupHtml(locker), { maxWidth: 260 }) return [String(locker.id), marker] })) if (this.markers.size) { this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2)) } } popupHtml(locker) { const isSelected = String(locker.id) === this.selectedId return `
${locker.name}
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')} ${locker.postalCode ? ` ${locker.postalCode}` : ''}
${locker.note ? `${locker.note}
` : ''}