From 7577426f49c62d75bdf54756f891ef097bf05bf6 Mon Sep 17 00:00:00 2001 From: elvira Date: Fri, 4 Sep 2026 19:39:37 +0300 Subject: [PATCH] cart drawer and general structure --- .../Controllers/Checkout/CartController.php | 89 ++++ .../CheckoutModuleServiceProvider.php | 53 +++ bootstrap/providers.php | 2 + resources/css/app.css | 6 + resources/css/checkout.css | 384 ++++++++++++++++++ resources/js/app.js | 5 + .../js/checkout/bbk-add-to-cart-controller.js | 38 ++ resources/js/checkout/bbk-cart-controller.js | 113 ++++++ resources/js/checkout/csrf.js | 6 + resources/js/checkout/index.js | 15 + .../js/stimulus/cart-count-controller.js | 31 ++ resources/js/stimulus/index.js | 2 + .../js/stimulus/product-form-controller.js | 7 + .../checkout/components/add-to-cart.blade.php | 42 ++ resources/views/checkout/drawer.blade.php | 31 ++ .../checkout/partials/cart-body.blade.php | 102 +++++ .../checkout/partials/cart-line.blade.php | 77 ++++ resources/views/components/header.blade.php | 18 +- resources/views/layouts/app.blade.php | 4 +- resources/views/product/show.blade.php | 10 +- routes/checkout.php | 39 ++ vite.config.js | 9 +- 22 files changed, 1075 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Checkout/CartController.php create mode 100644 app/Providers/CheckoutModuleServiceProvider.php create mode 100644 resources/css/checkout.css create mode 100644 resources/js/checkout/bbk-add-to-cart-controller.js create mode 100644 resources/js/checkout/bbk-cart-controller.js create mode 100644 resources/js/checkout/csrf.js create mode 100644 resources/js/checkout/index.js create mode 100644 resources/js/stimulus/cart-count-controller.js create mode 100644 resources/views/checkout/components/add-to-cart.blade.php create mode 100644 resources/views/checkout/drawer.blade.php create mode 100644 resources/views/checkout/partials/cart-body.blade.php create mode 100644 resources/views/checkout/partials/cart-line.blade.php create mode 100644 routes/checkout.php diff --git a/app/Http/Controllers/Checkout/CartController.php b/app/Http/Controllers/Checkout/CartController.php new file mode 100644 index 0000000..438195f --- /dev/null +++ b/app/Http/Controllers/Checkout/CartController.php @@ -0,0 +1,89 @@ +validate([ + 'purchasable_id' => ['required', 'integer'], + 'quantity' => ['nullable', 'integer', 'min:1'], + ]); + + $variant = ProductVariant::findOrFail($data['purchasable_id']); + + $this->cart->addLine($variant, $data['quantity'] ?? 1); + + return view('checkout::partials.cart-body'); + } + + public function updateLine(string $locale, Request $request, int $line): View + { + $quantity = (int) $request->validate([ + 'quantity' => ['required', 'integer', 'min:0'], + ])['quantity']; + + $quantity === 0 + ? $this->cart->removeLine($line) + : $this->cart->updateLine($line, $quantity); + + return view('checkout::partials.cart-body'); + } + + public function remove(string $locale, int $line): View + { + $this->cart->removeLine($line); + + return view('checkout::partials.cart-body'); + } + + /** + * A bad code is a normal, expected outcome here (typo, expired code), not + * an error state for the request — it re-renders the same cart-body + * partial with $couponError set, rather than a 4xx/redirect, so the fetch + * + swap in bbk-cart-controller stays the one code path for every cart + * mutation. + */ + public function applyCoupon(string $locale, Request $request): View + { + $code = $request->validate([ + 'code' => ['required', 'string'], + ])['code']; + + $couponError = false; + + try { + $this->cart->applyCoupon($code); + } catch (InvalidCouponException) { + $couponError = true; + } + + return view('checkout::partials.cart-body', ['couponError' => $couponError]); + } + + public function removeCoupon(string $locale): View + { + $this->cart->removeCoupon(); + + return view('checkout::partials.cart-body'); + } +} diff --git a/app/Providers/CheckoutModuleServiceProvider.php b/app/Providers/CheckoutModuleServiceProvider.php new file mode 100644 index 0000000..0080fd7 --- /dev/null +++ b/app/Providers/CheckoutModuleServiceProvider.php @@ -0,0 +1,53 @@ +loadViewsFrom(resource_path('views/checkout'), 'checkout'); + Blade::anonymousComponentNamespace('checkout::components', 'checkout'); + + Route::middleware('web')->group(base_path('routes/checkout.php')); + + // The drawer is rendered on every page (from the layout) and its body + // partial is re-rendered on every cart mutation — both need the current + // cart without a controller in the loop. + View::composer( + ['checkout::drawer', 'checkout::partials.cart-body'], + function (ViewInstance $view) { + $service = app(CartService::class); + $cart = $service->current(); + + $view->with('cart', $cart); + $view->with('lines', $cart ? $service->activeLines($cart) : collect()); + }, + ); + } +} diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 7efcc45..ff2f1e3 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,9 +1,11 @@ ), so: + * + * - a later PLAIN (unlayered) `.bbk-*` rule in the host stylesheet wins — + * same specificity, later in source order + * - a later host rule with a MORE specific selector wins regardless + * - a host rule inside `@layer components`/`@layer utilities` does NOT + * win — unlayered always beats layered. Theme this module from plain + * rules in app.css, not from inside a Tailwind layer. + * + * Two ways to theme this, cheapest first: + * + * 1. Redefine the --bbk-* custom properties below (from :root, or scoped to + * .bbk-cart for a cart-only override) — covers colour, radius, shadow, + * font without touching a single selector below. + * + * :root { --bbk-color-accent: var(--color-brand); --bbk-radius: 0; } + * + * 2. Override individual `.bbk-*` rules directly (as plain rules, per + * above) for anything structural (spacing, layout) the variables don't + * cover. + * + * This file's own look is a deliberately neutral placeholder — inoffensive, + * not "designed" — so a project always has something reasonable before it + * themes; it is not meant to be edited per project. + */ + +:root { + --bbk-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + + --bbk-color-text: #18181b; + --bbk-color-muted: #71717a; + --bbk-color-bg: #ffffff; + --bbk-color-bg-muted: #f4f4f5; + --bbk-color-border: #e4e4e7; + --bbk-color-accent: #18181b; + --bbk-color-accent-text: #ffffff; + --bbk-color-danger: #dc2626; + + --bbk-radius: 8px; + --bbk-radius-sm: 4px; + --bbk-shadow: 0 12px 32px rgba(0, 0, 0, 0.16); +} + +.bbk-cart[hidden] { display: none; } + +.bbk-cart { + position: fixed; + inset: 0; + z-index: 1000; + font-family: var(--bbk-font); + font-size: 0.9375rem; + line-height: 1.4; + color: var(--bbk-color-text); +} + +.bbk-cart-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.4); + opacity: 0; + transition: opacity 0.25s ease; +} + +.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-backdrop { opacity: 1; } + +.bbk-cart-panel { + position: absolute; + top: 0; + right: 0; + display: flex; + flex-direction: column; + width: min(420px, 100vw); + height: 100%; + background: var(--bbk-color-bg); + box-shadow: var(--bbk-shadow); + transform: translateX(100%); + transition: transform 0.25s ease; +} + +.bbk-cart[data-bbk-cart-state="open"] .bbk-cart-panel { transform: translateX(0); } + +.bbk-cart-panel-header { + flex: 0 0 auto; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 1.5rem 1.5rem 1.25rem; + border-bottom: 1px solid var(--bbk-color-border); +} + +.bbk-cart-heading { + margin: 0; + font-size: 1.375rem; + font-weight: 700; +} + +.bbk-cart-dismiss, +.bbk-cart-item-remove, +.bbk-cart-qty-btn { + cursor: pointer; + background: none; + border: 0; + padding: 0; + font: inherit; + line-height: 1; + color: var(--bbk-color-muted); + transition: color 0.15s ease, background-color 0.15s ease, border-color 0.15s ease; +} + +.bbk-cart-dismiss { + font-size: 1.75rem; + width: 2.5rem; + height: 2.5rem; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--bbk-radius-sm); + flex-shrink: 0; +} + +.bbk-cart-dismiss:hover { color: var(--bbk-color-text); background: var(--bbk-color-bg-muted); } + +.bbk-cart-item-remove:hover { color: var(--bbk-color-danger); } + +.bbk-cart-dismiss:focus-visible, +.bbk-cart-item-remove:focus-visible, +.bbk-cart-qty-btn:focus-visible, +.bbk-cart-qty-input:focus-visible, +.bbk-cart-checkout:focus-visible, +.bbk-cart-coupon-input:focus-visible, +.bbk-cart-coupon-submit:focus-visible, +.bbk-cart-coupon-remove:focus-visible { + outline: 2px solid var(--bbk-color-accent); + outline-offset: 2px; +} + +.bbk-visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.bbk-cart-panel-body { + flex: 1 1 auto; + overflow-y: auto; + overscroll-behavior: contain; + padding: 1.5rem; +} + +.bbk-cart-items { + list-style: none; + margin: 0 0 2rem; + padding: 0; + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.bbk-cart-item { + display: grid; + grid-template-columns: 72px 1fr auto; + gap: 0.875rem; + align-items: start; +} + +.bbk-cart-item-media img { + display: block; + width: 72px; + height: 72px; + object-fit: cover; + border-radius: var(--bbk-radius-sm); + background: var(--bbk-color-bg-muted); +} + +.bbk-cart-item-detail { min-width: 0; } + +.bbk-cart-item-title { + margin: 0 0 0.25rem; + font-weight: 600; +} + +.bbk-cart-item-unit { + margin: 0 0 0.625rem; + 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-remove { + font-size: 1.125rem; + width: 1.5rem; + height: 1.5rem; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.bbk-cart-qty { + display: inline-flex; + align-items: center; + gap: 0; + border: 1px solid var(--bbk-color-border); + border-radius: var(--bbk-radius-sm); + overflow: hidden; +} + +.bbk-cart-qty-btn { + width: 1.75rem; + height: 1.75rem; + background: var(--bbk-color-bg-muted); +} + +.bbk-cart-qty-btn:hover { background: var(--bbk-color-border); color: var(--bbk-color-text); } + +.bbk-cart-qty-input { + width: 2.25rem; + height: 1.75rem; + border: 0; + border-left: 1px solid var(--bbk-color-border); + border-right: 1px solid var(--bbk-color-border); + text-align: center; + font: inherit; + color: inherit; + background: var(--bbk-color-bg); + appearance: textfield; + -moz-appearance: textfield; +} + +.bbk-cart-qty-input::-webkit-outer-spin-button, +.bbk-cart-qty-input::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} + +.bbk-cart-summary { + padding-top: 1.25rem; + border-top: 1px solid var(--bbk-color-border); + display: flex; + flex-direction: column; + gap: 1rem; +} + +.bbk-cart-summary-row { + display: flex; + justify-content: space-between; + font-weight: 600; +} + +.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); } + +.bbk-cart-summary-row--total { + padding-top: 0.75rem; + border-top: 1px solid var(--bbk-color-border); + font-size: 1.0625rem; +} + +.bbk-cart-coupon-form { + display: flex; + gap: 0.5rem; +} + +.bbk-cart-coupon-input { + flex: 1 1 auto; + min-width: 0; + padding: 0.5rem 0.75rem; + border: 1px solid var(--bbk-color-border); + border-radius: var(--bbk-radius-sm); + font: inherit; + color: inherit; + background: var(--bbk-color-bg); +} + +.bbk-cart-coupon-submit { + flex: 0 0 auto; + padding: 0.5rem 0.875rem; + border: 1px solid var(--bbk-color-border); + border-radius: var(--bbk-radius-sm); + background: var(--bbk-color-bg-muted); + font: inherit; + font-weight: 600; + cursor: pointer; + transition: background-color 0.15s ease, border-color 0.15s ease; +} + +.bbk-cart-coupon-submit:hover { background: var(--bbk-color-border); } + +.bbk-cart-coupon-applied { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.625rem 0.875rem; + border: 1px solid var(--bbk-color-border); + border-radius: var(--bbk-radius-sm); + background: var(--bbk-color-bg-muted); +} + +.bbk-cart-coupon-code { + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.02em; +} + +.bbk-cart-coupon-remove { + flex: 0 0 auto; + background: none; + border: 0; + padding: 0; + font: inherit; + font-size: 0.8125rem; + color: var(--bbk-color-muted); + text-decoration: underline; + cursor: pointer; + transition: color 0.15s ease; +} + +.bbk-cart-coupon-remove:hover { color: var(--bbk-color-danger); } + +.bbk-cart-coupon-error { + margin: 0.5rem 0 0; + font-size: 0.8125rem; + color: var(--bbk-color-danger); +} + +.bbk-cart-checkout { + display: block; + width: 100%; + padding: 0.875rem 1.25rem; + border: 1px solid var(--bbk-color-accent); + border-radius: var(--bbk-radius); + background: var(--bbk-color-accent); + color: var(--bbk-color-accent-text); + font: inherit; + font-weight: 600; + text-align: center; + text-decoration: none; + cursor: pointer; + transition: opacity 0.15s ease; +} + +.bbk-cart-checkout:hover { opacity: 0.85; } + +.bbk-cart-checkout:disabled { + cursor: not-allowed; + opacity: 0.4; +} + +.bbk-cart-empty { + text-align: center; + color: var(--bbk-color-muted); + padding: 2.5rem 0; +} diff --git a/resources/js/app.js b/resources/js/app.js index 53024ee..82841fb 100644 --- a/resources/js/app.js +++ b/resources/js/app.js @@ -10,8 +10,13 @@ window.Turbo.session.drive = false; import { Application } from "@hotwired/stimulus"; import { registerControllers } from "./stimulus/index"; +import { registerCheckout } from "./checkout"; const application = Application.start(); application.debug = false; registerControllers(application); + +// Portable cart + checkout module (destined for boboko-core). Owns its own +// bbk-* Stimulus controllers; this is the only wiring line it needs here. +registerCheckout(application); diff --git a/resources/js/checkout/bbk-add-to-cart-controller.js b/resources/js/checkout/bbk-add-to-cart-controller.js new file mode 100644 index 0000000..49abb02 --- /dev/null +++ b/resources/js/checkout/bbk-add-to-cart-controller.js @@ -0,0 +1,38 @@ +import { Controller } from '@hotwired/stimulus' +import { csrfToken } from './csrf' + +// Sits on an
. Submits the line to the cart +// via fetch and hands the server-rendered cart body to the drawer through the +// `bbk-cart:changed` window event. No DOM building here — the drawer +// (bbk-cart-controller) owns rendering. +export default class extends Controller { + async add(event) { + event.preventDefault() + + const form = this.element + const submit = form.querySelector('[type="submit"]') + + form.setAttribute('data-bbk-add-to-cart-state', 'loading') + if (submit) submit.disabled = true + + try { + const response = await fetch(form.action, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': csrfToken(), + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new FormData(form), + }) + + if (!response.ok) return + + window.dispatchEvent(new CustomEvent('bbk-cart:changed', { + detail: { html: await response.text() }, + })) + } finally { + form.removeAttribute('data-bbk-add-to-cart-state') + if (submit) submit.disabled = false + } + } +} diff --git a/resources/js/checkout/bbk-cart-controller.js b/resources/js/checkout/bbk-cart-controller.js new file mode 100644 index 0000000..465e7c4 --- /dev/null +++ b/resources/js/checkout/bbk-cart-controller.js @@ -0,0 +1,113 @@ +import { Controller } from '@hotwired/stimulus' +import { csrfToken } from './csrf' + +// Drives the slide-in cart drawer. One instance, on the drawer root in +// checkout/drawer.blade.php. +// +// - listens on window for `bbk-cart:changed` (from bbk-add-to-cart and from +// this drawer's own line forms) and swaps in the server-rendered cart body +// - 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 +// +// 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'] + + connect() { + this.onChanged = this.onChanged.bind(this) + this.onKeydown = this.onKeydown.bind(this) + + window.addEventListener('bbk-cart:changed', this.onChanged) + window.addEventListener('bbk-cart:open', this.open.bind(this)) + document.addEventListener('keydown', this.onKeydown) + + // Prime the host with the count rendered server-side on page load. + this.emitUpdated(this.element.querySelector('[data-bbk-cart-count]')) + } + + disconnect() { + window.removeEventListener('bbk-cart:changed', this.onChanged) + document.removeEventListener('keydown', this.onKeydown) + } + + onChanged(event) { + if (event.detail?.html) this.replaceBody(event.detail.html) + this.open() + } + + onKeydown(event) { + if (event.key === 'Escape' && !this.element.hidden) this.close() + } + + open() { + if (!this.element.hidden) return + this.element.hidden = false + // Next frame, so the panel transitions from its off-canvas start. + requestAnimationFrame(() => this.element.setAttribute('data-bbk-cart-state', 'open')) + } + + close() { + this.element.removeAttribute('data-bbk-cart-state') + + const panel = this.panelTarget + const done = () => { + this.element.hidden = true + panel.removeEventListener('transitionend', done) + } + panel.addEventListener('transitionend', done) + } + + // change on a line quantity input, or submit of a line's remove form + submit(event) { + event.preventDefault() + const form = event.target.closest('form') + if (form) this.send(form) + } + + // +/- stepper buttons inside a line + step(event) { + event.preventDefault() + const form = event.target.closest('form') + const input = form.querySelector('input[type="number"]') + const next = Math.max(0, parseInt(input.value || '0', 10) + Number(event.params.dir)) + input.value = String(next) + this.send(form) + } + + async send(form) { + this.bodyTarget.setAttribute('aria-busy', 'true') + + try { + const response = await fetch(form.action, { + method: 'POST', + headers: { + 'X-CSRF-TOKEN': csrfToken(), + 'X-Requested-With': 'XMLHttpRequest', + }, + body: new FormData(form), + }) + + if (response.ok) this.replaceBody(await response.text()) + } finally { + this.bodyTarget.removeAttribute('aria-busy') + } + } + + replaceBody(html) { + this.bodyTarget.innerHTML = html + this.emitUpdated(this.bodyTarget.querySelector('[data-bbk-cart-count]')) + } + + emitUpdated(node) { + if (!node) return + + window.dispatchEvent(new CustomEvent('bbk-cart:updated', { + detail: { + count: parseInt(node.dataset.bbkCartCount || '0', 10), + total: parseInt(node.dataset.bbkCartTotal || '0', 10), + }, + })) + } +} diff --git a/resources/js/checkout/csrf.js b/resources/js/checkout/csrf.js new file mode 100644 index 0000000..b3a3757 --- /dev/null +++ b/resources/js/checkout/csrf.js @@ -0,0 +1,6 @@ +// Reads the CSRF token from the standard tag every +// boboko host renders in its layout . Kept as its own module so both +// checkout controllers share one source. +export function csrfToken() { + return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '' +} diff --git a/resources/js/checkout/index.js b/resources/js/checkout/index.js new file mode 100644 index 0000000..15f2df0 --- /dev/null +++ b/resources/js/checkout/index.js @@ -0,0 +1,15 @@ +import BbkAddToCartController from './bbk-add-to-cart-controller' +import BbkCartController from './bbk-cart-controller' + +// Registers the checkout module's Stimulus controllers onto the host app's +// Stimulus application. Call once from the host's JS entry point: +// +// import { registerCheckout } from './checkout' +// registerCheckout(application) +// +// 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. +export function registerCheckout(application) { + application.register('bbk-add-to-cart', BbkAddToCartController) + application.register('bbk-cart', BbkCartController) +} diff --git a/resources/js/stimulus/cart-count-controller.js b/resources/js/stimulus/cart-count-controller.js new file mode 100644 index 0000000..6c6090c --- /dev/null +++ b/resources/js/stimulus/cart-count-controller.js @@ -0,0 +1,31 @@ +import { Controller } from '@hotwired/stimulus' + +// 3dealer-side glue for the checkout module. The module owns the cart and emits +// `bbk-cart:updated` {count, total} on window after every change; this reflects +// the line count on the header bag icon. How (or whether) that count is shown +// is the host's call — hence this lives here, not in the module. +export default class extends Controller { + static targets = ['badge'] + + connect() { + this.onUpdate = (event) => this.render(event.detail?.count ?? 0) + window.addEventListener('bbk-cart:updated', this.onUpdate) + } + + disconnect() { + window.removeEventListener('bbk-cart:updated', this.onUpdate) + } + + // Header cart icon click — there's no separate cart page, the drawer IS + // the cart. `bbk-cart:open` is the module's own event, already listened + // for by bbk-cart-controller. + open() { + window.dispatchEvent(new CustomEvent('bbk-cart:open')) + } + + render(count) { + if (!this.hasBadgeTarget) return + this.badgeTarget.textContent = String(count) + this.badgeTarget.hidden = count < 1 + } +} diff --git a/resources/js/stimulus/index.js b/resources/js/stimulus/index.js index 4b152d2..d9341e6 100644 --- a/resources/js/stimulus/index.js +++ b/resources/js/stimulus/index.js @@ -6,6 +6,7 @@ import AppearController from './appear-controller' import AutoSubmitController from './auto-submit-controller' import BackToTopController from './back-to-top-controller' +import CartCountController from './cart-count-controller' import CarouselController from './carousel-controller' import DropdownController from './dropdown-controller' import FrameScrollController from './frame-scroll-controller' @@ -21,6 +22,7 @@ export function registerControllers(application) { application.register('appear', AppearController) application.register('auto-submit', AutoSubmitController) application.register('back-to-top', BackToTopController) + application.register('cart-count', CartCountController) application.register('carousel', CarouselController) application.register('dropdown', DropdownController) application.register('frame-scroll', FrameScrollController) diff --git a/resources/js/stimulus/product-form-controller.js b/resources/js/stimulus/product-form-controller.js index 80b0159..767b0a1 100644 --- a/resources/js/stimulus/product-form-controller.js +++ b/resources/js/stimulus/product-form-controller.js @@ -38,6 +38,13 @@ export default class extends Controller { this.imageTarget.src = variant.image } + // Keep the checkout module's add-to-cart form pointed at the chosen + // variant. [data-bbk-purchasable-input] is that module's documented + // hook (see resources/views/checkout/components/add-to-cart.blade.php); + // this is the one place the two touch. + const purchasableInput = this.element.querySelector('[data-bbk-purchasable-input]') + if (purchasableInput) purchasableInput.value = id + this.swatchTargets.forEach(swatch => { const isSelected = parseInt(swatch.dataset.variantId) === id swatch.classList.toggle('is-selected', isSelected) diff --git a/resources/views/checkout/components/add-to-cart.blade.php b/resources/views/checkout/components/add-to-cart.blade.php new file mode 100644 index 0000000..bcd2b05 --- /dev/null +++ b/resources/views/checkout/components/add-to-cart.blade.php @@ -0,0 +1,42 @@ +{{-- + + + A self-contained add-to-cart form. Posts the line via bbk-add-to-cart-controller + (fetch) and hands the rendered cart body to the drawer over the + `bbk-cart:changed` window event. + + Props: + purchasable ProductVariant id. Omit to render no hidden id field — the host + must then supply [data-bbk-purchasable-input] itself (e.g. a + variant picker writing the selected id into it). + quantity Integer for the hidden quantity field, or false to omit it + (the host then puts its own name="quantity" control in the slot). + + The button and any quantity control come from the slot, so the host owns all + appearance. Extra attributes (class, etc.) land on the . +--}} +@props([ + 'purchasable' => null, + 'quantity' => 1, + 'action' => null, +]) + +class('bbk-add-to-cart') }} +> + @csrf + + @if (! is_null($purchasable)) + + @endif + + @if ($quantity !== false) + + @endif + + {{ $slot }} + diff --git a/resources/views/checkout/drawer.blade.php b/resources/views/checkout/drawer.blade.php new file mode 100644 index 0000000..cd0ff6a --- /dev/null +++ b/resources/views/checkout/drawer.blade.php @@ -0,0 +1,31 @@ +{{-- + Slide-in cart drawer. Rendered once, globally, from the app layout + (@include('checkout::drawer')). Structure only — all styling lives in + resources/css/checkout.css under @layer bbk-checkout; the host restyles the + .bbk-* classes from its own stylesheet. No host components, no Tailwind. +--}} + diff --git a/resources/views/checkout/partials/cart-body.blade.php b/resources/views/checkout/partials/cart-body.blade.php new file mode 100644 index 0000000..9fce2e5 --- /dev/null +++ b/resources/views/checkout/partials/cart-body.blade.php @@ -0,0 +1,102 @@ +{{-- + Server-rendered cart contents. Rendered inline on first page load inside + checkout/drawer.blade.php, and re-fetched + swapped into the drawer by + bbk-cart-controller after every mutation. $cart / $lines come from the view + composer in CheckoutModuleServiceProvider. + + The data-bbk-cart-* attributes on the root are the module's read API for the + host (e.g. the header bag-icon count) — bbk-cart-controller reads them after + each swap and re-emits them on the `bbk-cart:updated` window event. +--}} +@php($count = $lines->sum('quantity')) + +{{-- @dump($lines) --}} + +
+ @if ($lines->isEmpty()) +

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

