Compare commits

...
14 changed files with 477 additions and 140 deletions
+50
View File
@@ -4,6 +4,56 @@ 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.27.4] - 2026-09-29
### Added
- Generic `.bbk-notice` / `.bbk-notice--info` message box and a
`--bbk-color-info` custom property in `checkout.css`.
- Cart drawer focus handling: focus moves into the drawer on open, stays
inside it, returns to the opener on close, and is restored after each
cart update. Updates are announced as "Cart updated" instead of re-reading
the whole cart. New translation line `checkout.cart.updated` — re-run
`CheckoutTranslationsSeeder` in consuming apps to add it.
### Changed
- Cart drawer line: larger remove button on the title row, line total on
the quantity-stepper row, and screen-reader labels tied to the product
name. `.bbk-cart-item-aside` is removed — hosts restyling it should target
`.bbk-cart-item-head` / `.bbk-cart-item-foot` instead.
- Order confirmation page: narrower (560px), the confirmation-email note is
now an info box under the heading, the order summary comes before
shipping/billing (shown side by side), and the order number is prefixed
with `#`.
## [0.27.3] - 2026-09-29
### Added
- The checkout confirmation page now ends with the store's bank transfer
instructions (Store Details → Bank transfer) for a bank transfer order,
via `StoreDetailsService::bankTransferInstructionsFor()`. New translation
line `checkout.page.confirmation_bank_transfer_heading` — re-run
`CheckoutTranslationsSeeder` in consuming apps to add it.
### Fixed
- `StoreDetailsService::bankTransferInstructionsFor()` now fills a
`{order_reference}` (or `{{ order_reference }}`) typed into the bank
transfer instructions with the order's display reference
(`OrderReferenceDisplay`). It previously rendered literally in the order
confirmation email.
## [0.27.2] - 2026-09-29
### Fixed
- `Modules\Core\Order\Services\TransactionRecorder::record()` — made idempotent
on `(order_id, type, reference)`. A successful Stripe payment can legitimately
report `PaymentCaptured` twice for the same PaymentIntent (checkout's
synchronous capture via `pay()`, then the webhook confirming the same
outcome asynchronously via `handleCallback()`), both routing through
`resultFromIntent()`. With no dedupe check, this wrote two identical
`Transaction` rows for one real payment. Now returns the existing row
instead of creating a duplicate.
## [0.27.1] - 2026-09-29
### Added
- Temp logger for Stripe webhook
## [0.27.0] - 2026-09-29 ## [0.27.0] - 2026-09-29
### Added ### Added
+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.27.0", "version": "0.27.4",
"autoload": { "autoload": {
"psr-4": { "psr-4": {
"Modules\\Core\\": "src/" "Modules\\Core\\": "src/"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@boboko/core", "name": "@boboko/core",
"version": "0.27.0", "version": "0.27.4",
"private": true, "private": true,
"type": "module", "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.", "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.",
+110 -26
View File
@@ -53,6 +53,7 @@
--bbk-color-accent: #18181b; --bbk-color-accent: #18181b;
--bbk-color-accent-text: #ffffff; --bbk-color-accent-text: #ffffff;
--bbk-color-danger: #dc2626; --bbk-color-danger: #dc2626;
--bbk-color-info: #2563eb;
--bbk-radius: 8px; --bbk-radius: 8px;
--bbk-radius-sm: 4px; --bbk-radius-sm: 4px;
@@ -153,6 +154,11 @@
outline-offset: 2px; outline-offset: 2px;
} }
/* Programmatic focus targets only (tabindex=-1) — never reached by Tab, so
no ring needed. */
.bbk-cart-heading:focus,
.bbk-checkout-summary-heading:focus { outline: none; }
.bbk-visually-hidden { .bbk-visually-hidden {
position: absolute; position: absolute;
width: 1px; width: 1px;
@@ -183,7 +189,7 @@
.bbk-cart-item { .bbk-cart-item {
display: grid; display: grid;
grid-template-columns: 72px 1fr auto; grid-template-columns: 72px 1fr;
gap: 0.875rem; gap: 0.875rem;
align-items: start; align-items: start;
} }
@@ -199,8 +205,23 @@
.bbk-cart-item-detail { min-width: 0; } .bbk-cart-item-detail { min-width: 0; }
/* Title + remove button share the first row; quantity stepper + line total
share the last one. */
.bbk-cart-item-head,
.bbk-cart-item-foot {
display: flex;
justify-content: space-between;
gap: 0.75rem;
}
.bbk-cart-item-head { align-items: flex-start; }
.bbk-cart-item-foot { align-items: center; }
.bbk-cart-item-remove-form { flex: 0 0 auto; }
.bbk-cart-item-title { .bbk-cart-item-title {
display: block; display: block;
min-width: 0;
margin: 0 0 0.25rem; margin: 0 0 0.25rem;
font-weight: 600; font-weight: 600;
color: inherit; color: inherit;
@@ -252,24 +273,24 @@ a.bbk-cart-item-title:hover { text-decoration: underline; }
color: var(--bbk-color-muted); color: var(--bbk-color-muted);
} }
.bbk-cart-item-aside { .bbk-cart-item-total { margin: 0; font-weight: 600; white-space: nowrap; }
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
.bbk-cart-item-total { margin: 0; font-weight: 600; }
/* 2.5rem hit area (same as the drawer's own close button), pulled up/right by
negative margins so the glyph lines up with the title's first line instead
of pushing the row taller. */
.bbk-cart-item-remove { .bbk-cart-item-remove {
font-size: 1.125rem; font-size: 1.75rem;
width: 1.5rem; width: 2.5rem;
height: 1.5rem; height: 2.5rem;
margin: -0.5rem -0.5rem 0 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
border-radius: var(--bbk-radius-sm);
} }
.bbk-cart-item-remove:hover { background: var(--bbk-color-bg-muted); }
.bbk-cart-qty { .bbk-cart-qty {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@@ -905,10 +926,39 @@ textarea.bbk-field-input { resize: vertical; }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── Notice ────────────────────────────────────────────────────────────
Inline, static message box: `.bbk-notice` + a tone modifier. Static
content, so no live-region role — for messages injected after load, add
role="status" (or role="alert" for errors) on the element itself. */
.bbk-notice {
--bbk-notice-color: var(--bbk-color-text);
display: flex;
align-items: flex-start;
gap: 0.625rem;
padding: 0.875rem 1rem;
border: 1px solid color-mix(in srgb, var(--bbk-notice-color) 25%, transparent);
border-radius: var(--bbk-radius-sm);
background: color-mix(in srgb, var(--bbk-notice-color) 6%, var(--bbk-color-bg));
color: var(--bbk-color-text);
font-size: 0.875rem;
}
.bbk-notice--info { --bbk-notice-color: var(--bbk-color-info); }
.bbk-notice-icon {
flex: 0 0 auto;
width: 1.25rem;
height: 1.25rem;
color: var(--bbk-notice-color);
}
.bbk-notice-text { margin: 0; align-self: center; }
/* ── Confirmation page ─────────────────────────────────────────────── */ /* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation { .bbk-confirmation {
max-width: 720px; max-width: 560px;
margin: 0 auto; margin: 0 auto;
padding: 3rem 1.5rem 5rem; padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font); font-family: var(--bbk-font);
@@ -916,7 +966,7 @@ textarea.bbk-field-input { resize: vertical; }
} }
.bbk-confirmation-heading { .bbk-confirmation-heading {
margin: 0 0 1rem; margin: 0 0 1.25rem;
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
} }
@@ -924,7 +974,7 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-ref { margin: 0 0 0.25rem; } .bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta { .bbk-confirmation-meta {
margin: 0 0 1rem; margin: 1.5rem 0 1rem;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.25rem; gap: 0.25rem;
@@ -940,17 +990,22 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); } .bbk-confirmation-meta-row dt { color: var(--bbk-color-muted); }
.bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; } .bbk-confirmation-meta-row dd { margin: 0; font-weight: 600; }
.bbk-confirmation-body { .bbk-confirmation-section {
margin: 2rem 0; margin-top: 2rem;
display: grid; padding-top: 1.5rem;
gap: 2.5rem; border-top: 1px solid var(--bbk-color-border);
} }
@media (min-width: 640px) { .bbk-confirmation-section-heading {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; } margin: 0 0 1rem;
font-size: 1.125rem;
font-weight: 700;
} }
.bbk-confirmation-lines { .bbk-confirmation-lines {
list-style: none;
margin: 0 0 1.25rem;
padding: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.75rem; gap: 0.75rem;
@@ -965,22 +1020,51 @@ textarea.bbk-field-input { resize: vertical; }
.bbk-confirmation-line-detail { min-width: 0; } .bbk-confirmation-line-detail { min-width: 0; }
.bbk-confirmation-line-name { margin: 0 0 0.25rem; }
.bbk-confirmation-line-total { margin: 0; white-space: nowrap; }
.bbk-confirmation-line-qty { color: var(--bbk-color-muted); } .bbk-confirmation-line-qty { color: var(--bbk-color-muted); }
.bbk-confirmation-lines .bbk-cart-summary { margin-top: 0.75rem; }
.bbk-confirmation-addresses { .bbk-confirmation-addresses {
display: flex; display: grid;
flex-direction: column;
gap: 1.5rem; gap: 1.5rem;
} }
.bbk-confirmation-address-heading { @media (min-width: 480px) {
.bbk-confirmation-addresses { grid-template-columns: 1fr 1fr; }
}
.bbk-confirmation-address-heading,
.bbk-confirmation-bank-transfer-heading {
margin: 0 0 0.5rem; margin: 0 0 0.5rem;
font-size: 0.9375rem; font-size: 0.9375rem;
font-weight: 700; font-weight: 700;
} }
/* Store-authored rich text (ManageStoreDetails' RichEditor) — may be
paragraphs, bold text or a bank/IBAN/BIC table. */
.bbk-confirmation-bank-transfer-body {
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.bbk-confirmation-bank-transfer-body > :first-child { margin-top: 0; }
.bbk-confirmation-bank-transfer-body > :last-child { margin-bottom: 0; }
.bbk-confirmation-bank-transfer-body table {
width: 100%;
border-collapse: collapse;
}
.bbk-confirmation-bank-transfer-body th,
.bbk-confirmation-bank-transfer-body td {
padding: 0.375rem 0.5rem;
border: 1px solid var(--bbk-color-border);
text-align: left;
vertical-align: top;
}
.bbk-address-lines { .bbk-address-lines {
font-style: normal; font-style: normal;
display: flex; display: flex;
+82 -3
View File
@@ -9,11 +9,13 @@ import { csrfToken } from './csrf'
// - handles the in-drawer quantity / remove forms (fetch + method spoofing) // - handles the in-drawer quantity / remove forms (fetch + method spoofing)
// - re-emits `bbk-cart:updated` {count, total} after every render so the host // - re-emits `bbk-cart:updated` {count, total} after every render so the host
// (e.g. the header bag icon) can react // (e.g. the header bag icon) can react
// - dialog focus handling: focus moves into the panel on open, Tab is kept
// inside it, and focus returns to whatever opened it on close
// //
// Appearance is entirely CSS-driven: open state is the data-bbk-cart-state // Appearance is entirely CSS-driven: open state is the data-bbk-cart-state
// attribute on the root, nothing here touches styles or class lists. // attribute on the root, nothing here touches styles or class lists.
export default class extends Controller { export default class extends Controller {
static targets = ['panel', 'body', 'error'] static targets = ['panel', 'body', 'error', 'heading', 'status']
connect() { connect() {
this.onChanged = this.onChanged.bind(this) this.onChanged = this.onChanged.bind(this)
@@ -40,19 +42,31 @@ export default class extends Controller {
} }
onKeydown(event) { onKeydown(event) {
if (event.key === 'Escape' && !this.element.hidden) this.close() // Only the drawer instance is a dialog — the checkout page's summary
// reuses this controller without a panel.
if (!this.hasPanelTarget || this.element.hidden) return
if (event.key === 'Escape') this.close()
if (event.key === 'Tab') this.trapFocus(event)
} }
open() { open() {
if (!this.element.hidden) return if (!this.element.hidden) return
this.returnFocusTo = document.activeElement
this.element.hidden = false this.element.hidden = false
// Next frame, so the panel transitions from its off-canvas start. // Next frame, so the panel transitions from its off-canvas start.
requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open')) requestAnimationFrame(() => {
this.element.setAttribute('data-bbk-cart-state', 'open')
if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
})
} }
close() { close() {
this.element.removeAttribute('data-bbk-cart-state') this.element.removeAttribute('data-bbk-cart-state')
if (this.returnFocusTo?.isConnected) this.returnFocusTo.focus({ preventScroll: true })
this.returnFocusTo = null
const panel = this.panelTarget const panel = this.panelTarget
const done = () => { const done = () => {
this.element.hidden = true this.element.hidden = true
@@ -132,6 +146,28 @@ export default class extends Controller {
} }
} }
// aria-modal hides the page from screen readers but doesn't stop Tab from
// walking out of the panel into it — wrap at either end instead.
trapFocus(event) {
const focusable = [...this.panelTarget.querySelectorAll(
'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
)].filter((el) => !el.closest('[hidden], [aria-hidden="true"]'))
if (!focusable.length) return
const first = focusable[0]
const last = focusable[focusable.length - 1]
const active = document.activeElement
if (event.shiftKey && (active === first || !this.panelTarget.contains(active) || (this.hasHeadingTarget && active === this.headingTarget))) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && (active === last || !this.panelTarget.contains(active))) {
event.preventDefault()
first.focus()
}
}
showError(message) { showError(message) {
if (!this.hasErrorTarget || !message) return if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message this.errorTarget.textContent = message
@@ -144,10 +180,53 @@ export default class extends Controller {
} }
replaceBody(html) { replaceBody(html) {
const restore = this.focusSnapshot()
this.bodyTarget.innerHTML = html this.bodyTarget.innerHTML = html
restore()
this.announce()
this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]')) this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]'))
} }
// Swapping the body destroys whatever control had focus (a qty stepper,
// a remove button, the coupon field), dropping keyboard/screen-reader
// users back at the top of the document. Returns a callback that, after
// the swap, re-focuses the equivalent control in the new markup — or the
// heading, when that control is gone (e.g. its line was just removed).
focusSnapshot() {
const active = document.activeElement
if (!active || !this.bodyTarget.contains(active)) return () => {}
let selector = null
if (active.id) {
selector = `#${CSS.escape(active.id)}`
} else {
const lineId = active.closest('[data-bbk-line-id]')?.dataset.bbkLineId
const dir = active.dataset.bbkCartDirParam
const control = ['bbk-cart-qty-input', 'bbk-cart-qty-btn', 'bbk-cart-item-remove']
.find((name) => active.classList.contains(name))
if (lineId && control) {
selector = `[data-bbk-line-id="${CSS.escape(lineId)}"] .${control}`
+ (dir ? `[data-bbk-cart-dir-param="${CSS.escape(dir)}"]` : '')
}
}
return () => {
const target = selector && this.bodyTarget.querySelector(selector)
if (target) target.focus({ preventScroll: true })
else if (this.hasHeadingTarget) this.headingTarget.focus({ preventScroll: true })
}
}
// Polite "Cart updated" — cleared first so an identical message is
// re-announced on the next update.
announce() {
if (!this.hasStatusTarget) return
const message = this.statusTarget.dataset.bbkCartMessage || ''
this.statusTarget.textContent = ''
requestAnimationFrame(() => { this.statusTarget.textContent = message })
}
emitUpdated(node) { emitUpdated(node) {
if (!node) return if (!node) return
+50 -20
View File
@@ -2,6 +2,9 @@
Order confirmation. Reached only via a session flash of the placed order id Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a (CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded. Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
$bankTransferInstructions is already-sanitized HTML from
StoreDetailsService::bankTransferInstructionsFor(), or null unless this
is a bank transfer order with instructions filled in for this locale.
--}} --}}
@extends('layouts.app') @extends('layouts.app')
@@ -11,10 +14,19 @@
<div class="bbk-confirmation"> <div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1> <h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<div class="bbk-notice bbk-notice--info">
<svg class="bbk-notice-icon" aria-hidden="true" focusable="false" viewBox="0 0 20 20" width="20" height="20">
<circle cx="10" cy="10" r="8.25" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M10 9v5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="10" cy="6.25" r="1" fill="currentColor"/>
</svg>
<p class="bbk-notice-text">{{ __('checkout.page.confirmation_email_note') }}</p>
</div>
<dl class="bbk-confirmation-meta"> <dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row"> <div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt> <dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd> <dd>#{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}</dd>
</div> </div>
@if ($order->billingAddress?->contact_email) @if ($order->billingAddress?->contact_email)
@@ -39,8 +51,6 @@
@endif @endif
</dl> </dl>
<p class="bbk-checkout-note">{{ __('checkout.page.confirmation_email_note') }}</p>
{{-- Guests: logging in with the order's email attaches it to an account {{-- Guests: logging in with the order's email attaches it to an account
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin), (boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
so it shows in their history. --}} so it shows in their history. --}}
@@ -53,21 +63,29 @@
@endif @endif
@endguest @endguest
<div class="bbk-confirmation-body"> <section class="bbk-confirmation-section" aria-labelledby="bbk-confirmation-summary-heading">
<div class="bbk-confirmation-lines"> <h2 class="bbk-confirmation-section-heading" id="bbk-confirmation-summary-heading">
{{ __('checkout.page.order_summary_heading') }}
</h2>
<ul class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line) @foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line"> <li class="bbk-confirmation-line">
<div class="bbk-cart-item-media"> <div class="bbk-cart-item-media">
{{-- alt="" — the description is right beside it. --}}
@if ($thumb = $line->purchasable?->getThumbnailImage()) @if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
@endif @endif
</div> </div>
<div class="bbk-confirmation-line-detail"> <div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name"> <p class="bbk-confirmation-line-name">
{{ $line->description }} {{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span> <span class="bbk-confirmation-line-qty">
<span aria-hidden="true">&times; {{ $line->quantity }}</span>
<span class="bbk-visually-hidden">— {{ __('checkout.cart.quantity') }}: {{ $line->quantity }}</span>
</span> </span>
</p>
@if ($line->option) @if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p> <p class="bbk-cart-item-variant">{{ $line->option }}</p>
@@ -76,9 +94,13 @@
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
</div> </div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span> <p class="bbk-confirmation-line-total">
</div> <span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->sub_total?->formatted() }}
</p>
</li>
@endforeach @endforeach
</ul>
<div class="bbk-cart-summary"> <div class="bbk-cart-summary">
<div class="bbk-cart-summary-row"> <div class="bbk-cart-summary-row">
@@ -110,23 +132,31 @@
<span>{{ $order->total?->formatted() }}</span> <span>{{ $order->total?->formatted() }}</span>
</div> </div>
</div> </div>
</div> </section>
<div class="bbk-confirmation-addresses"> @if ($order->shippingAddress || $order->billingAddress)
<div class="bbk-confirmation-section bbk-confirmation-addresses">
@if ($order->shippingAddress) @if ($order->shippingAddress)
<div class="bbk-confirmation-address"> <section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-shipping-heading">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2> <h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-shipping-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" /> <x-checkout::address-lines :address="$order->shippingAddress" />
</div> </section>
@endif @endif
@if ($order->billingAddress) @if ($order->billingAddress)
<div class="bbk-confirmation-address"> <section class="bbk-confirmation-address" aria-labelledby="bbk-confirmation-billing-heading">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2> <h2 class="bbk-confirmation-address-heading" id="bbk-confirmation-billing-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" /> <x-checkout::address-lines :address="$order->billingAddress" />
</div> </section>
@endif @endif
</div> </div>
</div> @endif
@if ($bankTransferInstructions)
<section class="bbk-confirmation-section bbk-confirmation-bank-transfer" aria-labelledby="bbk-confirmation-bank-transfer-heading">
<h2 class="bbk-confirmation-bank-transfer-heading" id="bbk-confirmation-bank-transfer-heading">{{ __('checkout.page.confirmation_bank_transfer_heading') }}</h2>
<div class="bbk-confirmation-bank-transfer-body">{!! $bankTransferInstructions !!}</div>
</section>
@endif
</div> </div>
@endsection @endsection
+13 -2
View File
@@ -15,7 +15,9 @@
data-bbk-cart-target="panel" data-bbk-cart-target="panel"
> >
<header class="bbk-cart-panel-header"> <header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2> {{-- tabindex=-1: the controller moves focus here on open, and back
here when the focused line is removed from under the user. --}}
<h2 class="bbk-cart-heading" id="bbk-cart-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.cart.title') }}</h2>
<button <button
type="button" type="button"
class="bbk-cart-dismiss" class="bbk-cart-dismiss"
@@ -26,7 +28,16 @@
@include('checkout::partials.cart-error') @include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite"> {{-- A short announcement after each update, rather than aria-live on
the body itself, which re-read the whole cart on every change. --}}
<p
class="bbk-visually-hidden"
role="status"
data-bbk-cart-target="status"
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
></p>
<div class="bbk-cart-panel-body" data-bbk-cart-target="body">
@include('checkout::partials.cart-body') @include('checkout::partials.cart-body')
</div> </div>
</aside> </aside>
+8 -2
View File
@@ -267,9 +267,15 @@
<aside class="bbk-checkout-aside"> <aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart"> <div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2> <h2 class="bbk-checkout-summary-heading" tabindex="-1" data-bbk-cart-target="heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error') @include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite"> <p
class="bbk-visually-hidden"
role="status"
data-bbk-cart-target="status"
data-bbk-cart-message="{{ __('checkout.cart.updated') }}"
></p>
<div data-bbk-cart-target="body">
@include('checkout::partials.cart-body') @include('checkout::partials.cart-body')
</div> </div>
</div> </div>
@@ -24,32 +24,60 @@
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}"> <li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media"> <div class="bbk-cart-item-media">
@if ($thumb) @if ($thumb)
{{-- Decorative duplicate of the title link below — hidden from AT
and skipped by keyboard so the product isn't announced twice. --}}
@if ($productUrl) @if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1"> <a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
</a> </a>
@else @else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy"> <img src="{{ $thumb }}" alt="" width="72" height="72" loading="lazy">
@endif @endif
@endif @endif
</div> </div>
<div class="bbk-cart-item-detail"> <div class="bbk-cart-item-detail">
<div class="bbk-cart-item-head">
@if ($productUrl) @if ($productUrl)
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a> <a href="{{ $productUrl }}" class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</a>
@else @else
<p class="bbk-cart-item-title">{{ $name }}</p> <p class="bbk-cart-item-title" id="bbk-cart-item-title-{{ $line->id }}">{{ $name }}</p>
@endif @endif
<form
class="bbk-cart-item-remove-form"
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
{{-- Named "Remove", described by the product title, so AT
hears which line it removes rather than a bare "Remove". --}}
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
aria-describedby="bbk-cart-item-title-{{ $line->id }}"
><span aria-hidden="true">&times;</span></button>
</form>
</div>
@if ($variantLabel) @if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p> <p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif @endif
@include('checkout::partials.line-custom-fields', ['line' => $line]) @include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p> <p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<div class="bbk-cart-item-foot">
{{-- role=group + the title as its name: entering the stepper
announces which product's quantity is being changed. --}}
<form <form
class="bbk-cart-qty" class="bbk-cart-qty"
method="POST" method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}" action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
role="group"
aria-labelledby="bbk-cart-item-title-{{ $line->id }}"
> >
@csrf @csrf
@method('PATCH') @method('PATCH')
@@ -59,7 +87,7 @@
data-action="bbk-cart#step" data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1" data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}" aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button> ><span aria-hidden="true">&minus;</span></button>
<input <input
type="number" type="number"
@@ -79,25 +107,13 @@
data-action="bbk-cart#step" data-action="bbk-cart#step"
data-bbk-cart-dir-param="1" data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}" aria-label="{{ __('checkout.cart.increase') }}"
>+</button> ><span aria-hidden="true">+</span></button>
</form> </form>
<p class="bbk-cart-item-total">
<span class="bbk-visually-hidden">{{ __('checkout.cart.total') }}:</span>
{{ $line->subTotal?->formatted() }}
</p>
</div> </div>
<div class="bbk-cart-item-aside">
<p class="bbk-cart-item-total">{{ $line->subTotal?->formatted() }}</p>
<form
method="POST"
action="{{ route('checkout.cart.remove', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button
type="submit"
class="bbk-cart-item-remove"
aria-label="{{ __('checkout.cart.remove') }}"
>&times;</button>
</form>
</div> </div>
</li> </li>
@@ -60,6 +60,7 @@ class CheckoutTranslationsSeeder extends Seeder
'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'], 'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'],
'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'], 'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'],
'cart.remove' => ['Remove', 'Αφαίρεση'], 'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.updated' => ['Cart updated', 'Το καλάθι ενημερώθηκε'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'], 'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'], 'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'], 'cart.shipping' => ['Shipping', 'Μεταφορικά'],
@@ -190,6 +191,10 @@ class CheckoutTranslationsSeeder extends Seeder
'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή', 'Θέλεις να παρακολουθείς την παραγγελία σου; Δημιούργησε λογαριασμό ή',
], ],
'page.confirmation_billing' => ['Billing', 'Χρέωση'], 'page.confirmation_billing' => ['Billing', 'Χρέωση'],
'page.confirmation_bank_transfer_heading' => [
'Bank transfer details',
'Στοιχεία τραπεζικής μεταφοράς',
],
'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'], 'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'],
'page.box_now_locker_label' => [ 'page.box_now_locker_label' => [
'Choose a Box Now locker', 'Choose a Box Now locker',
@@ -28,6 +28,7 @@ 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; use Modules\Core\Shipping\Carriers\BoxNow\BoxNowClient;
use Modules\Core\Store\Services\StoreDetailsService;
/** /**
* The checkout page — one page, sections (contact / billing / shipping / * The checkout page — one page, sections (contact / billing / shipping /
@@ -554,6 +555,8 @@ class CheckoutController extends Controller
return view('checkout::confirmation', [ return view('checkout::confirmation', [
'order' => $order, 'order' => $order,
'paymentMethodName' => $paymentMethodName, 'paymentMethodName' => $paymentMethodName,
'bankTransferInstructions' => app(StoreDetailsService::class)
->bankTransferInstructionsFor($order, $locale),
]); ]);
} }
@@ -39,8 +39,26 @@ class TransactionRecorder
* elsewhere in this codebase (see the old, now-removed * elsewhere in this codebase (see the old, now-removed
* TransactionRecorder this replaces). * TransactionRecorder this replaces).
*/ */
/**
* Idempotent on (order_id, type, reference): a successful payment
* outcome can legitimately be reported twice for the same gateway
* reference — e.g. Stripe's pay()/handleCallback() both call
* resultFromIntent() and both dispatch PaymentCaptured once a
* PaymentIntent reaches "succeeded" (checkout's synchronous capture,
* then the webhook confirming the same outcome asynchronously) — so
* this returns the existing row instead of writing a duplicate.
*/
public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction public function record(Order $order, string $type, string $driver, PaymentResult $result): Transaction
{ {
$existing = $order->transactions()
->where('type', $type)
->where('reference', $result->reference)
->first();
if ($existing !== null) {
return $existing;
}
return $order->transactions()->create([ return $order->transactions()->create([
'success' => $result->status === PaymentResultStatus::Succeeded, 'success' => $result->status === PaymentResultStatus::Succeeded,
'type' => $type, 'type' => $type,
@@ -33,6 +33,17 @@ class StripeWebhookMiddleware
$secret $secret
); );
} catch (UnexpectedValueException|SignatureVerificationException $e) { } catch (UnexpectedValueException|SignatureVerificationException $e) {
\Illuminate\Support\Facades\Log::error('Stripe webhook signature verification failed', [
'signature_header' => $stripeSig,
'secret_prefix' => substr((string) $secret, 0, 12),
'secret_length' => strlen((string) $secret),
'body_length' => strlen($request->getContent()),
'body_sha256' => hash('sha256', $request->getContent()),
'body_raw' => $request->getContent(),
'content_type' => $request->header('Content-Type'),
'content_encoding' => $request->header('Content-Encoding'),
]);
abort(400, $e->getMessage()); abort(400, $e->getMessage());
} }
+27 -3
View File
@@ -8,6 +8,7 @@ use Illuminate\Support\Facades\Event;
use Lunar\Models\Language; use Lunar\Models\Language;
use Lunar\Models\Order; use Lunar\Models\Order;
use Modules\Core\Order\Services\OrderStatusFlow; use Modules\Core\Order\Services\OrderStatusFlow;
use Modules\Core\Order\Support\OrderReferenceDisplay;
use Modules\Core\Store\Events\StoreDetailsUpdated; use Modules\Core\Store\Events\StoreDetailsUpdated;
use Modules\Core\Store\Models\StoreDetails; use Modules\Core\Store\Models\StoreDetails;
@@ -39,8 +40,8 @@ class StoreDetailsService
} }
/** /**
* Null for any non-bank-transfer order — the confirmation email only * Null for any non-bank-transfer order — the confirmation email and page
* shows this block when there's actually a wire to send (see * only show this block when there's actually a wire to send (see
* BankTransferPaymentDriver's own docblock for why a bank transfer * BankTransferPaymentDriver's own docblock for why a bank transfer
* order stays at 'awaiting_payment' until staff confirm the wire * order stays at 'awaiting_payment' until staff confirm the wire
* arrived). Null also when the store hasn't filled the field in for * arrived). Null also when the store hasn't filled the field in for
@@ -66,7 +67,30 @@ class StoreDetailsService
return null; return null;
} }
return RichContentRenderer::make($content)->toHtml(); return $this->fillOrderReference(
RichContentRenderer::make($content)->toHtml(),
$order,
);
}
/**
* Replaces a `{order_reference}` (or `{{ order_reference }}`) the shop
* owner typed into the instructions with the order's display reference
* (OrderReferenceDisplay — same form as the email subject).
*
* A plain text replace rather than RichContentRenderer::mergeTags():
* that only fills genuine Tiptap mergeTag nodes, which this editor never
* creates — Lunar's TranslatedText can't pass mergeTags() through to its
* per-locale RichEditors, so the placeholder is always stored as
* ordinary typed text.
*/
private function fillOrderReference(string $html, Order $order): string
{
return preg_replace(
'/\{\{?\s*order_reference\s*\}\}?/',
e(OrderReferenceDisplay::resolve($order)),
$html,
);
} }
public function update(array $attributes): StoreDetails public function update(array $attributes): StoreDetails