From 0fb606ff3f50730bb8ba3de71e1b5e3671f34c29 Mon Sep 17 00:00:00 2001 From: elvira Date: Wed, 9 Sep 2026 13:51:00 +0300 Subject: [PATCH] checkout and shipping part --- .../Checkout/CheckoutController.php | 294 +++++++++++++----- .../seeders/CheckoutTranslationsSeeder.php | 13 +- resources/css/checkout.css | 42 ++- .../checkout/bbk-checkout-form-controller.js | 179 ++++++++++- .../views/checkout/components/field.blade.php | 5 +- .../components/region-country.blade.php | 46 +++ .../checkout/components/select.blade.php | 14 +- .../checkout/components/textarea.blade.php | 4 +- resources/views/checkout/page.blade.php | 117 ++++--- .../checkout/partials/cart-body.blade.php | 33 +- .../partials/shipping-options.blade.php | 50 +++ 11 files changed, 614 insertions(+), 183 deletions(-) create mode 100644 resources/views/checkout/components/region-country.blade.php create mode 100644 resources/views/checkout/partials/shipping-options.blade.php diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index 11ce674..d47c4a1 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -3,27 +3,43 @@ namespace App\Http\Controllers\Checkout; use App\Http\Controllers\Controller; -use Illuminate\Http\RedirectResponse; +use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; +use Illuminate\Support\Collection; +use Illuminate\Support\Facades\Validator; +use Illuminate\Validation\Rule; use Illuminate\View\View; +use Lunar\Facades\ShippingManifest; +use Lunar\Models\Cart; use Lunar\Models\Country; +use Lunar\Models\State; use Modules\Core\Cart\Services\CartService; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Services\CheckoutService; /** - * The checkout page itself — one page, sections (contact / billing / shipping - * / shipping method), reviewed against boboko-core's CheckoutService. Deliberately - * stops short of payment for this slice (see project memory): every action here - * ends in a redirect back to `show()`, which just re-renders against whatever - * state the cart/CheckoutService is now in — there's no client-side state to - * keep in sync. + * The checkout page — one page, sections (contact / billing / shipping / + * shipping method), reviewed against boboko-core's CheckoutService. Stops + * short of payment for this slice (see project memory). * - * Guest-only for now — the Contact section's login/register tab is UI only, - * not wired to Modules\Core\Auth\Services\UserOtpService yet. + * The address form and the shipping-method radios **autosave** — no submit + * buttons. `saveAddress()` / `selectShippingOption()` are called by + * bbk-checkout-form (debounced fetch) and return a JSON envelope of + * server-rendered fragments (shipping options + order summary) plus any + * field errors, rather than redirecting. Address validation is deliberately + * lenient — nothing is rejected mid-typing; required-field enforcement is the + * job of the (not-yet-built) "Continue to payment" gate. + * + * Guest-only for now — the Contact section's login tab is UI only. + * + * Single-country store: STORE_COUNTRY_ISO3 fixes the country to Greece (hidden + * field, forced server-side). Set it to null for the full country picker (the + * portable path for a multi-country boboko store). */ class CheckoutController extends Controller { + private const STORE_COUNTRY_ISO3 = 'GRC'; + public function __construct( private readonly CartService $cart, private readonly CheckoutService $checkout, @@ -33,125 +49,239 @@ public function show(string $locale): View { $cart = $this->cart->current(); $lines = $cart ? $this->cart->activeLines($cart) : collect(); + $storeCountry = $this->storeCountry(); - $shippingAddress = $cart?->shippingAddress; - $billingAddress = $cart?->billingAddress; + $shippingOptions = collect(); - // Rate quoting needs a shipping address to resolve against (postcode/ - // country for ACS/BoxNow live rates, table-rate-shipping's zones) — - // nothing to offer yet on a cart with no address saved. - $shippingOptions = $shippingAddress - ? $this->checkout->getShippingOptions() - : collect(); + if ($cart?->shippingAddress) { + $this->resetShippingManifest($cart); + $shippingOptions = $this->syncShipping($cart); + $cart->refresh(); + } return view('checkout::page', [ 'cart' => $cart, 'lines' => $lines, - 'billingAddress' => $billingAddress, - 'shippingAddress' => $shippingAddress, + 'billingAddress' => $cart?->billingAddress, + 'shippingAddress' => $cart?->shippingAddress, 'shippingOptions' => $shippingOptions, - 'countries' => Country::orderBy('name')->get(['id', 'name']), + 'shipToBilling' => (bool) data_get($cart, 'meta.ship_to_billing', true), + '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(), ]); } - /** - * One form, two CheckoutService calls — billing and shipping are always - * both set together here. When "same as billing" is checked the shipping - * fields are disabled client-side (so the browser never submits them) and - * the billing values are reused for shipping too, plus delivery - * instructions. - */ - public function saveAddress(string $locale, Request $request): RedirectResponse + public function saveAddress(string $locale, Request $request): JsonResponse { + $storeCountry = $this->storeCountry(); $sameAsBilling = $request->boolean('same_as_billing'); - $rules = [ - 'contact_email' => ['required', 'email'], + // 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. + $rateKeyBefore = $this->cart->current()?->shippingAddress?->only(['postcode', 'state', 'country_id']); - 'billing_first_name' => ['required', 'string', 'max:255'], - 'billing_last_name' => ['required', 'string', 'max:255'], + $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' => ['required', 'string', 'max:255'], + 'billing_line_one' => ['nullable', 'string', 'max:255'], 'billing_line_two' => ['nullable', 'string', 'max:255'], - 'billing_city' => ['required', 'string', 'max:255'], - 'billing_state' => ['nullable', 'string', 'max:255'], - 'billing_postcode' => ['required', 'string', 'max:20'], - 'billing_country_id' => ['required', 'integer', 'exists:'.(new Country)->getTable().',id'], + '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_company_name' => ['nullable', 'string', 'max:255'], + 'shipping_line_one' => ['nullable', 'string', 'max:255'], + 'shipping_line_two' => ['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'], - ]; + ]); - if (! $sameAsBilling) { - $rules += [ - 'shipping_first_name' => ['required', 'string', 'max:255'], - 'shipping_last_name' => ['required', 'string', 'max:255'], - 'shipping_company_name' => ['nullable', 'string', 'max:255'], - 'shipping_line_one' => ['required', 'string', 'max:255'], - 'shipping_line_two' => ['nullable', 'string', 'max:255'], - 'shipping_city' => ['required', 'string', 'max:255'], - 'shipping_state' => ['nullable', 'string', 'max:255'], - 'shipping_postcode' => ['required', 'string', 'max:20'], - 'shipping_country_id' => ['required', 'integer', 'exists:'.(new Country)->getTable().',id'], - 'shipping_contact_phone' => ['nullable', 'string', 'max:50'], - ]; - } + $errors = $validator->errors()->toArray(); + $data = $validator->valid(); - $data = $request->validate($rules); + $billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null); + $shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId); $billing = [ - 'first_name' => $data['billing_first_name'], - 'last_name' => $data['billing_last_name'], + 'first_name' => $data['billing_first_name'] ?? null, + 'last_name' => $data['billing_last_name'] ?? null, 'company_name' => $data['billing_company_name'] ?? null, 'tax_identifier' => $data['billing_tax_identifier'] ?? null, - 'line_one' => $data['billing_line_one'], + 'line_one' => $data['billing_line_one'] ?? null, 'line_two' => $data['billing_line_two'] ?? null, - 'city' => $data['billing_city'], + 'city' => $data['billing_city'] ?? null, 'state' => $data['billing_state'] ?? null, - 'postcode' => $data['billing_postcode'], - 'country_id' => $data['billing_country_id'], - 'contact_email' => $data['contact_email'], + 'postcode' => $data['billing_postcode'] ?? null, + 'country_id' => $billingCountryId, + 'contact_email' => $data['contact_email'] ?? null, 'contact_phone' => $data['billing_contact_phone'] ?? null, ]; $shipping = $sameAsBilling ? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null] : [ - 'first_name' => $data['shipping_first_name'], - 'last_name' => $data['shipping_last_name'], + 'first_name' => $data['shipping_first_name'] ?? null, + 'last_name' => $data['shipping_last_name'] ?? null, 'company_name' => $data['shipping_company_name'] ?? null, - 'line_one' => $data['shipping_line_one'], + 'line_one' => $data['shipping_line_one'] ?? null, 'line_two' => $data['shipping_line_two'] ?? null, - 'city' => $data['shipping_city'], + 'city' => $data['shipping_city'] ?? null, 'state' => $data['shipping_state'] ?? null, - 'postcode' => $data['shipping_postcode'], - 'country_id' => $data['shipping_country_id'], - 'contact_email' => $data['contact_email'], + '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); - $this->checkout->setShippingAddress($shipping); + $cart = $this->checkout->setShippingAddress($shipping); - return redirect()->route('checkout.show', $locale); - } + $cart->meta = [...($cart->meta?->toArray() ?? []), 'ship_to_billing' => $sameAsBilling]; + $cart->save(); - public function selectShippingOption(string $locale, Request $request): RedirectResponse - { - $data = $request->validate([ - 'shipping_option' => ['required', 'string'], - ]); + // ShippingManifest is a request-lifetime singleton whose getOptions() + // appends without ever clearing, and addOption() keeps the FIRST entry + // per identifier. The setBillingAddress() recalc above ran ApplyShipping + // against the still-stale shipping address, so an option resolved for + // the old region can shadow the correct one for the new region (a + // carrier priced differently across two matching zones). Reset it and + // recompute totals from a clean slate before we read anything back. + $this->resetShippingManifest($cart); - try { - $this->checkout->selectShippingOption($data['shipping_option']); - } catch (InvalidShippingOptionException) { - return back()->withErrors([ - 'shipping_option' => __('checkout.page.shipping_option_invalid'), - ]); + $rateKeyAfter = $cart->shippingAddress?->only(['postcode', 'state', 'country_id']); + + // Nothing rate-relevant moved — persist and acknowledge, no re-render. + if ($rateKeyAfter == $rateKeyBefore) { + return $this->fragments($cart, null, $errors); } - return redirect()->route('checkout.show', $locale); + return $this->fragments($cart, $this->syncShipping($cart), $errors); + } + + public function selectShippingOption(string $locale, Request $request): JsonResponse + { + $identifier = (string) $request->input('shipping_option'); + + if ($cart = $this->cart->current()) { + $this->resetShippingManifest($cart); + } + + 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); + } + + /** + * Re-resolve shipping options for the cart's current address and keep the + * selection sane: auto-select when exactly one resolves, and drop a + * previously-picked option that no longer applies (e.g. region changed). + * + * @return Collection + */ + private function syncShipping(Cart $cart): Collection + { + $address = $cart->shippingAddress; + + if (! $address) { + return collect(); + } + + $options = $this->checkout->getShippingOptions(); + $current = $address->shipping_option; + + if ($options->count() === 1) { + $only = $options->first(); + + if ($current !== $only->identifier) { + try { + $this->checkout->selectShippingOption($only->identifier); + } catch (InvalidShippingOptionException) { + // nothing to select against — leave as is + } + } + } elseif ($current !== null && ! $options->contains(fn ($option) => $option->identifier === $current)) { + $address->update(['shipping_option' => null]); + $cart->calculate(); + } + + 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 (CheckoutModuleServiceProvider) fills $cart / $lines. + 'summaryHtml' => $options === null ? null : view('checkout::partials.cart-body')->render(), + ]); + } + + /** + * Wipe the request-lifetime ShippingManifest singleton and recompute the + * cart's totals, so both the options list and the cart's shippingTotal are + * resolved against the current address rather than an accumulation of + * every resolution that ran earlier in the request. See saveAddress(). + */ + private function resetShippingManifest(Cart $cart): void + { + ShippingManifest::clearOptions(); + $cart->calculate(); + } + + private function storeCountry(): ?Country + { + if (self::STORE_COUNTRY_ISO3 === null) { + return null; + } + + return Country::where('iso3', self::STORE_COUNTRY_ISO3)->first(); } } diff --git a/database/seeders/CheckoutTranslationsSeeder.php b/database/seeders/CheckoutTranslationsSeeder.php index 9b76089..1883d09 100644 --- a/database/seeders/CheckoutTranslationsSeeder.php +++ b/database/seeders/CheckoutTranslationsSeeder.php @@ -60,6 +60,9 @@ private function lines(): array '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', 'Κωδικός κουπονιού'], @@ -94,18 +97,26 @@ private function lines(): array 'page.address_line_one' => ['Address', 'Διεύθυνση'], 'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'], 'page.city' => ['City', 'Πόλη'], - 'page.state' => ['State / Region', 'Νομός / Περιοχή'], + '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.', diff --git a/resources/css/checkout.css b/resources/css/checkout.css index a716c30..ad52daa 100644 --- a/resources/css/checkout.css +++ b/resources/css/checkout.css @@ -269,21 +269,28 @@ .bbk-cart-summary { border-top: 1px solid var(--bbk-color-border); display: flex; flex-direction: column; - gap: 1rem; + gap: 0.625rem; } .bbk-cart-summary-row { display: flex; justify-content: space-between; - font-weight: 600; + 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 { - padding-top: 0.75rem; + 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 { @@ -518,6 +525,16 @@ .bbk-field-error { 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 { @@ -581,6 +598,25 @@ .bbk-checkout-shipping-option-description { .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 { diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js index a21edc7..5ec63e7 100644 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -1,20 +1,47 @@ import { Controller } from '@hotwired/stimulus' +import { csrfToken } from './csrf' -// Static, client-only interactivity for the checkout page's address form — no -// fetch, no cart mutation, just show/hide. Nothing here talks to the server; -// CheckoutController::saveAddress() is what actually persists anything. +// 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 = [ 'guestTab', 'loginTab', 'guestPanel', 'loginPanel', 'sameAsBilling', 'shippingFields', + 'form', 'shippingOptions', 'status', ] - connect() { - // Reflect whatever the server rendered (old input on a validation - // redisplay, or the default) before any click happens. - if (this.hasSameAsBillingTarget) this.syncShippingFields() + static values = { + saveUrl: String, + selectShippingUrl: String, + statusSaving: String, + statusSaved: String, + statusError: String, } + connect() { + this.saveTimer = null + this.saveController = null + this.statusTimer = null + + if (this.hasSameAsBillingTarget) this.applySameAsBilling() + } + + disconnect() { + clearTimeout(this.saveTimer) + clearTimeout(this.statusTimer) + this.saveController?.abort() + } + + // ── Contact tabs ──────────────────────────────────────────────────── + showGuest() { this.guestPanelTarget.hidden = false this.loginPanelTarget.hidden = true @@ -29,18 +56,142 @@ export default class extends Controller { this.loginTabTarget.setAttribute('aria-selected', 'true') } + // ── Same as billing ──────────────────────────────────────────────── + toggleSameAsBilling() { - this.syncShippingFields() + this.applySameAsBilling() } - // Disabled fields are simply never submitted by the browser — the server - // never sees stale shipping values while "same as billing" is checked. - syncShippingFields() { - const sameAsBilling = this.sameAsBillingTarget.checked + applySameAsBilling() { + const on = this.sameAsBillingTarget.checked - this.shippingFieldsTarget.hidden = sameAsBilling + // 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 = sameAsBilling + field.disabled = on }) } + + // ── Autosave ─────────────────────────────────────────────────────── + + scheduleSave(event) { + // The shipping-method radios live inside this controller's element too, + // but they have their own handler — don't also autosave the address for them. + if (this.hasShippingOptionsTarget && this.shippingOptionsTarget.contains(event.target)) { + 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) + } + + 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) { + this.saveController?.abort() + this.setStatus('saving') + + const body = new FormData() + body.append('shipping_option', event.target.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') + } + } + + 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/views/checkout/components/field.blade.php b/resources/views/checkout/components/field.blade.php index 200283c..368b894 100644 --- a/resources/views/checkout/components/field.blade.php +++ b/resources/views/checkout/components/field.blade.php @@ -24,7 +24,6 @@ @if ($required) required @endif {{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }} > - @error($name) -

{{ $message }}

- @enderror + {{-- 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..bbaebae --- /dev/null +++ b/resources/views/checkout/components/region-country.blade.php @@ -0,0 +1,46 @@ +{{-- + 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') }} +

{{ $storeCountry->name }}

+ +
+ @else + + + + @endif +
diff --git a/resources/views/checkout/components/select.blade.php b/resources/views/checkout/components/select.blade.php index 3671594..6a722a1 100644 --- a/resources/views/checkout/components/select.blade.php +++ b/resources/views/checkout/components/select.blade.php @@ -1,8 +1,11 @@ {{-- + - `options` is an iterable of {id, name} (a Collection of models works - directly) — value/label pulled from those two keys. + `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). --}} @props([ 'name', @@ -11,6 +14,7 @@ 'value' => null, 'placeholder' => null, 'required' => false, + 'valueField' => 'id', ]) @php($selected = old($name, $value)) @@ -27,12 +31,10 @@ @endif @foreach ($options as $option) - @endforeach - @error($name) -

{{ $message }}

- @enderror +

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 index 4d1c257..82beae1 100644 --- a/resources/views/checkout/components/textarea.blade.php +++ b/resources/views/checkout/components/textarea.blade.php @@ -14,7 +14,5 @@ @if ($required) required @endif {{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }} >{{ old($name, $value) }} - @error($name) -

{{ $message }}

- @enderror +

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

diff --git a/resources/views/checkout/page.blade.php b/resources/views/checkout/page.blade.php index 4f19b48..05e8ee9 100644 --- a/resources/views/checkout/page.blade.php +++ b/resources/views/checkout/page.blade.php @@ -17,7 +17,16 @@

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

-
+
{{-- Contact --}}
@@ -25,7 +34,6 @@ class="bbk-checkout-tabs" role="tablist" aria-label="{{ __('checkout.page.contact_heading') }}" - data-controller="bbk-checkout-form" >
-
+ {{-- 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 --}} @@ -114,17 +131,13 @@ class="bbk-checkout-tab"
-
- - -
+ @@ -140,7 +153,7 @@ class="bbk-checkout-tab" value="1" data-bbk-checkout-form-target="sameAsBilling" data-action="bbk-checkout-form#toggleSameAsBilling" - {{ old('same_as_billing', $shippingAddress === null || $shippingAddress?->is($billingAddress)) ? 'checked' : '' }} + @checked($shipToBilling) > {{ __('checkout.page.same_as_billing') }} @@ -161,17 +174,13 @@ class="bbk-checkout-tab"
-
- - -
+
@@ -182,50 +191,28 @@ class="bbk-checkout-tab" :value="$shippingAddress?->delivery_instructions" /> - - - {{-- Shipping method — only resolvable once a shipping address exists --}} + + + {{-- 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') }}

- @if ($shippingOptions->isEmpty()) -

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

- @else -
- @csrf -
- @foreach ($shippingOptions as $option) - - @endforeach -
- @error('shipping_option') -

{{ $message }}

- @enderror - - -
- @endif +
+ @include('checkout::partials.shipping-options', [ + 'shippingAddress' => $shippingAddress, + 'shippingOptions' => $shippingOptions, + ]) +
{{-- TODO: payment — next slice. boboko-core's Offline driver is the only diff --git a/resources/views/checkout/partials/cart-body.blade.php b/resources/views/checkout/partials/cart-body.blade.php index 4bcee8b..1bfdb95 100644 --- a/resources/views/checkout/partials/cart-body.blade.php +++ b/resources/views/checkout/partials/cart-body.blade.php @@ -73,6 +73,11 @@ class="bbk-cart-coupon-input" @endif +
+ {{ __('checkout.cart.subtotal') }} + {{ $cart?->subTotal?->formatted() }} +
+ @if ($cart?->discountTotal?->value > 0)
{{ __('checkout.cart.discount') }} @@ -80,13 +85,29 @@ class="bbk-cart-coupon-input"
@endif -
- {{ __('checkout.cart.subtotal') }} - {{ $cart?->subTotal?->formatted() }} -
+ {{-- 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 - {{-- Always shown, even with no discount — equals subtotal then, - diverges once one's applied. --}} + @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() }} 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