From ba6d68c5b944cad54c82036e236f0db3cc0433a1 Mon Sep 17 00:00:00 2001 From: elvira Date: Tue, 15 Sep 2026 19:20:59 +0300 Subject: [PATCH] payment fix --- .../Checkout/CheckoutController.php | 77 +++++++--- composer.lock | 138 +++++++++--------- .../seeders/CheckoutTranslationsSeeder.php | 4 + .../checkout/bbk-checkout-form-controller.js | 15 +- .../js/checkout/bbk-payment-controller.js | 17 ++- 5 files changed, 155 insertions(+), 96 deletions(-) diff --git a/app/Http/Controllers/Checkout/CheckoutController.php b/app/Http/Controllers/Checkout/CheckoutController.php index ee058be..f5452bb 100644 --- a/app/Http/Controllers/Checkout/CheckoutController.php +++ b/app/Http/Controllers/Checkout/CheckoutController.php @@ -61,7 +61,9 @@ public function show(string $locale): View $shippingOptions = collect(); if ($cart?->shippingAddress) { - $shippingOptions = $this->syncShipping($cart); + // Nothing recreates the address row in this path — its own current + // value is the correct "previous" to carry forward if still valid. + $shippingOptions = $this->syncShipping($cart, $cart->shippingAddress->shipping_option); // Cart's CachesProperties::refresh() explicitly nulls total/ // subTotal/shippingTotal/etc. back to their defaults — every @@ -97,7 +99,13 @@ public function saveAddress(string $locale, Request $request): JsonResponse // 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']); + $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)] @@ -195,7 +203,7 @@ public function saveAddress(string $locale, Request $request): JsonResponse // 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); + $options = $this->syncShipping($cart, $previousOption); if (! $rateChanged) { return $this->fragments($cart, null, $errors); @@ -265,7 +273,21 @@ public function placeOrder(string $locale, Request $request): JsonResponse return response()->json(['error' => __('checkout.page.choose_payment_method')], 422); } - $fingerprint = (string) ($this->cart->current()?->meta['checkout_fingerprint'] ?? ''); + $cart = $this->cart->current(); + + // 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')] @@ -360,35 +382,46 @@ private function placedOrder(): ?Order /** * 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). + * 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): Collection + private function syncShipping(Cart $cart, ?string $previousOption): Collection { - $address = $cart->shippingAddress; - - if (! $address) { + if (! $cart->shippingAddress) { return collect(); } $options = $this->checkout->getShippingOptions(); - $current = $address->shipping_option; - if ($options->count() === 1) { - $only = $options->first(); + $target = match (true) { + $options->count() === 1 => $options->first()->identifier, + $previousOption !== null && $options->contains(fn ($option) => $option->identifier === $previousOption) => $previousOption, + default => null, + }; - if ($current !== $only->identifier) { - try { - $this->checkout->selectShippingOption($only->identifier); - } catch (InvalidShippingOptionException) { - // nothing to select against — leave as is - } + if ($target !== null) { + try { + $this->checkout->selectShippingOption($target); + } catch (InvalidShippingOptionException) { + // $target came from $options itself — shouldn't happen, stay defensive } - } elseif ($current !== null && ! $options->contains(fn ($option) => $option->identifier === $current)) { - $address->update(['shipping_option' => null]); - $cart->calculate(); } return $options; diff --git a/composer.lock b/composer.lock index fe937f9..9e13a72 100644 --- a/composer.lock +++ b/composer.lock @@ -515,11 +515,11 @@ }, { "name": "boboko/core", - "version": "0.17.0", + "version": "0.17.1", "source": { "type": "git", "url": "https://code.radical-elements.com/boboko/core.git", - "reference": "57fc28ca0680841fddc222be4a5a936b43247883" + "reference": "d9fb3bbde661bc8f49b3032462af029c5983b03a" }, "require": { "laravel/framework": "^12.0", @@ -568,7 +568,7 @@ } }, "description": "Core module — authentication and shared panel behaviour", - "time": "2026-09-14T17:18:26+00:00" + "time": "2026-09-15T13:12:03+00:00" }, { "name": "brick/math", @@ -4302,16 +4302,16 @@ }, { "name": "livewire/livewire", - "version": "v3.8.8", + "version": "v3.8.9", "source": { "type": "git", "url": "https://github.com/livewire/livewire.git", - "reference": "ec19e5fb1e0b60df22ad15b901c2f7740267ba11" + "reference": "993a3f5051774ca12768067ab8644ea97c417417" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/livewire/livewire/zipball/ec19e5fb1e0b60df22ad15b901c2f7740267ba11", - "reference": "ec19e5fb1e0b60df22ad15b901c2f7740267ba11", + "url": "https://api.github.com/repos/livewire/livewire/zipball/993a3f5051774ca12768067ab8644ea97c417417", + "reference": "993a3f5051774ca12768067ab8644ea97c417417", "shasum": "" }, "require": { @@ -4366,7 +4366,7 @@ "description": "A front-end framework for Laravel.", "support": { "issues": "https://github.com/livewire/livewire/issues", - "source": "https://github.com/livewire/livewire/tree/v3.8.8" + "source": "https://github.com/livewire/livewire/tree/v3.8.9" }, "funding": [ { @@ -4374,7 +4374,7 @@ "type": "github" } ], - "time": "2026-09-07T16:02:29+00:00" + "time": "2026-09-14T19:27:17+00:00" }, { "name": "lukascivil/treewalker", @@ -9740,16 +9740,16 @@ }, { "name": "symfony/console", - "version": "v7.4.18", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0" + "reference": "3a1973458b153f566a8d7499bc06ece323d60063" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", - "reference": "23d6f88a29f6d0eac45bd77d70307adf83ba7ab0", + "url": "https://api.github.com/repos/symfony/console/zipball/3a1973458b153f566a8d7499bc06ece323d60063", + "reference": "3a1973458b153f566a8d7499bc06ece323d60063", "shasum": "" }, "require": { @@ -9814,7 +9814,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v7.4.18" + "source": "https://github.com/symfony/console/tree/v7.4.19" }, "funding": [ { @@ -9834,7 +9834,7 @@ "type": "tidelift" } ], - "time": "2026-08-25T14:18:37+00:00" + "time": "2026-09-13T10:38:41+00:00" }, { "name": "symfony/css-selector", @@ -10296,16 +10296,16 @@ }, { "name": "symfony/finder", - "version": "v7.4.17", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/finder.git", - "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6" + "reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/finder/zipball/5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", - "reference": "5ce28827081f6d1f0c32eaf3882750f19cb5bbe6", + "url": "https://api.github.com/repos/symfony/finder/zipball/1b900bc6ae5ba60c5eee69c11ba44566190576fe", + "reference": "1b900bc6ae5ba60c5eee69c11ba44566190576fe", "shasum": "" }, "require": { @@ -10340,7 +10340,7 @@ "description": "Finds files and directories via an intuitive fluent interface", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/finder/tree/v7.4.17" + "source": "https://github.com/symfony/finder/tree/v7.4.19" }, "funding": [ { @@ -10360,20 +10360,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T12:09:28+00:00" + "time": "2026-09-10T19:14:10+00:00" }, { "name": "symfony/html-sanitizer", - "version": "v8.1.6", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/html-sanitizer.git", - "reference": "f8bbdb0704e6b6e9a3412481c1a87886a0633199" + "reference": "3d79bdecd01f71bccc45ddfa1d33c024df347a1f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/f8bbdb0704e6b6e9a3412481c1a87886a0633199", - "reference": "f8bbdb0704e6b6e9a3412481c1a87886a0633199", + "url": "https://api.github.com/repos/symfony/html-sanitizer/zipball/3d79bdecd01f71bccc45ddfa1d33c024df347a1f", + "reference": "3d79bdecd01f71bccc45ddfa1d33c024df347a1f", "shasum": "" }, "require": { @@ -10412,7 +10412,7 @@ "sanitizer" ], "support": { - "source": "https://github.com/symfony/html-sanitizer/tree/v8.1.6" + "source": "https://github.com/symfony/html-sanitizer/tree/v8.1.7" }, "funding": [ { @@ -10432,20 +10432,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T01:03:44+00:00" + "time": "2026-09-04T10:14:04+00:00" }, { "name": "symfony/http-foundation", - "version": "v7.4.18", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/http-foundation.git", - "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133" + "reference": "f7b953be7801974a5a0685e1228bbc3b12b35936" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-foundation/zipball/d070b716a32fbe3bf04204db0f58ace73b86d133", - "reference": "d070b716a32fbe3bf04204db0f58ace73b86d133", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/f7b953be7801974a5a0685e1228bbc3b12b35936", + "reference": "f7b953be7801974a5a0685e1228bbc3b12b35936", "shasum": "" }, "require": { @@ -10494,7 +10494,7 @@ "description": "Defines an object-oriented layer for the HTTP specification", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-foundation/tree/v7.4.18" + "source": "https://github.com/symfony/http-foundation/tree/v7.4.19" }, "funding": [ { @@ -10514,20 +10514,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T20:10:52+00:00" + "time": "2026-09-14T17:41:17+00:00" }, { "name": "symfony/http-kernel", - "version": "v7.4.18", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351" + "reference": "683507424cc84ca85ac1551adb609b6b98216667" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/275d2d2d24530f2a0eaf17704a3a93860a036351", - "reference": "275d2d2d24530f2a0eaf17704a3a93860a036351", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/683507424cc84ca85ac1551adb609b6b98216667", + "reference": "683507424cc84ca85ac1551adb609b6b98216667", "shasum": "" }, "require": { @@ -10613,7 +10613,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v7.4.18" + "source": "https://github.com/symfony/http-kernel/tree/v7.4.19" }, "funding": [ { @@ -10633,20 +10633,20 @@ "type": "tidelift" } ], - "time": "2026-08-30T21:24:29+00:00" + "time": "2026-09-15T07:09:35+00:00" }, { "name": "symfony/mailer", - "version": "v7.4.17", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/mailer.git", - "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584" + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mailer/zipball/b17c9bf3a551d5f635638a3b6c05f06c4dc87584", - "reference": "b17c9bf3a551d5f635638a3b6c05f06c4dc87584", + "url": "https://api.github.com/repos/symfony/mailer/zipball/6f6f2441bef07a42d2617b9b3aadf04a321aa514", + "reference": "6f6f2441bef07a42d2617b9b3aadf04a321aa514", "shasum": "" }, "require": { @@ -10697,7 +10697,7 @@ "description": "Helps sending emails", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/mailer/tree/v7.4.17" + "source": "https://github.com/symfony/mailer/tree/v7.4.19" }, "funding": [ { @@ -10717,20 +10717,20 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-09-15T06:01:15+00:00" }, { "name": "symfony/mime", - "version": "v7.4.18", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/mime.git", - "reference": "bf328d82105831db3e409195db0540ff57f27c80" + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/mime/zipball/bf328d82105831db3e409195db0540ff57f27c80", - "reference": "bf328d82105831db3e409195db0540ff57f27c80", + "url": "https://api.github.com/repos/symfony/mime/zipball/f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", + "reference": "f8ac7d0a800bcabbcd1f7de56594cf05e6cd5693", "shasum": "" }, "require": { @@ -10786,7 +10786,7 @@ "mime-type" ], "support": { - "source": "https://github.com/symfony/mime/tree/v7.4.18" + "source": "https://github.com/symfony/mime/tree/v7.4.19" }, "funding": [ { @@ -10806,7 +10806,7 @@ "type": "tidelift" } ], - "time": "2026-08-22T09:04:42+00:00" + "time": "2026-09-04T10:45:44+00:00" }, { "name": "symfony/options-resolver", @@ -11870,16 +11870,16 @@ }, { "name": "symfony/process", - "version": "v7.4.18", + "version": "v7.4.19", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "058d17fc284cce14efb2385783b55014a461b176" + "reference": "ed0ae095b86994d370d5791612e55984f15aa30e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/058d17fc284cce14efb2385783b55014a461b176", - "reference": "058d17fc284cce14efb2385783b55014a461b176", + "url": "https://api.github.com/repos/symfony/process/zipball/ed0ae095b86994d370d5791612e55984f15aa30e", + "reference": "ed0ae095b86994d370d5791612e55984f15aa30e", "shasum": "" }, "require": { @@ -11911,7 +11911,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v7.4.18" + "source": "https://github.com/symfony/process/tree/v7.4.19" }, "funding": [ { @@ -11931,7 +11931,7 @@ "type": "tidelift" } ], - "time": "2026-08-21T17:40:08+00:00" + "time": "2026-09-02T12:38:34+00:00" }, { "name": "symfony/routing", @@ -12107,16 +12107,16 @@ }, { "name": "symfony/string", - "version": "v8.1.2", + "version": "v8.1.7", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc" + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", - "reference": "286a76b7255e5cc4bf0101a0bc5388ecf1c38ccc", + "url": "https://api.github.com/repos/symfony/string/zipball/d950140b5f56f31901e5b7a0c04ffc3a3deb943c", + "reference": "d950140b5f56f31901e5b7a0c04ffc3a3deb943c", "shasum": "" }, "require": { @@ -12173,7 +12173,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.1.2" + "source": "https://github.com/symfony/string/tree/v8.1.7" }, "funding": [ { @@ -12193,7 +12193,7 @@ "type": "tidelift" } ], - "time": "2026-07-28T07:35:25+00:00" + "time": "2026-09-11T14:51:16+00:00" }, { "name": "symfony/translation", @@ -12665,16 +12665,16 @@ }, { "name": "technikermathe/blade-lucide-icons", - "version": "v3.179.0", + "version": "v3.180.0", "source": { "type": "git", "url": "https://github.com/PascaleBeier/blade-lucide-icons.git", - "reference": "bfae6d39e35bd78f72b7372a26a30b500687c923" + "reference": "adac9da3a65910fedc271129eea5340093910203" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/bfae6d39e35bd78f72b7372a26a30b500687c923", - "reference": "bfae6d39e35bd78f72b7372a26a30b500687c923", + "url": "https://api.github.com/repos/PascaleBeier/blade-lucide-icons/zipball/adac9da3a65910fedc271129eea5340093910203", + "reference": "adac9da3a65910fedc271129eea5340093910203", "shasum": "" }, "require": { @@ -12724,9 +12724,9 @@ ], "support": { "issues": "https://github.com/PascaleBeier/blade-lucide-icons/issues", - "source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.179.0" + "source": "https://github.com/PascaleBeier/blade-lucide-icons/tree/v3.180.0" }, - "time": "2026-09-12T02:06:59+00:00" + "time": "2026-09-15T02:25:56+00:00" }, { "name": "thecodingmachine/safe", diff --git a/database/seeders/CheckoutTranslationsSeeder.php b/database/seeders/CheckoutTranslationsSeeder.php index f650db6..965a15b 100644 --- a/database/seeders/CheckoutTranslationsSeeder.php +++ b/database/seeders/CheckoutTranslationsSeeder.php @@ -145,6 +145,10 @@ private function lines(): array ], '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.', diff --git a/resources/js/checkout/bbk-checkout-form-controller.js b/resources/js/checkout/bbk-checkout-form-controller.js index 5060cb8..7dfbe05 100644 --- a/resources/js/checkout/bbk-checkout-form-controller.js +++ b/resources/js/checkout/bbk-checkout-form-controller.js @@ -30,6 +30,7 @@ export default class extends Controller { this.saveTimer = null this.saveController = null this.statusTimer = null + this.shippingPromise = null if (this.hasSameAsBillingTarget) this.applySameAsBilling() } @@ -108,6 +109,9 @@ export default class extends Controller { 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() { @@ -137,11 +141,18 @@ export default class extends Controller { } 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', event.target.value) + body.append('shipping_option', value) try { const response = await fetch(this.selectShippingUrlValue, { @@ -160,6 +171,8 @@ export default class extends Controller { this.setStatus('saved') } catch { this.setStatus('error') + } finally { + this.shippingPromise = null } } diff --git a/resources/js/checkout/bbk-payment-controller.js b/resources/js/checkout/bbk-payment-controller.js index 4fcbd38..96a43e7 100644 --- a/resources/js/checkout/bbk-payment-controller.js +++ b/resources/js/checkout/bbk-payment-controller.js @@ -215,6 +215,15 @@ export default class extends Controller { 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) } @@ -252,15 +261,15 @@ export default class extends Controller { // ── helpers ────────────────────────────────────────────────────── - fail(message) { - this.showError(message) + fail(message, { scroll = true } = {}) { + this.showError(message, { scroll }) this.submitTarget.disabled = false } - showError(message) { + showError(message, { scroll = true } = {}) { this.errorTarget.textContent = message this.errorTarget.hidden = false - this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' }) + if (scroll) this.errorTarget.scrollIntoView({ block: 'center', behavior: 'smooth' }) } clearError() {