generated from boboko/starter
Feat: Box now tests
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -141,12 +141,34 @@ export default class extends Controller {
|
||||
}
|
||||
|
||||
async selectShipping(event) {
|
||||
this.toggleBoxNowLocker(event.target.value)
|
||||
|
||||
// Tracked so flush() can await it — nothing else stops "place order"
|
||||
// (a separate, unrelated click) from racing ahead of this request.
|
||||
this.shippingPromise = this.doSelectShipping(event.target.value)
|
||||
await this.shippingPromise
|
||||
}
|
||||
|
||||
// The dummy Box Now locker <select> (see shipping-options.blade.php)
|
||||
// lives inside the #bbk-shipping-options fragment this controller
|
||||
// re-renders wholesale on every shipping-option change — so its own
|
||||
// Stimulus controller reconnects fresh each time and has no memory of
|
||||
// which option was previously selected. This is the one place that
|
||||
// knows the newly-chosen option's identifier, so it also owns
|
||||
// showing/hiding the picker.
|
||||
toggleBoxNowLocker(identifier) {
|
||||
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
|
||||
if (!picker) return
|
||||
|
||||
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
|
||||
|
||||
if (identifier === 'box-now') {
|
||||
controller?.show()
|
||||
} else {
|
||||
controller?.hide()
|
||||
}
|
||||
}
|
||||
|
||||
async doSelectShipping(value) {
|
||||
this.saveController?.abort()
|
||||
this.setStatus('saving')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
||||
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
|
||||
import BbkCartController from './bbk-cart-controller'
|
||||
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
||||
import BbkPaymentController from './bbk-payment-controller'
|
||||
@@ -13,6 +14,7 @@ import BbkPaymentController from './bbk-payment-controller'
|
||||
// only that one import line in the host entry point differs per project.
|
||||
export function registerCheckout(application) {
|
||||
application.register('bbk-add-to-cart', BbkAddToCartController)
|
||||
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
|
||||
application.register('bbk-cart', BbkCartController)
|
||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
||||
application.register('bbk-payment', BbkPaymentController)
|
||||
|
||||
Reference in New Issue
Block a user