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;
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();
}
}
@@ -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.',
+39 -3
View File
@@ -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 {
@@ -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)
}
}
}
@@ -24,7 +24,6 @@
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>
@error($name)
<p class="bbk-field-error">{{ $message }}</p>
@enderror
{{-- Always present so bbk-checkout-form can fill it live on an autosave. --}}
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</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="shipping_state" label="Region" :options="$regions" value-field="name" required />
`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 @@
<option value="" @selected(! $selected)>{{ $placeholder }}</option>
@endif
@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>
@endforeach
</select>
@error($name)
<p class="bbk-field-error">{{ $message }}</p>
@enderror
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
@@ -14,7 +14,5 @@
@if ($required) required @endif
{{ $attributes->class(['bbk-field-input', 'bbk-field-input--error' => $errors->has($name)]) }}
>{{ old($name, $value) }}</textarea>
@error($name)
<p class="bbk-field-error">{{ $message }}</p>
@enderror
<p class="bbk-field-error" data-bbk-field-error="{{ $name }}" @unless ($errors->has($name)) hidden @endunless>{{ $errors->first($name) }}</p>
</div>
+52 -65
View File
@@ -17,7 +17,16 @@
<h1 class="bbk-checkout-heading">{{ __('checkout.page.title') }}</h1>
<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 --}}
<section class="bbk-checkout-section">
@@ -25,7 +34,6 @@
class="bbk-checkout-tabs"
role="tablist"
aria-label="{{ __('checkout.page.contact_heading') }}"
data-controller="bbk-checkout-form"
>
<button
type="button"
@@ -89,7 +97,16 @@ class="bbk-checkout-tab"
</div>
</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
{{-- Billing --}}
@@ -114,17 +131,13 @@ class="bbk-checkout-tab"
<x-checkout::field name="billing_postcode" label="{{ __('checkout.page.postcode') }}" :value="$billingAddress?->postcode" required />
</div>
<div class="bbk-field-row">
<x-checkout::field name="billing_state" label="{{ __('checkout.page.state') }}" :value="$billingAddress?->state" />
<x-checkout::select
name="billing_country_id"
label="{{ __('checkout.page.country') }}"
:options="$countries"
:value="$billingAddress?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
</div>
<x-checkout::region-country
prefix="billing"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$billingAddress"
/>
<x-checkout::field name="billing_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$billingAddress?->contact_phone" />
</section>
@@ -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') }}
</label>
@@ -161,17 +174,13 @@ class="bbk-checkout-tab"
<x-checkout::field name="shipping_postcode" label="{{ __('checkout.page.postcode') }}" :value="$shippingAddress?->postcode" required />
</div>
<div class="bbk-field-row">
<x-checkout::field name="shipping_state" label="{{ __('checkout.page.state') }}" :value="$shippingAddress?->state" />
<x-checkout::select
name="shipping_country_id"
label="{{ __('checkout.page.country') }}"
:options="$countries"
:value="$shippingAddress?->country_id"
placeholder="{{ __('checkout.page.country_placeholder') }}"
required
/>
</div>
<x-checkout::region-country
prefix="shipping"
:store-country="$storeCountry"
:regions="$regions"
:countries="$countries"
:address="$shippingAddress"
/>
<x-checkout::field name="shipping_contact_phone" label="{{ __('checkout.page.phone') }}" type="tel" :value="$shippingAddress?->contact_phone" />
</div>
@@ -182,50 +191,28 @@ class="bbk-checkout-tab"
:value="$shippingAddress?->delivery_instructions"
/>
</section>
<button type="submit" class="bbk-checkout-continue">
{{ __('checkout.page.save_address') }}
</button>
</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">
<h2 class="bbk-checkout-section-heading">{{ __('checkout.page.shipping_method_heading') }}</h2>
@if ($shippingOptions->isEmpty())
<p class="bbk-checkout-note">{{ __('checkout.page.shipping_method_empty') }}</p>
@else
<form method="POST" action="{{ route('checkout.shipping-option.select', app()->getLocale()) }}">
@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>
@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
<div id="bbk-shipping-options" data-bbk-checkout-form-target="shippingOptions">
@include('checkout::partials.shipping-options', [
'shippingAddress' => $shippingAddress,
'shippingOptions' => $shippingOptions,
])
</div>
</section>
{{-- TODO: payment — next slice. boboko-core's Offline driver is the only
@@ -73,6 +73,11 @@ class="bbk-cart-coupon-input"
@endif
</div>
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span>
</div>
@if ($cart?->discountTotal?->value > 0)
<div class="bbk-cart-summary-row bbk-cart-summary-row--discount">
<span>{{ __('checkout.cart.discount') }}</span>
@@ -80,13 +85,29 @@ class="bbk-cart-coupon-input"
</div>
@endif
<div class="bbk-cart-summary-row">
<span>{{ __('checkout.cart.subtotal') }}</span>
<span>{{ $cart?->subTotal?->formatted() }}</span>
</div>
{{-- 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">
<span>{{ __('checkout.cart.shipping') }}</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>
@endif
{{-- Always shown, even with no discount — equals subtotal then,
diverges once one's applied. --}}
@if ($cart?->taxTotal?->value > 0)
<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">
<span>{{ __('checkout.cart.total') }}</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