diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index f5452bb..1e391c2 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -19,10 +19,12 @@ use Lunar\Models\State; use Modules\Core\Cart\Services\CartService; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; +use Modules\Core\Checkout\Exceptions\NoShippingAddressException; use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException; use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException; use Modules\Core\Checkout\Services\CheckoutService; use Modules\Core\Payment\Enums\PaymentResultStatus; +use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient; /** * 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); } + /** + * 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 * CheckoutService (which also records it on Cart::meta and re-snapshots diff --git a/database/seeders/CheckoutTranslationsSeeder.php b/database/seeders/CheckoutTranslationsSeeder.php index 965a15b..b1f60c7 100644 --- a/database/seeders/CheckoutTranslationsSeeder.php +++ b/database/seeders/CheckoutTranslationsSeeder.php @@ -117,6 +117,34 @@ private function lines(): array '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.shipping_option_invalid' => [ 'That shipping option is no longer available.', diff --git a/package-lock.json b/package-lock.json index 8b0b914..5f554c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "boboko-starter", + "name": "app", "lockfileVersion": 3, "requires": true, "packages": { @@ -7,9 +7,8 @@ "dependencies": { "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo": "^8.0.23", - "@phosphor-icons/web": "^2.1.2", "axios": "^1.15.2", - "flatpickr": "^4.6.13" + "leaflet": "^1.9.4" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", @@ -147,12 +146,6 @@ "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": { "version": "1.1.4", "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": { "version": "1.16.0", "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": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", diff --git a/package.json b/package.json index fb3b9f4..d3d0beb 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "dependencies": { "@hotwired/stimulus": "^3.2.2", "@hotwired/turbo": "^8.0.23", - "axios": "^1.15.2" + "axios": "^1.15.2", + "leaflet": "^1.9.4" } } diff --git a/resources/css/checkout.css b/resources/css/checkout.css index 56b9437..6cb8dc5 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -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 { 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 ` +
${locker.name}
++ ${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')} + ${locker.postalCode ? ` ${locker.postalCode}` : ''} +
+ ${locker.note ? `${locker.note}
` : ''} + +