From 547f07f01e043206d36b54290633f9f8fe6c1e14 Mon Sep 17 00:00:00 2001 From: Konstantinos Arvanitakis Date: Fri, 25 Sep 2026 20:19:11 +0300 Subject: [PATCH] Feat: Extracting Cart and Checout from 3dealer --- composer.json | 1 + config/checkout.php | 44 + resources/css/checkout.css | 856 ++++++++++++++++++ .../js/checkout/bbk-add-to-cart-controller.js | 57 ++ resources/js/checkout/bbk-cart-controller.js | 161 ++++ .../checkout/bbk-checkout-form-controller.js | 204 +++++ .../js/checkout/bbk-payment-controller.js | 289 ++++++ resources/js/checkout/csrf.js | 6 + resources/js/checkout/index.js | 19 + .../checkout/components/add-to-cart.blade.php | 44 + .../components/address-lines.blade.php | 15 + .../views/checkout/components/field.blade.php | 29 + .../components/region-country.blade.php | 52 ++ .../checkout/components/select.blade.php | 62 ++ .../checkout/components/textarea.blade.php | 18 + .../views/checkout/confirmation.blade.php | 132 +++ resources/views/checkout/drawer.blade.php | 33 + resources/views/checkout/page.blade.php | 279 ++++++ .../checkout/partials/cart-body.blade.php | 121 +++ .../checkout/partials/cart-error.blade.php | 9 + .../checkout/partials/cart-line.blade.php | 103 +++ .../partials/line-custom-fields.blade.php | 50 + .../partials/payment-methods.blade.php | 30 + .../partials/shipping-options.blade.php | 50 + src/Cart/Http/Controllers/CartController.php | 253 ++++++ .../Seeders/CheckoutTranslationsSeeder.php | 185 ++++ .../Http/Controllers/CheckoutController.php | 676 ++++++++++++++ src/Checkout/routes/checkout.php | 58 ++ .../CheckoutModuleServiceProvider.php | 61 ++ src/Providers/CheckoutServiceProvider.php | 8 + 30 files changed, 3905 insertions(+) create mode 100644 config/checkout.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/bbk-checkout-form-controller.js create mode 100644 resources/js/checkout/bbk-payment-controller.js create mode 100644 resources/js/checkout/csrf.js create mode 100644 resources/js/checkout/index.js create mode 100644 resources/views/checkout/components/add-to-cart.blade.php create mode 100644 resources/views/checkout/components/address-lines.blade.php create mode 100644 resources/views/checkout/components/field.blade.php create mode 100644 resources/views/checkout/components/region-country.blade.php create mode 100644 resources/views/checkout/components/select.blade.php create mode 100644 resources/views/checkout/components/textarea.blade.php create mode 100644 resources/views/checkout/confirmation.blade.php create mode 100644 resources/views/checkout/drawer.blade.php create mode 100644 resources/views/checkout/page.blade.php create mode 100644 resources/views/checkout/partials/cart-body.blade.php create mode 100644 resources/views/checkout/partials/cart-error.blade.php create mode 100644 resources/views/checkout/partials/cart-line.blade.php create mode 100644 resources/views/checkout/partials/line-custom-fields.blade.php create mode 100644 resources/views/checkout/partials/payment-methods.blade.php create mode 100644 resources/views/checkout/partials/shipping-options.blade.php create mode 100644 src/Cart/Http/Controllers/CartController.php create mode 100644 src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php create mode 100644 src/Checkout/Http/Controllers/CheckoutController.php create mode 100644 src/Checkout/routes/checkout.php create mode 100644 src/Providers/CheckoutModuleServiceProvider.php diff --git a/composer.json b/composer.json index 1d3bf23..bb07335 100644 --- a/composer.json +++ b/composer.json @@ -38,6 +38,7 @@ "Modules\\Core\\Providers\\AuthServiceProvider", "Modules\\Core\\Providers\\CustomerServiceProvider", "Modules\\Core\\Providers\\CheckoutServiceProvider", + "Modules\\Core\\Providers\\CheckoutModuleServiceProvider", "Modules\\Core\\Providers\\PaymentServiceProvider", "Modules\\Core\\Providers\\LocalizationServiceProvider", "Modules\\Core\\Providers\\CatalogServiceProvider", diff --git a/config/checkout.php b/config/checkout.php new file mode 100644 index 0000000..22d571a --- /dev/null +++ b/config/checkout.php @@ -0,0 +1,44 @@ +`, so the + * login page must send the shopper back there afterwards. null: no login + * offered in checkout at all. + */ + 'login_route' => 'login', + + /* + * Name of the storefront's product-listing route — where confirmation() + * redirects a visit with no placed order to look at (session expired, + * direct navigation, a bookmark). route($this, $locale) must resolve. + */ + 'products_route' => 'products', + + /* + * ISO 3166-1 alpha-3 code fixing checkout to a single country (a hidden + * field, forced server-side — no country picker shown at all). null (the + * default) gives the full country/region picker, for a multi-country + * store. + */ + 'store_country_iso3' => null, + + /* + * The `purpose` tag CartController expects a product custom field's + * `file` answer to already carry (see Modules\Core\File\Models\File) — + * matches whatever purpose string the host's own upload endpoint + * (extending Modules\Core\File\Http\Controllers\UploadFileController) + * tags its stored files with. This module never reaches into that + * host controller directly; this config value is the one shared + * source of truth between the two. + */ + 'custom_field_upload_purpose' => 'custom-field-upload', + +]; diff --git a/resources/css/checkout.css b/resources/css/checkout.css new file mode 100644 index 0000000..51a4648 --- /dev/null +++ b/resources/css/checkout.css @@ -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 ), 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); +} 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..1f1cdf2 --- /dev/null +++ b/resources/js/checkout/bbk-add-to-cart-controller.js @@ -0,0 +1,57 @@ +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 { + 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 + } +} diff --git a/resources/js/checkout/bbk-cart-controller.js b/resources/js/checkout/bbk-cart-controller.js new file mode 100644 index 0000000..dfe5901 --- /dev/null +++ b/resources/js/checkout/bbk-cart-controller.js @@ -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), + }, + })) + } +} diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js new file mode 100644 index 0000000..a8e720e --- /dev/null +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -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 . 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) + } + } +} diff --git a/resources/js/checkout/bbk-payment-controller.js b/resources/js/checkout/bbk-payment-controller.js new file mode 100644 index 0000000..9cc59a5 --- /dev/null +++ b/resources/js/checkout/bbk-payment-controller.js @@ -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 + } +} 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..1163918 --- /dev/null +++ b/resources/js/checkout/index.js @@ -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) +} 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..3f39e61 --- /dev/null +++ b/resources/views/checkout/components/add-to-cart.blade.php @@ -0,0 +1,44 @@ +{{-- + + + 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/components/address-lines.blade.php b/resources/views/checkout/components/address-lines.blade.php new file mode 100644 index 0000000..957989d --- /dev/null +++ b/resources/views/checkout/components/address-lines.blade.php @@ -0,0 +1,15 @@ +{{-- + Read-only formatted address. $address is any Lunar address model + (OrderAddress / CartAddress) — same column names on both. +--}} +@props(['address']) + +
+ {{ trim(($address->first_name ?? '') . ' ' . ($address->last_name ?? '')) }} + @if ($address->company_name){{ $address->company_name }}@endif + {{ $address->line_one }} + @if ($address->line_two){{ $address->line_two }}@endif + {{ trim(($address->postcode ?? '') . ' ' . ($address->city ?? '')) }} + @if ($address->state){{ $address->state }}@endif + @if ($address->contact_phone){{ $address->contact_phone }}@endif +
diff --git a/resources/views/checkout/components/field.blade.php b/resources/views/checkout/components/field.blade.php new file mode 100644 index 0000000..368b894 --- /dev/null +++ b/resources/views/checkout/components/field.blade.php @@ -0,0 +1,29 @@ +{{-- + + + 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, +]) + +
+ + class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }} + > + {{-- Always present so bbk-checkout-form can fill it live on an autosave. --}} +

has($name)) hidden @endunless>{{ $errors->first($name) }}

+
diff --git a/resources/views/checkout/components/region-country.blade.php b/resources/views/checkout/components/region-country.blade.php new file mode 100644 index 0000000..7e1e006 --- /dev/null +++ b/resources/views/checkout/components/region-country.blade.php @@ -0,0 +1,52 @@ +{{-- + The state/region + country pair for one address (billing or shipping). + + Single-country store ($storeCountry set): region is a , as before. +--}} +@props([ + 'prefix', + 'storeCountry' => null, + 'regions' => [], + 'countries' => [], + 'address' => null, +]) + +
+ @if ($storeCountry) + + +
+ {{ __('checkout.page.country') }} +

+ {{ \Illuminate\Support\Facades\Lang::has("core::countries.{$storeCountry->name}") + ? __("core::countries.{$storeCountry->name}") + : $storeCountry->name }} +

+ +
+ @else + + + + @endif +
diff --git a/resources/views/checkout/components/select.blade.php b/resources/views/checkout/components/select.blade.php new file mode 100644 index 0000000..3474c67 --- /dev/null +++ b/resources/views/checkout/components/select.blade.php @@ -0,0 +1,62 @@ +{{-- + + + + `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)) + +
+ + +

