From fd3bbbe7de64db9488170cc0788eaa2e277f5dd3 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 25 Sep 2026 22:01:55 +0300 Subject: [PATCH] Chore: Updating Box Now controllers, updates to product indexer for recommended products --- .claude/settings.json | 41 ++++ 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 +++++ src/Catalog/Recommendations/RandomRule.php | 11 +- src/Catalog/Services/ProductIndexer.php | 17 ++ .../Seeders/CheckoutTranslationsSeeder.php | 28 +++ .../Http/Controllers/CheckoutController.php | 73 ++++++ src/Checkout/routes/checkout.php | 8 + 11 files changed, 612 insertions(+), 1 deletion(-) create mode 100644 .claude/settings.json create mode 100644 resources/js/checkout/bbk-box-now-locker-controller.js 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 ` +
+ ${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 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 +
+ + + diff --git a/src/Catalog/Recommendations/RandomRule.php b/src/Catalog/Recommendations/RandomRule.php index c55621f..4808b20 100644 --- a/src/Catalog/Recommendations/RandomRule.php +++ b/src/Catalog/Recommendations/RandomRule.php @@ -3,6 +3,8 @@ namespace Modules\Core\Catalog\Recommendations; use Illuminate\Support\Collection; +use Lunar\Facades\ModelManifest; +use Lunar\Models\Contracts\Product as ProductContract; use Lunar\Models\Product; use Modules\Core\Catalog\Contracts\RecommendationRule; @@ -18,7 +20,14 @@ class RandomRule implements RecommendationRule { public function recommend(Product $product, int $limit, array $exclude): Collection { - return Product::query() + // ModelManifest::get(), not Product::query() directly — the base + // Lunar\Models\Product has no custom_fields cast/fillable entry + // (see Catalog\Models\Product's own docblock), so a recommendation + // resolved as the base class silently lost that field once + // ProductIndexer started reading it for recommendations.has_custom_fields. + $model = ModelManifest::get(ProductContract::class); + + return $model::query() ->whereKeyNot($exclude) ->inRandomOrder() ->limit($limit) diff --git a/src/Catalog/Services/ProductIndexer.php b/src/Catalog/Services/ProductIndexer.php index b80892e..a30c874 100644 --- a/src/Catalog/Services/ProductIndexer.php +++ b/src/Catalog/Services/ProductIndexer.php @@ -189,6 +189,23 @@ class ProductIndexer extends BaseProductIndexer 'name' => $recommendation->translateAttribute('name'), 'price' => $this->cheapestPrice($recommendation, $currency), 'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null, + // Same fields ProductCard::fromIndexed() (3dealer) reads off + // a normal listing document to decide which button a card + // shows at all — a recommendation with neither used to + // render no button whatsoever, since it's built from this + // embedded shape rather than a full ProductService document. + // variant_id: same "first variant, no picker at card scope" + // default every other listing card uses. custom_fields is + // only readable at all because every RecommendationRule now + // resolves products through ModelManifest (see Recommendations\ + // RandomRule) rather than the base Lunar\Models\Product + // directly — that class has no custom_fields cast/fillable + // entry (see Catalog\Models\Product's own docblock), so a + // recommendation resolved as the base class would have + // silently read null here regardless of the product's real + // custom fields. + 'variant_id' => $recommendation->variants->first()?->id, + 'has_custom_fields' => ! empty($recommendation->custom_fields), ]) ->all(); diff --git a/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php b/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php index 5890967..162d182 100644 --- a/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php +++ b/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php @@ -180,6 +180,34 @@ class CheckoutTranslationsSeeder extends Seeder 'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'], 'page.confirmation_billing' => ['Billing', 'Χρέωση'], 'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'], + '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 για αυτή την αναζήτηση.', + ] ]; } } diff --git a/src/Checkout/Http/Controllers/CheckoutController.php b/src/Checkout/Http/Controllers/CheckoutController.php index 3efefc5..c686531 100644 --- a/src/Checkout/Http/Controllers/CheckoutController.php +++ b/src/Checkout/Http/Controllers/CheckoutController.php @@ -20,12 +20,14 @@ use Lunar\Models\Order; 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\Customer\Services\CustomerAccountService; use Modules\Core\Payment\Enums\PaymentResultStatus; use Modules\Core\Payment\Models\PaymentMethod; +use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient; /** * The checkout page — one page, sections (contact / billing / shipping / @@ -280,6 +282,77 @@ class CheckoutController extends Controller 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/src/Checkout/routes/checkout.php b/src/Checkout/routes/checkout.php index bda5764..7d01880 100644 --- a/src/Checkout/routes/checkout.php +++ b/src/Checkout/routes/checkout.php @@ -14,6 +14,7 @@ use Modules\Core\Checkout\Http\Controllers\CheckoutController; * Loaded from Providers\CheckoutModuleServiceProvider inside the `web` * middleware group. */ + Route::prefix('{locale}') ->middleware('locale') ->group(function () { @@ -27,6 +28,13 @@ Route::prefix('{locale}') 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');