generated from boboko/starter
221 lines
7.5 KiB
JavaScript
221 lines
7.5 KiB
JavaScript
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 = `
|
||
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
|
||
|
|
<path
|
||
|
|
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
|
||
|
|
fill="${color}"
|
||
|
|
stroke="#ffffff"
|
||
|
|
stroke-width="1.5"
|
||
|
|
/>
|
||
|
|
<circle cx="17" cy="17" r="7" fill="#ffffff" />
|
||
|
|
</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 `
|
||
|
|
<div class="bbk-box-now-popup">
|
||
|
|
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
|
||
|
|
<p class="bbk-box-now-popup-name">${locker.name}</p>
|
||
|
|
<p class="bbk-box-now-popup-address">
|
||
|
|
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
|
||
|
|
${locker.postalCode ? ` ${locker.postalCode}` : ''}
|
||
|
|
</p>
|
||
|
|
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
|
||
|
|
data-locker-id="${locker.id}"
|
||
|
|
${isSelected ? 'disabled' : ''}
|
||
|
|
>
|
||
|
|
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
|
||
|
|
</button>
|
||
|
|
</div>
|
||
|
|
`
|
||
|
|
}
|
||
|
|
|
||
|
|
async select(lockerId) {
|
||
|
|
const locker = this.lockers?.get(String(lockerId))
|
||
|
|
if (!locker) return
|
||
|
|
|
||
|
|
const previousId = this.selectedId
|
||
|
|
this.selectedId = String(lockerId)
|
||
|
|
|
||
|
|
this.restyleMarker(previousId, ICON)
|
||
|
|
this.restyleMarker(this.selectedId, ICON_SELECTED)
|
||
|
|
this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker))
|
||
|
|
|
||
|
|
this.chosenTarget.hidden = false
|
||
|
|
this.chosenTarget.textContent = locker.addressLine1
|
||
|
|
? `${locker.name} — ${locker.addressLine1}`
|
||
|
|
: locker.name
|
||
|
|
|
||
|
|
const body = new FormData()
|
||
|
|
body.append('locker_id', locker.id)
|
||
|
|
body.append('locker_name', locker.name ?? '')
|
||
|
|
body.append('locker_address', locker.addressLine1 ?? '')
|
||
|
|
|
||
|
|
try {
|
||
|
|
await fetch(this.selectUrlValue, {
|
||
|
|
method: 'POST',
|
||
|
|
headers: {
|
||
|
|
'X-CSRF-TOKEN': csrfToken(),
|
||
|
|
'X-Requested-With': 'XMLHttpRequest',
|
||
|
|
Accept: 'application/json',
|
||
|
|
},
|
||
|
|
body,
|
||
|
|
})
|
||
|
|
} catch {
|
||
|
|
// Best-effort autosave, same as the rest of checkout — a failed
|
||
|
|
// save here surfaces later at place-order time via the normal
|
||
|
|
// shipment-creation error path, not as an inline field error.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
restyleMarker(lockerId, icon) {
|
||
|
|
if (!lockerId) return
|
||
|
|
this.markers.get(lockerId)?.setIcon(icon)
|
||
|
|
}
|
||
|
|
|
||
|
|
setStatus(text) {
|
||
|
|
if (!this.hasStatusTarget) return
|
||
|
|
|
||
|
|
this.statusTarget.textContent = text
|
||
|
|
this.statusTarget.hidden = !text
|
||
|
|
}
|
||
|
|
}
|