has($name)) hidden @endunless>{{ $errors->first($name) }}

+
diff --git a/resources/views/checkout/components/textarea.blade.php b/resources/views/checkout/components/textarea.blade.php new file mode 100644 index 0000000..82beae1 --- /dev/null +++ b/resources/views/checkout/components/textarea.blade.php @@ -0,0 +1,18 @@ +@props([ + 'name', + 'label', + 'value' => null, + 'required' => false, +]) + +
+ + +

has($name)) hidden @endunless>{{ $errors->first($name) }}

+
diff --git a/resources/views/checkout/confirmation.blade.php b/resources/views/checkout/confirmation.blade.php new file mode 100644 index 0000000..ae97d87 --- /dev/null +++ b/resources/views/checkout/confirmation.blade.php @@ -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') +
+

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

+ +
+
+
{{ __('checkout.page.confirmation_order_number') }}
+
{{ $order->reference }}
+
+ + @if ($order->billingAddress?->contact_email) +
+
{{ __('checkout.page.email_label') }}
+
{{ $order->billingAddress->contact_email }}
+
+ @endif + + @if ($paymentMethodName) +
+
{{ __('checkout.page.payment_heading') }}
+
{{ $paymentMethodName }}
+
+ @endif + + @if ($shippingLine = $order->lines->firstWhere('type', 'shipping')) +
+
{{ __('checkout.page.shipping_method_heading') }}
+
{{ $shippingLine->description }}
+
+ @endif +
+ +

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

+ + {{-- Guests: logging in with the order's email attaches it to an account + (boboko-core's Modules\Core\Customer\Listeners\ClaimGuestOrdersOnLogin), + so it shows in their history. --}} + @guest + @if ($loginRoute = config('checkout.login_route')) +

+ {{ __('checkout.page.confirmation_login_hint') }} + {{ __('checkout.page.login_link') }} +

+ @endif + @endguest + +
+
+ @foreach ($order->lines->where('type', '!=', 'shipping') as $line) +
+
+ @if ($thumb = $line->purchasable?->getThumbnailImage()) + {{ $line->description }} + @endif +
+ +
+ + {{ $line->description }} + × {{ $line->quantity }} + + + @if ($line->option) +

{{ $line->option }}

+ @endif + + @include('checkout::partials.line-custom-fields', ['line' => $line]) +
+ + {{ $line->sub_total?->formatted() }} +
+ @endforeach + +
+
+ {{ __('checkout.cart.subtotal') }} + {{ $order->sub_total?->formatted() }} +
+ + @if ($order->discount_total?->value > 0) +
+ {{ __('checkout.cart.discount') }} + −{{ $order->discount_total->formatted() }} +
+ @endif + +
+ {{ __('checkout.cart.shipping') }} + {{ $order->shipping_total?->formatted() }} +
+ + @if ($order->tax_total?->value > 0) +
+ {{ __('checkout.cart.tax') }} + {{ $order->tax_total->formatted() }} +
+ @endif + +
+ {{ __('checkout.cart.total') }} + {{ $order->total?->formatted() }} +
+
+
+ +
+ @if ($order->shippingAddress) +
+

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

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

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

+ +
+ @endif +
+
+
+@endsection diff --git a/resources/views/checkout/drawer.blade.php b/resources/views/checkout/drawer.blade.php new file mode 100644 index 0000000..24a98ed --- /dev/null +++ b/resources/views/checkout/drawer.blade.php @@ -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. +--}} + diff --git a/resources/views/checkout/page.blade.php b/resources/views/checkout/page.blade.php new file mode 100644 index 0000000..68a44e6 --- /dev/null +++ b/resources/views/checkout/page.blade.php @@ -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') +
+

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

+ +
+
+ + {{-- 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')) +
+ @auth +

+ {{ __('checkout.page.logged_in_as') }} {{ auth()->user()->email }} +

+ @else + @if ($loginRoute) + + @endif + + + @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. --}} + +
+ + {{-- 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(). --}} +
+ @csrf + + {{-- Billing --}} +
+

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

+ +
+ + +
+ + {{-- Company/ΑΦΜ only when an invoice is wanted. Revealed by CSS + (:has on the checkbox), saved/cleared by saveAddress(), and + required at place-order. --}} +
+ + +
+ + +
+
+ + + +
+ + +
+ + + + +
+ + {{-- Shipping --}} +
+

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

+ + + +
+
+ + +
+ + + +
+ + +
+ + + + +
+ + +
+
+ + + + {{-- Shipping method — resolves from the saved shipping address; + re-rendered as a fragment by bbk-checkout-form after each + autosave / option change. --}} +
+

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

+ +
+ @include('checkout::partials.shipping-options', [ + 'shippingAddress' => $shippingAddress, + 'shippingOptions' => $shippingOptions, + ]) +
+
+ + {{-- Payment --}} +
+

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

+ +
+ @include('checkout::partials.payment-methods', [ + 'paymentMethods' => $paymentMethods, + 'cart' => $cart, + ]) +
+ + {{-- Stripe Payment Element mounts here when a Stripe method is picked. --}} + + + + +

+ {!! __('checkout.page.withdrawal_notice', [ + 'link' => route('legal.shipping-returns', app()->getLocale()), + ]) !!} +

