diff --git a/.claude/settings.json b/.claude/settings.json
new file mode 100644
index 0000000..0d322e8
--- /dev/null
+++ b/.claude/settings.json
@@ -0,0 +1,41 @@
+{
+ "permissions": {
+ "allow": [
+ "Bash(find /home/konstantinos/Projects/RadicalElements/boboko-core/docs/scratch -iname \"*cart*\" 2>/dev/null; find /home/konstantinos/Projects/RadicalElements -iname \"*cart-feature*\" -o -iname \"*feature-survey*\" 2>/dev/null)",
+ "Read(//home/konstantinos/Projects/RadicalElements/**)",
+ "Bash(find /home/konstantinos/Projects/RadicalElements/3dealer -path \"*config/lunar/payments.php\" 2>/dev/null; find /home/konstantinos/Projects/RadicalElements -maxdepth 4 -iname \"*stripe*\" -type d 2>/dev/null)",
+ "Bash(grep -n 'process\\(\\\\|->using\\\\|\\\\$data' /home/konstantinos/Projects/RadicalElements/boboko-core/vendor/filament/actions/src/CreateAction.php)",
+ "Bash(php -l src/Order/Commands/CloseExpiredReturnWindows.php)",
+ "Bash(php -l config/core.php)",
+ "Bash(./bin/dc-core.sh exec *)",
+ "Bash(php -l src/Shipping/Extensions/OrderViewExtension.php)",
+ "Bash(php -l src/Cart/Filament/Resources/CartResource/Pages/ViewCart.php)",
+ "Bash(./bin/dc-core.sh exec app php artisan tinker '--execute= *)",
+ "Bash(mkdir -p /home/konstantinos/Projects/RadicalElements/boboko-core/src/Cart/Http/Controllers)",
+ "Bash(rmdir /home/konstantinos/Projects/RadicalElements/boboko-core/src/Checkout/routes)",
+ "Bash(mkdir -p /home/konstantinos/Projects/RadicalElements/boboko-core/src/Checkout/routes)",
+ "Bash(php -l src/Cart/Http/Controllers/CartController.php)",
+ "Bash(php -l src/Checkout/Http/Controllers/CheckoutController.php)",
+ "Bash(php -l src/Providers/CheckoutModuleServiceProvider.php)",
+ "Bash(php -l src/Providers/CheckoutServiceProvider.php)",
+ "Bash(php -l src/Checkout/routes/checkout.php)",
+ "Bash(php -l config/checkout.php)",
+ "Bash(cp /home/konstantinos/Projects/RadicalElements/3dealer/resources/css/checkout.css /home/konstantinos/Projects/RadicalElements/boboko-core/resources/css/)",
+ "Bash(cp /home/konstantinos/Projects/RadicalElements/3dealer/resources/js/checkout/*.js /home/konstantinos/Projects/RadicalElements/boboko-core/resources/js/checkout/)",
+ "Bash(rm /home/konstantinos/Projects/RadicalElements/3dealer/app/Providers/CheckoutModuleServiceProvider.php)",
+ "Bash(rm -rf /home/konstantinos/Projects/RadicalElements/3dealer/app/Http/Controllers/Checkout)",
+ "Bash(rm /home/konstantinos/Projects/RadicalElements/3dealer/routes/checkout.php)",
+ "Bash(rm -rf /home/konstantinos/Projects/RadicalElements/3dealer/resources/js/checkout)",
+ "Bash(rm /home/konstantinos/Projects/RadicalElements/3dealer/resources/css/checkout.css)",
+ "Bash(composer dump-autoload *)",
+ "Bash(curl -s -o /tmp/checkout_test.html -w \"%{http_code}\\\\n\" http://localhost:8091/en/checkout)",
+ "Read(//tmp/**)",
+ "Bash(curl -s -o /tmp/home_test.html -w \"%{http_code}\\\\n\" http://localhost:8091/en/)",
+ "Bash(php -l bootstrap/providers.php)"
+ ],
+ "additionalDirectories": [
+ "/home/konstantinos/Projects/RadicalElements/3dealer/bootstrap",
+ "/home/konstantinos/Projects/RadicalElements/3dealer/config"
+ ]
+ }
+}
diff --git a/resources/css/checkout.css b/resources/css/checkout.css
index 51a4648..7f77fd3 100644
--- a/resources/css/checkout.css
+++ b/resources/css/checkout.css
@@ -656,6 +656,141 @@ textarea.bbk-field-input { resize: vertical; }
.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 {
diff --git a/resources/js/checkout/bbk-box-now-locker-controller.js b/resources/js/checkout/bbk-box-now-locker-controller.js
new file mode 100644
index 0000000..37b649e
--- /dev/null
+++ b/resources/js/checkout/bbk-box-now-locker-controller.js
@@ -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 = `
+
+ `
+
+ 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 `
+
+ `
+ }
+
+ 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
+ }
+}
diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js
index a8e720e..aa310ec 100644
--- a/resources/js/checkout/bbk-checkout-form-controller.js
+++ b/resources/js/checkout/bbk-checkout-form-controller.js
@@ -123,12 +123,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