@extends('layouts.public') @section('content') @php /** @var array $items @var array $restaurant @var array $totals @var array $zones @var array|null $user * @var bool $tipsEnabled @var bool $schedulingEnabled */ // Dine-in: offered only when the cart is bound to a scanned table AND the restaurant // allows QR ordering. When present it is the first option and pre-selected, so delivery // is NOT checked by default. Resolved here rather than beside the radios because the // step rail below names step 2 after the same choice. $dineIn = $dineIn ?? false; $tableLabel = (string) ($tableLabel ?? ''); /* --------------------------------------------------------------------------- Three-step checkout. The fieldsets below are the ones this page has always had; they are only GROUPED into three panes. The form still posts ONCE, to the same action, so there is no new route and no per-step save that can half-succeed. Nothing is hidden server-side: every pane renders, and only checkout.js — once it has run — collapses them to one at a time. That is what makes the page work with JavaScript disabled: three panes stacked, one submit, order completes. Step names reuse the headings the sections already carry, so the rail and the fieldset it points at are never worded differently. --------------------------------------------------------------------------- */ $coSteps = [ 1 => t('checkout.contact'), 2 => $dineIn ? t('checkout.order_type') : t('checkout.delivery_method'), 3 => t('checkout.payment_method'), ]; // Which pane a rejected submit re-opens. CheckoutController::checkoutFail puts the field // the server refused into the same flashed-input bag old() reads, so this needs no new // mechanism and expires with the rest of that input. An error naming no field (the // restaurant is closed, the cart went stale) opens the first pane, where the flash // banner above the form is already in view. $coErrorPane = [ 'customer_name' => 1, 'customer_phone' => 1, 'customer_email' => 1, 'service_mode' => 2, 'delivery_address' => 2, 'zone_id' => 2, 'scheduled_at' => 2, 'payment_method' => 3, 'tip' => 3, 'coupon' => 3, 'loyalty_reward_id' => 3, 'notes' => 3, ][(string) \App\Support\Session::old('_error_field', '')] ?? 1; /* Choices a rejected submit must not make the customer enter twice. old() already restores every text field on this page; these are the radios and selects it cannot reach. Each falls back to exactly the default it had before, so a first visit — where the flashed bag is empty — renders identically to how it always did. */ $coOld = static fn (string $k): string => (string) \App\Support\Session::old($k, ''); $coOldPay = $coOld('payment_method'); $coOldReward = $coOld('loyalty_reward_id'); $coMode = $coOld('service_mode') !== '' ? $coOld('service_mode') : ($dineIn ? 'dine_in' : 'delivery'); $coLater = $coOld('when') === 'later'; // The first enabled payment method stays pre-selected until the customer has actually // chosen one; after a rejected submit their choice wins. $coPayChecked = static fn (string $value, bool $isFirst): string => ($coOldPay !== '' ? $coOldPay === $value : $isFirst) ? ' checked' : ''; @endphp

{{ t_raw('checkout.title') }}

@php /* The page's messages belong UNDER its title band, inside the page the reader is looking at — not floating above it. getFlashes() consumes, so the layout's own call further out finds nothing and does not draw it twice. */ @endphp
@partial('flash')
@php /* Whether the checked mode is the server SAYING something (a validation bounce echoing the customer's own submit, a table-bound dine-in) or merely the default. The storefront's delivery/pickup toggle lives in localStorage — client state — and may only override the DEFAULT: overriding a bounce would discard a choice the customer made on this form. */ @endphp
@csrf @if (empty($user))
{!! t_raw('checkout.guest_note', [':link' => '' . t('common.sign_in') . '']) !!}
@endif
@php /* Numbered step rail — the order builder's own .qm-ob-step badge, so the storefront has one numbered-step look and not a second, framework one. Shipped hidden and revealed by checkout.js: a browser without JavaScript is never shown chrome that cannot do anything. */ @endphp

{{ t_raw('checkout.contact') }}

{!! $dineIn ? t('checkout.order_type') : t('checkout.delivery_method') !!}