+ @else +
    + @each('checkout::partials.cart-line', $lines, 'line') +
+ +
+
+ @if ($cart?->coupon_code) +
+ {{ $cart->coupon_code }} + +
+ @csrf + @method('DELETE') + +
+
+ @else +
+ @csrf + + + +
+ + @if ($couponError ?? false) + + @endif + @endif +
+ + @if ($cart?->discountTotal?->value > 0) +
+ {{ __('checkout.cart.discount') }} + −{{ $cart->discountTotal->formatted() }} +
+ @endif + +
+ {{ __('checkout.cart.subtotal') }} + {{ $cart?->subTotal?->formatted() }} +
+ + {{-- Always shown, even with no discount — equals subtotal then, + diverges once one's applied. --}} +
+ {{ __('checkout.cart.total') }} + {{ $cart?->total?->formatted() }} +
+ + {{-- TODO: point at the checkout page once that slice exists — + the drawer is the cart, there's no cart page for this to fall back to. --}} + +
+ @endif +
diff --git a/resources/views/checkout/partials/cart-line.blade.php b/resources/views/checkout/partials/cart-line.blade.php new file mode 100644 index 0000000..a78f0fb --- /dev/null +++ b/resources/views/checkout/partials/cart-line.blade.php @@ -0,0 +1,77 @@ +{{-- + One cart line. $line is a Lunar\Models\CartLine (iteration var set by + @each in cart-body). The two forms post through bbk-cart-controller + (fetch + method spoofing) and the response re-renders cart-body. +--}} +@php + $variant = $line->purchasable; + $product = $variant?->product; + $name = $product?->translateAttribute('name') ?? $variant?->sku ?? '—'; + $thumb = $product?->getThumbnailImage() ?: null; +@endphp + +
  • +
    + @if ($thumb) + {{ $name }} + @endif +
    + +
    +

    {{ $name }}

    +

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

    + +
    + @csrf + @method('PATCH') + + + + + +
    +
    + +
    +

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

    + +
    + @csrf + @method('DELETE') + +
    +
    +
  • diff --git a/resources/views/components/header.blade.php b/resources/views/components/header.blade.php index f514bab..f88d407 100644 --- a/resources/views/components/header.blade.php +++ b/resources/views/components/header.blade.php @@ -41,10 +41,22 @@ @endforeach - {{-- Cart --}} - + {{-- Cart — opens the checkout module's drawer, no separate cart page --}} + {{-- Search --}}