generated from boboko/starter
Feat: Box now tests
This commit is contained in:
@@ -617,6 +617,141 @@ .bbk-checkout-status {
|
||||
|
||||
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
||||
|
||||
/* Dummy Box Now locker picker — see shipping-options.blade.php. */
|
||||
.bbk-checkout-box-now-locker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-label {
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-map {
|
||||
width: 100%;
|
||||
height: 480px;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-search {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid var(--bbk-color-border);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-search:focus {
|
||||
outline: none;
|
||||
border-color: var(--bbk-color-accent);
|
||||
}
|
||||
|
||||
.bbk-checkout-box-now-locker-chosen {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--bbk-color-accent);
|
||||
}
|
||||
|
||||
/* Custom SVG pin (see bbk-box-now-locker-controller.js pinIcon()) — no
|
||||
default Leaflet drop-shadow image, so a CSS shadow stands in for it. */
|
||||
.bbk-box-now-pin svg {
|
||||
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
|
||||
}
|
||||
|
||||
/* Leaflet's own popup chrome, restyled to match the checkout's cards
|
||||
instead of the library's square-cornered default. */
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 0.875rem;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-image {
|
||||
width: calc(100% + 1.75rem);
|
||||
margin: -0.875rem -0.875rem 0.125rem;
|
||||
height: 110px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
margin: 0;
|
||||
font-weight: 700;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-name::before {
|
||||
content: '';
|
||||
flex: 0 0 auto;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 999px;
|
||||
background: #00c389;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-address {
|
||||
margin: 0;
|
||||
padding-left: 0.875rem;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-note {
|
||||
margin: 0 0 0 0.875rem;
|
||||
padding: 0.5rem 0.625rem;
|
||||
background: color-mix(in srgb, #00c389 8%, transparent);
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
font-size: 0.75rem;
|
||||
font-style: italic;
|
||||
color: var(--bbk-color-muted);
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-select {
|
||||
margin-top: 0.25rem;
|
||||
padding: 0.625rem 0.875rem;
|
||||
width: 100%;
|
||||
border: none;
|
||||
border-radius: var(--bbk-radius-sm);
|
||||
background: #00c389;
|
||||
color: #ffffff;
|
||||
font-weight: 700;
|
||||
font-size: 0.8125rem;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||
}
|
||||
|
||||
.bbk-box-now-popup-select:hover { background: #00a876; transform: translateY(-1px); }
|
||||
.bbk-box-now-popup-select:active { transform: translateY(0); }
|
||||
|
||||
.bbk-box-now-popup-select--selected,
|
||||
.bbk-box-now-popup-select--selected:hover {
|
||||
background: var(--bbk-color-muted);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
||||
|
||||
.bbk-checkout-continue {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -48,3 +48,59 @@
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
{{--
|
||||
Dummy Box Now locker picker — a Leaflet map standing in for Box Now's own
|
||||
Destination Map JS widget, which only talks to their Production API (not
|
||||
Stage/sandbox — see their Partner API manual §4.1), making it useless
|
||||
for local/staging development. Backed by the same GET /destinations data
|
||||
(including lat/lng) via CheckoutController::boxNowLockers(). Only shown
|
||||
once the "box-now" shipping option is selected (bbk-checkout-form
|
||||
toggles [hidden] on shipping-option change; see bbk-box-now-locker
|
||||
Stimulus controller). Persists the choice via a separate autosave POST
|
||||
(checkout.box-now.locker.select) rather than piggybacking on the
|
||||
shipping-option field, since the two are independent pieces of state
|
||||
(method vs. destination) that CheckoutService models as two calls
|
||||
(selectShippingOption() / selectBoxNowLocker()).
|
||||
--}}
|
||||
<div
|
||||
id="bbk-box-now-locker"
|
||||
class="bbk-checkout-box-now-locker"
|
||||
data-controller="bbk-box-now-locker"
|
||||
data-bbk-box-now-locker-lockers-url-value="{{ route('checkout.box-now.lockers', app()->getLocale()) }}"
|
||||
data-bbk-box-now-locker-select-url-value="{{ route('checkout.box-now.locker.select', app()->getLocale()) }}"
|
||||
data-bbk-box-now-locker-loading-value="{{ __('checkout.page.box_now_locker_loading') }}"
|
||||
data-bbk-box-now-locker-select-label-value="{{ __('checkout.page.box_now_locker_select') }}"
|
||||
data-bbk-box-now-locker-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}"
|
||||
data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}"
|
||||
@if ($selected !== 'box-now') hidden @endif
|
||||
>
|
||||
<p class="bbk-checkout-box-now-locker-label">
|
||||
{{ __('checkout.page.box_now_locker_label') }}
|
||||
</p>
|
||||
<input
|
||||
type="search"
|
||||
class="bbk-checkout-box-now-locker-search"
|
||||
placeholder="{{ __('checkout.page.box_now_locker_search') }}"
|
||||
data-bbk-box-now-locker-target="search"
|
||||
data-action="input->bbk-box-now-locker#search"
|
||||
autocomplete="off"
|
||||
>
|
||||
<div
|
||||
id="bbk-box-now-locker-map"
|
||||
class="bbk-checkout-box-now-locker-map"
|
||||
data-bbk-box-now-locker-target="map"
|
||||
></div>
|
||||
<p
|
||||
class="bbk-checkout-box-now-locker-chosen"
|
||||
data-bbk-box-now-locker-target="chosen"
|
||||
hidden
|
||||
></p>
|
||||
<p
|
||||
class="bbk-checkout-status"
|
||||
data-bbk-box-now-locker-target="status"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
hidden
|
||||
></p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user