Feat: Extracting Cart and Checout from 3dealer

This commit is contained in:
2026-09-25 20:19:11 +03:00
parent ccab9bbb8e
commit 547f07f01e
30 changed files with 3905 additions and 0 deletions
+856
View File
@@ -0,0 +1,856 @@
/*
* Cart + checkout module — generic default styling.
*
* Deliberately NOT wrapped in a Tailwind-style `@layer`. An earlier version
* put these rules in `@layer bbk-checkout`, positioned (via a cross-file
* @layer ordering statement) to sit between Tailwind's `base` and
* `components` — in theory enough to beat Preflight's element resets while
* still losing to a host override. In practice a build tool processing each
* CSS file in isolation (Vite/Lightning CSS here) optimizes away exactly the
* cross-file ordering information that trick depends on, so it silently
* didn't work: Preflight's `button { background-color: transparent }`,
* `* { border-width: 0 }` etc. (layered, in `base`) were beating every
* `.bbk-*` rule below regardless of specificity — buttons with no
* background, no border, wrong font-size.
*
* Plain, unlayered CSS sidesteps the whole problem: an unlayered rule always
* beats ANY layered rule (Preflight included), full stop, no ordering tricks,
* nothing a bundler can silently invalidate. This file is loaded BEFORE the
* host's own stylesheet (see the @vite call in the layout <head>), 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 {
display: block;
margin: 0 0 0.25rem;
font-weight: 600;
color: inherit;
text-decoration: none;
}
a.bbk-cart-item-title:hover { text-decoration: underline; }
.bbk-cart-item-variant {
margin: 0 0 0.25rem;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
/* A line's custom-field answers (checkout::partials.line-custom-fields). */
.bbk-line-fields {
display: grid;
gap: 0.25rem;
margin: 0 0 0.5rem;
font-size: 0.8125rem;
}
.bbk-line-field dt {
color: var(--bbk-color-muted);
}
.bbk-line-field dd {
margin: 0;
white-space: pre-line;
overflow-wrap: anywhere;
}
.bbk-line-field-file {
display: inline-flex;
align-items: center;
gap: 0.5rem;
color: inherit;
}
.bbk-line-field-file img {
width: 40px;
height: 40px;
object-fit: cover;
border-radius: 0;
}
.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: 0.625rem;
}
.bbk-cart-summary-row {
display: flex;
justify-content: space-between;
gap: 1rem;
}
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); }
.bbk-cart-summary-pending {
color: var(--bbk-color-muted);
font-size: 0.8125rem;
}
.bbk-cart-summary-row--total {
margin-top: 0.375rem;
padding-top: 0.875rem;
border-top: 1px solid var(--bbk-color-border);
font-size: 1.0625rem;
font-weight: 700;
}
.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-error {
margin: 0;
padding: 0.75rem 1.5rem 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
.bbk-add-to-cart-error {
margin: 0.375rem 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;
}
/* ───────────────────────────────────────────────────────────────────
Checkout page — two columns: fields on the left, order summary (the
same cart-body partial the drawer uses) on the right.
─────────────────────────────────────────────────────────────────── */
.bbk-checkout-page {
max-width: 1100px;
margin: 0 auto;
padding: 2.5rem 1.5rem 5rem;
font-family: var(--bbk-font);
font-size: 0.9375rem;
line-height: 1.4;
color: var(--bbk-color-text);
}
.bbk-checkout-heading {
margin: 0 0 2rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-checkout {
display: grid;
grid-template-columns: 1fr 380px;
gap: 3rem;
align-items: start;
}
@media (max-width: 860px) {
.bbk-checkout { grid-template-columns: 1fr; }
}
.bbk-checkout-main {
display: flex;
flex-direction: column;
gap: 2rem;
}
.bbk-checkout-section {
padding-bottom: 2rem;
border-bottom: 1px solid var(--bbk-color-border);
display: flex;
flex-direction: column;
gap: 1rem;
}
.bbk-checkout-section-heading {
margin: 0;
font-size: 1.125rem;
font-weight: 700;
}
.bbk-checkout-note {
margin: 0;
color: var(--bbk-color-muted);
font-size: 0.875rem;
}
/* Contact: "logged in as" line, or the guest login prompt */
.bbk-checkout-logged-in,
.bbk-checkout-login-prompt { margin: 0; }
.bbk-checkout-login-prompt a { color: inherit; font-weight: 600; }
/* Fields */
.bbk-field { display: flex; flex-direction: column; gap: 0.375rem; }
.bbk-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 480px) {
.bbk-field-row { grid-template-columns: 1fr; }
}
.bbk-field-label {
font-size: 0.8125rem;
font-weight: 600;
color: var(--bbk-color-muted);
}
.bbk-field-input {
padding: 0.625rem 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-field-input:focus-visible {
outline: 2px solid var(--bbk-color-accent);
outline-offset: 2px;
}
.bbk-field-input:disabled {
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
.bbk-field-input--error { border-color: var(--bbk-color-danger); }
.bbk-field-error {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-danger);
}
/* A fixed, non-editable field value (e.g. the store's single country). */
.bbk-field-static {
margin: 0;
padding: 0.625rem 0.75rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
background: var(--bbk-color-bg-muted);
color: var(--bbk-color-muted);
}
textarea.bbk-field-input { resize: vertical; }
.bbk-checkbox {
display: inline-flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
}
/* For a full-sentence label that can wrap — align the box to the first line. */
/* "I want an invoice": company/ΑΦΜ only while ticked */
.bbk-invoice { display: flex; flex-direction: column; gap: 1rem; }
.bbk-invoice:not(:has(input[name="wants_invoice"]:checked)) .bbk-invoice-fields { display: none; }
.bbk-checkbox--stacked {
display: flex;
align-items: flex-start;
margin-top: 0.75rem;
color: var(--bbk-color-muted);
}
.bbk-checkbox--stacked input { margin-top: 0.15rem; flex-shrink: 0; }
.bbk-checkout-shipping-fields {
display: flex;
flex-direction: column;
gap: 1rem;
}
/* Shipping method */
.bbk-checkout-shipping-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-shipping-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-shipping-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-shipping-option-detail {
flex: 1 1 auto;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.bbk-checkout-shipping-option-name { font-weight: 600; }
.bbk-checkout-shipping-option-description {
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-shipping-option-price { font-weight: 600; }
/* The single auto-selected option — a fixed line, not a choosable radio. */
.bbk-checkout-shipping-confirmed {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-accent);
border-radius: var(--bbk-radius-sm);
}
/* Autosave status line under the address form. */
.bbk-checkout-status {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-status[data-state="error"] { color: var(--bbk-color-danger); }
/* Continue / submit buttons — same look as the drawer's checkout CTA */
.bbk-checkout-continue {
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;
box-sizing: border-box;
cursor: pointer;
transition: opacity 0.15s ease;
}
.bbk-checkout-continue:hover { opacity: 0.85; }
.bbk-checkout-continue:disabled {
cursor: not-allowed;
opacity: 0.4;
}
/* Order summary column */
.bbk-checkout-aside { position: sticky; top: 1.5rem; }
.bbk-checkout-summary {
padding: 1.5rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius);
background: var(--bbk-color-bg);
}
.bbk-checkout-summary-heading {
margin: 0 0 1.25rem;
font-size: 1.125rem;
font-weight: 700;
}
/* Already on the checkout page — the drawer's own "go to checkout" CTA has
nowhere further to send you from here. */
.bbk-checkout-summary .bbk-cart-checkout { display: none; }
/* ── Payment ───────────────────────────────────────────────────────── */
.bbk-checkout-payment-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-checkout-payment-option {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.875rem 1rem;
border: 1px solid var(--bbk-color-border);
border-radius: var(--bbk-radius-sm);
cursor: pointer;
transition: border-color 0.15s ease;
}
.bbk-checkout-payment-option:has(input:checked) { border-color: var(--bbk-color-accent); }
.bbk-checkout-payment-option-name { font-weight: 600; }
.bbk-payment-element { margin: 0.25rem 0; }
.bbk-checkout-withdrawal {
margin: 0;
font-size: 0.8125rem;
color: var(--bbk-color-muted);
}
.bbk-checkout-withdrawal a { color: inherit; }
.bbk-checkout-error {
margin: 0;
font-size: 0.875rem;
color: var(--bbk-color-danger);
}
/* Processing overlay — fixed, covers the page while a payment confirms. */
.bbk-checkout-processing {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
background: color-mix(in srgb, var(--bbk-color-bg) 92%, transparent);
text-align: center;
padding: 1.5rem;
}
.bbk-spinner {
width: 2rem;
height: 2rem;
border: 3px solid var(--bbk-color-border);
border-top-color: var(--bbk-color-accent);
border-radius: 50%;
animation: bbk-spin 0.8s linear infinite;
}
@keyframes bbk-spin {
to { transform: rotate(360deg); }
}
/* ── Confirmation page ─────────────────────────────────────────────── */
.bbk-confirmation {
max-width: 720px;
margin: 0 auto;
padding: 3rem 1.5rem 5rem;
font-family: var(--bbk-font);
color: var(--bbk-color-text);
}
.bbk-confirmation-heading {
margin: 0 0 1rem;
font-size: 1.75rem;
font-weight: 700;
}
.bbk-confirmation-ref { margin: 0 0 0.25rem; }
.bbk-confirmation-meta {
margin: 0 0 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.bbk-confirmation-meta-row {
display: flex;
justify-content: space-between;
gap: 1rem;
font-size: 0.9375rem;
}
.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;
}
@media (min-width: 640px) {
.bbk-confirmation-body { grid-template-columns: 1fr 1fr; }
}
.bbk-confirmation-lines {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.bbk-confirmation-line {
display: grid;
grid-template-columns: 72px 1fr auto;
align-items: start;
gap: 0.875rem;
}
.bbk-confirmation-line-detail { min-width: 0; }
.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;
gap: 1.5rem;
}
.bbk-confirmation-address-heading {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 700;
}
.bbk-address-lines {
font-style: normal;
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.875rem;
color: var(--bbk-color-muted);
}
@@ -0,0 +1,57 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Sits on an <x-checkout::add-to-cart> <form>. 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 {
static targets = ['error']
async add(event) {
event.preventDefault()
const form = this.element
const submit = form.querySelector('[type="submit"]')
this.clearError()
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',
Accept: 'application/json',
},
body: new FormData(form),
})
if (!response.ok) {
const data = await response.json().catch(() => null)
this.showError(data?.error)
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
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
}
@@ -0,0 +1,161 @@
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', 'error']
connect() {
this.onChanged = this.onChanged.bind(this)
this.onKeydown = this.onKeydown.bind(this)
this.updateTimers = new Map() // line id -> pending debounce timer
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)
this.updateTimers.forEach((timer) => clearTimeout(timer))
}
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) return
// A remove is a deliberate, one-shot action — only the quantity form
// (typing, or the +/- stepper below) benefits from debouncing.
form.classList.contains('bbk-cart-qty') ? this.scheduleSend(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.scheduleSend(form)
}
// Repeated clicks (or spinner nudges) update the input instantly but only
// send once they settle for 300ms — sending on every single click was
// firing overlapping requests that raced each other and made the drawer
// visibly flicker/lag under quick clicking.
scheduleSend(form) {
const lineId = form.closest('[data-bbk-line-id]')?.dataset.bbkLineId
if (!lineId) return this.send(form)
clearTimeout(this.updateTimers.get(lineId))
this.updateTimers.set(lineId, setTimeout(() => {
this.updateTimers.delete(lineId)
this.send(form)
}, 300))
}
async send(form) {
this.bodyTarget.setAttribute('aria-busy', 'true')
this.clearError()
try {
const response = await fetch(form.action, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(form),
})
if (response.ok) {
this.replaceBody(await response.text())
return
}
const data = await response.json().catch(() => null)
this.showError(data?.error)
// The rejected quantity (typed, or from a +/- click) is left
// sitting in the input with nothing to correct it — the update
// never reached the cart, so the input must be put back to what
// the cart actually still holds, not just left showing whatever
// was rejected.
const input = form.querySelector('[data-bbk-cart-confirmed-quantity]')
if (input) input.value = input.dataset.bbkCartConfirmedQuantity
} finally {
this.bodyTarget.removeAttribute('aria-busy')
}
}
showError(message) {
if (!this.hasErrorTarget || !message) return
this.errorTarget.textContent = message
this.errorTarget.hidden = false
}
clearError() {
if (!this.hasErrorTarget) return
this.errorTarget.hidden = true
}
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),
},
}))
}
}
@@ -0,0 +1,204 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Drives the checkout page's left column: contact tabs, the same-as-billing
// toggle, and — the bulk of it — autosaving the address form and the shipping
// method with no submit buttons.
//
// Flow: any `change` in the address form is debounced ~400ms, then the whole
// form is POSTed to saveUrl. The server persists leniently and returns
// { errors, shippingOptionsHtml, summaryHtml }. We swap the shipping-options
// block in place and hand the summary fragment to the drawer's bbk-cart
// controller via the `bbk-cart:changed` window event (same mechanism the drawer
// already uses). Shipping-method radios post to selectShippingUrl the same way.
export default class extends Controller {
static targets = [
'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
]
static values = {
saveUrl: String,
selectShippingUrl: String,
statusSaving: String,
statusSaved: String,
statusError: String,
}
connect() {
this.saveTimer = null
this.saveController = null
this.statusTimer = null
this.shippingPromise = null
if (this.hasSameAsBillingTarget) this.applySameAsBilling()
}
disconnect() {
clearTimeout(this.saveTimer)
clearTimeout(this.statusTimer)
this.saveController?.abort()
}
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() {
this.applySameAsBilling()
}
applySameAsBilling() {
const on = this.sameAsBillingTarget.checked
// Checked: shipping *is* billing — copy every value across, then hide +
// disable so the browser doesn't submit them; the server reuses billing.
// Unchecked: reveal them pre-filled from billing wherever still empty.
this.element.querySelectorAll('[name^="billing_"]').forEach((billingField) => {
const shippingField = this.element.querySelector(
`[name="${billingField.name.replace(/^billing_/, 'shipping_')}"]`,
)
if (shippingField && (on || !shippingField.value)) {
shippingField.value = billingField.value
}
})
this.shippingFieldsTarget.hidden = on
this.shippingFieldsTarget.querySelectorAll('input, select, textarea').forEach((field) => {
field.disabled = on
})
}
// ── Autosave ───────────────────────────────────────────────────────
scheduleSave(event) {
// The shipping-method and payment radios live inside this controller's
// element too, and this action is bound on .bbk-checkout-main to also
// catch the contact email/consent that sit outside the <form>. Only
// react to fields that actually belong to the address form.
const el = event.target
const belongsToForm = el.form?.id === 'bbk-address-form'
if (!belongsToForm) return
// No status during the wait — it only shows once the request is in flight,
// so the indicator isn't flickering "saving" on every keystroke.
clearTimeout(this.saveTimer)
this.saveTimer = setTimeout(() => this.save(), 700)
}
// Called by bbk-payment right before place-order — a debounced save (and
// the shipping-option auto-select that happens as part of it) might still
// be pending when the shopper clicks "place order"; this guarantees the
// server has processed the current form state first.
async flush() {
clearTimeout(this.saveTimer)
await this.save()
// A shipping-method radio click fires its own (undebounced) request —
// still async, still racy against an immediate "place order" click.
if (this.shippingPromise) await this.shippingPromise
}
async save() {
this.saveController?.abort()
this.saveController = new AbortController()
this.setStatus('saving')
try {
const response = await fetch(this.saveUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new FormData(this.formTarget),
signal: this.saveController.signal,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch (error) {
if (error.name !== 'AbortError') this.setStatus('error')
}
}
async selectShipping(event) {
// Tracked so flush() can await it — nothing else stops "place order"
// (a separate, unrelated click) from racing ahead of this request.
this.shippingPromise = this.doSelectShipping(event.target.value)
await this.shippingPromise
}
async doSelectShipping(value) {
this.saveController?.abort()
this.setStatus('saving')
const body = new FormData()
body.append('shipping_option', value)
try {
const response = await fetch(this.selectShippingUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body,
})
if (!response.ok) return this.setStatus('error')
this.applyResult(await response.json())
this.setStatus('saved')
} catch {
this.setStatus('error')
} finally {
this.shippingPromise = null
}
}
applyResult(data) {
this.applyErrors(data.errors || {})
if (data.shippingOptionsHtml != null) {
this.shippingOptionsTarget.innerHTML = data.shippingOptionsHtml
}
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', {
detail: { html: data.summaryHtml },
}))
}
}
applyErrors(errors) {
this.element.querySelectorAll('[data-bbk-field-error]').forEach((el) => {
const message = errors[el.dataset.bbkFieldError]
el.textContent = message || ''
el.hidden = !message
const field = this.element.querySelector(`[name="${el.dataset.bbkFieldError}"]`)
field?.classList.toggle('bbk-field-input--error', Boolean(message))
})
}
setStatus(state) {
if (!this.hasStatusTarget) return
const text = {
saving: this.statusSavingValue,
saved: this.statusSavedValue,
error: this.statusErrorValue,
}[state]
this.statusTarget.textContent = text
this.statusTarget.hidden = false
this.statusTarget.dataset.state = state
clearTimeout(this.statusTimer)
if (state === 'saved') {
this.statusTimer = setTimeout(() => { this.statusTarget.hidden = true }, 2000)
}
}
}
@@ -0,0 +1,289 @@
import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
const STRIPE_JS = 'https://js.stripe.com/v3/'
const POLL_INTERVAL = 1500
const POLL_TIMEOUT = 30000
// The payment step of the checkout page. Sits alongside bbk-checkout-form on
// .bbk-checkout-main.
//
// - selectMethod: radio change -> persist via /payment-method, refresh the
// summary (COD fee), mount/unmount the Stripe Payment Element
// - placeOrder: the real submit. For Stripe, builds a PaymentMethod client-side
// and POSTs it to /place-order, then routes on the JSON result:
// { redirect } -> order placed, go to confirmation
// { status:'pending', clientSecret } -> 3-D Secure: handleNextAction, then
// poll /order-status until the webhook places it
// { status:'failed'|'invalid'|'stale', message } -> show inline, re-enable
export default class extends Controller {
static targets = ['element', 'terms', 'error', 'submit', 'processing', 'processingText']
static values = {
selectUrl: String,
placeOrderUrl: String,
orderStatusUrl: String,
stripeKey: String,
amount: Number,
currency: String,
termsRequired: String,
chooseMethod: String,
genericError: String,
processingSlow: String,
}
connect() {
this.stripe = null
this.elements = null
this.paymentElement = null
this.onSummaryUpdate = (event) => {
const total = event.detail?.total
if (typeof total === 'number' && this.elements) {
this.amountValue = total
this.elements.update({ amount: Math.max(total, 1) })
}
// Removing the last line while sitting on the checkout page (via
// the order summary's own remove form) must not leave "place
// order" clickable with nothing left to charge for — this fires
// from both the drawer and the checkout page's own summary
// instance, whichever the shopper actually used.
const count = event.detail?.count
if (typeof count === 'number' && this.hasSubmitTarget) {
this.submitTarget.disabled = count === 0
}
}
window.addEventListener('bbk-cart:updated', this.onSummaryUpdate)
if (this.selectedIsStripe()) this.mountStripe()
}
disconnect() {
window.removeEventListener('bbk-cart:updated', this.onSummaryUpdate)
this.unmountStripe()
}
// ── Method selection ──────────────────────────────────────────────
async selectMethod(event) {
const isStripe = event.target.dataset.paymentDriver === 'stripe'
try {
const response = await fetch(this.selectUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({ payment_type: event.target.value }),
})
if (response.ok) {
const data = await response.json()
if (data.summaryHtml != null) {
window.dispatchEvent(new CustomEvent('bbk-cart:changed', { detail: { html: data.summaryHtml } }))
}
}
} catch {
// summary just won't refresh — non-fatal
}
isStripe ? this.mountStripe() : this.unmountStripe()
}
selectedRadio() {
return this.element.querySelector('input[name="payment_type"]:checked')
}
selectedIsStripe() {
return this.selectedRadio()?.dataset.paymentDriver === 'stripe'
}
// ── Stripe Payment Element ────────────────────────────────────────
async loadStripe() {
if (window.Stripe) return window.Stripe
await new Promise((resolve, reject) => {
const existing = document.querySelector(`script[src="${STRIPE_JS}"]`)
if (existing) {
existing.addEventListener('load', resolve)
existing.addEventListener('error', reject)
return
}
const script = document.createElement('script')
script.src = STRIPE_JS
script.onload = resolve
script.onerror = reject
document.head.appendChild(script)
})
return window.Stripe
}
async mountStripe() {
if (this.paymentElement || !this.stripeKeyValue) return
const Stripe = await this.loadStripe()
this.stripe = this.stripe || Stripe(this.stripeKeyValue)
this.elements = this.stripe.elements({
mode: 'payment',
amount: Math.max(this.amountValue, 1),
currency: this.currencyValue,
paymentMethodCreation: 'manual',
// Card only — matches the server confirming with
// automatic_payment_methods.allow_redirects = 'never' (no
// return_url in our flow: 3-D Secure resolves in-page via
// handleNextAction, never a full-page redirect).
paymentMethodTypes: ['card'],
})
this.paymentElement = this.elements.create('payment')
this.paymentElement.mount(this.elementTarget)
this.elementTarget.hidden = false
}
unmountStripe() {
this.paymentElement?.unmount()
this.paymentElement = null
this.elements = null
if (this.hasElementTarget) {
this.elementTarget.innerHTML = ''
this.elementTarget.hidden = true
}
}
// ── Place order ──────────────────────────────────────────────────
// Sibling controller on the same element (.bbk-checkout-main) — used to
// flush a pending debounced address autosave before placing the order.
get checkoutForm() {
return this.application.getControllerForElementAndIdentifier(this.element, 'bbk-checkout-form')
}
async placeOrder() {
this.clearError()
this.submitTarget.disabled = true
// A debounced address save (and the shipping-option auto-select that
// happens as part of it) might still be pending — make sure the
// server has the latest state before we ask it to place the order.
await this.checkoutForm?.flush()
if (!this.termsTarget.checked) {
this.submitTarget.disabled = false
this.showError(this.termsRequiredValue)
return
}
const radio = this.selectedRadio()
if (!radio) {
this.submitTarget.disabled = false
this.showError(this.chooseMethodValue)
return
}
let paymentMethodId = null
if (radio.dataset.paymentDriver === 'stripe') {
const { error: submitError } = await this.elements.submit()
if (submitError) return this.fail(submitError.message)
const { error: pmError, paymentMethod } = await this.stripe.createPaymentMethod({ elements: this.elements })
if (pmError) return this.fail(pmError.message)
paymentMethodId = paymentMethod.id
}
let data
try {
const response = await fetch(this.placeOrderUrlValue, {
method: 'POST',
headers: {
'X-CSRF-TOKEN': csrfToken(),
'X-Requested-With': 'XMLHttpRequest',
Accept: 'application/json',
},
body: new URLSearchParams({
payment_type: radio.value,
payment_method: paymentMethodId ?? '',
terms_accepted: '1',
}),
})
data = await response.json()
} catch {
return this.fail(this.genericErrorValue)
}
if (data.redirect) {
window.location.assign(data.redirect)
return
}
if (data.status === 'pending' && data.clientSecret) {
await this.resolvePending(data.clientSecret)
return
}
// Points at the section that actually needs attention, rather than
// leaving a generic error and making the shopper hunt for it — e.g. a
// region with 2+ shipping methods needs an explicit pick, easy to miss.
if (data.field === 'shipping_option') {
document.getElementById('bbk-shipping-options')?.scrollIntoView({ block: 'center', behavior: 'smooth' })
this.fail(data.message || data.error || this.genericErrorValue, { scroll: false })
return
}
this.fail(data.message || data.error || this.genericErrorValue)
}
async resolvePending(clientSecret) {
this.processingTarget.hidden = false
const { error } = await this.stripe.handleNextAction({ clientSecret })
if (error) {
this.processingTarget.hidden = true
return this.fail(error.message)
}
// 3-D Secure cleared client-side — the webhook places the order. Poll.
const startedAt = Date.now()
const tick = async () => {
try {
const response = await fetch(this.orderStatusUrlValue, { headers: { Accept: 'application/json' } })
const data = await response.json()
if (data.placed && data.redirect) {
window.location.assign(data.redirect)
return
}
} catch {
// keep polling
}
if (Date.now() - startedAt > POLL_TIMEOUT) {
this.processingTextTarget.textContent = this.processingSlowValue
return
}
setTimeout(tick, POLL_INTERVAL)
}
tick()
}
// ── helpers ──────────────────────────────────────────────────────
fail(message, { scroll = true } = {}) {
this.showError(message, { scroll })
this.submitTarget.disabled = false
}
showError(message, { scroll = true } = {}) {
this.errorTarget.textContent = message
this.errorTarget.hidden = false
if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' })
}
clearError() {
this.errorTarget.textContent = ''
this.errorTarget.hidden = true
}
}
+6
View File
@@ -0,0 +1,6 @@
// Reads the CSRF token from the standard <meta name="csrf-token"> tag every
// boboko host renders in its layout <head>. Kept as its own module so both
// checkout controllers share one source.
export function csrfToken() {
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
+19
View File
@@ -0,0 +1,19 @@
import BbkAddToCartController from './bbk-add-to-cart-controller'
import BbkCartController from './bbk-cart-controller'
import BbkCheckoutFormController from './bbk-checkout-form-controller'
import BbkPaymentController from './bbk-payment-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)
application.register('bbk-checkout-form', BbkCheckoutFormController)
application.register('bbk-payment', BbkPaymentController)
}
@@ -0,0 +1,44 @@
{{--
<x-checkout::add-to-cart :purchasable="$variantId" />
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 <form>.
--}}
@props([
'purchasable' => null,
'quantity' => 1,
'action' => null,
])
<form
method="POST"
action="{{ $action ?? route('checkout.cart.add', app()->getLocale()) }}"
data-controller="bbk-add-to-cart"
data-action="bbk-add-to-cart#add"
{{ $attributes->class('bbk-add-to-cart') }}
>
@csrf
@if (! is_null($purchasable))
<input type="hidden" name="purchasable_id" value="{{ $purchasable }}" data-bbk-purchasable-input>
@endif
@if ($quantity !== false)
<input type="hidden" name="quantity" value="{{ $quantity }}">
@endif
{{ $slot }}
<p class="bbk-add-to-cart-error" data-bbk-add-to-cart-target="error" hidden role="alert"></p>
</form>
@@ -0,0 +1,15 @@
{{--
Read-only formatted address. $address is any Lunar address model
(OrderAddress / CartAddress) — same column names on both.
--}}
@props(['address'])
<address class="bbk-address-lines">
<span>{{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }}</span>
@if ($address->company_name)<span>{{ $address->company_name }}</span>@endif
<span>{{ $address->line_one }}</span>
@if ($address->line_two)<span>{{ $address->line_two }}</span>@endif
<span>{{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }}</span>
@if ($address->state)<span>{{ $address->state }}</span>@endif
@if ($address->contact_phone)<span>{{ $address->contact_phone }}</span>@endif
</address>
@@ -0,0 +1,29 @@
{{--
<x-checkout::field name="billing_first_name" label="First name" required />
Generic labelled text input with old-input repopulation and validation
error display — the module's own equivalent of a host x-ui.field, used
instead of it per the module's independence rule. All styling is .bbk-field*
(resources/css/checkout.css); no host classes.
--}}
@props([
'name',
'label',
'type' => 'text',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<input
type="{{ $type }}"
name="{{ $name }}"
id="bbk-{{ $name }}"
value="{{ old($name, $value) }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
{{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,52 @@
{{--
The state/region + country pair for one address (billing or shipping).
Single-country store ($storeCountry set): region is a <select> of that
country's Lunar states, submitting `->name` (table-rate-shipping resolves
zones with State::whereName()), and country is a fixed hidden field + label.
Otherwise: free-text region + full country <select>, as before.
--}}
@props([
'prefix',
'storeCountry' => null,
'regions' => [],
'countries' => [],
'address' => null,
])
<div class="bbk-field-row">
@if ($storeCountry)
<x-checkout::select
:name="$prefix . '_state'"
label="{{ __('checkout.page.state') }}"
:options="$regions"
value-field="name"
translation-group="states"
:value="$address?->state"
placeholder="{{ __('checkout.page.state_placeholder') }}"
required
/>
<div class="bbk-field">
<span class="bbk-field-label">{{ __('checkout.page.country') }}</span>
<p class="bbk-field-static">
{{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}")
? __("core::countries.{$storeCountry->name}")
: $storeCountry->name }}
</p>
<input type="hidden" name="{{ $prefix }}_country_id" value="{{ $storeCountry->id }}">
</div>
@else
<x-checkout::field :name="$prefix . '_state'" label="{{ __('checkout.page.state') }}" :value="$address?->state" />
<x-checkout::select
:name="$prefix . '_country_id'"
label="{{ __('checkout.page.country') }}"
:options="$countries"
translation-group="countries"
:value="$address?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
@endif
</div>
@@ -0,0 +1,62 @@
{{--
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" translation-group="states" required />
`options` is an iterable of models/objects; `label` is always read from
`->name`, the submitted value from `->{$valueField}` (default `id`, but e.g.
`name` for Lunar states — table-rate-shipping resolves those with
State::whereName(), so the address must carry the exact name string).
`translationGroup` (optional, e.g. "countries"/"states") looks the raw
`->name` up in boboko-core's `core::{group}.{name}` lang file (see
boboko-core's lang/el/countries.php, lang/el/states.php) for the
DISPLAYED label only — the submitted `value` is always the untranslated
`->{$valueField}`, since table-rate-shipping/Lunar's Country lookups key
off the original English name. Falls back to the raw name when no
translation exists for the current locale (e.g. English, or a country
outside the covered set).
--}}
@props([
'name',
'label',
'options' => [],
'value' => null,
'placeholder' => null,
'required' => false,
'valueField' => 'id',
'translationGroup' => null,
])
@php
$optionLabel = function ($option) use ($translationGroup) {
if (! $translationGroup) {
return $option->name;
}
$key = "core::{$translationGroup}.{$option->name}";
return \Illuminate\Support\Facades\Lang::has($key) ? __($key) : $option->name;
};
@endphp
@php($selected = old($name, $value))
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<select
name="{{ $name }}"
id="bbk-{{ $name }}"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
@if ($placeholder)
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
@endif
@foreach ($options as $option)
<option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
{{ $optionLabel($option) }}
</option>
@endforeach
</select>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,18 @@
@props([
'name',
'label',
'value' => null,
'required' => false,
])
<div class="bbk-field">
<label class="bbk-field-label" for="bbk-{{ $name }}">{{ $label }}</label>
<textarea
name="{{ $name }}"
id="bbk-{{ $name }}"
rows="3"
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>{{ old($name, $value) }}</textarea>
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -0,0 +1,132 @@
{{--
Order confirmation. Reached only via a session flash of the placed order id
(CheckoutController::confirmation) — not deep-linkable. $order is a
Lunar\Models\Order with lines + shipping/billing addresses eager-loaded.
--}}
@extends('layouts.app')
@section('title', __('checkout.page.confirmation_title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-confirmation">
<h1 class="bbk-confirmation-heading">{{ __('checkout.page.confirmation_heading') }}</h1>
<dl class="bbk-confirmation-meta">
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.confirmation_order_number') }}</dt>
<dd>{{ $order->reference }}</dd>
</div>
@if ($order->billingAddress?->contact_email)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.email_label') }}</dt>
<dd>{{ $order->billingAddress->contact_email }}</dd>
</div>
@endif
@if ($paymentMethodName)
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.payment_heading') }}</dt>
<dd>{{ $paymentMethodName }}</dd>
</div>
@endif
@if ($shippingLine = $order->lines->firstWhere('type', 'shipping'))
<div class="bbk-confirmation-meta-row">
<dt>{{ __('checkout.page.shipping_method_heading') }}</dt>
<dd>{{ $shippingLine->description }}</dd>
</div>
@endif
</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
(boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin),
so it shows in their history. --}}
@guest
@if ($loginRoute = config('checkout.login_route'))
<p class="bbk-checkout-note">
{{ __('checkout.page.confirmation_login_hint') }}
<a href="{{ route($loginRoute) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
@endguest
<div class="bbk-confirmation-body">
<div class="bbk-confirmation-lines">
@foreach ($order->lines->where('type', '!=', 'shipping') as $line)
<div class="bbk-confirmation-line">
<div class="bbk-cart-item-media">
@if ($thumb = $line->purchasable?->getThumbnailImage())
<img src="{{ $thumb }}" alt="{{ $line->description }}" width="72" height="72" loading="lazy">
@endif
</div>
<div class="bbk-confirmation-line-detail">
<span class="bbk-confirmation-line-name">
{{ $line->description }}
<span class="bbk-confirmation-line-qty">&times; {{ $line->quantity }}</span>
</span>
@if ($line->option)
<p class="bbk-cart-item-variant">{{ $line->option }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
</div>
<span class="bbk-confirmation-line-total">{{ $line->sub_total?->formatted() }}</span>
</div>
@endforeach
<div class="bbk-cart-summary">
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $order->sub_total?->formatted() }}</span>
</div>
@if ($order->discount_total?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $order->discount_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $order->shipping_total?->formatted() }}</span>
</div>
@if ($order->tax_total?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $order->tax_total->formatted() }}</span>
</div>
@endif
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $order->total?->formatted() }}</span>
</div>
</div>
</div>
<div class="bbk-confirmation-addresses">
@if ($order->shippingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_shipping_to') }}</h2>
<x-checkout::address-lines :address="$order->shippingAddress" />
</div>
@endif
@if ($order->billingAddress)
<div class="bbk-confirmation-address">
<h2 class="bbk-confirmation-address-heading">{{ __('checkout.page.confirmation_billing') }}</h2>
<x-checkout::address-lines :address="$order->billingAddress" />
</div>
@endif
</div>
</div>
</div>
@endsection
+33
View File
@@ -0,0 +1,33 @@
{{--
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.
--}}
<div class="bbk-cart" data-controller="bbk-cart" hidden>
<div class="bbk-cart-backdrop" data-action="click->bbk-cart#close"></div>
<aside
class="bbk-cart-panel"
role="dialog"
aria-modal="true"
aria-labelledby="bbk-cart-heading"
data-bbk-cart-target="panel"
>
<header class="bbk-cart-panel-header">
<h2 class="bbk-cart-heading" id="bbk-cart-heading">{{ __('checkout.cart.title') }}</h2>
<button
type="button"
class="bbk-cart-dismiss"
data-action="bbk-cart#close"
aria-label="{{ __('checkout.cart.close') }}"
>&times;</button>
</header>
@include('checkout::partials.cart-error')
<div class="bbk-cart-panel-body" data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</aside>
</div>
+279
View File
@@ -0,0 +1,279 @@
{{--
The checkout page. Two columns: left is contact + billing + shipping +
shipping method, right is the order summary (the same cart-body partial the
drawer uses, minus its own "Checkout" CTA — see .bbk-checkout-summary in
checkout.css). Stops short of payment for this slice — see
CheckoutController's class docblock.
$cart, $lines, $billingAddress, $shippingAddress, $shippingOptions,
$countries come from CheckoutController::show().
--}}
@extends('layouts.app')
@section('title', __('checkout.page.title') . ' — ' . config('app.name'))
@section('content')
<div class="bbk-checkout-page">
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
<div class="bbk-checkout">
<div
class="bbk-checkout-main"
data-controller="bbk-checkout-form bbk-payment"
data-action="input->bbk-checkout-form#scheduleSave"
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
data-bbk-payment-select-url-value="{{ route('checkout.payment-method.select', app()->getLocale()) }}"
data-bbk-payment-place-order-url-value="{{ route('checkout.place-order', app()->getLocale()) }}"
data-bbk-payment-order-status-url-value="{{ route('checkout.order-status', app()->getLocale()) }}"
data-bbk-payment-stripe-key-value="{{ config('services.stripe.public_key') }}"
data-bbk-payment-amount-value="{{ $cart?->total?->value ?? 0 }}"
data-bbk-payment-currency-value="{{ strtolower($cart?->total?->currency?->code ?? 'eur') }}"
data-bbk-payment-terms-required-value="{{ __('checkout.page.terms_required') }}"
data-bbk-payment-choose-method-value="{{ __('checkout.page.choose_payment_method') }}"
data-bbk-payment-generic-error-value="{{ __('checkout.page.payment_failed') }}"
data-bbk-payment-processing-slow-value="{{ __('checkout.page.payment_processing_slow') }}"
>
{{-- Contact. Logged in: the order email is the account's (forced
server-side in saveAddress()), so there's no field. Guests type
their email, plus a login link when config('checkout.login_route')
is set; the storefront's login page sends them back here and Lunar
merges the guest cart into the account. --}}
@php($loginRoute = config('checkout.login_route'))
<section class="bbk-checkout-section">
@auth
<p class="bbk-checkout-logged-in">
{{ __('checkout.page.logged_in_as') }} <strong>{{ auth()->user()->email }}</strong>
</p>
@else
@if ($loginRoute)
<p class="bbk-checkout-login-prompt">
{{ __('checkout.page.login_prompt') }}
<a href="{{ route($loginRoute, ['redirect' => route('checkout.show', app()->getLocale(), false)]) }}">{{ __('checkout.page.login_link') }}</a>
</p>
@endif
<x-checkout::field
name="contact_email"
label="{{ __('checkout.page.email_label') }}"
type="email"
:value="$shippingAddress?->contact_email ?? $billingAddress?->contact_email"
required
form="bbk-address-form"
/>
@endauth
{{-- Abandoned-cart-recovery opt-in. Optional, unticked, never
required — direct marketing under ePrivacy (GR L. 3471/2006
art. 11), so it needs an explicit opt-in and checkout can't be
gated on it. Narrow scope by design (boboko-core's
setRecoveryConsent) — a general newsletter opt-in, if wanted,
is a separate checkbox. --}}
<label class="bbk-checkbox bbk-checkbox--stacked">
<input
type="checkbox"
name="recovery_consent"
value="1"
form="bbk-address-form"
{{ old('recovery_consent', data_get($cart, 'meta.recovery_consent')) ? 'checked' : '' }}
>
{{ __('checkout.page.recovery_consent') }}
</label>
</section>
{{-- Autosaves — no submit button. Any `change` inside .bbk-checkout-main
(this form, plus the contact email/consent which sit outside it but
link via form="bbk-address-form") is debounced and POSTed as the whole
form; the shipping-method radios are excluded in scheduleSave(). --}}
<form
id="bbk-address-form"
method="POST"
action="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-target="form"
>
@csrf
{{-- Billing --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.billing_heading') }}</h2>
<div class="bbk-field-row">
<x-checkout::field name="billing_first_name" label="{{ __('checkout.page.first_name') }}" :value="$billingAddress?->first_name" required />
<x-checkout::field name="billing_last_name" label="{{ __('checkout.page.last_name') }}" :value="$billingAddress?->last_name" required />
</div>
{{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS
(:has on the checkbox), saved/cleared by saveAddress(), and
required at place-order. --}}
<div class="bbk-invoice">
<label class="bbk-checkbox">
<input
type="checkbox"
name="wants_invoice"
value="1"
aria-controls="bbk-invoice-fields"
@checked($wantsInvoice)
>
{{ __('checkout.page.wants_invoice') }}
</label>
<div class="bbk-field-row bbk-invoice-fields" id="bbk-invoice-fields">
<x-checkout::field name="billing_company_name" label="{{ __('checkout.page.company_name') }}" :value="$billingAddress?->company_name" />
<x-checkout::field name="billing_tax_identifier" label="{{ __('checkout.page.tax_identifier') }}" :value="$billingAddress?->tax_identifier" />
</div>
</div>
<x-checkout::field name="billing_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$billingAddress?->line_one" required />
<div class="bbk-field-row">
<x-checkout::field name="billing_city" label="{{ __('checkout.page.city') }}" :value="$billingAddress?->city" required />
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="billing"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$billingAddress"
/>
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
</section>
{{-- Shipping --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_heading') }}</h2>
<label class="bbk-checkbox">
<input
type="checkbox"
name="same_as_billing"
value="1"
data-bbk-checkout-form-target="sameAsBilling"
data-action="bbk-checkout-form#toggleSameAsBilling"
@checked($shipToBilling)
>
{{ __('checkout.page.same_as_billing') }}
</label>
<div class="bbk-checkout-shipping-fields" data-bbk-checkout-form-target="shippingFields">
<div class="bbk-field-row">
<x-checkout::field name="shipping_first_name" label="{{ __('checkout.page.first_name') }}" :value="$shippingAddress?->first_name" required />
<x-checkout::field name="shipping_last_name" label="{{ __('checkout.page.last_name') }}" :value="$shippingAddress?->last_name" required />
</div>
<x-checkout::field name="shipping_line_one" label="{{ __('checkout.page.address_line_one') }}" :value="$shippingAddress?->line_one" required />
<div class="bbk-field-row">
<x-checkout::field name="shipping_city" label="{{ __('checkout.page.city') }}" :value="$shippingAddress?->city" required />
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
</div>
<x-checkout::region-country
prefix="shipping"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$shippingAddress"
/>
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
</div>
<x-checkout::textarea
name="shipping_delivery_instructions"
label="{{ __('checkout.page.delivery_instructions') }}"
:value="$shippingAddress?->delivery_instructions"
/>
</section>
</form>
<p
class="bbk-checkout-status"
data-bbk-checkout-form-target="status"
role="status"
aria-live="polite"
hidden
></p>
{{-- Shipping method — resolves from the saved shipping address;
re-rendered as a fragment by bbk-checkout-form after each
autosave / option change. --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
@include('checkout::partials.shipping-options', [
'shippingAddress' => $shippingAddress,
'shippingOptions' => $shippingOptions,
])
</div>
</section>
{{-- Payment --}}
<section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.payment_heading') }}</h2>
<div id="bbk-payment-methods">
@include('checkout::partials.payment-methods', [
'paymentMethods' => $paymentMethods,
'cart' => $cart,
])
</div>
{{-- Stripe Payment Element mounts here when a Stripe method is picked. --}}
<div class="bbk-payment-element" data-bbk-payment-target="element" hidden></div>
<label class="bbk-checkbox bbk-checkbox--stacked">
<input type="checkbox" data-bbk-payment-target="terms">
{!! __('checkout.page.terms_accept', [
'terms' => route('legal.terms', app()->getLocale()),
'privacy' => route('legal.privacy', app()->getLocale()),
]) !!}
</label>
<p class="bbk-checkout-withdrawal">
{!! __('checkout.page.withdrawal_notice', [
'link' => route('legal.shipping-returns', app()->getLocale()),
]) !!}
</p>
<p class="bbk-checkout-error" data-bbk-payment-target="error" role="alert" hidden></p>
<button
type="button"
class="bbk-checkout-continue"
data-bbk-payment-target="submit"
data-action="bbk-payment#placeOrder"
@disabled($lines->isEmpty())
>
{{ __('checkout.page.place_order') }}
</button>
</section>
{{-- Fixed overlay while a payment is confirming (3-D Secure / webhook
poll). Inside .bbk-checkout-main so bbk-payment can target it. --}}
<div class="bbk-checkout-processing" data-bbk-payment-target="processing" hidden>
<span class="bbk-spinner" aria-hidden="true"></span>
<p data-bbk-payment-target="processingText">{{ __('checkout.page.payment_processing') }}</p>
</div>
</div>
<aside class="bbk-checkout-aside">
<div class="bbk-checkout-summary" data-controller="bbk-cart">
<h2 class="bbk-checkout-summary-heading">{{ __('checkout.page.order_summary_heading') }}</h2>
@include('checkout::partials.cart-error')
<div data-bbk-cart-target="body" aria-live="polite">
@include('checkout::partials.cart-body')
</div>
</div>
</aside>
</div>
</div>
@endsection
@@ -0,0 +1,121 @@
{{--
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) --}}
<div
class="bbk-cart-content"
data-bbk-cart-count="{{ $count }}"
data-bbk-cart-total="{{ $cart?->total?->value ?? 0 }}"
>
@if ($lines->isEmpty())
<p class="bbk-cart-empty">{{ __('checkout.cart.empty') }}</p>
@else
<ul class="bbk-cart-items">
@each('checkout::partials.cart-line', $lines, 'line')
</ul>
<div class="bbk-cart-summary">
<div class="bbk-cart-coupon">
@if ($cart?->coupon_code)
<div class="bbk-cart-coupon-applied">
<span class="bbk-cart-coupon-code">{{ $cart->coupon_code }}</span>
<form
method="POST"
action="{{ route('checkout.cart.coupon.remove', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
@method('DELETE')
<button type="submit" class="bbk-cart-coupon-remove">
{{ __('checkout.cart.coupon_remove') }}
</button>
</form>
</div>
@else
<form
class="bbk-cart-coupon-form"
method="POST"
action="{{ route('checkout.cart.coupon.apply', app()->getLocale()) }}"
data-action="submit->bbk-cart#submit"
>
@csrf
<label class="bbk-visually-hidden" for="bbk-coupon-code">
{{ __('checkout.cart.coupon_label') }}
</label>
<input
type="text"
name="code"
id="bbk-coupon-code"
class="bbk-cart-coupon-input"
placeholder="{{ __('checkout.cart.coupon_placeholder') }}"
autocomplete="off"
required
>
<button type="submit" class="bbk-cart-coupon-submit">
{{ __('checkout.cart.coupon_apply') }}
</button>
</form>
@if ($couponError ?? false)
<p class="bbk-cart-coupon-error" role="alert">{{ __('checkout.cart.coupon_invalid') }}</p>
@endif
@endif
</div>
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span>
</div>
@if ($cart?->discountTotal?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
<span>&minus;{{ $cart->discountTotal->formatted() }}</span>
</div>
@endif
{{-- Shipping + tax appear once the shopper has a shipping address
(i.e. they're on the checkout page). In the drawer, where no
address is set yet, only subtotal + total show. --}}
@if ($cart?->shippingAddress)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.shipping') }}</span>
@if ($cart->shippingAddress->shipping_option)
<span>{{ $cart->shippingTotal?->formatted() }}</span>
@else
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
@endif
</div>
@endif
@if ($cart?->taxTotal?->value > 0)
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $cart->taxTotal->formatted() }}</span>
</div>
@endif
{{-- Always shown — equals subtotal with nothing else applied,
diverges as discount / shipping / tax come in. --}}
<div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span>
<span>{{ $cart?->total?->formatted() }}</span>
</div>
<a class="bbk-cart-checkout" href="{{ route('checkout.show', app()->getLocale()) }}">
{{ __('checkout.cart.checkout') }}
</a>
</div>
@endif
</div>
@@ -0,0 +1,9 @@
{{--
Shared error slot for any host wrapping cart-body in a bbk-cart controller
instance (the drawer, and the checkout page's own order summary) —
bbk-cart-controller.js#showError() writes into whichever one is present.
Without this element in a given host, a rejected quantity update (e.g.
over stock) still gets rejected server-side, but the shopper never sees
why.
--}}
<p class="bbk-cart-error" data-bbk-cart-target="error" hidden role="alert"></p>
@@ -0,0 +1,103 @@
{{--
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 ?? '—';
// The variant's own image (falls back to the product's thumbnail
// internally — see ProductVariant::getThumbnail()) — the specific option
// the shopper picked, not just the product in general.
$thumb = $variant?->getThumbnailImage() ?: null;
$variantLabel = $variant?->getOption();
// Not routed through checkout::'s own locale-explicit convention — this
// is a storefront route, so it follows the storefront's own (implicit
// locale) call shape, same as App\Catalog\ProductCard. Carries the
// variant id along so the product page can restore the same option the
// shopper actually has in their cart, not just default to the first one
// (see product-form-controller.js reading ?variant= on connect()).
$productUrl = $product ? route('product.show', ['id' => $product->id, 'variant' => $variant?->id]) : null;
@endphp
<li class="bbk-cart-item" data-bbk-line-id="{{ $line->id }}">
<div class="bbk-cart-item-media">
@if ($thumb)
@if ($productUrl)
<a href="{{ $productUrl }}" aria-hidden="true" tabindex="-1">
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
</a>
@else
<img src="{{ $thumb }}" alt="{{ $name }}" width="72" height="72" loading="lazy">
@endif
@endif
</div>
<div class="bbk-cart-item-detail">
@if ($productUrl)
<a href="{{ $productUrl }}" class="bbk-cart-item-title">{{ $name }}</a>
@else
<p class="bbk-cart-item-title">{{ $name }}</p>
@endif
@if ($variantLabel)
<p class="bbk-cart-item-variant">{{ $variantLabel }}</p>
@endif
@include('checkout::partials.line-custom-fields', ['line' => $line])
<p class="bbk-cart-item-unit">{{ $line->unitPrice?->formatted() }}</p>
<form
class="bbk-cart-qty"
method="POST"
action="{{ route('checkout.cart.update', ['locale' => app()->getLocale(), 'line' => $line->id]) }}"
>
@csrf
@method('PATCH')
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="-1"
aria-label="{{ __('checkout.cart.decrease') }}"
>&minus;</button>
<input
type="number"
name="quantity"
value="{{ $line->quantity }}"
min="0"
inputmode="numeric"
class="bbk-cart-qty-input"
data-action="change->bbk-cart#submit"
data-bbk-cart-confirmed-quantity="{{ $line->quantity }}"
aria-label="{{ __('checkout.cart.quantity') }}"
>
<button
type="button"
class="bbk-cart-qty-btn"
data-action="bbk-cart#step"
data-bbk-cart-dir-param="1"
aria-label="{{ __('checkout.cart.increase') }}"
>+</button>
</form>
</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>
</li>
@@ -0,0 +1,50 @@
{{--
@include('checkout::partials.line-custom-fields', ['line' => $line])
A cart or order line's custom-field answers (meta.custom_fields, written by
Cart\Http\Controllers\CartController::customFieldsMeta()). A file answer
only carries a File id (Modules\Core\File\Models\File is the source of
truth for name/mime/disk/path — never duplicated into meta), resolved
here and linked through the signed download route (files.download),
minted fresh on every render, with a thumbnail when the browser can
display the format (HEIC can't be shown outside Safari, so it gets the
name only).
--}}
@php
$fields = $line->meta['custom_fields'] ?? [];
$previewable = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
@endphp
@if (! empty($fields))
<dl class="bbk-line-fields">
@foreach ($fields as $field)
<div class="bbk-line-field">
<dt>{{ $field['label'] }}</dt>
<dd>
@if ($field['type'] === 'file')
@php
$file = \Modules\Core\File\Models\File::find($field['file_id'] ?? null);
@endphp
@if ($file)
@php
$fileUrl = \Illuminate\Support\Facades\URL::temporarySignedRoute(
'files.download',
now()->addHours(2),
['file' => $file->id],
);
@endphp
<a href="{{ $fileUrl }}" class="bbk-line-field-file" target="_blank" rel="noopener">
@if (in_array($file->mime, $previewable, true))
<img src="{{ $fileUrl }}" alt="" width="40" height="40" loading="lazy">
@endif
<span>{{ $file->original_name }}</span>
</a>
@endif
@else
{{ $field['value'] }}
@endif
</dd>
</div>
@endforeach
</dl>
@endif
@@ -0,0 +1,30 @@
{{--
Payment method radios. $paymentMethods is Collection<Modules\Core\Payment\
Models\PaymentMethod> from CheckoutService::getPaymentMethods() (already
filtered to enabled + driver-resolves + isConfigured()). Selecting one
autosaves via bbk-payment#selectMethod; `data-payment-driver` tells the
controller whether to mount the Stripe Element.
$paymentMethods, $cart come from the page / controller.
--}}
@php($selected = $cart?->meta['payment_method'] ?? null)
@if ($paymentMethods->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.payment_method_none') }}</p>
@else
<div class="bbk-checkout-payment-options">
@foreach ($paymentMethods as $method)
<label class="bbk-checkout-payment-option">
<input
type="radio"
name="payment_type"
value="{{ $method->type }}"
data-payment-driver="{{ $method->driver }}"
@checked($selected === $method->type)
data-action="change->bbk-payment#selectMethod"
>
<span class="bbk-checkout-payment-option-name">{{ $method->translate('name') }}</span>
</label>
@endforeach
</div>
@endif
@@ -0,0 +1,50 @@
{{--
Shipping methods for the checkout page. Rendered inline by page.blade.php on
load, and re-rendered as a fragment by CheckoutController after every
address save / option change (bbk-checkout-form swaps it in). Radios
autosave via bbk-checkout-form#selectShipping — no submit button. A single
resolved option is auto-selected server-side and shown as a fixed line.
$shippingAddress, $shippingOptions come from the controller / page scope.
--}}
@php($selected = $shippingAddress?->shipping_option)
{{-- Rate resolution needs country (always Greece here) + postcode; until a
postcode is saved there's nothing to quote against yet. --}}
@if (! $shippingAddress?->postcode)
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
@elseif ($shippingOptions->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
@elseif ($shippingOptions->count() === 1)
@php($only = $shippingOptions->first())
<div class="bbk-checkout-shipping-confirmed">
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
@if ($only->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
</div>
@else
<div class="bbk-checkout-shipping-options">
@foreach ($shippingOptions as $option)
<label class="bbk-checkout-shipping-option">
<input
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
@checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping"
>
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
@if ($option->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
</label>
@endforeach
</div>
@endif