generated from boboko/starter
checkout and shipping part
This commit is contained in:
@@ -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<int, \Lunar\DataTypes\ShippingOption>
|
||||
*/
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user