Compare commits

...
4 Commits
19 changed files with 898 additions and 9 deletions
+41
View File
@@ -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"
]
}
}
+78
View File
@@ -4,8 +4,85 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.24.0] - 2026-09-28
### Added
- Box Now locker picker support for the newly-extracted checkout module: `CheckoutController::
boxNowLockers()`/`selectBoxNowLocker()`, the `bbk-box-now-locker-controller.js` Stimulus
controller, its picker markup in `shipping-options.blade.php`, and its styles in
`checkout.css` — the routes and translation seeder for this already existed from the previous
extraction, but the controller methods and JS/CSS themselves hadn't been carried over, leaving
the `checkout.box-now.lockers` route throwing `BadMethodCallException`.
- `ProductIndexer`'s `recommendations` entries now include `variant_id` and `has_custom_fields` —
previously only `{id, name, price, image}`, which left a recommended product's card with
neither an "Add to cart" nor a "Personalize" button, since a storefront card needs one of
those two fields to decide which to show at all.
- A real `package.json` for this package's JS (Stimulus controllers) and CSS, installed by a
consuming app as a normal npm dependency (`file:../boboko-core` in local dev, a tagged git
install — `git+https://...#semver:0.x`, mirroring `composer.json`'s own `0.*` constraint — in
prod) so `npm install`/`npm update @boboko/core` resolves this package's own JS dependencies
(`leaflet`, `@hotwired/stimulus`) transitively, the same way `composer update boboko/*` already
does for PHP. A consuming app registers `boboko()` from the new `vite-plugin.js` export in its
own `vite.config.js`, which encapsulates every quirk of that installation method (symlink
resolution, HMR watching, dependency pre-bundling) so the consumer's own config stays a
one-line plugin registration. Consumers import this package's JS from one stable entry point,
`resources/js/index.js` (`@boboko/core`'s package root export), rather than reaching into a
specific module's internal file layout directly — see this package's `CONTRIBUTE.md` and
`docs/modules.md`.
### Fixed
- `Catalog\Recommendations\RandomRule` resolved products via the base `Lunar\Models\Product`
directly instead of through `ModelManifest`, silently losing `custom_fields` (which only the
registered `Modules\Core\Catalog\Models\Product` subclass can read) for any recommendation it
produced. `SameCategoryRule` was already correct, since `Collection::products()` resolves via
Lunar's own `Product::modelClass()`.
## [0.23.0] - 2026-09-25
### Added
- `Modules\Core\Checkout\` - extracted Checkout and Cart view, resources, Controllers,
services, etc. to Core
## [0.22.0] - 2026-09-25 ## [0.22.0] - 2026-09-25
### Added
- `Modules\Core\Customer\Services\CustomerEmailChangeService` — changing an account's login
email (core's login is passwordless, so the email IS the login): `request()` validates the new
address is free and throttled (3 codes/10min), `confirm()` allows 5 wrong guesses per code,
re-checks the address is still free, switches it, notifies the old address (masked new
address), and claims guest orders for the new email. The pending change lives on the user's
own row (`pending_email`/`pending_email_code_hash`/`pending_email_expires_at`/
`pending_email_attempts` — new migration), the same convention as the existing OTP login
columns, rather than the session — a code arrives by email and is often opened on a different
device/session than the one that requested it. New core-owned mailables
(`Auth\Mail\EmailChangeCodeMail`/`EmailChangedNoticeMail`) with default views, overridable
per-app the same way `UserOtpMail`'s already is. Dispatches a new `Auth\Events\
UserEmailChanged` event.
- `Modules\Core\Customer\Services\CustomerAccountService::setRecoveryConsent()` — the account's
standing "email me a reminder if I don't finish my order" opt-in, written to the customer's
meta in the same shape `Checkout\Services\CheckoutService::setRecoveryConsent()` already writes
on the cart. Skips the write when nothing changed; dispatches a new `Customer\Events\
CustomerRecoveryConsentSet` event (also wired into the existing account-activity audit log).
3dealer's own duplicated implementations in `CheckoutController`/`AccountController` now call
this instead.
- `terms_accepted_at`/`terms_version`/`privacy_policy_version` columns on `users` — recorded once,
by a new `Auth\Listeners\RecordLegalAcceptanceForNewUser` (listening on `UserCreated`), the
moment a genuinely new signup requests their first OTP code; never touched again for an
existing user. Included in the User-scope privacy export (`CustomerDataProvider::
exportForUser()`).
- ~90 previously-unseeded `storefront.*` translation keys (login/OTP copy, account profile and
email-change flow, order history, contact form, product custom-fields and stock-error
messages, reviews, wishlist) added to `Localization\Services\StorefrontLabels` — these were
already called via `__()`/`trans_choice()` across a consuming app's views with no seeded
value at all, silently rendering the raw translation key in production.
### Fixed
- `CustomerAccountService::WRITABLE_PROFILE_FIELDS` listed `vat_no`, but Lunar's `customers`
column has been `tax_identifier` since a 2025 Lunar migration — passing `vat_no` was silently
dropped by the allowlist, and `tax_identifier` couldn't be written through `updateProfile()` at
### Added ### Added
- `Modules\Core\Customer\Services\CustomerEmailChangeService` — changing an account's login - `Modules\Core\Customer\Services\CustomerEmailChangeService` — changing an account's login
email (core's login is passwordless, so the email IS the login): `request()` validates the new email (core's login is passwordless, so the email IS the login): `request()` validates the new
@@ -100,6 +177,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.20.2] - 2026-09-25 ## [0.20.2] - 2026-09-25
## [0.22.0] - 2026-09-25
### Changed ### Changed
- Product custom fields (`Product::$custom_fields`) moved off the main product edit form onto - Product custom fields (`Product::$custom_fields`) moved off the main product edit form onto
their own "Custom Fields" sub-page (`Modules\Core\Catalog\Filament\Pages\ their own "Custom Fields" sub-page (`Modules\Core\Catalog\Filament\Pages\
+50
View File
@@ -31,6 +31,56 @@ This is shorthand for `docker compose -f docker-compose.dev.yml -f docker-compos
Skipping this step is the most common cause of "my change isn't showing up." Skipping this step is the most common cause of "my change isn't showing up."
## JS/CSS: no separate npm package
This package's JS (Stimulus controllers) and CSS ship as plain source files under `resources/js/` and `resources/css/`, read directly by a consumer app's own Vite build — there is no separate `@boboko/core` npm package, and no `npm install`/`file:` dependency step of any kind.
The reason: Composer already gives every environment one single, unconditional path — `vendor/boboko/core` — whether that resolves to a real symlink into `../boboko-core` (local path repo) or a real installed copy (tagged VCS release). A consumer's `vite.config.js` and JS entry point just read straight from that path, so there is nothing to toggle on the JS side — whatever Composer resolved is exactly what Vite sees, automatically, in both dev and prod.
**Stable entry point.** A consumer imports from [resources/js/index.js](resources/js/index.js) only — never from a path reaching into a specific module's internals (e.g. `resources/js/checkout/index.js` directly). That barrel file re-exports whatever a consumer needs (currently just `registerCheckout`), so this package's internal file layout can change without breaking every consumer's own entry point:
```js
// consumer app's resources/js/app.js
import { registerCheckout } from "../../vendor/boboko/core/resources/js/index.js";
registerCheckout(application);
```
```php
{{-- consumer app's layout --}}
@vite(['vendor/boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
```
**What a consumer's `vite.config.js` needs**, because `vendor/boboko/core` is a symlink in local path-repo dev (not a real directory):
```js
export default defineConfig({
server: {
watch: {
// vendor/boboko/core is a symlink into ../boboko-core in local
// path-repo dev. Vite/chokidar don't follow symlinks for watched
// files by default, so edits to core's source wouldn't otherwise
// trigger HMR. No-op against a real installed copy (tagged VCS
// release) in production — there's no symlink to follow, and
// production only ever runs a one-shot `npm run build`, which
// doesn't watch anything regardless.
followSymlinks: true,
},
},
});
```
Bare imports inside this package's own JS (`leaflet`, `@hotwired/stimulus`) resolve against the *consumer's* `node_modules` — Node's normal upward `node_modules` resolution walks from `vendor/boboko/core/resources/js/...` up through `vendor/boboko/`, `vendor/`, to the consumer app's root, where `node_modules` lives. This works with zero extra config as long as `vendor/boboko/core` sits inside the consumer's own directory tree (true for both the symlink and the real-copy case) — a consuming app's `vite`-equivalent Docker service just needs the same bind mount PHP containers already get, landing at the same path:
```yaml
# consumer app's docker-compose.core-dev.yml
services:
vite:
volumes:
- ../boboko-core:/app/vendor/boboko/core
```
(Match whatever the consumer's Vite container's working directory actually is — `/app` above, `/var/www/html` for the PHP containers in `boboko-test`'s convention.)
## Verifying changes against a real database ## Verifying changes against a real database
There is no automated test suite for this package — too much of Lunar's behavior (table prefixing, nested sets, translatable attributes, Filament panel filters) only breaks in combination, against real Postgres, in a way that's impractical to fake in isolation. Instead, verify changes directly against a consumer app's live database. The practical workflow used throughout this package's `MigrateImport` feature: There is no automated test suite for this package — too much of Lunar's behavior (table prefixing, nested sets, translatable attributes, Filament panel filters) only breaks in combination, against real Postgres, in a way that's impractical to fake in isolation. Instead, verify changes directly against a consumer app's live database. The practical workflow used throughout this package's `MigrateImport` feature:
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "boboko/core", "name": "boboko/core",
"description": "Core module — authentication and shared panel behaviour", "description": "Core module — authentication and shared panel behaviour",
"type": "library", "type": "library",
"version": "0.22.0", "version": "0.24.0",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+57
View File
@@ -122,6 +122,63 @@ Docker Compose merges `volumes:` lists additively across `-f` files, so the over
--- ---
## Frontend Assets (JS/CSS)
A module's JS (Stimulus controllers) and CSS ship as plain source files under `resources/js/` and `resources/css/` — **there is no separate npm package per module.** A module is never `npm install`ed; its frontend assets are read directly by the consuming app's own Vite build, straight out of `vendor/boboko/<module>`.
This mirrors the PHP story above exactly: Composer already gives every environment one single, unconditional path — `vendor/boboko/<module>` — whether that resolves to a symlink into a sibling checkout (local path repo) or a real installed copy (tagged VCS release). A consumer's `vite.config.js` and JS entry point read from that same path, so there is nothing to toggle on the JS side — whatever Composer resolved is exactly what Vite sees, in both dev and prod, automatically.
**Each module exposes one stable JS entry point** — `resources/js/index.js` — that re-exports whatever a consumer needs, e.g. `boboko-core`'s:
```js
// boboko-core/resources/js/index.js
export { registerCheckout } from './checkout/index.js'
```
A consuming app imports from that one file only, never from a path reaching into a module's internal folder structure directly:
```js
// consumer app's resources/js/app.js
import { registerCheckout } from "../../vendor/boboko/core/resources/js/index.js";
registerCheckout(application);
```
```php
{{-- consumer app's layout --}}
@vite(['vendor/boboko/core/resources/css/checkout.css', 'resources/css/app.css', 'resources/js/app.js'])
```
This keeps a module's internal file layout free to change without breaking every consumer's entry point — the same reasoning as PSR-4 namespaces for PHP, just for JS imports.
**A consuming app's `vite.config.js` needs one addition**, because `vendor/boboko/<module>` is a symlink in local path-repo dev (not a real directory Vite would otherwise watch through):
```js
export default defineConfig({
server: {
watch: {
// vendor/boboko/<module> is a symlink into ../boboko-<module> in
// local path-repo dev. Vite/chokidar don't follow symlinks for
// watched files by default, so edits to a module's source
// wouldn't otherwise trigger HMR. No-op against a real installed
// copy (tagged VCS release) in production.
followSymlinks: true,
},
},
});
```
Bare imports inside a module's own JS (e.g. `leaflet`, `@hotwired/stimulus`) resolve against the **consumer's** `node_modules` via Node's normal upward resolution walk from `vendor/boboko/<module>/resources/js/...` — no extra config needed, as long as `vendor/boboko/<module>` sits inside the consumer's own directory tree (true for both the symlink and real-copy case). The consumer's Vite Docker service (if any) needs the same bind mount the PHP containers already get, landing at the equivalent path relative to its own working directory:
```yaml
# consumer app's docker-compose.core-dev.yml
services:
vite:
volumes:
- ../boboko-core:/app/vendor/boboko/core # match /app to the vite service's actual workdir
```
---
## Creating a New Module ## Creating a New Module
**1. Create the repository and `composer.json`:** **1. Create the repository and `composer.json`:**
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@boboko/core",
"version": "0.24.0",
"private": true,
"type": "module",
"description": "Portable Stimulus controllers and styles for boboko-core's cart + checkout module. Installed as a real npm dependency (file:../boboko-core in dev, a tagged git install in prod) so a consuming app's `npm install` resolves this package's own dependencies (leaflet, @hotwired/stimulus) transitively, the same way `composer update boboko/*` does for PHP. See CONTRIBUTE.md's \"JS/CSS: a real npm package\" section.",
"exports": {
".": "./resources/js/index.js",
"./checkout": "./resources/js/checkout/index.js",
"./checkout/*": "./resources/js/checkout/*",
"./css/*": "./resources/css/*",
"./vite-plugin": "./vite-plugin.js"
},
"dependencies": {
"@hotwired/stimulus": "^3.2.2",
"leaflet": "^1.9.4"
},
"peerDependencies": {
"vite": "^8.0.0"
}
}
+135
View File
@@ -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: '&copy; 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')
+3
View File
@@ -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'
@@ -12,7 +13,9 @@ import BbkPaymentController from './bbk-payment-controller'
// When this module moves to boboko-core this file ships with it unchanged; // When this module moves to boboko-core this file ships with it unchanged;
// 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) {
console.log('[@boboko/core] checkout module loaded from', import.meta.url, '- test 2')
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)
+9
View File
@@ -0,0 +1,9 @@
// Single stable JS entry point for this package. A consuming app imports
// from here (vendor/boboko/core/resources/js/index.js), never from a path
// reaching into a specific module's internals — so this file's exports can
// grow or its modules' internal layout can change without breaking every
// consumer's own entry point.
//
// stoic_embed.js is not re-exported here: per its own docblock, it's a
// standalone vendored script meant to be included directly, not imported.
export { registerCheckout } from './checkout/index.js'
@@ -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>
+10 -1
View File
@@ -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)
+17
View File
@@ -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
+8
View File
@@ -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');
@@ -18,13 +18,16 @@ use Modules\Core\Cart\Services\CartService;
* CheckoutTranslationsSeeder rather than shipped as lang/ files. * CheckoutTranslationsSeeder rather than shipped as lang/ files.
* *
* A consuming app wires this module in with: * A consuming app wires this module in with:
* 1. `php artisan vendor:publish --tag=core-checkout-assets` — copies * 1. `"@boboko/core": "file:../boboko-core"` as an npm dependency (see this
* resources/js/checkout/** and resources/css/checkout.css into the * package's own package.json `exports`), with a bind-mount of the core
* host's own resources/ tree. Vite only ever bundles from a host's * checkout into the host's Vite container so the `file:` symlink
* own resources/ directory, so these are published (an explicit, * resolves in dev (see 3dealer's docker-compose.core-dev.yml) and
* host-owned, re-publishable copy) rather than imported cross-package. * `resolve.preserveSymlinks: true` in the host's vite.config.js so bare
* 2. `import { registerCheckout } from './checkout'` in the host's own * imports (stimulus, leaflet) still resolve against the host's own
* JS entry point, and a @vite entry for the published checkout.css. * node_modules through that symlink.
* 2. `import { registerCheckout } from '@boboko/core/checkout'` in the
* host's own JS entry point, and a @vite entry for
* `node_modules/@boboko/core/resources/css/checkout.css`.
* 3. `@include('checkout::drawer')` in the host's own layout. * 3. `@include('checkout::drawer')` in the host's own layout.
* See config/checkout.php for the handful of per-site settings (login * See config/checkout.php for the handful of per-site settings (login
* route, single-country mode, ...) a host is expected to publish and * route, single-country mode, ...) a host is expected to publish and
+59
View File
@@ -0,0 +1,59 @@
// Vite integration for @boboko/core, mirroring how CoreServiceProvider owns
// and ships its own PHP wiring instead of making every consumer hand-copy
// it. A consuming app's vite.config.js just does:
//
// import { boboko } from '@boboko/core/vite-plugin'
// export default defineConfig({ plugins: [..., boboko()] })
//
// All of the settings below exist only because @boboko/core is typically
// installed as a local `file:../boboko-core` path dependency in dev
// (symlinked into node_modules by npm) rather than a real installed copy —
// see this package's own CONTRIBUTE.md.
export function boboko() {
return {
name: 'boboko-core',
config() {
return {
optimizeDeps: {
// @boboko/core is a live local dependency in dev, not a
// stable third-party lib. Vite's dependency pre-bundler
// otherwise caches it once under node_modules/.vite/deps
// and never re-scans it on a plain source edit, silently
// serving a stale bundle. Excluding it makes Vite treat
// it like first-party source: always transformed live.
exclude: ['@boboko/core'],
// Excluding @boboko/core above means its own dependencies
// (leaflet, @hotwired/stimulus) are no longer discovered
// by Vite's dependency scanner, since that scanner only
// crawls from already-optimized entry points. Without
// this, leaflet is served straight from its raw UMD
// source instead of the pre-bundled ESM shim, and
// `import L from 'leaflet'` fails with "does not provide
// an export named 'default'". Forces pre-bundling
// regardless of how they're reached in the import graph.
include: ['leaflet', '@hotwired/stimulus'],
},
resolve: {
// The local `file:../boboko-core` form installs as a
// symlink, same as npm always does for a local `file:`
// target. Without this, Vite resolves the symlink's bare
// imports relative to its real path outside the
// consumer's own root, where there's no node_modules,
// instead of from the symlink's location in the
// consumer's own node_modules. Harmless no-op against a
// real installed copy (tagged VCS release).
preserveSymlinks: true,
},
server: {
watch: {
// Same symlink as above: Vite/chokidar don't follow
// symlinks for watched files by default, so edits to
// core's source wouldn't otherwise trigger HMR.
// No-op against a real installed copy.
followSymlinks: true,
},
},
}
},
}
}