From 78c8165c04e9f323de8c4fb8d1e000e9fd216148 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Thu, 17 Sep 2026 19:55:39 +0300 Subject: [PATCH 1/5] Feat: Box now tests --- .../Checkout/CheckoutController.php | 73 ++++++ .../seeders/CheckoutTranslationsSeeder.php | 28 +++ package-lock.json | 23 +- package.json | 3 +- resources/css/checkout.css | 135 +++++++++++ .../checkout/bbk-box-now-locker-controller.js | 220 ++++++++++++++++++ .../checkout/bbk-checkout-form-controller.js | 22 ++ resources/js/checkout/index.js | 2 + .../partials/shipping-options.blade.php | 56 +++++ routes/checkout.php | 6 + 10 files changed, 552 insertions(+), 16 deletions(-) create mode 100644 resources/js/checkout/bbk-box-now-locker-controller.js 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.image ? `` : ''} +

${locker.name}

+

+ ${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')} + ${locker.postalCode ? ` ${locker.postalCode}` : ''} +

+ ${locker.note ? `

${locker.note}

` : ''} + +
+ ` + } + + 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 7dfbe05..d2a7f0f 100644 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -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 +
+ + + diff --git a/routes/checkout.php b/routes/checkout.php index 47f8f36..6d9a071 100644 --- a/routes/checkout.php +++ b/routes/checkout.php @@ -29,6 +29,12 @@ Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption']) ->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']) ->name('checkout.payment-method.select'); From cd45d8f5788f8ce22368e6328d0963b7d48135f4 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Sun, 27 Sep 2026 20:49:07 +0300 Subject: [PATCH 2/5] Feat: Removing Controllers and Movong to core --- resources/css/checkout.css | 991 ------------------ .../js/checkout/bbk-add-to-cart-controller.js | 57 - .../checkout/bbk-box-now-locker-controller.js | 220 ---- resources/js/checkout/bbk-cart-controller.js | 161 --- .../checkout/bbk-checkout-form-controller.js | 226 ---- .../js/checkout/bbk-payment-controller.js | 289 ----- resources/js/checkout/csrf.js | 6 - resources/js/checkout/index.js | 21 - 8 files changed, 1971 deletions(-) delete mode 100644 resources/css/checkout.css delete mode 100644 resources/js/checkout/bbk-add-to-cart-controller.js delete mode 100644 resources/js/checkout/bbk-box-now-locker-controller.js delete mode 100644 resources/js/checkout/bbk-cart-controller.js delete mode 100644 resources/js/checkout/bbk-checkout-form-controller.js delete mode 100644 resources/js/checkout/bbk-payment-controller.js delete mode 100644 resources/js/checkout/csrf.js delete mode 100644 resources/js/checkout/index.js diff --git a/resources/css/checkout.css b/resources/css/checkout.css deleted file mode 100644 index 7f77fd3..0000000 --- a/resources/css/checkout.css +++ /dev/null @@ -1,991 +0,0 @@ -/* - * Cart + checkout module — generic default styling. - * - * Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version - * put these rules in `@layer bbk-checkout`, positioned (via a cross-file - * @layer ordering statement) to sit between Tailwind's `base` and - * `components` — in theory enough to beat Preflight's element resets while - * still losing to a host override. In practice a build tool processing each - * CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the - * cross-file ordering information that trick depends on, so it silently - * didn't work: Preflight's `button { background-color: transparent }`, - * `* { border-width: 0 }` etc. (layered, in `base`) were beating every - * `.bbk-*` rule below regardless of specificity — buttons with no - * background, no border, wrong font-size. - * - * Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always - * beats ANY layered rule (Preflight included), full stop, no ordering tricks, - * nothing a bundler can silently invalidate. This file is loaded BEFORE the - * host's own stylesheet (see the @vite call in the layout ), so: - * - * - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins — - * same specificity, later in source order - * - a later host rule with a MORE specific selector wins regardless - * - a host rule inside `@layer components`/`@layer utilities` does NOT - * win — unlayered always beats layered. Theme this module from plain - * rules in app.css, not from inside a Tailwind layer. - * - * Two ways to theme this, cheapest first: - * - * 1. Redefine the --bbk-* custom properties below (from :root, or scoped to - * .bbk-cart for a cart-only override) — covers colour, radius, shadow, - * font without touching a single selector below. - * - * :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; } - * - * 2. Override individual `.bbk-*` rules directly (as plain rules, per - * above) for anything structural (spacing, layout) the variables don't - * cover. - * - * This file's own look is a deliberately neutral placeholder — inoffensive, - * not "designed" — so a project always has something reasonable before it - * themes; it is not meant to be edited per project. - */ - -:root { - --bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; - - --bbk-color-text: #18181b; - --bbk-color-muted: #71717a; - --bbk-color-bg: #ffffff; - --bbk-color-bg-muted: #f4f4f5; - --bbk-color-border: #e4e4e7; - --bbk-color-accent: #18181b; - --bbk-color-accent-text: #ffffff; - --bbk-color-danger: #dc2626; - - --bbk-radius: 8px; - --bbk-radius-sm: 4px; - --bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); -} - -.bbk-cart[hidden] { display: none; } - -.bbk-cart { - position: fixed; - inset: 0; - z-index: 1000; - font-family: var(--bbk-font); - font-size: 0.9375rem; - line-height: 1.4; - color: var(--bbk-color-text); -} - -.bbk-cart-backdrop { - position: absolute; - inset: 0; - background: rgba(0, 0, 0, 0.4); - opacity: 0; - transition: opacity 0.25s ease; -} - -.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; } - -.bbk-cart-panel { - position: absolute; - top: 0; - right: 0; - display: flex; - flex-direction: column; - width: min(420px, 100vw); - height: 100%; - background: var(--bbk-color-bg); - box-shadow: var(--bbk-shadow); - transform: translateX(100%); - transition: transform 0.25s ease; -} - -.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); } - -.bbk-cart-panel-header { - flex: 0 0 auto; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; - padding: 1.5rem 1.5rem 1.25rem; - border-bottom: 1px solid var(--bbk-color-border); -} - -.bbk-cart-heading { - margin: 0; - font-size: 1.375rem; - font-weight: 700; -} - -.bbk-cart-dismiss, -.bbk-cart-item-remove, -.bbk-cart-qty-btn { - cursor: pointer; - background: none; - border: 0; - padding: 0; - font: inherit; - line-height: 1; - color: var(--bbk-color-muted); - transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; -} - -.bbk-cart-dismiss { - font-size: 1.75rem; - width: 2.5rem; - height: 2.5rem; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: var(--bbk-radius-sm); - flex-shrink: 0; -} - -.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); } - -.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); } - -.bbk-cart-dismiss:focus-visible, -.bbk-cart-item-remove:focus-visible, -.bbk-cart-qty-btn:focus-visible, -.bbk-cart-qty-input:focus-visible, -.bbk-cart-checkout:focus-visible, -.bbk-cart-coupon-input:focus-visible, -.bbk-cart-coupon-submit:focus-visible, -.bbk-cart-coupon-remove:focus-visible { - outline: 2px solid var(--bbk-color-accent); - outline-offset: 2px; -} - -.bbk-visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.bbk-cart-panel-body { - flex: 1 1 auto; - overflow-y: auto; - overscroll-behavior: contain; - padding: 1.5rem; -} - -.bbk-cart-items { - list-style: none; - margin: 0 0 2rem; - padding: 0; - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.bbk-cart-item { - display: grid; - grid-template-columns: 72px 1fr auto; - gap: 0.875rem; - align-items: start; -} - -.bbk-cart-item-media img { - display: block; - width: 72px; - height: 72px; - object-fit: cover; - border-radius: var(--bbk-radius-sm); - background: var(--bbk-color-bg-muted); -} - -.bbk-cart-item-detail { min-width: 0; } - -.bbk-cart-item-title { - display: block; - margin: 0 0 0.25rem; - font-weight: 600; - color: inherit; - text-decoration: none; -} - -a.bbk-cart-item-title:hover { text-decoration: underline; } - -.bbk-cart-item-variant { - margin: 0 0 0.25rem; - font-size: 0.8125rem; - color: var(--bbk-color-muted); -} - -/* A line's custom-field answers (checkout::partials.line-custom-fields). */ -.bbk-line-fields { - display: grid; - gap: 0.25rem; - margin: 0 0 0.5rem; - font-size: 0.8125rem; -} - -.bbk-line-field dt { - color: var(--bbk-color-muted); -} - -.bbk-line-field dd { - margin: 0; - white-space: pre-line; - overflow-wrap: anywhere; -} - -.bbk-line-field-file { - display: inline-flex; - align-items: center; - gap: 0.5rem; - color: inherit; -} - -.bbk-line-field-file img { - width: 40px; - height: 40px; - object-fit: cover; - border-radius: 0; -} - -.bbk-cart-item-unit { - margin: 0 0 0.625rem; - color: var(--bbk-color-muted); -} - -.bbk-cart-item-aside { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.5rem; -} - -.bbk-cart-item-total { margin: 0; font-weight: 600; } - -.bbk-cart-item-remove { - font-size: 1.125rem; - width: 1.5rem; - height: 1.5rem; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.bbk-cart-qty { - display: inline-flex; - align-items: center; - gap: 0; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - overflow: hidden; -} - -.bbk-cart-qty-btn { - width: 1.75rem; - height: 1.75rem; - background: var(--bbk-color-bg-muted); -} - -.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); } - -.bbk-cart-qty-input { - width: 2.25rem; - height: 1.75rem; - border: 0; - border-left: 1px solid var(--bbk-color-border); - border-right: 1px solid var(--bbk-color-border); - text-align: center; - font: inherit; - color: inherit; - background: var(--bbk-color-bg); - appearance: textfield; - -moz-appearance: textfield; -} - -.bbk-cart-qty-input::-webkit-outer-spin-button, -.bbk-cart-qty-input::-webkit-inner-spin-button { - -webkit-appearance: none; - margin: 0; -} - -.bbk-cart-summary { - padding-top: 1.25rem; - border-top: 1px solid var(--bbk-color-border); - display: flex; - flex-direction: column; - gap: 0.625rem; -} - -.bbk-cart-summary-row { - display: flex; - justify-content: space-between; - gap: 1rem; -} - -.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); } - -.bbk-cart-summary-pending { - color: var(--bbk-color-muted); - font-size: 0.8125rem; -} - -.bbk-cart-summary-row--total { - margin-top: 0.375rem; - padding-top: 0.875rem; - border-top: 1px solid var(--bbk-color-border); - font-size: 1.0625rem; - font-weight: 700; -} - -.bbk-cart-coupon-form { - display: flex; - gap: 0.5rem; -} - -.bbk-cart-coupon-input { - flex: 1 1 auto; - min-width: 0; - padding: 0.5rem 0.75rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - font: inherit; - color: inherit; - background: var(--bbk-color-bg); -} - -.bbk-cart-coupon-submit { - flex: 0 0 auto; - padding: 0.5rem 0.875rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - background: var(--bbk-color-bg-muted); - font: inherit; - font-weight: 600; - cursor: pointer; - transition: background-color 0.15s ease, border-color 0.15s ease; -} - -.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); } - -.bbk-cart-coupon-applied { - display: flex; - align-items: center; - justify-content: space-between; - gap: 0.75rem; - padding: 0.625rem 0.875rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - background: var(--bbk-color-bg-muted); -} - -.bbk-cart-coupon-code { - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.02em; -} - -.bbk-cart-coupon-remove { - flex: 0 0 auto; - background: none; - border: 0; - padding: 0; - font: inherit; - font-size: 0.8125rem; - color: var(--bbk-color-muted); - text-decoration: underline; - cursor: pointer; - transition: color 0.15s ease; -} - -.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); } - -.bbk-cart-coupon-error { - margin: 0.5rem 0 0; - font-size: 0.8125rem; - color: var(--bbk-color-danger); -} - -.bbk-cart-error { - margin: 0; - padding: 0.75rem 1.5rem 0; - font-size: 0.8125rem; - color: var(--bbk-color-danger); -} - -.bbk-add-to-cart-error { - margin: 0.375rem 0 0; - font-size: 0.8125rem; - color: var(--bbk-color-danger); -} - -.bbk-cart-checkout { - display: block; - width: 100%; - padding: 0.875rem 1.25rem; - border: 1px solid var(--bbk-color-accent); - border-radius: var(--bbk-radius); - background: var(--bbk-color-accent); - color: var(--bbk-color-accent-text); - font: inherit; - font-weight: 600; - text-align: center; - text-decoration: none; - cursor: pointer; - transition: opacity 0.15s ease; -} - -.bbk-cart-checkout:hover { opacity: 0.85; } - -.bbk-cart-checkout:disabled { - cursor: not-allowed; - opacity: 0.4; -} - -.bbk-cart-empty { - text-align: center; - color: var(--bbk-color-muted); - padding: 2.5rem 0; -} - -/* ─────────────────────────────────────────────────────────────────── - Checkout page — two columns: fields on the left, order summary (the - same cart-body partial the drawer uses) on the right. - ─────────────────────────────────────────────────────────────────── */ - -.bbk-checkout-page { - max-width: 1100px; - margin: 0 auto; - padding: 2.5rem 1.5rem 5rem; - font-family: var(--bbk-font); - font-size: 0.9375rem; - line-height: 1.4; - color: var(--bbk-color-text); -} - -.bbk-checkout-heading { - margin: 0 0 2rem; - font-size: 1.75rem; - font-weight: 700; -} - -.bbk-checkout { - display: grid; - grid-template-columns: 1fr 380px; - gap: 3rem; - align-items: start; -} - -@media (max-width: 860px) { - .bbk-checkout { grid-template-columns: 1fr; } -} - -.bbk-checkout-main { - display: flex; - flex-direction: column; - gap: 2rem; -} - -.bbk-checkout-section { - padding-bottom: 2rem; - border-bottom: 1px solid var(--bbk-color-border); - display: flex; - flex-direction: column; - gap: 1rem; -} - -.bbk-checkout-section-heading { - margin: 0; - font-size: 1.125rem; - font-weight: 700; -} - -.bbk-checkout-note { - margin: 0; - color: var(--bbk-color-muted); - font-size: 0.875rem; -} - -/* Contact: "logged in as" line, or the guest login prompt */ - -.bbk-checkout-logged-in, -.bbk-checkout-login-prompt { margin: 0; } - -.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; } - -/* Fields */ - -.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; } - -.bbk-field-row { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 1rem; -} - -@media (max-width: 480px) { - .bbk-field-row { grid-template-columns: 1fr; } -} - -.bbk-field-label { - font-size: 0.8125rem; - font-weight: 600; - color: var(--bbk-color-muted); -} - -.bbk-field-input { - padding: 0.625rem 0.75rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - font: inherit; - color: inherit; - background: var(--bbk-color-bg); -} - -.bbk-field-input:focus-visible { - outline: 2px solid var(--bbk-color-accent); - outline-offset: 2px; -} - -.bbk-field-input:disabled { - background: var(--bbk-color-bg-muted); - color: var(--bbk-color-muted); -} - -.bbk-field-input--error { border-color: var(--bbk-color-danger); } - -.bbk-field-error { - margin: 0; - font-size: 0.8125rem; - color: var(--bbk-color-danger); -} - -/* A fixed, non-editable field value (e.g. the store's single country). */ -.bbk-field-static { - margin: 0; - padding: 0.625rem 0.75rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - background: var(--bbk-color-bg-muted); - color: var(--bbk-color-muted); -} - -textarea.bbk-field-input { resize: vertical; } - -.bbk-checkbox { - display: inline-flex; - align-items: center; - gap: 0.5rem; - font-size: 0.875rem; - cursor: pointer; -} - -/* For a full-sentence label that can wrap — align the box to the first line. */ -/* "I want an invoice": company/ΑΦΜ only while ticked */ - -.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; } - -.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; } - -.bbk-checkbox--stacked { - display: flex; - align-items: flex-start; - margin-top: 0.75rem; - color: var(--bbk-color-muted); -} - -.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; } - -.bbk-checkout-shipping-fields { - display: flex; - flex-direction: column; - gap: 1rem; -} - -/* Shipping method */ - -.bbk-checkout-shipping-options { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.bbk-checkout-shipping-option { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem 1rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - cursor: pointer; - transition: border-color 0.15s ease; -} - -.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); } - -.bbk-checkout-shipping-option-detail { - flex: 1 1 auto; - display: flex; - flex-direction: column; - gap: 0.125rem; -} - -.bbk-checkout-shipping-option-name { font-weight: 600; } - -.bbk-checkout-shipping-option-description { - font-size: 0.8125rem; - color: var(--bbk-color-muted); -} - -.bbk-checkout-shipping-option-price { font-weight: 600; } - -/* The single auto-selected option — a fixed line, not a choosable radio. */ -.bbk-checkout-shipping-confirmed { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem 1rem; - border: 1px solid var(--bbk-color-accent); - border-radius: var(--bbk-radius-sm); -} - -/* Autosave status line under the address form. */ -.bbk-checkout-status { - margin: 0; - font-size: 0.8125rem; - color: var(--bbk-color-muted); -} - -.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 { - display: block; - width: 100%; - padding: 0.875rem 1.25rem; - border: 1px solid var(--bbk-color-accent); - border-radius: var(--bbk-radius); - background: var(--bbk-color-accent); - color: var(--bbk-color-accent-text); - font: inherit; - font-weight: 600; - text-align: center; - text-decoration: none; - box-sizing: border-box; - cursor: pointer; - transition: opacity 0.15s ease; -} - -.bbk-checkout-continue:hover { opacity: 0.85; } - -.bbk-checkout-continue:disabled { - cursor: not-allowed; - opacity: 0.4; -} - -/* Order summary column */ - -.bbk-checkout-aside { position: sticky; top: 1.5rem; } - -.bbk-checkout-summary { - padding: 1.5rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius); - background: var(--bbk-color-bg); -} - -.bbk-checkout-summary-heading { - margin: 0 0 1.25rem; - font-size: 1.125rem; - font-weight: 700; -} - -/* Already on the checkout page — the drawer's own "go to checkout" CTA has - nowhere further to send you from here. */ -.bbk-checkout-summary .bbk-cart-checkout { display: none; } - -/* ── Payment ───────────────────────────────────────────────────────── */ - -.bbk-checkout-payment-options { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.bbk-checkout-payment-option { - display: flex; - align-items: center; - gap: 0.75rem; - padding: 0.875rem 1rem; - border: 1px solid var(--bbk-color-border); - border-radius: var(--bbk-radius-sm); - cursor: pointer; - transition: border-color 0.15s ease; -} - -.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); } - -.bbk-checkout-payment-option-name { font-weight: 600; } - -.bbk-payment-element { margin: 0.25rem 0; } - -.bbk-checkout-withdrawal { - margin: 0; - font-size: 0.8125rem; - color: var(--bbk-color-muted); -} - -.bbk-checkout-withdrawal a { color: inherit; } - -.bbk-checkout-error { - margin: 0; - font-size: 0.875rem; - color: var(--bbk-color-danger); -} - -/* Processing overlay — fixed, covers the page while a payment confirms. */ -.bbk-checkout-processing { - position: fixed; - inset: 0; - z-index: 1100; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 1rem; - background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent); - text-align: center; - padding: 1.5rem; -} - -.bbk-spinner { - width: 2rem; - height: 2rem; - border: 3px solid var(--bbk-color-border); - border-top-color: var(--bbk-color-accent); - border-radius: 50%; - animation: bbk-spin 0.8s linear infinite; -} - -@keyframes bbk-spin { - to { transform: rotate(360deg); } -} - -/* ── Confirmation page ─────────────────────────────────────────────── */ - -.bbk-confirmation { - max-width: 720px; - margin: 0 auto; - padding: 3rem 1.5rem 5rem; - font-family: var(--bbk-font); - color: var(--bbk-color-text); -} - -.bbk-confirmation-heading { - margin: 0 0 1rem; - font-size: 1.75rem; - font-weight: 700; -} - -.bbk-confirmation-ref { margin: 0 0 0.25rem; } - -.bbk-confirmation-meta { - margin: 0 0 1rem; - display: flex; - flex-direction: column; - gap: 0.25rem; -} - -.bbk-confirmation-meta-row { - display: flex; - justify-content: space-between; - gap: 1rem; - font-size: 0.9375rem; -} - -.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); } -.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; } - -.bbk-confirmation-body { - margin: 2rem 0; - display: grid; - gap: 2.5rem; -} - -@media (min-width: 640px) { - .bbk-confirmation-body { grid-template-columns: 1fr 1fr; } -} - -.bbk-confirmation-lines { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.bbk-confirmation-line { - display: grid; - grid-template-columns: 72px 1fr auto; - align-items: start; - gap: 0.875rem; -} - -.bbk-confirmation-line-detail { min-width: 0; } - -.bbk-confirmation-line-qty { color: var(--bbk-color-muted); } - -.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; } - -.bbk-confirmation-addresses { - display: flex; - flex-direction: column; - gap: 1.5rem; -} - -.bbk-confirmation-address-heading { - margin: 0 0 0.5rem; - font-size: 0.9375rem; - font-weight: 700; -} - -.bbk-address-lines { - font-style: normal; - display: flex; - flex-direction: column; - gap: 0.125rem; - font-size: 0.875rem; - color: var(--bbk-color-muted); -} diff --git a/resources/js/checkout/bbk-add-to-cart-controller.js b/resources/js/checkout/bbk-add-to-cart-controller.js deleted file mode 100644 index 1f1cdf2..0000000 --- a/resources/js/checkout/bbk-add-to-cart-controller.js +++ /dev/null @@ -1,57 +0,0 @@ -import { Controller } from '@hotwired/stimulus' -import { csrfToken } from './csrf' - -// Sits on an
. Submits the line to the cart -// via fetch and hands the server-rendered cart body to the drawer through the -// `bbk-cart:changed` window event. No DOM building here — the drawer -// (bbk-cart-controller) owns rendering. -export default class extends Controller { - static targets = ['error'] - - async add(event) { - event.preventDefault() - - const form = this.element - const submit = form.querySelector('[type="submit"]') - - this.clearError() - form.setAttribute('data-bbk-add-to-cart-state', 'loading') - if (submit) submit.disabled = true - - try { - const response = await fetch(form.action, { - method: 'POST', - headers: { - 'X-CSRF-TOKEN': csrfToken(), - 'X-Requested-With': 'XMLHttpRequest', - Accept: 'application/json', - }, - body: new FormData(form), - }) - - if (!response.ok) { - const data = await response.json().catch(() => null) - this.showError(data?.error) - return - } - - window.dispatchEvent(new CustomEvent('bbk-cart:changed', { - detail: { html: await response.text() }, - })) - } finally { - form.removeAttribute('data-bbk-add-to-cart-state') - if (submit) submit.disabled = false - } - } - - showError(message) { - if (!this.hasErrorTarget || !message) return - this.errorTarget.textContent = message - this.errorTarget.hidden = false - } - - clearError() { - if (!this.hasErrorTarget) return - this.errorTarget.hidden = true - } -} diff --git a/resources/js/checkout/bbk-box-now-locker-controller.js b/resources/js/checkout/bbk-box-now-locker-controller.js deleted file mode 100644 index 37b649e..0000000 --- a/resources/js/checkout/bbk-box-now-locker-controller.js +++ /dev/null @@ -1,220 +0,0 @@ -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.image ? `` : ''} -

${locker.name}

-

- ${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')} - ${locker.postalCode ? ` ${locker.postalCode}` : ''} -

- ${locker.note ? `

${locker.note}

` : ''} - -
- ` - } - - 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-cart-controller.js b/resources/js/checkout/bbk-cart-controller.js deleted file mode 100644 index dfe5901..0000000 --- a/resources/js/checkout/bbk-cart-controller.js +++ /dev/null @@ -1,161 +0,0 @@ -import { Controller } from '@hotwired/stimulus' -import { csrfToken } from './csrf' - -// Drives the slide-in cart drawer. One instance, on the drawer root in -// checkout/drawer.blade.php. -// -// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from -// this drawer's own line forms) and swaps in the server-rendered cart body -// - handles the in-drawer quantity / remove forms (fetch + method spoofing) -// - re-emits `bbk-cart:updated` {count, total} after every render so the host -// (e.g. the header bag icon) can react -// -// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state -// attribute on the root, nothing here touches styles or class lists. -export default class extends Controller { - static targets = ['panel', 'body', 'error'] - - connect() { - this.onChanged = this.onChanged.bind(this) - this.onKeydown = this.onKeydown.bind(this) - this.updateTimers = new Map() // line id -> pending debounce timer - - window.addEventListener('bbk-cart:changed', this.onChanged) - window.addEventListener('bbk-cart:open', this.open.bind(this)) - document.addEventListener('keydown', this.onKeydown) - - // Prime the host with the count rendered server-side on page load. - this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]')) - } - - disconnect() { - window.removeEventListener('bbk-cart:changed', this.onChanged) - document.removeEventListener('keydown', this.onKeydown) - this.updateTimers.forEach((timer) => clearTimeout(timer)) - } - - onChanged(event) { - if (event.detail?.html) this.replaceBody(event.detail.html) - this.open() - } - - onKeydown(event) { - if (event.key === 'Escape' && !this.element.hidden) this.close() - } - - open() { - if (!this.element.hidden) return - this.element.hidden = false - // Next frame, so the panel transitions from its off-canvas start. - requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open')) - } - - close() { - this.element.removeAttribute('data-bbk-cart-state') - - const panel = this.panelTarget - const done = () => { - this.element.hidden = true - panel.removeEventListener('transitionend', done) - } - panel.addEventListener('transitionend', done) - } - - // change on a line quantity input, or submit of a line's remove form - submit(event) { - event.preventDefault() - const form = event.target.closest('form') - if (!form) return - - // A remove is a deliberate, one-shot action — only the quantity form - // (typing, or the +/- stepper below) benefits from debouncing. - form.classList.contains('bbk-cart-qty') ? this.scheduleSend(form) : this.send(form) - } - - // +/- stepper buttons inside a line - step(event) { - event.preventDefault() - const form = event.target.closest('form') - const input = form.querySelector('input[type="number"]') - const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir)) - input.value = String(next) - this.scheduleSend(form) - } - - // Repeated clicks (or spinner nudges) update the input instantly but only - // send once they settle for 300ms — sending on every single click was - // firing overlapping requests that raced each other and made the drawer - // visibly flicker/lag under quick clicking. - scheduleSend(form) { - const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId - if (!lineId) return this.send(form) - - clearTimeout(this.updateTimers.get(lineId)) - this.updateTimers.set(lineId, setTimeout(() => { - this.updateTimers.delete(lineId) - this.send(form) - }, 300)) - } - - async send(form) { - this.bodyTarget.setAttribute('aria-busy', 'true') - this.clearError() - - try { - const response = await fetch(form.action, { - method: 'POST', - headers: { - 'X-CSRF-TOKEN': csrfToken(), - 'X-Requested-With': 'XMLHttpRequest', - Accept: 'application/json', - }, - body: new FormData(form), - }) - - if (response.ok) { - this.replaceBody(await response.text()) - return - } - - const data = await response.json().catch(() => null) - this.showError(data?.error) - - // The rejected quantity (typed, or from a +/- click) is left - // sitting in the input with nothing to correct it — the update - // never reached the cart, so the input must be put back to what - // the cart actually still holds, not just left showing whatever - // was rejected. - const input = form.querySelector('[data-bbk-cart-confirmed-quantity]') - if (input) input.value = input.dataset.bbkCartConfirmedQuantity - } finally { - this.bodyTarget.removeAttribute('aria-busy') - } - } - - showError(message) { - if (!this.hasErrorTarget || !message) return - this.errorTarget.textContent = message - this.errorTarget.hidden = false - } - - clearError() { - if (!this.hasErrorTarget) return - this.errorTarget.hidden = true - } - - replaceBody(html) { - this.bodyTarget.innerHTML = html - this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]')) - } - - emitUpdated(node) { - if (!node) return - - window.dispatchEvent(new CustomEvent('bbk-cart:updated', { - detail: { - count: parseInt(node.dataset.bbkCartCount || '0', 10), - total: parseInt(node.dataset.bbkCartTotal || '0', 10), - }, - })) - } -} diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js deleted file mode 100644 index aa310ec..0000000 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ /dev/null @@ -1,226 +0,0 @@ -import { Controller } from '@hotwired/stimulus' -import { csrfToken } from './csrf' - -// Drives the checkout page's left column: contact tabs, the same-as-billing -// toggle, and — the bulk of it — autosaving the address form and the shipping -// method with no submit buttons. -// -// Flow: any `change` in the address form is debounced ~400ms, then the whole -// form is POSTed to saveUrl. The server persists leniently and returns -// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options -// block in place and hand the summary fragment to the drawer's bbk-cart -// controller via the `bbk-cart:changed` window event (same mechanism the drawer -// already uses). Shipping-method radios post to selectShippingUrl the same way. -export default class extends Controller { - static targets = [ - 'sameAsBilling', 'shippingFields', - 'form', 'shippingOptions', 'status', - ] - - static values = { - saveUrl: String, - selectShippingUrl: String, - statusSaving: String, - statusSaved: String, - statusError: String, - } - - connect() { - this.saveTimer = null - this.saveController = null - this.statusTimer = null - this.shippingPromise = null - - if (this.hasSameAsBillingTarget) this.applySameAsBilling() - } - - disconnect() { - clearTimeout(this.saveTimer) - clearTimeout(this.statusTimer) - this.saveController?.abort() - } - - // ── Same as billing ──────────────────────────────────────────────── - - toggleSameAsBilling() { - this.applySameAsBilling() - } - - applySameAsBilling() { - const on = this.sameAsBillingTarget.checked - - // Checked: shipping *is* billing — copy every value across, then hide + - // disable so the browser doesn't submit them; the server reuses billing. - // Unchecked: reveal them pre-filled from billing wherever still empty. - this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => { - const shippingField = this.element.querySelector( - `[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`, - ) - if (shippingField && (on || !shippingField.value)) { - shippingField.value = billingField.value - } - }) - - this.shippingFieldsTarget.hidden = on - this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => { - field.disabled = on - }) - } - - // ── Autosave ─────────────────────────────────────────────────────── - - scheduleSave(event) { - // The shipping-method and payment radios live inside this controller's - // element too, and this action is bound on .bbk-checkout-main to also - // catch the contact email/consent that sit outside the . Only - // react to fields that actually belong to the address form. - const el = event.target - const belongsToForm = el.form?.id === 'bbk-address-form' - if (!belongsToForm) return - - // No status during the wait — it only shows once the request is in flight, - // so the indicator isn't flickering "saving" on every keystroke. - clearTimeout(this.saveTimer) - this.saveTimer = setTimeout(() => this.save(), 700) - } - - // Called by bbk-payment right before place-order — a debounced save (and - // the shipping-option auto-select that happens as part of it) might still - // be pending when the shopper clicks "place order"; this guarantees the - // server has processed the current form state first. - async flush() { - clearTimeout(this.saveTimer) - await this.save() - // A shipping-method radio click fires its own (undebounced) request — - // still async, still racy against an immediate "place order" click. - if (this.shippingPromise) await this.shippingPromise - } - - async save() { - this.saveController?.abort() - this.saveController = new AbortController() - this.setStatus('saving') - - try { - const response = await fetch(this.saveUrlValue, { - method: 'POST', - headers: { - 'X-CSRF-TOKEN': csrfToken(), - 'X-Requested-With': 'XMLHttpRequest', - Accept: 'application/json', - }, - body: new FormData(this.formTarget), - signal: this.saveController.signal, - }) - - if (!response.ok) return this.setStatus('error') - - this.applyResult(await response.json()) - this.setStatus('saved') - } catch (error) { - if (error.name !== 'AbortError') this.setStatus('error') - } - } - - 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