From 6cf142e35b84afbfb733b6082b47bfd04744dfe0 Mon Sep 17 00:00:00 2001 From: elvira Date: Tue, 29 Sep 2026 21:25:28 +0300 Subject: [PATCH] Feat: Rework cart drawer line layout and order confirmation page --- resources/css/checkout.css | 115 ++++++++++++---- resources/js/checkout/bbk-cart-controller.js | 85 +++++++++++- .../views/checkout/confirmation.blade.php | 116 +++++++++------- resources/views/checkout/drawer.blade.php | 15 +- resources/views/checkout/page.blade.php | 10 +- .../checkout/partials/cart-line.blade.php | 128 ++++++++++-------- .../Seeders/CheckoutTranslationsSeeder.php | 1 + 7 files changed, 329 insertions(+), 141 deletions(-) diff --git a/resources/css/checkout.css b/resources/css/checkout.css index 7aed5b6..7cf136e 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -53,6 +53,7 @@ --bbk-color-accent: #18181b; --bbk-color-accent-text: #ffffff; --bbk-color-danger: #dc2626; + --bbk-color-info: #2563eb; --bbk-radius: 8px; --bbk-radius-sm: 4px; @@ -153,6 +154,11 @@ 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 { position: absolute; width: 1px; @@ -183,7 +189,7 @@ .bbk-cart-item { display: grid; - grid-template-columns: 72px 1fr auto; + grid-template-columns: 72px 1fr; gap: 0.875rem; align-items: start; } @@ -199,8 +205,23 @@ .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 { display: block; + min-width: 0; margin: 0 0 0.25rem; font-weight: 600; color: inherit; @@ -252,24 +273,24 @@ a.bbk-cart-item-title:hover { text-decoration: underline; } color: var(--bbk-color-muted); } -.bbk-cart-item-aside { - display: flex; - flex-direction: column; - align-items: flex-end; - gap: 0.5rem; -} - -.bbk-cart-item-total { margin: 0; font-weight: 600; } +.bbk-cart-item-total { margin: 0; font-weight: 600; white-space: nowrap; } +/* 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 { - font-size: 1.125rem; - width: 1.5rem; - height: 1.5rem; + font-size: 1.75rem; + width: 2.5rem; + height: 2.5rem; + margin: -0.5rem -0.5rem 0 0; display: inline-flex; align-items: center; justify-content: center; + border-radius: var(--bbk-radius-sm); } +.bbk-cart-item-remove:hover { background: var(--bbk-color-bg-muted); } + .bbk-cart-qty { display: inline-flex; align-items: center; @@ -905,10 +926,39 @@ textarea.bbk-field-input { resize: vertical; } 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 ─────────────────────────────────────────────── */ .bbk-confirmation { - max-width: 720px; + max-width: 560px; margin: 0 auto; padding: 3rem 1.5rem 5rem; font-family: var(--bbk-font); @@ -916,7 +966,7 @@ textarea.bbk-field-input { resize: vertical; } } .bbk-confirmation-heading { - margin: 0 0 1rem; + margin: 0 0 1.25rem; font-size: 1.75rem; font-weight: 700; } @@ -924,7 +974,7 @@ textarea.bbk-field-input { resize: vertical; } .bbk-confirmation-ref { margin: 0 0 0.25rem; } .bbk-confirmation-meta { - margin: 0 0 1rem; + margin: 1.5rem 0 1rem; display: flex; flex-direction: column; 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 dd { margin: 0; font-weight: 600; } -.bbk-confirmation-body { - margin: 2rem 0; - display: grid; - gap: 2.5rem; +.bbk-confirmation-section { + margin-top: 2rem; + padding-top: 1.5rem; + border-top: 1px solid var(--bbk-color-border); } -@media (min-width: 640px) { - .bbk-confirmation-body { grid-template-columns: 1fr 1fr; } +.bbk-confirmation-section-heading { + margin: 0 0 1rem; + font-size: 1.125rem; + font-weight: 700; } .bbk-confirmation-lines { + list-style: none; + margin: 0 0 1.25rem; + padding: 0; display: flex; flex-direction: column; gap: 0.75rem; @@ -965,16 +1020,21 @@ textarea.bbk-field-input { resize: vertical; } .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-lines .bbk-cart-summary { margin-top: 0.75rem; } - .bbk-confirmation-addresses { - display: flex; - flex-direction: column; + display: grid; gap: 1.5rem; } +@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; @@ -982,11 +1042,6 @@ textarea.bbk-field-input { resize: vertical; } font-weight: 700; } -.bbk-confirmation-bank-transfer { - padding-top: 1.5rem; - border-top: 1px solid var(--bbk-color-border); -} - /* Store-authored rich text (ManageStoreDetails' RichEditor) — may be paragraphs, bold text or a bank/IBAN/BIC table. */ .bbk-confirmation-bank-transfer-body { diff --git a/resources/js/checkout/bbk-cart-controller.js b/resources/js/checkout/bbk-cart-controller.js index dfe5901..5834150 100644 --- a/resources/js/checkout/bbk-cart-controller.js +++ b/resources/js/checkout/bbk-cart-controller.js @@ -9,11 +9,13 @@ import { csrfToken } from './csrf' // - handles the in-drawer quantity / remove forms (fetch + method spoofing) // - re-emits `bbk-cart:updated` {count, total} after every render so the host // (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 // attribute on the root, nothing here touches styles or class lists. export default class extends Controller { - static targets = ['panel', 'body', 'error'] + static targets = ['panel', 'body', 'error', 'heading', 'status'] connect() { this.onChanged = this.onChanged.bind(this) @@ -40,19 +42,31 @@ export default class extends Controller { } 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() { if (!this.element.hidden) return + this.returnFocusTo = document.activeElement this.element.hidden = false // 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() { this.element.removeAttribute('data-bbk-cart-state') + if (this.returnFocusTo?.isConnected) this.returnFocusTo.focus({ preventScroll: true }) + this.returnFocusTo = null + const panel = this.panelTarget const done = () => { 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) { if (!this.hasErrorTarget || !message) return this.errorTarget.textContent = message @@ -144,10 +180,53 @@ export default class extends Controller { } replaceBody(html) { + const restore = this.focusSnapshot() this.bodyTarget.innerHTML = html + restore() + this.announce() 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) { if (!node) return diff --git a/resources/views/checkout/confirmation.blade.php b/resources/views/checkout/confirmation.blade.php index d7b127a..39ad613 100644 --- a/resources/views/checkout/confirmation.blade.php +++ b/resources/views/checkout/confirmation.blade.php @@ -14,10 +14,19 @@