@if ($dineIn)
@endif
@php // A returning customer picks from their saved addresses instead of // retyping; the default is pre-selected. "Add a new address" (and every // guest) falls back to the free-text field. Each option carries the // one-line address in data-address, which the picker copies into the // hidden field the server already reads (delivery_address). $savedAddresses = $savedAddresses ?? []; $addrLine = static function (\App\Models\Address $a): string { $parts = array_filter([ trim((string) ($a['line1'] ?? '')), trim((string) ($a['line2'] ?? '')), trim((string) ($a['city'] ?? '')), trim((string) ($a['postcode'] ?? '')), ]); return implode(', ', $parts); }; $hasSaved = count($savedAddresses) > 0; @endphp
@if ($hasSaved) @endif @php /* A signed-in customer's address is kept after the order, so the next one starts from it instead of an empty box. The label is asked for here because it is the only moment anyone knows which of the two it is. Guests see nothing — there is no account to keep it on. */ @endphp @if (\App\Support\Auth::is('customer'))
@endif @php /* NEITHER THE PIN NOR THE ZONE IS THE CUSTOMER'S TO SET. The delivery area is a fact about how far the address is from the restaurant, measured against the tiers the vendor drew — not a preference. Asking for it let a customer 12 km away choose the free 8 km tier, and the "share your location" button made the fee depend on a permission prompt they could decline. Both existed only because the server could not turn a written address into a point; it can now, so the server measures the distance and picks the tier. The hidden lat/lng above still carry a pin when one is genuinely available. The view-scoped "smart delivery" script that drove those controls has now gone with them. It was wired to #zone_id, #qm-co-locate and #qm-co-zone-manual — none of which this page renders any more — so it bailed on its first guard and its result panel (#qm-co-zone-auto) was never written to, while reading as though a live tier preview were still on the page. It also cost every checkout render a GeoDetect::origin() call, which in `ip` mode is a blocking third-party lookup, to fill data-geo-lat/lng that nothing then read. The fee shown is the server's, full stop — same rule as checkout.js. */ @endphp
@php /* A closed restaurant can only be SCHEDULED. The as-soon-as-possible choice is not rendered at all - offering it would be offering something the server rejects on submit (CheckoutController's opening-hours gate), which is how a diner ended up bounced back up the wizard with no visible reason. */ $coClosed = !empty($restaurantClosed); $coMustSchedule = $coClosed && !empty($schedulingEnabled); $coLater = $coLater || $coMustSchedule; @endphp @if (!empty($schedulingEnabled))

{{ t_raw('checkout.when') }}

@if ($coMustSchedule) @php /* mb-3 because .alert carries no margin of its own - without it the notice sat flush against the schedule option below. */ @endphp
{{ t_raw('restaurant.closed_banner_title') }} {{ t_raw('checkout.closed_scheduled_only') }}
@else
@endif
@php /* A DATE plus a SLOT, not a free calendar-and-clock. The slots come from Restaurant::bookableSlots() over /checkout/slots - the same method the gate in place() validates against - so the diner can only pick something the server will accept. Prep-time lead, pause and overnight windows are all handled there, once. */ $coHours = is_array($scheduleHours ?? null) ? $scheduleHours : []; $coClosedDow = []; foreach ($coHours as $d => $w) { if ($w === false) { $coClosedDow[] = (int) $d; } } $coMinDate = substr((string) ($scheduleMinDate ?? ''), 0, 10); @endphp
@php /* The server contract is unchanged: 'Y-m-d H:i' in scheduled_at. */ @endphp
@elseif ($coClosed) @php /* Closed AND scheduling switched off: there is no way to place this order, so say so here rather than letting the diner reach the payment step. */ @endphp
{{ t_raw('restaurant.closed_banner_title') }} {{ t_raw('checkout.closed_blocked') }}
@endif

{{ t_raw('checkout.payment_method') }}

@php // Render exactly the methods the admin has enabled (COD/offline are // local; Stripe/PayPal appear only when enabled + keyed). The first // one is pre-selected. Card and PayPal are completed inline on this page. // Method labels use the interface translations; t returns escaped text. $payMeta = [ 'cod' => [t('checkout.cod'), t('checkout.cod_desc')], 'stripe' => [t('checkout.card'), t('checkout.card_desc')], 'paypal' => [t('checkout.paypal'), t('checkout.paypal_desc')], ]; $first = true; @endphp @foreach (active_payment_methods((int) ($restaurant['id'] ?? 0)) as $pm) @php [$plabel, $pdesc] = $payMeta[$pm] ?? [e(ucfirst($pm)), '']; @endphp
@php $first = false; @endphp @endforeach @php /* Saved cards (logged-in customers only), best first. Selecting one charges it on the gateway that holds it — the customer does not re-enter anything and there is no payment step. The first usable card comes preselected, so the common case is: open checkout, the last card used is already chosen, place the order. An expired card is still shown, marked, and unselectable: hiding it would leave the customer wondering where their card went. */ $savedCards = $savedCards ?? []; $preselectCardId = (int) ($preselectCardId ?? 0); @endphp @if (!empty($user) && !empty($savedCards)) @foreach ($savedCards as $sc) @php $scUsable = \App\Models\PaymentMethod::isUsable($sc); @endphp
@endforeach @endif @if (!empty($payConfig['stripeEnabled']) || !empty($payConfig['paypalEnabled'])) @if (!empty($payConfig['stripeEnabled'])) @endif @if (!empty($payConfig['paypalEnabled'])) @endif @endif
@if (!empty($tipsEnabled))

{{ t_raw('checkout.add_tip') }} {{ t_raw('common.optional') }}

{{ t_raw('checkout.tip_lead') }}

