checkout and shipping part

This commit is contained in:
elvira
2026-09-09 13:51:00 +03:00
parent 2d85591a43
commit 0fb606ff3f
11 changed files with 614 additions and 183 deletions
@@ -3,27 +3,43 @@
namespace App\Http\Controllers\Checkout; namespace App\Http\Controllers\Checkout;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use Illuminate\Validation\Rule;
use Illuminate\View\View; use Illuminate\View\View;
use Lunar\Facades\ShippingManifest;
use Lunar\Models\Cart;
use Lunar\Models\Country; use Lunar\Models\Country;
use Lunar\Models\State;
use Modules\Core\Cart\Services\CartService; use Modules\Core\Cart\Services\CartService;
use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException; use Modules\Core\Checkout\Exceptions\InvalidShippingOptionException;
use Modules\Core\Checkout\Services\CheckoutService; use Modules\Core\Checkout\Services\CheckoutService;
/** /**
* The checkout page itself — one page, sections (contact / billing / shipping * The checkout page — one page, sections (contact / billing / shipping /
* / shipping method), reviewed against boboko-core's CheckoutService. Deliberately * shipping method), reviewed against boboko-core's CheckoutService. Stops
* stops short of payment for this slice (see project memory): every action here * short of payment for this slice (see project memory).
* 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.
* *
* Guest-only for now — the Contact section's login/register tab is UI only, * The address form and the shipping-method radios **autosave** — no submit
* not wired to Modules\Core\Auth\Services\UserOtpService yet. * 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 class CheckoutController extends Controller
{ {
private const STORE_COUNTRY_ISO3 = 'GRC';
public function __construct( public function __construct(
private readonly CartService $cart, private readonly CartService $cart,
private readonly CheckoutService $checkout, private readonly CheckoutService $checkout,
@@ -33,125 +49,239 @@ public function show(string $locale): View
{ {
$cart = $this->cart->current(); $cart = $this->cart->current();
$lines = $cart ? $this->cart->activeLines($cart) : collect(); $lines = $cart ? $this->cart->activeLines($cart) : collect();
$storeCountry = $this->storeCountry();
$shippingAddress = $cart?->shippingAddress; $shippingOptions = collect();
$billingAddress = $cart?->billingAddress;
// Rate quoting needs a shipping address to resolve against (postcode/ if ($cart?->shippingAddress) {
// country for ACS/BoxNow live rates, table-rate-shipping's zones) — $this->resetShippingManifest($cart);
// nothing to offer yet on a cart with no address saved. $shippingOptions = $this->syncShipping($cart);
$shippingOptions = $shippingAddress $cart->refresh();
? $this->checkout->getShippingOptions() }
: collect();
return view('checkout::page', [ return view('checkout::page', [
'cart' => $cart, 'cart' => $cart,
'lines' => $lines, 'lines' => $lines,
'billingAddress' => $billingAddress, 'billingAddress' => $cart?->billingAddress,
'shippingAddress' => $shippingAddress, 'shippingAddress' => $cart?->shippingAddress,
'shippingOptions' => $shippingOptions, '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(),
]); ]);
} }
/** public function saveAddress(string $locale, Request $request): JsonResponse
* 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
{ {
$storeCountry = $this->storeCountry();
$sameAsBilling = $request->boolean('same_as_billing'); $sameAsBilling = $request->boolean('same_as_billing');
$rules = [ // Only the fields shipping rates resolve against — if none of these
'contact_email' => ['required', 'email'], // 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'], $stateRule = $storeCountry
'billing_last_name' => ['required', 'string', 'max:255'], ? ['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_company_name' => ['nullable', 'string', 'max:255'],
'billing_tax_identifier' => ['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_line_two' => ['nullable', 'string', 'max:255'],
'billing_city' => ['required', 'string', 'max:255'], 'billing_city' => ['nullable', 'string', 'max:255'],
'billing_state' => ['nullable', 'string', 'max:255'], 'billing_state' => $stateRule,
'billing_postcode' => ['required', 'string', 'max:20'], 'billing_postcode' => ['nullable', 'string', 'max:20'],
'billing_country_id' => ['required', 'integer', 'exists:'.(new Country)->getTable().',id'], 'billing_country_id' => $countryRule,
'billing_contact_phone' => ['nullable', 'string', 'max:50'], 'billing_contact_phone' => ['nullable', 'string', 'max:50'],
'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'], 'shipping_first_name' => ['nullable', 'string', 'max:255'],
]; 'shipping_last_name' => ['nullable', 'string', 'max:255'],
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_company_name' => ['nullable', 'string', 'max:255'],
'shipping_line_one' => ['required', 'string', 'max:255'], 'shipping_line_one' => ['nullable', 'string', 'max:255'],
'shipping_line_two' => ['nullable', 'string', 'max:255'], 'shipping_line_two' => ['nullable', 'string', 'max:255'],
'shipping_city' => ['required', 'string', 'max:255'], 'shipping_city' => ['nullable', 'string', 'max:255'],
'shipping_state' => ['nullable', 'string', 'max:255'], 'shipping_state' => $stateRule,
'shipping_postcode' => ['required', 'string', 'max:20'], 'shipping_postcode' => ['nullable', 'string', 'max:20'],
'shipping_country_id' => ['required', 'integer', 'exists:'.(new Country)->getTable().',id'], 'shipping_country_id' => $countryRule,
'shipping_contact_phone' => ['nullable', 'string', 'max:50'], 'shipping_contact_phone' => ['nullable', 'string', 'max:50'],
]; 'shipping_delivery_instructions' => ['nullable', 'string', 'max:1000'],
} ]);
$data = $request->validate($rules); $errors = $validator->errors()->toArray();
$data = $validator->valid();
$billingCountryId = $storeCountry?->id ?? ($data['billing_country_id'] ?? null);
$shippingCountryId = $storeCountry?->id ?? ($data['shipping_country_id'] ?? $billingCountryId);
$billing = [ $billing = [
'first_name' => $data['billing_first_name'], 'first_name' => $data['billing_first_name'] ?? null,
'last_name' => $data['billing_last_name'], 'last_name' => $data['billing_last_name'] ?? null,
'company_name' => $data['billing_company_name'] ?? null, 'company_name' => $data['billing_company_name'] ?? null,
'tax_identifier' => $data['billing_tax_identifier'] ?? 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, 'line_two' => $data['billing_line_two'] ?? null,
'city' => $data['billing_city'], 'city' => $data['billing_city'] ?? null,
'state' => $data['billing_state'] ?? null, 'state' => $data['billing_state'] ?? null,
'postcode' => $data['billing_postcode'], 'postcode' => $data['billing_postcode'] ?? null,
'country_id' => $data['billing_country_id'], 'country_id' => $billingCountryId,
'contact_email' => $data['contact_email'], 'contact_email' => $data['contact_email'] ?? null,
'contact_phone' => $data['billing_contact_phone'] ?? null, 'contact_phone' => $data['billing_contact_phone'] ?? null,
]; ];
$shipping = $sameAsBilling $shipping = $sameAsBilling
? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null] ? [...$billing, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null]
: [ : [
'first_name' => $data['shipping_first_name'], 'first_name' => $data['shipping_first_name'] ?? null,
'last_name' => $data['shipping_last_name'], 'last_name' => $data['shipping_last_name'] ?? null,
'company_name' => $data['shipping_company_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, 'line_two' => $data['shipping_line_two'] ?? null,
'city' => $data['shipping_city'], 'city' => $data['shipping_city'] ?? null,
'state' => $data['shipping_state'] ?? null, 'state' => $data['shipping_state'] ?? null,
'postcode' => $data['shipping_postcode'], 'postcode' => $data['shipping_postcode'] ?? null,
'country_id' => $data['shipping_country_id'], 'country_id' => $shippingCountryId,
'contact_email' => $data['contact_email'], 'contact_email' => $data['contact_email'] ?? null,
'contact_phone' => $data['shipping_contact_phone'] ?? null, 'contact_phone' => $data['shipping_contact_phone'] ?? null,
'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null, 'delivery_instructions' => $data['shipping_delivery_instructions'] ?? null,
]; ];
$this->checkout->setBillingAddress($billing); $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();
// 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);
} }
public function selectShippingOption(string $locale, Request $request): RedirectResponse return $this->fragments($cart, $this->syncShipping($cart), $errors);
}
public function selectShippingOption(string $locale, Request $request): JsonResponse
{ {
$data = $request->validate([ $identifier = (string) $request->input('shipping_option');
'shipping_option' => ['required', 'string'],
]); if ($cart = $this->cart->current()) {
$this->resetShippingManifest($cart);
}
try { try {
$this->checkout->selectShippingOption($data['shipping_option']); $this->checkout->selectShippingOption($identifier);
} catch (InvalidShippingOptionException) { } catch (InvalidShippingOptionException) {
return back()->withErrors([ // Re-render with whatever is currently valid; no hard error surfaced.
'shipping_option' => __('checkout.page.shipping_option_invalid'), }
$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(),
]); ]);
} }
return redirect()->route('checkout.show', $locale); /**
* 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();
} }
} }
@@ -60,6 +60,9 @@ private function lines(): array
'cart.remove' => ['Remove', 'Αφαίρεση'], 'cart.remove' => ['Remove', 'Αφαίρεση'],
'cart.subtotal' => ['Subtotal', 'Υποσύνολο'], 'cart.subtotal' => ['Subtotal', 'Υποσύνολο'],
'cart.discount' => ['Discount', 'Έκπτωση'], 'cart.discount' => ['Discount', 'Έκπτωση'],
'cart.shipping' => ['Shipping', 'Μεταφορικά'],
'cart.shipping_pending' => ['Not selected yet', 'Δεν έχει επιλεγεί ακόμη'],
'cart.tax' => ['VAT', 'ΦΠΑ'],
'cart.total' => ['Total', 'Σύνολο'], 'cart.total' => ['Total', 'Σύνολο'],
'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'], 'cart.checkout' => ['Checkout', 'Ολοκλήρωση παραγγελίας'],
'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'], 'cart.coupon_label' => ['Coupon code', 'Κωδικός κουπονιού'],
@@ -94,18 +97,26 @@ private function lines(): array
'page.address_line_one' => ['Address', 'Διεύθυνση'], 'page.address_line_one' => ['Address', 'Διεύθυνση'],
'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'], 'page.address_line_two' => ['Address line 2', 'Διεύθυνση (γραμμή 2)'],
'page.city' => ['City', 'Πόλη'], 'page.city' => ['City', 'Πόλη'],
'page.state' => ['State / Region', 'Νομός / Περιοχή'], 'page.state' => ['Region / Prefecture', 'Νομός / Περιοχή'],
'page.state_placeholder' => ['Select a region', 'Επίλεξε νομό'],
'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'], 'page.postcode' => ['Postcode', 'Ταχυδρομικός κώδικας'],
'page.country' => ['Country', 'Χώρα'], 'page.country' => ['Country', 'Χώρα'],
'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'], 'page.country_placeholder' => ['Select a country', 'Επίλεξε χώρα'],
'page.phone' => ['Phone', 'Τηλέφωνο'], 'page.phone' => ['Phone', 'Τηλέφωνο'],
'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'], 'page.delivery_instructions' => ['Delivery notes', 'Σχόλια για την παράδοση'],
'page.save_address' => ['Save and continue', 'Αποθήκευση και συνέχεια'], '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_heading' => ['Shipping method', 'Τρόπος αποστολής'],
'page.shipping_method_empty' => [ 'page.shipping_method_empty' => [
'Add your shipping address to see delivery options.', '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.select_shipping_method' => ['Continue', 'Συνέχεια'],
'page.shipping_option_invalid' => [ 'page.shipping_option_invalid' => [
'That shipping option is no longer available.', 'That shipping option is no longer available.',
+39 -3
View File
@@ -269,21 +269,28 @@ .bbk-cart-summary {
border-top: 1px solid var(--bbk-color-border); border-top: 1px solid var(--bbk-color-border);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 0.625rem;
} }
.bbk-cart-summary-row { .bbk-cart-summary-row {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
font-weight: 600; gap: 1rem;
} }
.bbk-cart-summary-row--discount { color: var(--bbk-color-danger); } .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 { .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); border-top: 1px solid var(--bbk-color-border);
font-size: 1.0625rem; font-size: 1.0625rem;
font-weight: 700;
} }
.bbk-cart-coupon-form { .bbk-cart-coupon-form {
@@ -518,6 +525,16 @@ .bbk-field-error {
color: var(--bbk-color-danger); 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; } textarea.bbk-field-input { resize: vertical; }
.bbk-checkbox { .bbk-checkbox {
@@ -581,6 +598,25 @@ .bbk-checkout-shipping-option-description {
.bbk-checkout-shipping-option-price { font-weight: 600; } .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 */ /* Continue / submit buttons — same look as the drawer's checkout CTA */
.bbk-checkout-continue { .bbk-checkout-continue {
@@ -1,20 +1,47 @@
import { Controller } from '@hotwired/stimulus' import { Controller } from '@hotwired/stimulus'
import { csrfToken } from './csrf'
// Static, client-only interactivity for the checkout page's address form — no // Drives the checkout page's left column: contact tabs, the same-as-billing
// fetch, no cart mutation, just show/hide. Nothing here talks to the server; // toggle, and — the bulk of it — autosaving the address form and the shipping
// CheckoutController::saveAddress() is what actually persists anything. // 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 { export default class extends Controller {
static targets = [ static targets = [
'guestTab', 'loginTab', 'guestPanel', 'loginPanel', 'guestTab', 'loginTab', 'guestPanel', 'loginPanel',
'sameAsBilling', 'shippingFields', 'sameAsBilling', 'shippingFields',
'form', 'shippingOptions', 'status',
] ]
connect() { static values = {
// Reflect whatever the server rendered (old input on a validation saveUrl: String,
// redisplay, or the default) before any click happens. selectShippingUrl: String,
if (this.hasSameAsBillingTarget) this.syncShippingFields() 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() { showGuest() {
this.guestPanelTarget.hidden = false this.guestPanelTarget.hidden = false
this.loginPanelTarget.hidden = true this.loginPanelTarget.hidden = true
@@ -29,18 +56,142 @@ export default class extends Controller {
this.loginTabTarget.setAttribute('aria-selected', 'true') this.loginTabTarget.setAttribute('aria-selected', 'true')
} }
// ── Same as billing ────────────────────────────────────────────────
toggleSameAsBilling() { toggleSameAsBilling() {
this.syncShippingFields() this.applySameAsBilling()
} }
// Disabled fields are simply never submitted by the browser — the server applySameAsBilling() {
// never sees stale shipping values while "same as billing" is checked. const on = this.sameAsBillingTarget.checked
syncShippingFields() {
const sameAsBilling = 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) => { 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)
}
}
} }
@@ -24,7 +24,6 @@
@if ($required) required @endif @if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }} {{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
> >
@error($name) {{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
<p class="bbk-field-error">{{ $message }}</p> <p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
@enderror
</div> </div>
@@ -0,0 +1,46 @@
{{--
The state/region + country pair for one address (billing or shipping).
Single-country store ($storeCountry set): region is a <select> of that
country's Lunar states, submitting `->name` (table-rate-shipping resolves
zones with State::whereName()), and country is a fixed hidden field + label.
Otherwise: free-text region + full country <select>, as before.
--}}
@props([
'prefix',
'storeCountry' => null,
'regions' => [],
'countries' => [],
'address' => null,
])
<div class="bbk-field-row">
@if ($storeCountry)
<x-checkout::select
:name="$prefix . '_state'"
label="{{ __('checkout.page.state') }}"
:options="$regions"
value-field="name"
:value="$address?->state"
placeholder="{{ __('checkout.page.state_placeholder') }}"
required
/>
<div class="bbk-field">
<span class="bbk-field-label">{{ __('checkout.page.country') }}</span>
<p class="bbk-field-static">{{ $storeCountry->name }}</p>
<input type="hidden" name="{{ $prefix }}_country_id" value="{{ $storeCountry->id }}">
</div>
@else
<x-checkout::field :name="$prefix . '_state'" label="{{ __('checkout.page.state') }}" :value="$address?->state" />
<x-checkout::select
:name="$prefix . '_country_id'"
label="{{ __('checkout.page.country') }}"
:options="$countries"
:value="$address?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
@endif
</div>
@@ -1,8 +1,11 @@
{{-- {{--
<x-checkout::select name="billing_country_id" label="Country" :options="$countries" required /> <x-checkout::select name="billing_country_id" label="Country" :options="$countries" required />
<x-checkout::select name="shipping_state" label="Region" :options="$regions" value-field="name" required />
`options` is an iterable of {id, name} (a Collection of models works `options` is an iterable of models/objects; `label` is always read from
directly) — value/label pulled from those two keys. `->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([ @props([
'name', 'name',
@@ -11,6 +14,7 @@
'value' => null, 'value' => null,
'placeholder' => null, 'placeholder' => null,
'required' => false, 'required' => false,
'valueField' => 'id',
]) ])
@php($selected = old($name, $value)) @php($selected = old($name, $value))
@@ -27,12 +31,10 @@
<option value="" @selected(! $selected)>{{ $placeholder }}</option> <option value="" @selected(! $selected)>{{ $placeholder }}</option>
@endif @endif
@foreach ($options as $option) @foreach ($options as $option)
<option value="{{ $option->id }}" @selected((string) $selected === (string) $option->id)> <option value="{{ $option->{$valueField} }}" @selected((string) $selected === (string) $option->{$valueField})>
{{ $option->name }} {{ $option->name }}
</option> </option>
@endforeach @endforeach
</select> </select>
@error($name) <p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
<p class="bbk-field-error">{{ $message }}</p>
@enderror
</div> </div>
@@ -14,7 +14,5 @@
@if ($required) required @endif @if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }} {{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>{{ old($name, $value) }}</textarea> >{{ old($name, $value) }}</textarea>
@error($name) <p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
<p class="bbk-field-error">{{ $message }}</p>
@enderror
</div> </div>
+49 -62
View File
@@ -17,7 +17,16 @@
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1> <h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
<div class="bbk-checkout"> <div class="bbk-checkout">
<div class="bbk-checkout-main"> <div
class="bbk-checkout-main"
data-controller="bbk-checkout-form"
data-action="input->bbk-checkout-form#scheduleSave"
data-bbk-checkout-form-save-url-value="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-select-shipping-url-value="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"
data-bbk-checkout-form-status-saving-value="{{ __('checkout.page.saving') }}"
data-bbk-checkout-form-status-saved-value="{{ __('checkout.page.saved') }}"
data-bbk-checkout-form-status-error-value="{{ __('checkout.page.save_error') }}"
>
{{-- Contact --}} {{-- Contact --}}
<section class="bbk-checkout-section"> <section class="bbk-checkout-section">
@@ -25,7 +34,6 @@
class="bbk-checkout-tabs" class="bbk-checkout-tabs"
role="tablist" role="tablist"
aria-label="{{ __('checkout.page.contact_heading') }}" aria-label="{{ __('checkout.page.contact_heading') }}"
data-controller="bbk-checkout-form"
> >
<button <button
type="button" type="button"
@@ -89,7 +97,16 @@ class="bbk-checkout-tab"
</div> </div>
</section> </section>
<form id="bbk-address-form" method="POST" action="{{ route('checkout.address.save', app()->getLocale()) }}"> {{-- 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(). --}}
<form
id="bbk-address-form"
method="POST"
action="{{ route('checkout.address.save', app()->getLocale()) }}"
data-bbk-checkout-form-target="form"
>
@csrf @csrf
{{-- Billing --}} {{-- Billing --}}
@@ -114,17 +131,13 @@ class="bbk-checkout-tab"
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required /> <x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
</div> </div>
<div class="bbk-field-row"> <x-checkout::region-country
<x-checkout::field name="billing_state" label="{{ __('checkout.page.state') }}" :value="$billingAddress?->state" /> prefix="billing"
<x-checkout::select :store-country="$storeCountry"
name="billing_country_id" :regions="$regions"
label="{{ __('checkout.page.country') }}" :countries="$countries"
:options="$countries" :address="$billingAddress"
:value="$billingAddress?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/> />
</div>
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" /> <x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
</section> </section>
@@ -140,7 +153,7 @@ class="bbk-checkout-tab"
value="1" value="1"
data-bbk-checkout-form-target="sameAsBilling" data-bbk-checkout-form-target="sameAsBilling"
data-action="bbk-checkout-form#toggleSameAsBilling" data-action="bbk-checkout-form#toggleSameAsBilling"
{{ old('same_as_billing', $shippingAddress === null || $shippingAddress?->is($billingAddress)) ? 'checked' : '' }} @checked($shipToBilling)
> >
{{ __('checkout.page.same_as_billing') }} {{ __('checkout.page.same_as_billing') }}
</label> </label>
@@ -161,17 +174,13 @@ class="bbk-checkout-tab"
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required /> <x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
</div> </div>
<div class="bbk-field-row"> <x-checkout::region-country
<x-checkout::field name="shipping_state" label="{{ __('checkout.page.state') }}" :value="$shippingAddress?->state" /> prefix="shipping"
<x-checkout::select :store-country="$storeCountry"
name="shipping_country_id" :regions="$regions"
label="{{ __('checkout.page.country') }}" :countries="$countries"
:options="$countries" :address="$shippingAddress"
:value="$shippingAddress?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/> />
</div>
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" /> <x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
</div> </div>
@@ -182,50 +191,28 @@ class="bbk-checkout-tab"
:value="$shippingAddress?->delivery_instructions" :value="$shippingAddress?->delivery_instructions"
/> />
</section> </section>
<button type="submit" class="bbk-checkout-continue">
{{ __('checkout.page.save_address') }}
</button>
</form> </form>
{{-- Shipping method — only resolvable once a shipping address exists --}} <p
class="bbk-checkout-status"
data-bbk-checkout-form-target="status"
role="status"
aria-live="polite"
hidden
></p>
{{-- Shipping method — resolves from the saved shipping address;
re-rendered as a fragment by bbk-checkout-form after each
autosave / option change. --}}
<section class="bbk-checkout-section"> <section class="bbk-checkout-section">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2> <h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
@if ($shippingOptions->isEmpty()) <div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p> @include('checkout::partials.shipping-options', [
@else 'shippingAddress' => $shippingAddress,
<form method="POST" action="{{ route('checkout.shipping-option.select', app()->getLocale()) }}"> 'shippingOptions' => $shippingOptions,
@csrf ])
<div class="bbk-checkout-shipping-options">
@foreach ($shippingOptions as $option)
<label class="bbk-checkout-shipping-option">
<input
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
{{ old('shipping_option', $shippingAddress?->shipping_option) === $option->identifier ? 'checked' : '' }}
required
>
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
@if ($option->description)
<span class="bbk-checkout-shipping-option-description">{{ $option->description }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
</label>
@endforeach
</div> </div>
@error('shipping_option')
<p class="bbk-field-error">{{ $message }}</p>
@enderror
<button type="submit" class="bbk-checkout-continue">
{{ __('checkout.page.select_shipping_method') }}
</button>
</form>
@endif
</section> </section>
{{-- TODO: payment — next slice. boboko-core's Offline driver is the only {{-- TODO: payment — next slice. boboko-core's Offline driver is the only
@@ -73,6 +73,11 @@ class="bbk-cart-coupon-input"
@endif @endif
</div> </div>
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span>
</div>
@if ($cart?->discountTotal?->value > 0) @if ($cart?->discountTotal?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount"> <div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span> <span>{{ __('checkout.cart.discount') }}</span>
@@ -80,13 +85,29 @@ class="bbk-cart-coupon-input"
</div> </div>
@endif @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)
<div class="bbk-cart-summary-row"> <div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span> <span>{{ __('checkout.cart.shipping') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span> @if ($cart->shippingAddress->shipping_option)
<span>{{ $cart->shippingTotal?->formatted() }}</span>
@else
<span class="bbk-cart-summary-pending">{{ __('checkout.cart.shipping_pending') }}</span>
@endif
</div> </div>
@endif
{{-- Always shown, even with no discount — equals subtotal then, @if ($cart?->taxTotal?->value > 0)
diverges once one's applied. --}} <div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.tax') }}</span>
<span>{{ $cart->taxTotal->formatted() }}</span>
</div>
@endif
{{-- Always shown — equals subtotal with nothing else applied,
diverges as discount / shipping / tax come in. --}}
<div class="bbk-cart-summary-row bbk-cart-summary-row--total"> <div class="bbk-cart-summary-row bbk-cart-summary-row--total">
<span>{{ __('checkout.cart.total') }}</span> <span>{{ __('checkout.cart.total') }}</span>
<span>{{ $cart?->total?->formatted() }}</span> <span>{{ $cart?->total?->formatted() }}</span>
@@ -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)
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
@elseif ($shippingOptions->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_none') }}</p>
@elseif ($shippingOptions->count() === 1)
@php($only = $shippingOptions->first())
<div class="bbk-checkout-shipping-confirmed">
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $only->name }}</span>
@if ($only->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($only->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $only->price->formatted() }}</span>
</div>
@else
<div class="bbk-checkout-shipping-options">
@foreach ($shippingOptions as $option)
<label class="bbk-checkout-shipping-option">
<input
type="radio"
name="shipping_option"
value="{{ $option->identifier }}"
@checked($selected === $option->identifier)
data-action="change->bbk-checkout-form#selectShipping"
>
<span class="bbk-checkout-shipping-option-detail">
<span class="bbk-checkout-shipping-option-name">{{ $option->name }}</span>
@if ($option->description)
<span class="bbk-checkout-shipping-option-description">{{ strip_tags($option->description) }}</span>
@endif
</span>
<span class="bbk-checkout-shipping-option-price">{{ $option->price->formatted() }}</span>
</label>
@endforeach
</div>
@endif