{{ __('checkout.page.confirmation_heading') }}

+
+ +

{{ __('checkout.page.confirmation_email_note') }}

+
+
{{ __('checkout.page.confirmation_order_number') }}
-
{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}
+
#{{ \Modules\Core\Order\Support\OrderReferenceDisplay::resolve($order) }}
@if ($order->billingAddress?->contact_email) @@ -42,8 +51,6 @@ @endif
-

{{ __('checkout.page.confirmation_email_note') }}

- {{-- Guests: logging in with the order's email attaches it to an account (boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin), so it shows in their history. --}} @@ -56,21 +63,29 @@ @endif @endguest -
-
+
+

+ {{ __('checkout.page.order_summary_heading') }} +

+ +
    @foreach ($order->lines->where('type', '!=', 'shipping') as $line) -
    +
  • + {{-- alt="" — the description is right beside it. --}} @if ($thumb = $line->purchasable?->getThumbnailImage()) - {{ $line->description }} + @endif
    - +

    {{ $line->description }} - × {{ $line->quantity }} - + + + — {{ __('checkout.cart.quantity') }}: {{ $line->quantity }} + +

    @if ($line->option)

    {{ $line->option }}

    @@ -79,62 +94,67 @@ @include('checkout::partials.line-custom-fields', ['line' => $line])
    - {{ $line->sub_total?->formatted() }} -
  • +

    + {{ __('checkout.cart.total') }}: + {{ $line->sub_total?->formatted() }} +

    + @endforeach +
-
+
+
+ {{ __('checkout.cart.subtotal') }} + {{ $order->sub_total?->formatted() }} +
+ + @if ($order->discount_total?->value > 0) +
+ {{ __('checkout.cart.discount') }} + −{{ $order->discount_total->formatted() }} +
+ @endif + +
+ {{ __('checkout.cart.shipping') }} + {{ $order->shipping_total?->formatted() }} +
+ + @if ($order->tax_total?->value > 0)
- {{ __('checkout.cart.subtotal') }} - {{ $order->sub_total?->formatted() }} + {{ __('checkout.cart.tax') }} + {{ $order->tax_total->formatted() }}
+ @endif - @if ($order->discount_total?->value > 0) -
- {{ __('checkout.cart.discount') }} - −{{ $order->discount_total->formatted() }} -
- @endif - -
- {{ __('checkout.cart.shipping') }} - {{ $order->shipping_total?->formatted() }} -
- - @if ($order->tax_total?->value > 0) -
- {{ __('checkout.cart.tax') }} - {{ $order->tax_total->formatted() }} -
- @endif - -
- {{ __('checkout.cart.total') }} - {{ $order->total?->formatted() }} -
+
+ {{ __('checkout.cart.total') }} + {{ $order->total?->formatted() }}
+
-
+ @if ($order->shippingAddress || $order->billingAddress) +
@if ($order->shippingAddress) -
-

{{ __('checkout.page.confirmation_shipping_to') }}

+
+

{{ __('checkout.page.confirmation_shipping_to') }}

-
+ @endif @if ($order->billingAddress) -
-

{{ __('checkout.page.confirmation_billing') }}

+
+

{{ __('checkout.page.confirmation_billing') }}

-
+ @endif
-
+ @endif @if ($bankTransferInstructions) -
-

{{ __('checkout.page.confirmation_bank_transfer_heading') }}

+
+

{{ __('checkout.page.confirmation_bank_transfer_heading') }}

{!! $bankTransferInstructions !!}
@endif diff --git a/resources/views/checkout/drawer.blade.php b/resources/views/checkout/drawer.blade.php index 24a98ed..570553b 100644 --- a/resources/views/checkout/drawer.blade.php +++ b/resources/views/checkout/drawer.blade.php @@ -15,7 +15,9 @@ data-bbk-cart-target="panel" >
-

{{ __('checkout.cart.title') }}

+ {{-- tabindex=-1: the controller moves focus here on open, and back + here when the focused line is removed from under the user. --}} +

{{ __('checkout.cart.title') }}

+ +
+ @if ($variantLabel)

{{ $variantLabel }}

@endif @include('checkout::partials.line-custom-fields', ['line' => $line])

{{ $line->unitPrice?->formatted() }}

-
- @csrf - @method('PATCH') - - - + {{-- role=group + the title as its name: entering the stepper + announces which product's quantity is being changed. --}} + + @csrf + @method('PATCH') + - -
-
+ -
-

{{ $line->subTotal?->formatted() }}

+ + -
- @csrf - @method('DELETE') - -
+

+ {{ __('checkout.cart.total') }}: + {{ $line->subTotal?->formatted() }} +

+
diff --git a/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php b/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php index 61057eb..4df6858 100644 --- a/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php +++ b/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php @@ -60,6 +60,7 @@ class CheckoutTranslationsSeeder extends Seeder 'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'], 'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'], 'cart.remove' => ['Remove', 'Αφαίρεση'], + 'cart.updated' => ['Cart updated', 'Το καλάθι ενημερώθηκε'], 'cart.subtotal' => ['Subtotal', 'Υποσύνολο'], 'cart.discount' => ['Discount', 'Έκπτωση'], 'cart.shipping' => ['Shipping', 'Μεταφορικά'],