+ + + + +
+ + {{-- Fixed overlay while a payment is confirming (3-D Secure / webhook + poll). Inside .bbk-checkout-main so bbk-payment can target it. --}} + + +
+ + +
+
+@endsection 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..1bfdb95 --- /dev/null +++ b/resources/views/checkout/partials/cart-body.blade.php @@ -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) --}} + +
+ @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 +
+ +
+ {{ __('checkout.cart.subtotal') }} + {{ $cart?->subTotal?->formatted() }} +
+ + @if ($cart?->discountTotal?->value > 0) +
+ {{ __('checkout.cart.discount') }} + −{{ $cart->discountTotal->formatted() }} +
+ @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) +
+ {{ __('checkout.cart.shipping') }} + @if ($cart->shippingAddress->shipping_option) + {{ $cart->shippingTotal?->formatted() }} + @else + {{ __('checkout.cart.shipping_pending') }} + @endif +
+ @endif + + @if ($cart?->taxTotal?->value > 0) +
+ {{ __('checkout.cart.tax') }} + {{ $cart->taxTotal->formatted() }} +
+ @endif + + {{-- Always shown — equals subtotal with nothing else applied, + diverges as discount / shipping / tax come in. --}} +
+ {{ __('checkout.cart.total') }} + {{ $cart?->total?->formatted() }} +
+ + + {{ __('checkout.cart.checkout') }} + +
+ @endif +
diff --git a/resources/views/checkout/partials/cart-error.blade.php b/resources/views/checkout/partials/cart-error.blade.php new file mode 100644 index 0000000..20cc2a1 --- /dev/null +++ b/resources/views/checkout/partials/cart-error.blade.php @@ -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. +--}} + 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..df76966 --- /dev/null +++ b/resources/views/checkout/partials/cart-line.blade.php @@ -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 + +
  • +
    + @if ($thumb) + @if ($productUrl) + + @else + {{ $name }} + @endif + @endif +
    + +
    + @if ($productUrl) + {{ $name }} + @else +

    {{ $name }}

    + @endif + @if ($variantLabel) +

    {{ $variantLabel }}

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

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

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

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

    + +
    + @csrf + @method('DELETE') + +
    +
    +
  • diff --git a/resources/views/checkout/partials/line-custom-fields.blade.php b/resources/views/checkout/partials/line-custom-fields.blade.php new file mode 100644 index 0000000..9c25af1 --- /dev/null +++ b/resources/views/checkout/partials/line-custom-fields.blade.php @@ -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)) +
    + @foreach ($fields as $field) +
    +
    {{ $field['label'] }}
    +
    + @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 + + @if (in_array($file->mime, $previewable, true)) + + @endif + {{ $file->original_name }} + + @endif + @else + {{ $field['value'] }} + @endif +
    +
    + @endforeach +
    +@endif diff --git a/resources/views/checkout/partials/payment-methods.blade.php b/resources/views/checkout/partials/payment-methods.blade.php new file mode 100644 index 0000000..ca239cf --- /dev/null +++ b/resources/views/checkout/partials/payment-methods.blade.php @@ -0,0 +1,30 @@ +{{-- + Payment method radios. $paymentMethods is Collection 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()) +

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

    +@else +
    + @foreach ($paymentMethods as $method) + + @endforeach +
    +@endif diff --git a/resources/views/checkout/partials/shipping-options.blade.php b/resources/views/checkout/partials/shipping-options.blade.php new file mode 100644 index 0000000..969dad6 --- /dev/null +++ b/resources/views/checkout/partials/shipping-options.blade.php @@ -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) +

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

    +@elseif ($shippingOptions->isEmpty()) +

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

    +@elseif ($shippingOptions->count() === 1) + @php($only = $shippingOptions->first()) +
    + + {{ $only->name }} + @if ($only->description) + {{ strip_tags($only->description) }} + @endif + + {{ $only->price->formatted() }} +
    +@else +
    + @foreach ($shippingOptions as $option) + + @endforeach +
    +@endif diff --git a/src/Cart/Http/Controllers/CartController.php b/src/Cart/Http/Controllers/CartController.php new file mode 100644 index 0000000..b52893a --- /dev/null +++ b/src/Cart/Http/Controllers/CartController.php @@ -0,0 +1,253 @@ +validate([ + 'purchasable_id' => ['required', 'integer'], + 'quantity' => ['nullable', 'integer', 'min:1'], + 'custom_fields' => ['nullable', 'array'], + ]); + + $variant = ProductVariant::findOrFail($data['purchasable_id']); + + try { + $meta = $this->customFieldsMeta($variant, $data['custom_fields'] ?? []); + } catch (ValidationException $e) { + return response()->json(['error' => collect($e->errors())->flatten()->first()], 422); + } + + try { + $this->cart->addLine($variant, $data['quantity'] ?? 1, $meta); + } catch (CartException) { + return $this->stockError($variant); + } + + return view('checkout::partials.cart-body'); + } + + /** + * The shopper's answers to the product's custom fields (Catalog\Models\ + * Product::$custom_fields — {key, type: text|textarea|file, label, + * required}), as cart line meta. Lunar copies CartLine.meta onto the + * OrderLine at order creation, so this is also what the order keeps. + * + * Only keys the product actually defines are kept, nested under + * `custom_fields` — line meta also carries behavior flags (core's + * `saved_for_later` zeroes the line's price), so shopper input must never + * be merged into it directly. Label and type are snapshotted alongside + * each value so the cart/order still reads correctly if the product's + * fields are edited later. + * + * A `file` answer is the id of a File row the host's own upload endpoint + * already created via FileService — never the file's bytes, disk, or + * path, all of which FileService alone is the source of truth for. A + * shopper can't point this at someone else's file: the id must resolve + * to a File that is BOTH unowned (isFileAnswerValid()) and tagged with + * config('checkout.custom_field_upload_purpose') — the host's own + * upload endpoint sets its File rows to this same purpose string, so + * this stays a single source of truth without this module reaching + * into a host controller class directly (an inverted dependency this + * module can't have — a host app's upload endpoint is deliberately its + * own concern, see config/checkout.php's own comment). Attaching the + * File to the real CartLine it belongs to happens afterward, in File\ + * Listeners\AttachCustomFieldFileToCartLine (listening for Cart\Events\ + * CartLineAdded) — not here, since this method only builds the meta + * $this->cart->addLine() is about to receive, before any CartLine + * actually exists to own anything. + * + * Two adds with identical answers merge into one line (Lunar matches + * existing lines on meta); different answers stay separate lines. + */ + private function customFieldsMeta(ProductVariant $variant, array $input): array + { + $fields = collect($variant->product?->custom_fields ?? []) + ->keyBy('key') + ->map(fn (array $field) => [...$field, 'label' => $this->resolveLabel($field['label'])]); + + if ($fields->isEmpty()) { + return []; + } + + $validated = Validator::make( + $input, + $fields->map(fn (array $field) => [ + ($field['required'] ?? false) ? 'required' : 'nullable', + ...match ($field['type']) { + 'textarea' => ['string', 'max:2000'], + 'file' => [function (string $attribute, mixed $value, Closure $fail) { + if (! $this->isFileAnswerValid($value)) { + $fail('validation.uploaded')->translate(); + } + }], + default => ['string', 'max:255'], + }, + ])->all(), + [], + $fields->map(fn (array $field) => $field['label'])->all(), + )->validate(); + + $answers = $fields + ->filter(fn (array $field) => filled($validated[$field['key']] ?? null)) + ->map(fn (array $field) => [ + 'key' => $field['key'], + 'label' => $field['label'], + 'type' => $field['type'], + ...($field['type'] === 'file' + ? ['file_id' => (int) $validated[$field['key']]] + : ['value' => $validated[$field['key']]]), + ]) + ->values() + ->all(); + + return $answers === [] ? [] : ['custom_fields' => $answers]; + } + + /** + * Product::$custom_fields stores `label` as {locale: string} (see + * Catalog\Filament\Pages\ManageProductCustomFields) — this resolves it + * to the single current-locale string cart/order line meta actually + * needs, the same filled()-over-?? fallback ProductDocumentLocalizer + * uses for every other translated field (an empty string for the + * current locale still falls through to the store's default language, + * rather than showing blank). A product saved before labels became + * translatable still has a plain string here, returned as-is. + */ + private function resolveLabel(mixed $label): string + { + if (! is_array($label)) { + return (string) $label; + } + + $locale = App::getLocale(); + $fallbackLocale = app(LanguageCache::class)->defaultLocale(); + + return filled($label[$locale] ?? null) + ? $label[$locale] + : ($label[$fallbackLocale] ?? ''); + } + + private function isFileAnswerValid(mixed $fileId): bool + { + $file = File::find($fileId); + + return $file !== null + && $file->purpose === config('checkout.custom_field_upload_purpose') + && $file->owner_id === null + && app(FileService::class)->exists($file); + } + + public function updateLine(string $locale, Request $request, int $line): View|JsonResponse + { + $quantity = (int) $request->validate([ + 'quantity' => ['required', 'integer', 'min:0'], + ])['quantity']; + + try { + $quantity === 0 + ? $this->cart->removeLine($line) + : $this->cart->updateLine($line, $quantity); + } catch (CartException) { + $variant = CartLine::find($line)?->purchasable; + + return $this->stockError($variant instanceof ProductVariant ? $variant : null); + } + + return view('checkout::partials.cart-body'); + } + + /** + * getTotalInventory() is the same number canBeFulfilledAtQuantity() + * checked against (stock, for a tracked in_stock variant) — telling the + * shopper how many are actually left beats a generic "not enough stock" + * they'd otherwise have to guess around by trial and error. + */ + private function stockError(?ProductVariant $variant): JsonResponse + { + $available = $variant?->getTotalInventory() ?? 0; + + return response()->json([ + 'error' => trans_choice('storefront.product.add_to_cart_failed', $available, ['count' => $available]), + ], 422); + } + + 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/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php b/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php new file mode 100644 index 0000000..5890967 --- /dev/null +++ b/src/Checkout/Database/Seeders/CheckoutTranslationsSeeder.php @@ -0,0 +1,185 @@ +lines() as $key => [$en, $el]) { + $exists = LanguageLine::query() + ->where('group', 'checkout') + ->where('key', $key) + ->exists(); + + if ($exists) { + $this->command?->warn("checkout.{$key} already exists — skipped"); + + continue; + } + + $translations->create('checkout', $key, ['en' => $en, 'el' => $el]); + $this->command?->info("checkout.{$key} added"); + } + } + + /** + * key => [English, Greek]. + * + * @return array + */ + private function lines(): array + { + return [ + // ── Cart drawer + order summary ────────────────────────────── + 'cart.title' => ['Your cart', 'Το καλάθι σου'], + 'cart.close' => ['Close', 'Κλείσιμο'], + 'cart.empty' => ['Your cart is empty', 'Το καλάθι σου είναι άδειο'], + 'cart.quantity' => ['Quantity', 'Ποσότητα'], + 'cart.increase' => ['Increase quantity', 'Αύξηση ποσότητας'], + 'cart.decrease' => ['Decrease quantity', 'Μείωση ποσότητας'], + 'cart.remove' => ['Remove', 'Αφαίρεση'], + 'cart.subtotal' => ['Subtotal', 'Υποσύνολο'], + 'cart.discount' => ['Discount', 'Έκπτωση'], + 'cart.shipping' => ['Shipping', 'Μεταφορικά'], + 'cart.shipping_pending' => ['Not selected yet', 'Δεν έχει επιλεγεί ακόμη'], + 'cart.tax' => ['VAT', 'ΦΠΑ'], + 'cart.total' => ['Total', 'Σύνολο'], + 'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'], + 'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'], + 'cart.coupon_placeholder' => ['Coupon code', 'Κωδικός κουπονιού'], + 'cart.coupon_apply' => ['Apply', 'Εφαρμογή'], + 'cart.coupon_remove' => ['Remove', 'Αφαίρεση'], + 'cart.coupon_invalid' => ["That coupon code isn't valid", 'Ο κωδικός κουπονιού δεν είναι έγκυρος'], + + // ── Checkout page ──────────────────────────────────────────── + 'page.title' => ['Checkout', 'Ολοκλήρωση παραγγελίας'], + 'page.contact_heading' => ['Contact', 'Στοιχεία επικοινωνίας'], + 'page.guest_tab' => ['Guest', 'Ως επισκέπτης'], + 'page.login_tab' => ['Log in', 'Σύνδεση'], + 'page.email_label' => ['Email', 'Email'], + 'page.recovery_consent' => [ + "Email me a reminder if I don't finish my order", + 'Στείλε μου μια υπενθύμιση αν δεν ολοκληρώσω την παραγγελία μου', + ], + 'page.login_email_label' => ['Email', 'Email'], + 'page.send_code' => ['Send code', 'Αποστολή κωδικού'], + 'page.login_coming_soon' => [ + 'Login is coming soon — continue as a guest for now.', + 'Η σύνδεση θα είναι διαθέσιμη σύντομα — προς το παρόν συνέχισε ως επισκέπτης.', + ], + 'page.billing_heading' => ['Billing information', 'Στοιχεία τιμολόγησης'], + 'page.shipping_heading' => ['Shipping information', 'Στοιχεία αποστολής'], + 'page.same_as_billing' => ['Same as billing address', 'Ίδια με τη διεύθυνση τιμολόγησης'], + 'page.first_name' => ['First name', 'Όνομα'], + 'page.last_name' => ['Last name', 'Επώνυμο'], + 'page.company_name' => ['Company name', 'Επωνυμία εταιρείας'], + 'page.tax_identifier' => ['Tax ID', 'ΑΦΜ'], + 'page.address_line_one' => ['Address', 'Διεύθυνση'], + 'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'], + 'page.city' => ['City', 'Πόλη'], + 'page.state' => ['Region / Prefecture', 'Νομός / Περιοχή'], + 'page.state_placeholder' => ['Select a region', 'Επίλεξε νομό'], + 'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'], + 'page.country' => ['Country', 'Χώρα'], + 'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'], + 'page.phone' => ['Phone', 'Τηλέφωνο'], + 'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'], + 'page.save_address' => ['Save and continue', 'Αποθήκευση και συνέχεια'], + 'page.saving' => ['Saving…', 'Αποθήκευση…'], + 'page.saved' => ['Saved', 'Αποθηκεύτηκε'], + 'page.save_error' => ["Couldn't save — check your connection", 'Δεν αποθηκεύτηκε — έλεγξε τη σύνδεσή σου'], + 'page.shipping_method_heading' => ['Shipping method', 'Τρόπος αποστολής'], + 'page.shipping_method_empty' => [ + 'Add your shipping address to see delivery options.', + 'Συμπλήρωσε τη διεύθυνση αποστολής για να δεις τις διαθέσιμες επιλογές.', + ], + 'page.shipping_method_none' => [ + 'No delivery options are available for this address.', + 'Δεν υπάρχουν διαθέσιμες επιλογές αποστολής για αυτή τη διεύθυνση.', + ], + 'page.select_shipping_method' => ['Continue', 'Συνέχεια'], + 'page.shipping_option_invalid' => [ + 'That shipping option is no longer available.', + 'Αυτός ο τρόπος αποστολής δεν είναι πλέον διαθέσιμος.', + ], + 'page.continue_to_payment' => ['Continue to payment', 'Συνέχεια στην πληρωμή'], + 'page.order_summary_heading' => ['Order summary', 'Σύνοψη παραγγελίας'], + + // ── Payment step ──────────────────────────────────────────── + 'page.payment_heading' => ['Payment', 'Πληρωμή'], + 'page.payment_method_none' => [ + 'No payment methods are available right now.', + 'Δεν υπάρχουν διαθέσιμοι τρόποι πληρωμής αυτή τη στιγμή.', + ], + 'page.terms_accept' => [ + "I accept the Terms of Sale and the Privacy Policy", + "Αποδέχομαι τους Όρους Πώλησης και την Πολιτική Απορρήτου", + ], + 'page.terms_required' => [ + 'You must accept the terms to place your order.', + 'Πρέπει να αποδεχτείς τους όρους για να ολοκληρώσεις την παραγγελία.', + ], + 'page.withdrawal_notice' => [ + "You have a 14-day right of withdrawal. See details.", + "Έχεις δικαίωμα υπαναχώρησης εντός 14 ημερών. Δες λεπτομέρειες.", + ], + 'page.place_order' => ['Place order — payment obligation', 'Παραγγελία με υποχρέωση πληρωμής'], + 'page.choose_payment_method' => ['Choose a payment method.', 'Επίλεξε τρόπο πληρωμής.'], + 'page.shipping_method_required' => [ + 'Choose a shipping method below to continue.', + 'Επίλεξε τρόπο αποστολής παρακάτω για να συνεχίσεις.', + ], + 'page.payment_failed' => ['Payment failed. Please try again.', 'Η πληρωμή απέτυχε. Δοκίμασε ξανά.'], + 'page.payment_incomplete_details' => [ + 'Complete your billing and shipping details above.', + 'Συμπλήρωσε τα στοιχεία χρέωσης και αποστολής παραπάνω.', + ], + 'page.payment_cart_changed' => [ + 'Your cart changed. Refresh the page and place your order again.', + 'Το καλάθι σου άλλαξε. Ανανέωσε τη σελίδα και ολοκλήρωσε ξανά.', + ], + 'page.payment_processing' => ['Confirming your payment…', 'Επιβεβαίωση πληρωμής…'], + 'page.payment_processing_slow' => [ + "Your payment is still processing. You'll get an email once it's confirmed.", + 'Η πληρωμή σου επεξεργάζεται ακόμη. Θα λάβεις email μόλις επιβεβαιωθεί.', + ], + + // ── Confirmation page ────────────────────────────────────── + 'page.confirmation_title' => ['Your order', 'Η παραγγελία σου'], + 'page.confirmation_heading' => [ + 'Thank you! Your order is confirmed.', + 'Ευχαριστούμε! Η παραγγελία σου καταχωρήθηκε.', + ], + 'page.confirmation_order_number' => ['Order number', 'Αριθμός παραγγελίας'], + 'page.confirmation_email_note' => [ + 'A confirmation email will follow shortly.', + 'Θα λάβεις email επιβεβαίωσης σύντομα.', + ], + 'page.confirmation_shipping_to' => ['Shipping to', 'Αποστολή σε'], + 'page.confirmation_billing' => ['Billing', 'Χρέωση'], + 'page.confirmation_continue' => ['Continue shopping', 'Συνέχεια αγορών'], + ]; + } +} diff --git a/src/Checkout/Http/Controllers/CheckoutController.php b/src/Checkout/Http/Controllers/CheckoutController.php new file mode 100644 index 0000000..3efefc5 --- /dev/null +++ b/src/Checkout/Http/Controllers/CheckoutController.php @@ -0,0 +1,676 @@ +cart->current(); + $lines = $cart ? $this->cart->activeLines($cart) : collect(); + $storeCountry = $this->storeCountry(); + + $shippingOptions = collect(); + + // Captured before prefillFromAccount(), which may recreate the address + // row (dropping its shipping_option) — same reason as in saveAddress(). + $previousOption = $cart?->shippingAddress?->shipping_option; + + if ($cart && Auth::check()) { + $cart = $this->prefillFromAccount($cart); + + // Nothing chosen on this cart yet: carry over the account's standing + // opt-in (an explicit earlier choice, recorded with its own + // timestamp/policy version). Never opts anyone in by default. + if (! array_key_exists('recovery_consent', $cart->meta?->toArray() ?? []) + && data_get($this->account->customer(Auth::user()), 'meta.recovery_consent')) { + $cart = $this->checkout->setRecoveryConsent(true); + } + } + + if ($cart?->shippingAddress) { + $shippingOptions = $this->syncShipping($cart, $previousOption); + + // Cart's CachesProperties::refresh() explicitly nulls total/ + // subTotal/shippingTotal/etc. back to their defaults — every + // Lunar call site pairs it with recalculate() for exactly that + // reason. Bare refresh() here was leaving $cart->total null on + // reload, which fed a 0 amount straight into the Stripe Element. + $cart->refresh()->recalculate(); + } + + $paymentMethods = $this->checkout->getPaymentMethods(); + + // Nothing checked yet (fresh cart), or the shopper's earlier pick is + // no longer offered (method disabled/removed since) — auto-select + // the first one, same as a manual click would, so the payment + // section (and the Stripe Element mounting under it) isn't sitting + // inert behind an unchecked radio. A still-valid previous choice is + // left alone. + $firstMethod = $paymentMethods->first(); + + if ($cart && $firstMethod && ! $paymentMethods->contains('type', data_get($cart, 'meta.payment_method'))) { + $cart = $this->checkout->selectPaymentMethod($firstMethod->type); + } + + return view('checkout::page', [ + 'cart' => $cart, + 'lines' => $lines, + 'billingAddress' => $cart?->billingAddress, + 'shippingAddress' => $cart?->shippingAddress, + 'shippingOptions' => $shippingOptions, + 'paymentMethods' => $paymentMethods, + 'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true), + 'wantsInvoice' => (bool) data_get($cart, 'meta.wants_invoice', false), + 'storeCountry' => $storeCountry, + 'countries' => $storeCountry + ? collect() + : Country::orderBy('name')->get(['id', 'name']), + 'regions' => $storeCountry + ? State::where('country_id', $storeCountry->id)->orderBy('name')->get(['id', 'name']) + : collect(), + ]); + } + + public function saveAddress(string $locale, Request $request): JsonResponse + { + $storeCountry = $this->storeCountry(); + $sameAsBilling = $request->boolean('same_as_billing'); + + // Only the fields shipping rates resolve against — if none of these + // changed (shopper edited their name, phone, email, …) there's no point + // re-quoting shipping or re-rendering the summary. + $addressBefore = $this->cart->current()?->shippingAddress; + $rateKeyBefore = $addressBefore?->only(['postcode', 'state', 'country_id']); + + // setShippingAddress() below always deletes + recreates this row (see + // syncShipping()'s docblock) — capture what was selected NOW, before + // it's gone, so it can be carried forward onto the fresh row. + $previousOption = $addressBefore?->shipping_option; + + $stateRule = $storeCountry + ? ['nullable', 'string', Rule::exists((new State)->getTable(), 'name')->where('country_id', $storeCountry->id)] + : ['nullable', 'string', 'max:255']; + $countryRule = $storeCountry + ? ['nullable'] + : ['nullable', 'integer', 'exists:'.(new Country)->getTable().',id']; + + // Lenient — only format checks. Anything that fails is simply left out + // of what gets persisted, and reported back for inline display. + $validator = Validator::make($request->all(), [ + 'contact_email' => ['nullable', 'email'], + + 'billing_first_name' => ['nullable', 'string', 'max:255'], + 'billing_last_name' => ['nullable', 'string', 'max:255'], + 'billing_company_name' => ['nullable', 'string', 'max:255'], + 'billing_tax_identifier' => ['nullable', 'string', 'max:255'], + 'billing_line_one' => ['nullable', 'string', 'max:255'], + 'billing_city' => ['nullable', 'string', 'max:255'], + 'billing_state' => $stateRule, + 'billing_postcode' => ['nullable', 'string', 'max:20'], + 'billing_country_id' => $countryRule, + 'billing_contact_phone' => ['nullable', 'string', 'max:50'], + + 'shipping_first_name' => ['nullable', 'string', 'max:255'], + 'shipping_last_name' => ['nullable', 'string', 'max:255'], + 'shipping_line_one' => ['nullable', 'string', 'max:255'], + 'shipping_city' => ['nullable', 'string', 'max:255'], + 'shipping_state' => $stateRule, + 'shipping_postcode' => ['nullable', 'string', 'max:20'], + 'shipping_country_id' => $countryRule, + 'shipping_contact_phone' => ['nullable', 'string', 'max:50'], + 'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'], + ]); + + $errors = $validator->errors()->toArray(); + $data = $validator->valid(); + + // Logged in: the order email is always the account's. It isn't a field + // on the page then, and a submitted value isn't trusted. + if ($user = Auth::user()) { + $data['contact_email'] = $user->email; + } + + $billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null); + $shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId); + + // Company/tax id only count when "I want an invoice" is ticked; the + // fields stay in the DOM (just hidden) when it isn't, so ignore what + // they send. + $wantsInvoice = $request->boolean('wants_invoice'); + + $billing = [ + 'first_name' => $data['billing_first_name'] ?? null, + 'last_name' => $data['billing_last_name'] ?? null, + 'company_name' => $wantsInvoice ? ($data['billing_company_name'] ?? null) : null, + 'tax_identifier' => $wantsInvoice ? ($data['billing_tax_identifier'] ?? null) : null, + 'line_one' => $data['billing_line_one'] ?? null, + 'city' => $data['billing_city'] ?? null, + 'state' => $data['billing_state'] ?? null, + 'postcode' => $data['billing_postcode'] ?? null, + 'country_id' => $billingCountryId, + 'contact_email' => $data['contact_email'] ?? null, + 'contact_phone' => $data['billing_contact_phone'] ?? null, + ]; + + $shipping = $sameAsBilling + ? [ + ...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]), + 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null, + ] + : [ + 'first_name' => $data['shipping_first_name'] ?? null, + 'last_name' => $data['shipping_last_name'] ?? null, + 'line_one' => $data['shipping_line_one'] ?? null, + 'city' => $data['shipping_city'] ?? null, + 'state' => $data['shipping_state'] ?? null, + 'postcode' => $data['shipping_postcode'] ?? null, + 'country_id' => $shippingCountryId, + 'contact_email' => $data['contact_email'] ?? null, + 'contact_phone' => $data['shipping_contact_phone'] ?? null, + 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null, + ]; + + $this->checkout->setBillingAddress($billing); + $cart = $this->checkout->setShippingAddress($shipping); + + $cart->meta = [ + ...($cart->meta?->toArray() ?? []), + 'ship_to_billing' => $sameAsBilling, + 'wants_invoice' => $wantsInvoice, + ]; + $cart->save(); + + // Abandoned-cart-recovery opt-in — boboko-core owns the record (bool + + // timestamp + policy version on Cart::meta, RecoveryConsentSet event). + // Deliberately its own scope, not merged with any future newsletter opt-in. + $this->checkout->setRecoveryConsent($request->boolean('recovery_consent')); + + if (Auth::check()) { + $this->account->setRecoveryConsent(Auth::user(), $request->boolean('recovery_consent')); + } + + $rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']); + $rateChanged = $rateKeyAfter != $rateKeyBefore; + + // setShippingAddress() above always deletes and recreates the + // CartAddress row (Lunar's AddAddress action), which drops whatever + // shipping_option was previously selected — regardless of whether the + // rate-determining fields actually changed. So this always has to run + // to restore/re-validate it, even on a save that only touched e.g. the + // phone number. Only the fragment RE-RENDER is skippable when nothing + // rate-relevant moved — the re-select itself is not optional. + $options = $this->syncShipping($cart, $previousOption); + + if (! $rateChanged) { + return $this->fragments($cart, null, $errors); + } + + return $this->fragments($cart, $options, $errors); + } + + public function selectShippingOption(string $locale, Request $request): JsonResponse + { + $identifier = (string) $request->input('shipping_option'); + + try { + $this->checkout->selectShippingOption($identifier); + } catch (InvalidShippingOptionException) { + // Re-render with whatever is currently valid; no hard error surfaced. + } + + $cart = $this->cart->current(); + $options = $cart?->shippingAddress + ? $this->checkout->getShippingOptions() + : collect(); + + return $this->fragments($cart, $options); + } + + /** + * Autosave-select a payment method (radio change). Persists it via + * CheckoutService (which also records it on Cart::meta and re-snapshots + * the fingerprint) so ApplyCashOnDeliveryFee etc. show in the summary. + */ + public function selectPaymentMethod(string $locale, Request $request): JsonResponse + { + $type = (string) $request->input('payment_type'); + + try { + $this->checkout->selectPaymentMethod($type); + } catch (UnknownPaymentTypeException) { + // Radio value out of sync with what's offered — ignore, the summary + // just won't reflect a method fee. place-order re-checks properly. + } + + return response()->json([ + 'summaryHtml' => view('checkout::partials.cart-body')->render(), + ]); + } + + /** + * The real submit — the hard gate. Re-selects the payment method (fresh + * fingerprint), then hands off to CheckoutService::initiatePayment(), which + * creates the draft order, records terms acceptance, and charges the driver. + * Returns JSON the bbk-payment controller routes on: + * { redirect } — placed, go to confirmation + * { status: 'pending', clientSecret }— 3-D Secure; client does handleNextAction then polls + * { status: 'failed', message } — declined + * { status: 'invalid'|'stale', ... } — cart incomplete / changed since selection + */ + public function placeOrder(string $locale, Request $request): JsonResponse + { + if (! $request->boolean('terms_accepted')) { + return response()->json(['error' => __('checkout.page.terms_required')], 422); + } + + try { + $this->checkout->selectPaymentMethod((string) $request->input('payment_type')); + } catch (UnknownPaymentTypeException) { + return response()->json(['error' => __('checkout.page.choose_payment_method')], 422); + } + + $cart = $this->cart->current(); + + // Captured now, before initiatePayment() can place the order — Lunar's + // CartSessionManager::fetchOrCreate() silently swaps the session onto a + // BRAND NEW empty cart the moment the current one hasCompletedOrders() + // (i.e. has an order with placed_at set), which happens synchronously + // for an immediately-captured payment. Any later $this->cart->current() + // call in this same flow (here, or in a subsequent orderStatus() poll + // once the 3-D Secure webhook sets placed_at) would then resolve to + // that fresh, order-less cart instead of the one that was just placed. + // Storing the real cart id ourselves, under our own session key, + // sidesteps CartSession entirely for the rest of the placement flow. + session(['checkout.cart_id' => $cart?->id]); + + // Lunar's own ValidateCartForOrderCreation (order_create validator) + // never checks for this — an empty cart with a valid billing address + // sails straight through it and would place a real, zero-line order. + // The disabled "place order" button is only the client-side half of + // this fix; this is the half that actually matters. + if ($cart === null || $this->cart->activeLines($cart)->isEmpty()) { + return response()->json([ + 'status' => 'invalid', + 'message' => __('checkout.page.cart_empty'), + ], 422); + } + + // Lenient autosave never requires these; this is the gate. + if (data_get($cart, 'meta.wants_invoice') + && (blank($cart->billingAddress?->company_name) || blank($cart->billingAddress?->tax_identifier))) { + return response()->json([ + 'status' => 'invalid', + 'message' => __('checkout.page.invoice_required'), + ], 422); + } + + // Same check Lunar's own ValidateCartForOrderCreation runs inside + // initiatePayment() (a product unpublished/deleted after it was + // added to the cart) — checked here first so the shopper is told + // which product is the problem, rather than falling into the + // catch-all "complete your billing/shipping details" message below, + // which is what actually happened and is generic to every + // CartException reason, misleading when the real cause is a line, + // not an address. + $unavailableLines = $this->cart->activeLines($cart)->filter( + fn ($line) => ! $line->purchasable || ! $line->purchasable->isPurchasable(), + ); + + if ($unavailableLines->isNotEmpty()) { + $names = $unavailableLines + ->map(fn ($line) => $line->purchasable?->product?->translateAttribute('name') ?? $line->purchasable?->getIdentifier()) + ->filter() + ->implode(', '); + + return response()->json([ + 'status' => 'invalid', + 'message' => __('checkout.page.cart_line_unavailable', ['name' => $names]), + ], 422); + } + + // The one incomplete-cart case worth a specific message + pointing the + // shopper at the right section: a region resolving 2+ methods needs an + // explicit pick (no auto-select), easy to miss since nothing else on + // the page demands it. Everything else CartException catches below. + if ($cart?->shippingAddress && ! $cart->shippingAddress->shipping_option) { + return response()->json([ + 'status' => 'invalid', + 'message' => __('checkout.page.shipping_method_required'), + 'field' => 'shipping_option', + ], 422); + } + + $fingerprint = (string) ($cart?->meta['checkout_fingerprint'] ?? ''); + + $data = $request->filled('payment_method') + ? ['payment_method' => (string) $request->input('payment_method')] + : []; + + try { + $result = $this->checkout->initiatePayment( + $fingerprint, + termsAccepted: true, + policyVersion: (string) config('legal.terms_version'), + data: $data, + ); + } catch (FingerprintMismatchException) { + return response()->json(['status' => 'stale', 'message' => __('checkout.page.payment_cart_changed')], 409); + } catch (CartException $e) { + return response()->json([ + 'status' => 'invalid', + 'message' => __('checkout.page.payment_incomplete_details'), + 'errors' => collect($e->errors()->toArray())->map(fn ($m) => is_array($m) ? ($m[0] ?? null) : $m)->all(), + ], 422); + } catch (TermsNotAcceptedException) { + return response()->json(['error' => __('checkout.page.terms_required')], 422); + } + + // Pending with no continuation (cash-on-delivery, or any other + // deferred/offline method) means CheckoutService::initiatePayment() + // already created the placed order — money just hasn't changed + // hands yet. Only a Pending WITH a continuation (Stripe's client + // secret) means the shopper still has something to do before the + // order exists as far as the storefront is concerned. + return match (true) { + $result->status === PaymentResultStatus::Succeeded => $this->orderPlacedResponse($locale), + $result->status === PaymentResultStatus::Pending && $result->continuation === null => $this->orderPlacedResponse($locale), + $result->status === PaymentResultStatus::Pending => response()->json([ + 'status' => 'pending', + 'clientSecret' => $result->continuation?->value, + ]), + default => response()->json([ + 'status' => 'failed', + 'message' => $result->failureReason ?: __('checkout.page.payment_failed'), + 'retriable' => $result->retriable, + ], 422), + }; + } + + /** + * Poll target for the 3-D Secure path: has the webhook placed the order yet? + * StripeWebhookController -> handleCallback -> PaymentCaptured -> + * ApplyResolvedPaymentStatus sets placed_at. + */ + public function orderStatus(string $locale): JsonResponse + { + $order = $this->placedOrder(); + + if (! $order) { + return response()->json(['placed' => false]); + } + + session(['checkout.order_id' => $order->id]); + CartSession::forget(); + + return response()->json(['placed' => true, 'redirect' => route('checkout.confirmation', $locale)]); + } + + public function confirmation(string $locale): View|RedirectResponse + { + $orderId = session('checkout.order_id'); + + $order = $orderId + ? Order::with(['lines.purchasable.product', 'shippingAddress', 'billingAddress'])->find($orderId) + : null; + + if (! $order) { + return redirect()->to(route((string) config('checkout.products_route', 'products'), $locale)); + } + + // Looked up by type rather than a stored relation — the method may since + // have been disabled/deleted, but the order still needs to show what was + // actually used at the time. + $paymentMethodName = PaymentMethod::where('type', $order->meta['payment_method'] ?? null) + ->first() + ?->translate('name'); + + return view('checkout::confirmation', [ + 'order' => $order, + 'paymentMethodName' => $paymentMethodName, + ]); + } + + private function orderPlacedResponse(string $locale): JsonResponse + { + if ($order = $this->placedOrder()) { + session(['checkout.order_id' => $order->id]); + } + + CartSession::forget(); + + return response()->json(['redirect' => route('checkout.confirmation', $locale)]); + } + + private function placedOrder(): ?Order + { + $cartId = session('checkout.cart_id'); + + if ($cartId === null) { + return null; + } + + return Order::where('cart_id', $cartId) + ->whereNotNull('placed_at') + ->latest('placed_at') + ->first(); + } + + /** + * Re-resolve shipping options for the cart's current address and keep the + * selection sane: auto-select when exactly one resolves, or carry a + * previous pick forward when it's still among the resolved options. + * + * $previousOption must be captured by the CALLER before setShippingAddress() + * runs — Lunar's AddAddress action always deletes and recreates the + * CartAddress row on every save (see saveAddress()), so by the time this + * runs, $address->shipping_option is unconditionally null regardless of + * what was selected a moment ago. There is nothing meaningful left to read + * off $address itself; $previousOption is the only source of truth for + * "what was chosen before this save wiped the row." show() passes the + * address's own (not-just-wiped) current value, since nothing recreated + * anything in that path. + * + * Always (re-)applies the resolved target via selectShippingOption() rather + * than comparing against the (always-blank, post-recreation) current value + * — the fresh row needs the write regardless of whether the decision + * "which option" actually changed. + * + * @return Collection + */ + private function syncShipping(Cart $cart, ?string $previousOption): Collection + { + if (! $cart->shippingAddress) { + return collect(); + } + + $options = $this->checkout->getShippingOptions(); + + $target = match (true) { + $options->count() === 1 => $options->first()->identifier, + $previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption, + default => null, + }; + + if ($target !== null) { + try { + $this->checkout->selectShippingOption($target); + } catch (InvalidShippingOptionException) { + // $target came from $options itself — shouldn't happen, stay defensive + } + } + + return $options; + } + + /** + * $options === null means "nothing money-relevant changed" — acknowledge the + * save (and any field errors) without re-rendering the shipping options or + * the order summary, so a plain name/phone edit is a cheap round-trip. + */ + private function fragments(?Cart $cart, ?Collection $options, array $errors = []): JsonResponse + { + return response()->json([ + 'errors' => collect($errors) + ->map(fn ($messages) => is_array($messages) ? ($messages[0] ?? null) : $messages) + ->all(), + 'shippingOptionsHtml' => $options === null ? null : view('checkout::partials.shipping-options', [ + 'shippingAddress' => $cart?->shippingAddress, + 'shippingOptions' => $options, + ])->render(), + // Composer (Providers\CheckoutModuleServiceProvider) fills $cart / $lines. + 'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(), + ]); + } + + /** + * Logged-in shopper: fills any BLANK cart address field from the account + * (name, saved default address, phone, email), on every checkout load, so + * an account filled in after checkout started still shows up. Never + * overwrites anything already in the cart. + * + * Company/tax id (and ticking "I want an invoice") only on the first pass + * (meta.account_prefilled): someone who then clears them or unticks the + * box for this order shouldn't get them back on the next reload. + * + * Writes only when something actually changes, so a normal reload costs + * nothing extra. + */ + private function prefillFromAccount(Cart $cart): Cart + { + $user = Auth::user(); + $customer = $this->account->customer($user); + $addresses = collect($this->account->addresses($user)); + $saved = $addresses->firstWhere('shipping_default', true) ?? $addresses->first(); + $firstPass = ! data_get($cart, 'meta.account_prefilled'); + + $fromAccount = array_filter([ + 'first_name' => $customer?->first_name ?: $saved?->first_name, + 'last_name' => $customer?->last_name ?: $saved?->last_name, + 'line_one' => $saved?->line_one, + 'city' => $saved?->city, + 'state' => $saved?->state, + 'postcode' => $saved?->postcode, + 'country_id' => $this->storeCountry()?->id ?? $saved?->country_id, + 'contact_email' => $user->email, + 'contact_phone' => $saved?->contact_phone, + ], 'filled'); + + $invoice = $firstPass + ? array_filter([ + 'company_name' => $customer?->company_name, + 'tax_identifier' => $customer?->tax_identifier, + ], 'filled') + : []; + + $fields = ['first_name', 'last_name', 'company_name', 'tax_identifier', 'line_one', 'city', + 'state', 'postcode', 'country_id', 'contact_email', 'contact_phone']; + + $fillBlanks = function (?array $current, array $values) { + $current ??= []; + + foreach ($values as $key => $value) { + if (blank($current[$key] ?? null)) { + $current[$key] = $value; + } + } + + return $current; + }; + + $billingBefore = $cart->billingAddress?->only($fields); + $billing = $fillBlanks($billingBefore, [...$fromAccount, ...$invoice]); + + // Shipping has no company/tax id (same shape saveAddress() writes). + $shipToBilling = (bool) data_get($cart, 'meta.ship_to_billing', true); + $shippingFields = [...array_diff($fields, ['company_name', 'tax_identifier']), 'delivery_instructions']; + + $shippingBefore = $cart->shippingAddress?->only($shippingFields); + $shipping = $shipToBilling + ? [ + ...array_diff_key($billing, ['company_name' => 1, 'tax_identifier' => 1]), + 'delivery_instructions' => $shippingBefore['delivery_instructions'] ?? null, + ] + : $fillBlanks($shippingBefore, $fromAccount); + + if ($billing != ($billingBefore ?? []) || $shipping != ($shippingBefore ?? [])) { + $this->checkout->setBillingAddress($billing); + $cart = $this->checkout->setShippingAddress($shipping); + } + + if ($firstPass) { + $cart->meta = [ + ...($cart->meta?->toArray() ?? []), + 'account_prefilled' => true, + 'wants_invoice' => (bool) data_get($cart, 'meta.wants_invoice') || $invoice !== [], + ]; + $cart->save(); + } + + return $cart; + } + + private function storeCountry(): ?Country + { + $iso3 = config('checkout.store_country_iso3'); + + if ($iso3 === null) { + return null; + } + + return Country::where('iso3', $iso3)->first(); + } +} diff --git a/src/Checkout/routes/checkout.php b/src/Checkout/routes/checkout.php new file mode 100644 index 0000000..bda5764 --- /dev/null +++ b/src/Checkout/routes/checkout.php @@ -0,0 +1,58 @@ +middleware('locale') + ->group(function () { + // No standalone cart page — the drawer (checkout::drawer) is the cart. + Route::get('checkout', [CheckoutController::class, 'show']) + ->name('checkout.show'); + + Route::post('checkout/address', [CheckoutController::class, 'saveAddress']) + ->name('checkout.address.save'); + + Route::post('checkout/shipping-option', [CheckoutController::class, 'selectShippingOption']) + ->name('checkout.shipping-option.select'); + + Route::post('checkout/payment-method', [CheckoutController::class, 'selectPaymentMethod']) + ->name('checkout.payment-method.select'); + + Route::post('checkout/place-order', [CheckoutController::class, 'placeOrder']) + ->name('checkout.place-order'); + + Route::get('checkout/order-status', [CheckoutController::class, 'orderStatus']) + ->name('checkout.order-status'); + + Route::get('checkout/confirmation', [CheckoutController::class, 'confirmation']) + ->name('checkout.confirmation'); + + Route::post('cart/lines', [CartController::class, 'add']) + ->name('checkout.cart.add'); + + Route::patch('cart/lines/{line}', [CartController::class, 'updateLine']) + ->whereNumber('line') + ->name('checkout.cart.update'); + + Route::delete('cart/lines/{line}', [CartController::class, 'remove']) + ->whereNumber('line') + ->name('checkout.cart.remove'); + + Route::post('cart/coupon', [CartController::class, 'applyCoupon']) + ->name('checkout.cart.coupon.apply'); + + Route::delete('cart/coupon', [CartController::class, 'removeCoupon']) + ->name('checkout.cart.coupon.remove'); + }); diff --git a/src/Providers/CheckoutModuleServiceProvider.php b/src/Providers/CheckoutModuleServiceProvider.php new file mode 100644 index 0000000..8a0a539 --- /dev/null +++ b/src/Providers/CheckoutModuleServiceProvider.php @@ -0,0 +1,61 @@ +loadViewsFrom(__DIR__.'/../../resources/views/checkout', 'checkout'); + Blade::anonymousComponentNamespace('checkout::components', 'checkout'); + + Route::middleware('web')->group(__DIR__.'/../Checkout/routes/checkout.php'); + + $this->publishes([ + __DIR__.'/../../resources/js/checkout' => resource_path('js/checkout'), + __DIR__.'/../../resources/css/checkout.css' => resource_path('css/checkout.css'), + ], 'core-checkout-assets'); + + // 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/src/Providers/CheckoutServiceProvider.php b/src/Providers/CheckoutServiceProvider.php index 3b35b84..e9f3b44 100644 --- a/src/Providers/CheckoutServiceProvider.php +++ b/src/Providers/CheckoutServiceProvider.php @@ -9,5 +9,13 @@ class CheckoutServiceProvider extends ServiceProvider public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../../config/legal.php', 'legal'); + $this->mergeConfigFrom(__DIR__ . '/../../config/checkout.php', 'checkout'); + } + + public function boot(): void + { + $this->publishes([ + __DIR__ . '/../../config/checkout.php' => config_path('checkout.php'), + ], 'core-config'); } }