cart->current(); $lines = $cart ? $this->cart->activeLines($cart) : collect(); $storeCountry = $this->storeCountry(); $shippingOptions = collect(); if ($cart?->shippingAddress) { $this->resetShippingManifest($cart); $shippingOptions = $this->syncShipping($cart); $cart->refresh(); } return view('checkout::page', [ 'cart' => $cart, 'lines' => $lines, 'billingAddress' => $cart?->billingAddress, 'shippingAddress' => $cart?->shippingAddress, 'shippingOptions' => $shippingOptions, '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(), ]); } 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. $rateKeyBefore = $this->cart->current()?->shippingAddress?->only(['postcode', 'state', 'country_id']); $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_line_two' => ['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_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'], ]); $errors = $validator->errors()->toArray(); $data = $validator->valid(); $billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null); $shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId); $billing = [ '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'] ?? null, 'line_two' => $data['billing_line_two'] ?? 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 ? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null] : [ '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'] ?? null, 'line_two' => $data['shipping_line_two'] ?? 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]; $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')); // 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); $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 $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(); } }