generated from boboko/starter
Feat: Box now tests
This commit is contained in:
@@ -19,10 +19,12 @@
|
|||||||
use Lunar\Models\State;
|
use Lunar\Models\State;
|
||||||
use Modules\Core\Cart\Services\CartService;
|
use Modules\Core\Cart\Services\CartService;
|
||||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||||
|
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
|
||||||
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||||
use Modules\Core\Checkout\Services\CheckoutService;
|
use Modules\Core\Checkout\Services\CheckoutService;
|
||||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||||
|
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The checkout page — one page, sections (contact / billing / shipping /
|
* The checkout page — one page, sections (contact / billing / shipping /
|
||||||
@@ -230,6 +232,77 @@ public function selectShippingOption(string $locale, Request $request): JsonResp
|
|||||||
return $this->fragments($cart, $options);
|
return $this->fragments($cart, $options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain, own-hosted stand-in for Box Now's Destination Map widget —
|
||||||
|
* that widget only talks to their Production environment (see their
|
||||||
|
* Partner API manual §4.1), which is useless while developing against
|
||||||
|
* Stage credentials. Same underlying data (GET /destinations), no map.
|
||||||
|
*/
|
||||||
|
public function boxNowLockers(string $locale, BoxNowClient $boxNow): JsonResponse
|
||||||
|
{
|
||||||
|
$lockers = collect($boxNow->destinations())
|
||||||
|
// Drops entries with a blank `name` (e.g. id 8288, "Virtual
|
||||||
|
// Locker" in Sudan at lat 12.3/lng 25.3) — a real, in-range
|
||||||
|
// coordinate, but sandbox test fixture noise rather than an
|
||||||
|
// actual pickup point, and it alone was enough to make
|
||||||
|
// fitBounds() below zoom the map out to the whole Balkans/
|
||||||
|
// Middle East to fit every marker's cluster in Greece.
|
||||||
|
->filter(fn (array $destination) => filled($destination['name'] ?? null))
|
||||||
|
->map(fn (array $destination) => [
|
||||||
|
'id' => $destination['id'],
|
||||||
|
'name' => $destination['name'] ?? $destination['title'] ?? $destination['id'],
|
||||||
|
'addressLine1' => $destination['addressLine1'] ?? null,
|
||||||
|
'addressLine2' => $destination['addressLine2'] ?? null,
|
||||||
|
'postalCode' => $destination['postalCode'] ?? null,
|
||||||
|
'country' => $destination['country'] ?? null,
|
||||||
|
'note' => $destination['note'] ?? null,
|
||||||
|
'image' => $destination['image'] ?? null,
|
||||||
|
'lat' => isset($destination['lat']) ? (float) $destination['lat'] : null,
|
||||||
|
'lng' => isset($destination['lng']) ? (float) $destination['lng'] : null,
|
||||||
|
])
|
||||||
|
// Box Now's own Stage/sandbox data has at least one malformed
|
||||||
|
// entry observed in practice (locker id 47: lat/lng as huge
|
||||||
|
// integers with the decimal point apparently dropped, e.g.
|
||||||
|
// 96065874308606 instead of ~37.96) — a single such point blows
|
||||||
|
// out L.featureGroup().getBounds() on the frontend, zooming the
|
||||||
|
// map out to near-nothing with every real marker imperceptible
|
||||||
|
// at that scale. Valid latitude/longitude ranges are absolute,
|
||||||
|
// not guesswork, so filtering on them is safe regardless of
|
||||||
|
// what BoxNow's API does or doesn't fix upstream.
|
||||||
|
->filter(fn (array $locker) => $locker['lat'] !== null && $locker['lng'] !== null
|
||||||
|
&& abs($locker['lat']) <= 90 && abs($locker['lng']) <= 180)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json(['lockers' => $lockers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the shopper's chosen locker (radio/select change, same
|
||||||
|
* autosave shape as selectShippingOption()) via
|
||||||
|
* CheckoutService::selectBoxNowLocker() onto the cart's shipping
|
||||||
|
* address meta.
|
||||||
|
*/
|
||||||
|
public function selectBoxNowLocker(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$locationId = (string) $request->input('locker_id');
|
||||||
|
|
||||||
|
if ($locationId === '') {
|
||||||
|
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->checkout->selectBoxNowLocker([
|
||||||
|
'locationId' => $locationId,
|
||||||
|
'name' => (string) $request->input('locker_name'),
|
||||||
|
'addressLine1' => (string) $request->input('locker_address'),
|
||||||
|
]);
|
||||||
|
} catch (NoShippingAddressException) {
|
||||||
|
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autosave-select a payment method (radio change). Persists it via
|
* Autosave-select a payment method (radio change). Persists it via
|
||||||
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
||||||
|
|||||||
@@ -117,6 +117,34 @@ private function lines(): array
|
|||||||
'No delivery options are available for this address.',
|
'No delivery options are available for this address.',
|
||||||
'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.',
|
'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.',
|
||||||
],
|
],
|
||||||
|
'page.box_now_locker_label' => [
|
||||||
|
'Choose a Box Now locker',
|
||||||
|
'Επίλεξε Box Now locker',
|
||||||
|
],
|
||||||
|
'page.box_now_locker_loading' => [
|
||||||
|
'Loading lockers…',
|
||||||
|
'Φόρτωση lockers…',
|
||||||
|
],
|
||||||
|
'page.box_now_locker_required' => [
|
||||||
|
'Choose a Box Now locker to continue.',
|
||||||
|
'Επίλεξε ένα Box Now locker για να συνεχίσεις.',
|
||||||
|
],
|
||||||
|
'page.box_now_locker_select' => [
|
||||||
|
'Select this locker',
|
||||||
|
'Επιλογή αυτού του locker',
|
||||||
|
],
|
||||||
|
'page.box_now_locker_selected' => [
|
||||||
|
'Selected',
|
||||||
|
'Επιλέχθηκε',
|
||||||
|
],
|
||||||
|
'page.box_now_locker_search' => [
|
||||||
|
'Search by area or address…',
|
||||||
|
'Αναζήτηση με περιοχή ή διεύθυνση…',
|
||||||
|
],
|
||||||
|
'page.box_now_locker_no_results' => [
|
||||||
|
'No lockers match your search.',
|
||||||
|
'Δεν βρέθηκαν lockers για αυτή την αναζήτηση.',
|
||||||
|
],
|
||||||
'page.select_shipping_method' => ['Continue', 'Συνέχεια'],
|
'page.select_shipping_method' => ['Continue', 'Συνέχεια'],
|
||||||
'page.shipping_option_invalid' => [
|
'page.shipping_option_invalid' => [
|
||||||
'That shipping option is no longer available.',
|
'That shipping option is no longer available.',
|
||||||
|
|||||||
Generated
+8
-15
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "boboko-starter",
|
"name": "app",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -7,9 +7,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@phosphor-icons/web": "^2.1.2",
|
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
"flatpickr": "^4.6.13"
|
"leaflet": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -147,12 +146,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@phosphor-icons/web": {
|
|
||||||
"version": "2.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@phosphor-icons/web/-/web-2.1.2.tgz",
|
|
||||||
"integrity": "sha512-rPAR9o/bEcp4Cw4DEeZHXf+nlGCMNGkNDRizYHM47NLxz9vvEHp/Tt6FMK1NcWadzw/pFDPnRBGi/ofRya958A==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
||||||
@@ -1015,12 +1008,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/flatpickr": {
|
|
||||||
"version": "4.6.13",
|
|
||||||
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
|
|
||||||
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/follow-redirects": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
||||||
@@ -1256,6 +1243,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"axios": "^1.15.2"
|
"axios": "^1.15.2",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -617,6 +617,141 @@ .bbk-checkout-status {
|
|||||||
|
|
||||||
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
.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 */
|
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
||||||
|
|
||||||
.bbk-checkout-continue {
|
.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) {
|
async selectShipping(event) {
|
||||||
|
this.toggleBoxNowLocker(event.target.value)
|
||||||
|
|
||||||
// Tracked so flush() can await it — nothing else stops "place order"
|
// Tracked so flush() can await it — nothing else stops "place order"
|
||||||
// (a separate, unrelated click) from racing ahead of this request.
|
// (a separate, unrelated click) from racing ahead of this request.
|
||||||
this.shippingPromise = this.doSelectShipping(event.target.value)
|
this.shippingPromise = this.doSelectShipping(event.target.value)
|
||||||
await this.shippingPromise
|
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) {
|
async doSelectShipping(value) {
|
||||||
this.saveController?.abort()
|
this.saveController?.abort()
|
||||||
this.setStatus('saving')
|
this.setStatus('saving')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
||||||
|
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
|
||||||
import BbkCartController from './bbk-cart-controller'
|
import BbkCartController from './bbk-cart-controller'
|
||||||
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
||||||
import BbkPaymentController from './bbk-payment-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.
|
// only that one import line in the host entry point differs per project.
|
||||||
export function registerCheckout(application) {
|
export function registerCheckout(application) {
|
||||||
application.register('bbk-add-to-cart', BbkAddToCartController)
|
application.register('bbk-add-to-cart', BbkAddToCartController)
|
||||||
|
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
|
||||||
application.register('bbk-cart', BbkCartController)
|
application.register('bbk-cart', BbkCartController)
|
||||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
||||||
application.register('bbk-payment', BbkPaymentController)
|
application.register('bbk-payment', BbkPaymentController)
|
||||||
|
|||||||
@@ -48,3 +48,59 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@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>
|
||||||
|
|||||||
@@ -29,6 +29,12 @@
|
|||||||
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
|
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
|
||||||
->name('checkout.shipping-option.select');
|
->name('checkout.shipping-option.select');
|
||||||
|
|
||||||
|
Route::get('checkout/box-now/lockers', [CheckoutController::class, 'boxNowLockers'])
|
||||||
|
->name('checkout.box-now.lockers');
|
||||||
|
|
||||||
|
Route::post('checkout/box-now/locker', [CheckoutController::class, 'selectBoxNowLocker'])
|
||||||
|
->name('checkout.box-now.locker.select');
|
||||||
|
|
||||||
Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
|
Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
|
||||||
->name('checkout.payment-method.select');
|
->name('checkout.payment-method.select');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user