@php $tipPresets = [0 => t_raw('checkout.tip_none'), 10 => '10%', 15 => '15%', 20 => '20%']; // Preset amounts, computed once so the restore below can tell a preset tip // from a typed one instead of guessing. $coTipAmounts = []; foreach ($tipPresets as $pct => $label) { $coTipAmounts[$pct] = number_format($pct === 0 ? 0.0 : round((float) $totals['subtotal'] * $pct / 100, 2), 2, '.', ''); } // A rejected submit brings the tip back exactly as it was: the matching // preset, or the custom field with the typed amount still in it. $coTipNow = number_format((float) $coOld('tip'), 2, '.', ''); $coTipIsCustom = (float) $coTipNow > 0 && !in_array($coTipNow, $coTipAmounts, true); $coTipMatched = false; @endphp
@foreach ($tipPresets as $pct => $label) @php $amt = $pct === 0 ? 0.0 : round((float) $totals['subtotal'] * $pct / 100, 2); $coTipOn = !$coTipIsCustom && !$coTipMatched && $coTipAmounts[$pct] === $coTipNow; $coTipMatched = $coTipMatched || $coTipOn; @endphp @endforeach
@endif

{{ t_raw('checkout.order_notes') }} {{ t_raw('common.optional') }}

@php /* Step navigation. Shipped hidden for the same reason as the rail: without JavaScript there is nothing to move between, and the order is placed from the summary's own "Place order" button — which is where it has always been and stays reachable on every step. */ @endphp

{{ t_raw('checkout.your_order') }}

{!! restaurant_logo_html($restaurant, 'md') !!} {{ $restaurant['name'] }}
@foreach ($items as $line)
{{ (int) $line['qty'] }}× {{ $line['name'] }}{{ money($line['unit_price'] * $line['qty']) }}
@endforeach
@php $loyaltyEnabled = $loyaltyEnabled ?? false; $loyaltyBalance = (int) ($loyaltyBalance ?? 0); $loyaltyRewards = $loyaltyRewards ?? []; $rewardItems = $rewardItems ?? []; $loyaltyLabel = (string) ($loyaltyLabel ?? 'points'); $showLoyalty = $loyaltyEnabled && !empty($user); @endphp @if ($showLoyalty)
{{ t_raw('checkout.loyalty_rewards') }} {{ loyalty_points($loyaltyBalance, $loyaltyLabel) }}
@php /* Points are keyed to a phone number: an account without one earns nothing on this order, and this is where the customer learns it, with the fix. */ @endphp @if (trim((string) ($user['phone'] ?? '')) === '')

{!! t_raw('checkout.loyalty_need_phone', [':link' => '' . t('checkout.loyalty_need_phone_link') . '']) !!}

@endif
@foreach ($loyaltyRewards as $rw) @php $cost = (int) $rw['points_cost']; $eligible = $loyaltyBalance >= $cost; $isFree = ($rw['type'] ?? '') === 'free_item'; $freeName = ''; $freePrice = 0.0; $discount = 0.0; if ($isFree) { $mi = $rewardItems[(int) ($rw['menu_item_id'] ?? 0)] ?? null; if ($mi) { $freeName = (string) $mi['name']; $freePrice = (float) $mi['price']; } $discount = $freePrice; } else { $discount = (float) ($rw['cash_value'] ?? 0); }; @endphp @endforeach
@endif
{{ t_raw('checkout.subtotal') }}{{ money($totals['subtotal']) }}
{{ t_raw('checkout.delivery_fee') }}{{ money($totals['delivery_fee']) }}
@if (($totals['service_charge'] ?? 0) > 0)
{{ t_raw('checkout.service_charge') }}{{ money($totals['service_charge']) }}
@endif @if ((float) $totals['tax'] > 0)
{!! \App\Services\Tax::isInclusive($restaurant) ? t('checkout.tax_label_incl', [':label' => \App\Services\Tax::label($restaurant)]) : e(\App\Services\Tax::label($restaurant)) !!}{{ money($totals['tax']) }}
@endif @if ($showLoyalty) @endif
{{ t_raw('checkout.total') }}{{ money($totals['total']) }}

{!! t_raw('checkout.coupon_preview_note', [':button' => '' . t('common.apply') . '']) !!}

@if (!empty($payConfig['stripeEnabled']) || !empty($payConfig['paypalEnabled'])) @php // Configuration for the inline-payment controller (checkout-pay.js). Only public values // are exposed; the order is created and the gateway session is started by posting the // checkout form back to /checkout over AJAX, which returns the details needed to finish // paying on this page. $qmPayCfg = [ 'prepareUrl' => route('checkout'), 'currency' => (string) $payConfig['currency'], 'stripe' => ['enabled' => (bool) $payConfig['stripeEnabled'], 'key' => (string) $payConfig['stripeKey']], 'paypal' => ['enabled' => (bool) $payConfig['paypalEnabled']], ]; @endphp @if (!empty($payConfig['stripeEnabled'])) @endif @if (!empty($payConfig['paypalEnabled']) && $payConfig['paypalClientId'] !== '') @endif @endif @endsection