generated from boboko/starter
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a51ce78538 | ||
|
|
78c8165c04 |
+2
-2
@@ -32,7 +32,7 @@
|
|||||||
"Tests\\": "tests/"
|
"Tests\\": "tests/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"repositories": [
|
"_repositories": [
|
||||||
{
|
{
|
||||||
"type": "path",
|
"type": "path",
|
||||||
"url": "../boboko-core",
|
"url": "../boboko-core",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"_repositories": [
|
"repositories": [
|
||||||
{
|
{
|
||||||
"type": "vcs",
|
"type": "vcs",
|
||||||
"url": "https://code.radical-elements.com/boboko/core.git"
|
"url": "https://code.radical-elements.com/boboko/core.git"
|
||||||
|
|||||||
Generated
+6
-10
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "ba9c83a482613f3bd1f9a2b82ab91f66",
|
"content-hash": "a89bc3e4e93372ee52664163d56f783b",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -516,10 +516,10 @@
|
|||||||
{
|
{
|
||||||
"name": "boboko/core",
|
"name": "boboko/core",
|
||||||
"version": "0.22.0",
|
"version": "0.22.0",
|
||||||
"dist": {
|
"source": {
|
||||||
"type": "path",
|
"type": "git",
|
||||||
"url": "../boboko-core",
|
"url": "https://code.radical-elements.com/boboko/core.git",
|
||||||
"reference": "547f07f01e043206d36b54290633f9f8fe6c1e14"
|
"reference": "985f53efa2d34849f35c8e0f4129f861be762e76"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"laravel/framework": "^12.0",
|
"laravel/framework": "^12.0",
|
||||||
@@ -552,7 +552,6 @@
|
|||||||
"Modules\\Core\\Providers\\AuthServiceProvider",
|
"Modules\\Core\\Providers\\AuthServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
"Modules\\Core\\Providers\\CustomerServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CheckoutServiceProvider",
|
"Modules\\Core\\Providers\\CheckoutServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CheckoutModuleServiceProvider",
|
|
||||||
"Modules\\Core\\Providers\\PaymentServiceProvider",
|
"Modules\\Core\\Providers\\PaymentServiceProvider",
|
||||||
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
"Modules\\Core\\Providers\\LocalizationServiceProvider",
|
||||||
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
"Modules\\Core\\Providers\\CatalogServiceProvider",
|
||||||
@@ -571,10 +570,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"description": "Core module — authentication and shared panel behaviour",
|
"description": "Core module — authentication and shared panel behaviour",
|
||||||
"transport-options": {
|
"time": "2026-09-25T12:58:41+00:00"
|
||||||
"symlink": true,
|
|
||||||
"relative": true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
|
|||||||
Generated
+8
-15
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "boboko-starter",
|
"name": "app",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
@@ -7,9 +7,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"@phosphor-icons/web": "^2.1.2",
|
|
||||||
"axios": "^1.15.2",
|
"axios": "^1.15.2",
|
||||||
"flatpickr": "^4.6.13"
|
"leaflet": "^1.9.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/vite": "^4.0.0",
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
@@ -147,12 +146,6 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"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": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.4.tgz",
|
"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": {
|
"node_modules/follow-redirects": {
|
||||||
"version": "1.16.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
|
"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": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -16,6 +16,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hotwired/stimulus": "^3.2.2",
|
"@hotwired/stimulus": "^3.2.2",
|
||||||
"@hotwired/turbo": "^8.0.23",
|
"@hotwired/turbo": "^8.0.23",
|
||||||
"axios": "^1.15.2"
|
"axios": "^1.15.2",
|
||||||
|
"leaflet": "^1.9.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -656,6 +656,141 @@ .bbk-checkout-status {
|
|||||||
|
|
||||||
.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)
|
||||||
|
|||||||
@@ -5,10 +5,6 @@
|
|||||||
inside the add-to-cart <form> so their values travel with it as
|
inside the add-to-cart <form> so their values travel with it as
|
||||||
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
custom_fields[key]. text → input, textarea → textarea, file → photo upload.
|
||||||
|
|
||||||
The label is styled like the product option labels (option-buttons /
|
|
||||||
color-swatch); the optional help_text shows under it. Help text is
|
|
||||||
product-page only; the cart/checkout line meta only snapshots the label.
|
|
||||||
|
|
||||||
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
|
A photo is uploaded as soon as it's picked (custom-field-upload-controller.js)
|
||||||
and only the resulting File row's id (boboko-core's Modules\Core\File\
|
and only the resulting File row's id (boboko-core's Modules\Core\File\
|
||||||
Models\File) is submitted with the form — the file input itself has no
|
Models\File) is submitted with the form — the file input itself has no
|
||||||
@@ -26,25 +22,18 @@
|
|||||||
$id = 'custom-field-'.$field['key'];
|
$id = 'custom-field-'.$field['key'];
|
||||||
$name = 'custom_fields['.$field['key'].']';
|
$name = 'custom_fields['.$field['key'].']';
|
||||||
$required = (bool) ($field['required'] ?? false);
|
$required = (bool) ($field['required'] ?? false);
|
||||||
$hint = filled($field['help_text'] ?? null) ? $field['help_text'] : null;
|
|
||||||
// mb-1 on top of the field's gap-2 matches the option labels' mb-3;
|
|
||||||
// with a hint, the label + hint group carries that spacing instead.
|
|
||||||
$labelClass = 'font-bold text-sm uppercase tracking-wide'.($hint ? '' : ' mb-1');
|
|
||||||
$hintId = $hint ? $id.'-hint' : null;
|
|
||||||
@endphp
|
@endphp
|
||||||
|
|
||||||
@switch($field['type'])
|
@switch($field['type'])
|
||||||
@case('textarea')
|
@case('textarea')
|
||||||
<x-ui.field :label="$field['label']" :label-class="$labelClass" :hint="$hint" :for="$id" :required="$required">
|
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
||||||
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" :aria-describedby="$hintId" />
|
<x-ui.textarea :id="$id" :name="$name" :required="$required" :rows="4" maxlength="2000" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
@break
|
@break
|
||||||
|
|
||||||
@case('file')
|
@case('file')
|
||||||
<x-ui.field
|
<x-ui.field
|
||||||
:label="$field['label']"
|
:label="$field['label']"
|
||||||
:label-class="$labelClass"
|
|
||||||
:hint="$hint"
|
|
||||||
:for="$id"
|
:for="$id"
|
||||||
:required="$required"
|
:required="$required"
|
||||||
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
|
:description="__('storefront.product.custom_field_photo_hint', ['size' => CustomFieldUploadController::MAX_KILOBYTES / 1024])"
|
||||||
@@ -60,7 +49,7 @@
|
|||||||
:id="$id"
|
:id="$id"
|
||||||
:accept="CustomFieldUploadController::accept()"
|
:accept="CustomFieldUploadController::accept()"
|
||||||
:required="$required"
|
:required="$required"
|
||||||
aria-describedby="{{ $hintId ? $hintId.' ' : '' }}{{ $id }}-description {{ $id }}-error"
|
aria-describedby="{{ $id }}-description {{ $id }}-error"
|
||||||
data-custom-field-upload-target="file"
|
data-custom-field-upload-target="file"
|
||||||
data-action="change->custom-field-upload#upload"
|
data-action="change->custom-field-upload#upload"
|
||||||
/>
|
/>
|
||||||
@@ -80,8 +69,8 @@ class="w-24 h-24 object-cover border border-black"
|
|||||||
@break
|
@break
|
||||||
|
|
||||||
@default
|
@default
|
||||||
<x-ui.field :label="$field['label']" :label-class="$labelClass" :hint="$hint" :for="$id" :required="$required">
|
<x-ui.field :label="$field['label']" :for="$id" :required="$required">
|
||||||
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" :aria-describedby="$hintId" />
|
<x-ui.input :id="$id" :name="$name" :required="$required" maxlength="255" />
|
||||||
</x-ui.field>
|
</x-ui.field>
|
||||||
@endswitch
|
@endswitch
|
||||||
@endforeach
|
@endforeach
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
@props([
|
@props([
|
||||||
'label' => null,
|
'label' => null,
|
||||||
'labelDescription' => null,
|
'labelDescription' => null,
|
||||||
'labelClass' => null,
|
|
||||||
'hint' => null,
|
|
||||||
'for' => null,
|
'for' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'error' => null,
|
'error' => null,
|
||||||
@@ -12,23 +10,9 @@
|
|||||||
<div {{ $attributes->merge(['class' => 'flex flex-col gap-2']) }}>
|
<div {{ $attributes->merge(['class' => 'flex flex-col gap-2']) }}>
|
||||||
|
|
||||||
@if ($label)
|
@if ($label)
|
||||||
{{-- With a hint, label + hint are grouped so the hint sits tight
|
|
||||||
under the label, with the usual gap before the input. The input
|
|
||||||
should reference {for}-hint in its aria-describedby. --}}
|
|
||||||
@if ($hint) <div class="flex flex-col gap-1"> @endif
|
|
||||||
|
|
||||||
<label
|
<label
|
||||||
@if ($for) for="{{ $for }}" @endif
|
@if ($for) for="{{ $for }}" @endif
|
||||||
@if ($labelClass) class="{{ $labelClass }}" @endif
|
|
||||||
>{{ $label }}@if ($required)<span class="ml-1" aria-hidden="true">*</span>@endif @if ($labelDescription) <span class="text-sm text-neutral-500">({{ $labelDescription }})</span> @endif</label>
|
>{{ $label }}@if ($required)<span class="ml-1" aria-hidden="true">*</span>@endif @if ($labelDescription) <span class="text-sm text-neutral-500">({{ $labelDescription }})</span> @endif</label>
|
||||||
|
|
||||||
@if ($hint)
|
|
||||||
<p
|
|
||||||
@if ($for) id="{{ $for }}-hint" @endif
|
|
||||||
class="text-sm text-neutral-500"
|
|
||||||
>{{ $hint }}</p>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
{{ $slot }}
|
{{ $slot }}
|
||||||
|
|||||||
Reference in New Issue
Block a user