Chore: Updating Box Now controllers, updates to product indexer for recommended products
This commit is contained in:
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -656,6 +656,141 @@ textarea.bbk-field-input { resize: vertical; }
|
|||||||
|
|
||||||
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
|
||||||
|
|
||||||
|
/* Dummy Box Now locker picker — see shipping-options.blade.php. */
|
||||||
|
.bbk-checkout-box-now-locker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding: 0.875rem 1rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-box-now-locker-label {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-box-now-locker-map {
|
||||||
|
width: 100%;
|
||||||
|
height: 480px;
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
z-index: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-box-now-locker-search {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
border: 1px solid var(--bbk-color-border);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-box-now-locker-search:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--bbk-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-checkout-box-now-locker-chosen {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bbk-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom SVG pin (see bbk-box-now-locker-controller.js pinIcon()) — no
|
||||||
|
default Leaflet drop-shadow image, so a CSS shadow stands in for it. */
|
||||||
|
.bbk-box-now-pin svg {
|
||||||
|
filter: drop-shadow(0 2px 3px rgb(0 0 0 / 0.35));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Leaflet's own popup chrome, restyled to match the checkout's cards
|
||||||
|
instead of the library's square-cornered default. */
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 8px 24px rgb(0 0 0 / 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.leaflet-popup-content {
|
||||||
|
margin: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-box-now-popup {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-box-now-popup-image {
|
||||||
|
width: calc(100% + 1.75rem);
|
||||||
|
margin: -0.875rem -0.875rem 0.125rem;
|
||||||
|
height: 110px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 0.75rem 0.75rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-box-now-popup-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-box-now-popup-name::before {
|
||||||
|
content: '';
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 0.5rem;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #00c389;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-box-now-popup-address {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 0.875rem;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-box-now-popup-note {
|
||||||
|
margin: 0 0 0 0.875rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
background: color-mix(in srgb, #00c389 8%, transparent);
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--bbk-color-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-box-now-popup-select {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
padding: 0.625rem 0.875rem;
|
||||||
|
width: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: var(--bbk-radius-sm);
|
||||||
|
background: #00c389;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
letter-spacing: 0.01em;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.15s ease, transform 0.1s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bbk-box-now-popup-select:hover { background: #00a876; transform: translateY(-1px); }
|
||||||
|
.bbk-box-now-popup-select:active { transform: translateY(0); }
|
||||||
|
|
||||||
|
.bbk-box-now-popup-select--selected,
|
||||||
|
.bbk-box-now-popup-select--selected:hover {
|
||||||
|
background: var(--bbk-color-muted);
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
/* Continue / submit buttons — same look as the drawer's checkout CTA */
|
||||||
|
|
||||||
.bbk-checkout-continue {
|
.bbk-checkout-continue {
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { Controller } from '@hotwired/stimulus'
|
||||||
|
import L from 'leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import { csrfToken } from './csrf'
|
||||||
|
|
||||||
|
// Box Now's own brand green, used for the pin instead of Leaflet's default
|
||||||
|
// blue teardrop — a small SVG data URI rather than another bundled asset.
|
||||||
|
const PIN_COLOR = '#00c389'
|
||||||
|
const PIN_COLOR_SELECTED = '#0a7a52'
|
||||||
|
|
||||||
|
function pinIcon(color) {
|
||||||
|
const svg = `
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="34" height="46" viewBox="0 0 34 46">
|
||||||
|
<path
|
||||||
|
d="M17 0C7.6 0 0 7.6 0 17c0 12.75 17 29 17 29s17-16.25 17-29C34 7.6 26.4 0 17 0Z"
|
||||||
|
fill="${color}"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
|
<circle cx="17" cy="17" r="7" fill="#ffffff" />
|
||||||
|
</svg>
|
||||||
|
`
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: 'bbk-box-now-pin',
|
||||||
|
html: svg,
|
||||||
|
iconSize: [34, 46],
|
||||||
|
iconAnchor: [17, 46],
|
||||||
|
popupAnchor: [0, -40],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const ICON = pinIcon(PIN_COLOR)
|
||||||
|
const ICON_SELECTED = pinIcon(PIN_COLOR_SELECTED)
|
||||||
|
|
||||||
|
// A self-hosted Leaflet map standing in for Box Now's own Destination Map
|
||||||
|
// JS widget — that widget only talks to Box Now's Production API (see
|
||||||
|
// their Partner API manual §4.1), so it can't be used while developing
|
||||||
|
// against Stage credentials. Same underlying /destinations data, rendered
|
||||||
|
// with OpenStreetMap tiles instead of Box Now's map.
|
||||||
|
//
|
||||||
|
// Visibility is toggled by bbk-checkout-form (see its own
|
||||||
|
// toggleBoxNowLocker()) whenever the "box-now" shipping option becomes
|
||||||
|
// selected/deselected — this controller only owns loading the locker list
|
||||||
|
// once visible, rendering pins, and autosaving the chosen one.
|
||||||
|
export default class extends Controller {
|
||||||
|
static targets = ['map', 'search', 'status', 'chosen']
|
||||||
|
|
||||||
|
static values = {
|
||||||
|
lockersUrl: String,
|
||||||
|
selectUrl: String,
|
||||||
|
loading: String,
|
||||||
|
selectLabel: String,
|
||||||
|
selectedLabel: String,
|
||||||
|
noResults: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Athens — a reasonable default center before any locker is loaded.
|
||||||
|
static DEFAULT_CENTER = [37.9838, 23.7275]
|
||||||
|
|
||||||
|
connect() {
|
||||||
|
this.map = null
|
||||||
|
this.markers = new Map()
|
||||||
|
this.selectedId = null
|
||||||
|
this.loaded = false
|
||||||
|
|
||||||
|
if (!this.element.hidden) this.show()
|
||||||
|
}
|
||||||
|
|
||||||
|
disconnect() {
|
||||||
|
this.map?.remove()
|
||||||
|
this.map = null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Called by bbk-checkout-form right after it un-hides this element.
|
||||||
|
show() {
|
||||||
|
this.element.hidden = false
|
||||||
|
|
||||||
|
// Leaflet measures its container's size on init — doing that while
|
||||||
|
// the element (or an ancestor) is still `hidden` produces a
|
||||||
|
// collapsed/blank map, so this is deferred to the same tick `hidden`
|
||||||
|
// is cleared, then Leaflet is nudged once more via invalidateSize().
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (!this.map) this.initMap()
|
||||||
|
this.map.invalidateSize()
|
||||||
|
|
||||||
|
if (!this.loaded) this.loadLockers()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hide() {
|
||||||
|
this.element.hidden = true
|
||||||
|
}
|
||||||
|
|
||||||
|
initMap() {
|
||||||
|
this.map = L.map(this.mapTarget).setView(this.constructor.DEFAULT_CENTER, 10)
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© OpenStreetMap contributors',
|
||||||
|
maxZoom: 19,
|
||||||
|
}).addTo(this.map)
|
||||||
|
|
||||||
|
// Delegated: popup content is re-inserted by Leaflet on every open,
|
||||||
|
// so a listener bound once on the map's container beats binding (and
|
||||||
|
// losing) one on the button each time a popup renders.
|
||||||
|
this.map.getContainer().addEventListener('click', (event) => {
|
||||||
|
const button = event.target.closest('[data-locker-id]')
|
||||||
|
if (button) this.select(button.dataset.lockerId)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadLockers() {
|
||||||
|
this.setStatus(this.loadingValue)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(this.lockersUrlValue, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
const { lockers } = await response.json()
|
||||||
|
this.loaded = true
|
||||||
|
this.lockers = new Map(lockers.map((locker) => [String(locker.id), locker]))
|
||||||
|
this.renderMarkers(lockers)
|
||||||
|
this.setStatus('')
|
||||||
|
} catch {
|
||||||
|
this.setStatus('')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMarkers(lockers) {
|
||||||
|
this.markers.forEach((marker) => marker.remove())
|
||||||
|
this.markers = new Map(lockers.map((locker) => {
|
||||||
|
const marker = L.marker([locker.lat, locker.lng], { icon: ICON })
|
||||||
|
.addTo(this.map)
|
||||||
|
.bindPopup(this.popupHtml(locker), { maxWidth: 260 })
|
||||||
|
|
||||||
|
return [String(locker.id), marker]
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (this.markers.size) {
|
||||||
|
this.map.fitBounds(L.featureGroup([...this.markers.values()]).getBounds().pad(0.2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
popupHtml(locker) {
|
||||||
|
const isSelected = String(locker.id) === this.selectedId
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="bbk-box-now-popup">
|
||||||
|
${locker.image ? `<img class="bbk-box-now-popup-image" src="${locker.image}" alt="">` : ''}
|
||||||
|
<p class="bbk-box-now-popup-name">${locker.name}</p>
|
||||||
|
<p class="bbk-box-now-popup-address">
|
||||||
|
${[locker.addressLine1, locker.addressLine2].filter(Boolean).join(', ')}
|
||||||
|
${locker.postalCode ? ` ${locker.postalCode}` : ''}
|
||||||
|
</p>
|
||||||
|
${locker.note ? `<p class="bbk-box-now-popup-note">${locker.note}</p>` : ''}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="bbk-box-now-popup-select${isSelected ? ' bbk-box-now-popup-select--selected' : ''}"
|
||||||
|
data-locker-id="${locker.id}"
|
||||||
|
${isSelected ? 'disabled' : ''}
|
||||||
|
>
|
||||||
|
${isSelected ? this.selectedLabelValue : this.selectLabelValue}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
|
||||||
|
async select(lockerId) {
|
||||||
|
const locker = this.lockers?.get(String(lockerId))
|
||||||
|
if (!locker) return
|
||||||
|
|
||||||
|
const previousId = this.selectedId
|
||||||
|
this.selectedId = String(lockerId)
|
||||||
|
|
||||||
|
this.restyleMarker(previousId, ICON)
|
||||||
|
this.restyleMarker(this.selectedId, ICON_SELECTED)
|
||||||
|
this.markers.get(this.selectedId)?.setPopupContent(this.popupHtml(locker))
|
||||||
|
|
||||||
|
this.chosenTarget.hidden = false
|
||||||
|
this.chosenTarget.textContent = locker.addressLine1
|
||||||
|
? `${locker.name} — ${locker.addressLine1}`
|
||||||
|
: locker.name
|
||||||
|
|
||||||
|
const body = new FormData()
|
||||||
|
body.append('locker_id', locker.id)
|
||||||
|
body.append('locker_name', locker.name ?? '')
|
||||||
|
body.append('locker_address', locker.addressLine1 ?? '')
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch(this.selectUrlValue, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'X-CSRF-TOKEN': csrfToken(),
|
||||||
|
'X-Requested-With': 'XMLHttpRequest',
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// Best-effort autosave, same as the rest of checkout — a failed
|
||||||
|
// save here surfaces later at place-order time via the normal
|
||||||
|
// shipment-creation error path, not as an inline field error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restyleMarker(lockerId, icon) {
|
||||||
|
if (!lockerId) return
|
||||||
|
this.markers.get(lockerId)?.setIcon(icon)
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus(text) {
|
||||||
|
if (!this.hasStatusTarget) return
|
||||||
|
|
||||||
|
this.statusTarget.textContent = text
|
||||||
|
this.statusTarget.hidden = !text
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -123,12 +123,34 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async selectShipping(event) {
|
async selectShipping(event) {
|
||||||
|
this.toggleBoxNowLocker(event.target.value)
|
||||||
|
|
||||||
// Tracked so flush() can await it — nothing else stops "place order"
|
// Tracked so flush() can await it — nothing else stops "place order"
|
||||||
// (a separate, unrelated click) from racing ahead of this request.
|
// (a separate, unrelated click) from racing ahead of this request.
|
||||||
this.shippingPromise = this.doSelectShipping(event.target.value)
|
this.shippingPromise = this.doSelectShipping(event.target.value)
|
||||||
await this.shippingPromise
|
await this.shippingPromise
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The dummy Box Now locker <select> (see shipping-options.blade.php)
|
||||||
|
// lives inside the #bbk-shipping-options fragment this controller
|
||||||
|
// re-renders wholesale on every shipping-option change — so its own
|
||||||
|
// Stimulus controller reconnects fresh each time and has no memory of
|
||||||
|
// which option was previously selected. This is the one place that
|
||||||
|
// knows the newly-chosen option's identifier, so it also owns
|
||||||
|
// showing/hiding the picker.
|
||||||
|
toggleBoxNowLocker(identifier) {
|
||||||
|
const picker = this.shippingOptionsTarget.querySelector('#bbk-box-now-locker')
|
||||||
|
if (!picker) return
|
||||||
|
|
||||||
|
const controller = this.application.getControllerForElementAndIdentifier(picker, 'bbk-box-now-locker')
|
||||||
|
|
||||||
|
if (identifier === 'box-now') {
|
||||||
|
controller?.show()
|
||||||
|
} else {
|
||||||
|
controller?.hide()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async doSelectShipping(value) {
|
async doSelectShipping(value) {
|
||||||
this.saveController?.abort()
|
this.saveController?.abort()
|
||||||
this.setStatus('saving')
|
this.setStatus('saving')
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
import BbkAddToCartController from './bbk-add-to-cart-controller'
|
||||||
|
import BbkBoxNowLockerController from './bbk-box-now-locker-controller'
|
||||||
import BbkCartController from './bbk-cart-controller'
|
import BbkCartController from './bbk-cart-controller'
|
||||||
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
import BbkCheckoutFormController from './bbk-checkout-form-controller'
|
||||||
import BbkPaymentController from './bbk-payment-controller'
|
import BbkPaymentController from './bbk-payment-controller'
|
||||||
@@ -13,6 +14,7 @@ import BbkPaymentController from './bbk-payment-controller'
|
|||||||
// only that one import line in the host entry point differs per project.
|
// only that one import line in the host entry point differs per project.
|
||||||
export function registerCheckout(application) {
|
export function registerCheckout(application) {
|
||||||
application.register('bbk-add-to-cart', BbkAddToCartController)
|
application.register('bbk-add-to-cart', BbkAddToCartController)
|
||||||
|
application.register('bbk-box-now-locker', BbkBoxNowLockerController)
|
||||||
application.register('bbk-cart', BbkCartController)
|
application.register('bbk-cart', BbkCartController)
|
||||||
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
application.register('bbk-checkout-form', BbkCheckoutFormController)
|
||||||
application.register('bbk-payment', BbkPaymentController)
|
application.register('bbk-payment', BbkPaymentController)
|
||||||
|
|||||||
@@ -48,3 +48,59 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</div>
|
</div>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
|
{{--
|
||||||
|
Dummy Box Now locker picker — a Leaflet map standing in for Box Now's own
|
||||||
|
Destination Map JS widget, which only talks to their Production API (not
|
||||||
|
Stage/sandbox — see their Partner API manual §4.1), making it useless
|
||||||
|
for local/staging development. Backed by the same GET /destinations data
|
||||||
|
(including lat/lng) via CheckoutController::boxNowLockers(). Only shown
|
||||||
|
once the "box-now" shipping option is selected (bbk-checkout-form
|
||||||
|
toggles [hidden] on shipping-option change; see bbk-box-now-locker
|
||||||
|
Stimulus controller). Persists the choice via a separate autosave POST
|
||||||
|
(checkout.box-now.locker.select) rather than piggybacking on the
|
||||||
|
shipping-option field, since the two are independent pieces of state
|
||||||
|
(method vs. destination) that CheckoutService models as two calls
|
||||||
|
(selectShippingOption() / selectBoxNowLocker()).
|
||||||
|
--}}
|
||||||
|
<div
|
||||||
|
id="bbk-box-now-locker"
|
||||||
|
class="bbk-checkout-box-now-locker"
|
||||||
|
data-controller="bbk-box-now-locker"
|
||||||
|
data-bbk-box-now-locker-lockers-url-value="{{ route('checkout.box-now.lockers', app()->getLocale()) }}"
|
||||||
|
data-bbk-box-now-locker-select-url-value="{{ route('checkout.box-now.locker.select', app()->getLocale()) }}"
|
||||||
|
data-bbk-box-now-locker-loading-value="{{ __('checkout.page.box_now_locker_loading') }}"
|
||||||
|
data-bbk-box-now-locker-select-label-value="{{ __('checkout.page.box_now_locker_select') }}"
|
||||||
|
data-bbk-box-now-locker-selected-label-value="{{ __('checkout.page.box_now_locker_selected') }}"
|
||||||
|
data-bbk-box-now-locker-no-results-value="{{ __('checkout.page.box_now_locker_no_results') }}"
|
||||||
|
@if ($selected !== 'box-now') hidden @endif
|
||||||
|
>
|
||||||
|
<p class="bbk-checkout-box-now-locker-label">
|
||||||
|
{{ __('checkout.page.box_now_locker_label') }}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
class="bbk-checkout-box-now-locker-search"
|
||||||
|
placeholder="{{ __('checkout.page.box_now_locker_search') }}"
|
||||||
|
data-bbk-box-now-locker-target="search"
|
||||||
|
data-action="input->bbk-box-now-locker#search"
|
||||||
|
autocomplete="off"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
id="bbk-box-now-locker-map"
|
||||||
|
class="bbk-checkout-box-now-locker-map"
|
||||||
|
data-bbk-box-now-locker-target="map"
|
||||||
|
></div>
|
||||||
|
<p
|
||||||
|
class="bbk-checkout-box-now-locker-chosen"
|
||||||
|
data-bbk-box-now-locker-target="chosen"
|
||||||
|
hidden
|
||||||
|
></p>
|
||||||
|
<p
|
||||||
|
class="bbk-checkout-status"
|
||||||
|
data-bbk-box-now-locker-target="status"
|
||||||
|
role="status"
|
||||||
|
aria-live="polite"
|
||||||
|
hidden
|
||||||
|
></p>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace Modules\Core\Catalog\Recommendations;
|
namespace Modules\Core\Catalog\Recommendations;
|
||||||
|
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
|
use Lunar\Facades\ModelManifest;
|
||||||
|
use Lunar\Models\Contracts\Product as ProductContract;
|
||||||
use Lunar\Models\Product;
|
use Lunar\Models\Product;
|
||||||
use Modules\Core\Catalog\Contracts\RecommendationRule;
|
use Modules\Core\Catalog\Contracts\RecommendationRule;
|
||||||
|
|
||||||
@@ -18,7 +20,14 @@ class RandomRule implements RecommendationRule
|
|||||||
{
|
{
|
||||||
public function recommend(Product $product, int $limit, array $exclude): Collection
|
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)
|
->whereKeyNot($exclude)
|
||||||
->inRandomOrder()
|
->inRandomOrder()
|
||||||
->limit($limit)
|
->limit($limit)
|
||||||
|
|||||||
@@ -189,6 +189,23 @@ class ProductIndexer extends BaseProductIndexer
|
|||||||
'name' => $recommendation->translateAttribute('name'),
|
'name' => $recommendation->translateAttribute('name'),
|
||||||
'price' => $this->cheapestPrice($recommendation, $currency),
|
'price' => $this->cheapestPrice($recommendation, $currency),
|
||||||
'image' => $recommendation->media->first() ? $this->mapMedia($recommendation->media->first())['thumb'] : null,
|
'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();
|
->all();
|
||||||
|
|
||||||
|
|||||||
@@ -180,6 +180,34 @@ class CheckoutTranslationsSeeder extends Seeder
|
|||||||
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
|
'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'],
|
||||||
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
|
'page.confirmation_billing' => ['Billing', 'Χρέωση'],
|
||||||
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
|
'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 για αυτή την αναζήτηση.',
|
||||||
|
]
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,12 +20,14 @@ use Lunar\Models\Order;
|
|||||||
use Lunar\Models\State;
|
use Lunar\Models\State;
|
||||||
use Modules\Core\Cart\Services\CartService;
|
use Modules\Core\Cart\Services\CartService;
|
||||||
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
|
||||||
|
use Modules\Core\Checkout\Exceptions\NoShippingAddressException;
|
||||||
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
use Modules\Core\Checkout\Exceptions\TermsNotAcceptedException;
|
||||||
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
use Modules\Core\Checkout\Exceptions\UnknownPaymentTypeException;
|
||||||
use Modules\Core\Checkout\Services\CheckoutService;
|
use Modules\Core\Checkout\Services\CheckoutService;
|
||||||
use Modules\Core\Customer\Services\CustomerAccountService;
|
use Modules\Core\Customer\Services\CustomerAccountService;
|
||||||
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
use Modules\Core\Payment\Enums\PaymentResultStatus;
|
||||||
use Modules\Core\Payment\Models\PaymentMethod;
|
use Modules\Core\Payment\Models\PaymentMethod;
|
||||||
|
use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The checkout page — one page, sections (contact / billing / shipping /
|
* The checkout page — one page, sections (contact / billing / shipping /
|
||||||
@@ -280,6 +282,77 @@ class CheckoutController extends Controller
|
|||||||
return $this->fragments($cart, $options);
|
return $this->fragments($cart, $options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plain, own-hosted stand-in for Box Now's Destination Map widget —
|
||||||
|
* that widget only talks to their Production environment (see their
|
||||||
|
* Partner API manual §4.1), which is useless while developing against
|
||||||
|
* Stage credentials. Same underlying data (GET /destinations), no map.
|
||||||
|
*/
|
||||||
|
public function boxNowLockers(string $locale, BoxNowClient $boxNow): JsonResponse
|
||||||
|
{
|
||||||
|
$lockers = collect($boxNow->destinations())
|
||||||
|
// Drops entries with a blank `name` (e.g. id 8288, "Virtual
|
||||||
|
// Locker" in Sudan at lat 12.3/lng 25.3) — a real, in-range
|
||||||
|
// coordinate, but sandbox test fixture noise rather than an
|
||||||
|
// actual pickup point, and it alone was enough to make
|
||||||
|
// fitBounds() below zoom the map out to the whole Balkans/
|
||||||
|
// Middle East to fit every marker's cluster in Greece.
|
||||||
|
->filter(fn (array $destination) => filled($destination['name'] ?? null))
|
||||||
|
->map(fn (array $destination) => [
|
||||||
|
'id' => $destination['id'],
|
||||||
|
'name' => $destination['name'] ?? $destination['title'] ?? $destination['id'],
|
||||||
|
'addressLine1' => $destination['addressLine1'] ?? null,
|
||||||
|
'addressLine2' => $destination['addressLine2'] ?? null,
|
||||||
|
'postalCode' => $destination['postalCode'] ?? null,
|
||||||
|
'country' => $destination['country'] ?? null,
|
||||||
|
'note' => $destination['note'] ?? null,
|
||||||
|
'image' => $destination['image'] ?? null,
|
||||||
|
'lat' => isset($destination['lat']) ? (float) $destination['lat'] : null,
|
||||||
|
'lng' => isset($destination['lng']) ? (float) $destination['lng'] : null,
|
||||||
|
])
|
||||||
|
// Box Now's own Stage/sandbox data has at least one malformed
|
||||||
|
// entry observed in practice (locker id 47: lat/lng as huge
|
||||||
|
// integers with the decimal point apparently dropped, e.g.
|
||||||
|
// 96065874308606 instead of ~37.96) — a single such point blows
|
||||||
|
// out L.featureGroup().getBounds() on the frontend, zooming the
|
||||||
|
// map out to near-nothing with every real marker imperceptible
|
||||||
|
// at that scale. Valid latitude/longitude ranges are absolute,
|
||||||
|
// not guesswork, so filtering on them is safe regardless of
|
||||||
|
// what BoxNow's API does or doesn't fix upstream.
|
||||||
|
->filter(fn (array $locker) => $locker['lat'] !== null && $locker['lng'] !== null
|
||||||
|
&& abs($locker['lat']) <= 90 && abs($locker['lng']) <= 180)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return response()->json(['lockers' => $lockers]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists the shopper's chosen locker (radio/select change, same
|
||||||
|
* autosave shape as selectShippingOption()) via
|
||||||
|
* CheckoutService::selectBoxNowLocker() onto the cart's shipping
|
||||||
|
* address meta.
|
||||||
|
*/
|
||||||
|
public function selectBoxNowLocker(string $locale, Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$locationId = (string) $request->input('locker_id');
|
||||||
|
|
||||||
|
if ($locationId === '') {
|
||||||
|
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->checkout->selectBoxNowLocker([
|
||||||
|
'locationId' => $locationId,
|
||||||
|
'name' => (string) $request->input('locker_name'),
|
||||||
|
'addressLine1' => (string) $request->input('locker_address'),
|
||||||
|
]);
|
||||||
|
} catch (NoShippingAddressException) {
|
||||||
|
return response()->json(['errors' => ['locker_id' => __('checkout.page.box_now_locker_required')]], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json(['ok' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Autosave-select a payment method (radio change). Persists it via
|
* Autosave-select a payment method (radio change). Persists it via
|
||||||
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
* CheckoutService (which also records it on Cart::meta and re-snapshots
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Modules\Core\Checkout\Http\Controllers\CheckoutController;
|
|||||||
* Loaded from Providers\CheckoutModuleServiceProvider inside the `web`
|
* Loaded from Providers\CheckoutModuleServiceProvider inside the `web`
|
||||||
* middleware group.
|
* middleware group.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
Route::prefix('{locale}')
|
Route::prefix('{locale}')
|
||||||
->middleware('locale')
|
->middleware('locale')
|
||||||
->group(function () {
|
->group(function () {
|
||||||
@@ -27,6 +28,13 @@ Route::prefix('{locale}')
|
|||||||
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
|
Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption'])
|
||||||
->name('checkout.shipping-option.select');
|
->name('checkout.shipping-option.select');
|
||||||
|
|
||||||
|
Route::get('checkout/box-now/lockers', [CheckoutController::class, 'boxNowLockers'])
|
||||||
|
->name('checkout.box-now.lockers');
|
||||||
|
|
||||||
|
Route::post('checkout/box-now/locker', [CheckoutController::class, 'selectBoxNowLocker'])
|
||||||
|
->name('checkout.box-now.locker.select');
|
||||||
|
|
||||||
|
|
||||||
Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
|
Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod'])
|
||||||
->name('checkout.payment-method.select');
|
->name('checkout.payment-method.select');
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user