@extends('layouts.dashboard') @section('content') @php /** @var array|null $restaurant @var array $cuisines @var array $zones @var array $hours */ $r = $restaurant ?? []; $cuisines = $cuisines ?? []; $zones = $zones ?? []; $hours = $hours ?? []; $layoutVal = in_array($r['detail_layout'] ?? 'classic', ['classic', 'style1', 'style2'], true) ? ($r['detail_layout'] ?? 'classic') : 'classic'; $dayKeys = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; // Money policy carries its own privileges — the controller refuses the POST; this hides // each tab so nobody is shown a form that would be rejected. Granted separately, because // a role may well be trusted with the loyalty programme but not with tax rates. $canTax = $canTax ?? \App\Support\Auth::can('settings.tax'); $canLoyalty = $canLoyalty ?? \App\Support\Auth::can('settings.loyalty'); // Delivery is the platform operator's decision, so the two tabs that exist only to configure it // are gone when it is off — same reasoning as the two above: never show a form whose POST the // controller will refuse. Read once here so the tab button and its panel cannot disagree. $canDeliver = \App\Services\Capability::has(\App\Services\Capability::DELIVERY); $ordersPaused = (bool) ($ordersPaused ?? false); // The auto-resume field wants the restaurant's own local wall-clock time. $pauseUntil = trim((string) ($r['orders_paused_until'] ?? '')); if ($pauseUntil !== '') { try { $pauseUntil = (new DateTimeImmutable($pauseUntil, restaurant_tz($r)))->format('Y-m-d\TH:i'); } catch (Throwable $e) { $pauseUntil = ''; } } @endphp @php /* .qm-d2: the dashboard density standard (app.css §35) */ @endphp
@if (($r['status'] ?? '') === 'pending')
{{ t_raw('vendor.settings.pending_approval_alert') }}
@endif
@csrf

{{ t_raw('vendor.settings.sec_profile') }}

@php /* Identity reads across, not down: name · cuisine · tagline on one line, then menu name · website, then the description full width because it is the only multi-line field. Six stacked single-line inputs made a short form scroll for no reason. */ @endphp
@php /* Menu name is what this restaurant calls its menu — "Carte", "Kitchen", "Small plates". Identity, shown on the storefront and the printed menu, so it sits here rather than among the dishes. */ @endphp
@php $brandHex = restaurant_brand_color($r); @endphp
{!! brand_monogram_html((string) ($r['name'] ?? ''), 'qm-brandmark--md', 'id="g-brand-preview"', $brandHex) !!}

{{ t_raw('vendor.settings.sec_branding') }}

{!! media_field_html('logo', $r['logo'] ?? null, ['accept' => 'image', 'circle' => true, 'allowClear' => true, 'label' => t_raw('vendor.settings.logo_label')]) !!}
{!! media_field_html('cover_image', $r['cover_image'] ?? null, ['accept' => 'image', 'allowClear' => true, 'label' => t_raw('vendor.settings.cover_image_label')]) !!}
@php /* Brand colour moved up beside Website in the Profile card. The live-preview script below stays here and finds both elements by id wherever they sit. */ @endphp @php // Storefront "about" media. Image, video and YouTube are stored INDEPENDENTLY, so setting // one never disturbs the others; the type dropdown only chooses which one is live on the // restaurant page. Each file box posters its own slot and has its own X. The master switch // hides the live block on the front end without deleting anything, and never this editor. $aboutImage = trim((string) ($r['about_image'] ?? '')); $aboutVideo = trim((string) ($r['about_video'] ?? '')); $aboutYoutube = trim((string) ($r['about_youtube'] ?? '')); $aboutType = in_array($r['about_media_type'] ?? 'image', ['image', 'video', 'youtube'], true) ? ($r['about_media_type'] ?? 'image') : 'image'; $aboutOn = (int) ($r['about_media_hidden'] ?? 0) === 0; $showFlag = (int) ($r['show_cuisine_flag'] ?? 1) === 1; @endphp
{!! media_field_html('about_media_image', $aboutImage !== '' ? $aboutImage : null, ['accept' => 'image', 'allowClear' => true, 'label' => t_raw('vendor.settings.about_media_label')]) !!}
{!! media_field_html('about_media_video', $aboutVideo !== '' ? $aboutVideo : null, ['accept' => 'video', 'allowClear' => true, 'label' => t_raw('vendor.settings.about_media_label')]) !!}

{{ t_raw('vendor.settings.sec_storefront') }}

{{ t_raw('vendor.settings.layout_help') }}

{{ t_raw('vendor.settings.tags_label') }}

@php // Group the tag picker by dimension (store type / food type / protein / dietary / course). $tagsByGroup = []; foreach (($tags ?? []) as $t) { $tagsByGroup[$t['group'] ?? 'dish_type'][] = $t; } foreach (\App\Models\Tag::groups() as $gKey => $gLabel): if (empty($tagsByGroup[$gKey])) { continue; } $gTagKey = $gKey === 'dish_type' ? 'food_type' : $gKey; @endphp
{{ t_raw('vendor.settings.taggroup_' . $gTagKey) }}
@foreach ($tagsByGroup[$gKey] as $t) @endforeach
@endforeach
@php /* Branches moved out to its own page (Settings → Branches, between Device settings and Billing). Every panel on this screen configures the venue you are operating; the branch list is how you leave it for another, which is not the same job. The five POST handlers still live on BranchesController and post to /vendor/branches/*. */ @endphp @if ($canDeliver)
@csrf

{{ t_raw('vendor.settings.tab_charges') }}

@php /* Whether this restaurant delivers. Above the fee and the radius because it decides whether either of them means anything — a fee on a restaurant that does not deliver is a number nobody will ever be charged. Hidden entirely when the platform does not deliver: a switch that cannot change the outcome is worse than no switch, and the operator's decision is not this restaurant's to reopen. Hidden, not disabled, so the card does not carry a dead control the owner has to reason about. */ @endphp @if (\App\Services\Capability::has(\App\Services\Capability::DELIVERY)) @endif
@php /* Spend this much and delivery is free. Sits beside the fee it waives, not beside `min_order` a few fields down: a minimum BLOCKS an order, this one discounts it, and putting them together is what makes operators confuse the two. Zero means no offer. */ @endphp
{{ \App\Services\Distance::unitLabel() }}
{{ t_raw('vendor.settings.delivery_radius_help') }}
@php /* Delivery zones live in the Charges tab, not one of their own. The fee and radius above and the tiers that apply them are a single decision, and splitting them meant switching tabs to see what the charges applied to. Keeps id="zones" so an existing #zones link still scrolls here. */ @endphp
@csrf

{{ t_raw('vendor.settings.zones_title') }}

@php /* Add tier sits in the card head, opposite the title, because that is where an "add a row" action lives on every other card in the dashboard. It was in the foot beside Save, which read as a second submit. .qm-card-head is space-between, so no alignment class is needed. */ @endphp

{{ t_raw('vendor.settings.zones_sub') }}

@php /* data-unit rides along with lat/lng because the script rebuilds this row's markup when a tier is added, and the radius label's key carries :unit — without the value the cloned row printed the placeholder verbatim. */ @endphp

@php $zoneRows = !empty($zones) ? $zones : [['zone_name' => t_raw('vendor.settings.default_zone_name'), 'radius_km' => '3', 'delivery_fee' => '0', 'min_order' => '']]; @endphp @foreach ($zoneRows as $i => $z) @php $i = (int) $i; @endphp
@php /* Remove is the LAST cell of the row, on an auto-width track, so it sits at the end of the four fields instead of on a line of its own. The grid bottom-aligns, which lines the button up with the inputs; the empty label is the spacer that makes that work without a magic margin. The same markup is built again in zone-map-editor.js for rows added after load — change one, change both. */ @endphp
@endforeach
@endif
@php /* Pause new orders — the temporary stop switch, above the weekly schedule it overrides. Opening hours are a fixed rota; this is "the kitchen is 40 minutes behind, stop the queue" and it resumes on its own. */ @endphp
@csrf

{{ t_raw('vendor.settings.tab_hours') }}

{{ t_raw('vendor.settings.pause_until_help') }}
@if ($ordersPaused)
{{ restaurant_pause_message($r) }}
@endif
@php /* No save button. The switch IS the control — a "Pause orders" button under a "Pause orders" toggle read as two ways to do one thing, and the button was only there because the form needed a submit. The script below submits on change instead, so the switch behaves like every other switch. */ @endphp
@csrf @foreach ($dayKeys as $d => $dayKey) @php $dayName = t_raw('common.day_' . $dayKey); $h = $hours[$d] ?? ['open' => '09:00', 'close' => '22:00', 'closed' => false]; @endphp
{{ $dayName }}
@endforeach
@php /* Closed dates sit in the Opening Hours tab: both answer "when is this venue open", and splitting them meant setting a holiday closure on a different screen from the weekly hours it overrides. Keeps id="closed-dates" so an existing #closed-dates link still scrolls here. */ @endphp

{{ t_raw('vendor.settings.closed_dates_title') }}

@partial('blackout-manager', ['blackouts' => $blackouts ?? [], 'returnTo' => 'settings'])
@csrf

{{ t_raw('vendor.settings.contact_title') }}

{{ t_raw('vendor.settings.contact_name_help') }}
@php /* The weekly performance report. WeeklyReport::due() gates on this column and nothing in the product ever wrote it — the cron says the report goes "to owners who subscribed" and there was nothing to subscribe with, so all 66 demo rows sat at 0 and the report could never be delivered to anyone. The marker beside it is the same rule every other switch here uses: an unticked box posts nothing, so without it a save would always read "off". */ @endphp
@php /* OUTSIDE THE FIELD GRID. .qm-switch-row is a full-width band — label, one-line explanation, switch on the right. Dropped into a .qm-form-grid cell it became a bordered box sitting beside a bare text input, starting at the input's height rather than the label's, and taller than the field next to it. Every other switch on this page sits on its own row; this one now does too. */ @endphp
@php $bizModel = \App\Services\BusinessModel::current(); $isMarketpl = $bizModel === 'marketplace'; $isFranchise = $bizModel === 'franchise'; $payMethod = (string) ($r['payout_method'] ?? 'none'); // Split payments overlays every model: the platform collects and disburses the share, so // the restaurant needs a payee (constrained to the active gateway) and no longer needs its // own inbound gateway keys. /* Split only changes WHEN a marketplace share is sent, never which rail it leaves on, so nothing on this form is narrowed by it any more. It is read purely to word the payee hint: under split the money arrives as each order is captured rather than in a run. */ $isSplit = \App\Services\BusinessModel::splitPayments(); // A Stripe payee is a connected account the platform opens, so a stored acct_ id is never // shown in the box beside it: that box takes a PayPal email, and prefilling it with an // account reference only invites the vendor to post something PayPal cannot use. The // Connect state below owns the acct_ id, and the id itself survives untouched on save. $payAccount = str_starts_with((string) ($r['payout_account'] ?? ''), 'acct_') ? '' : (string) ($r['payout_account'] ?? ''); $sc = $stripeConnect ?? null; // live Stripe read, or null when there is none $scAcct = (string) ($sc['account'] ?? ''); $scRead = !empty($sc['ok']); $scReady = !empty($sc['transfers_active']); @endphp
@if ($isMarketpl || $isSplit) @php /* The Connect handshake is a POST of its own and HTML forbids nesting it inside the settings form, so it stands here empty and its button reaches it from inside the payee card by `form=`. */ @endphp
@csrf
@endif
@csrf

{{ t_raw('vendor.settings.payout_cash_title') }}

@if ($isMarketpl || $isSplit)

{{ t_raw('vendor.settings.payee_title') }}

@php /* PayPal only. Stripe swaps this box for the Connect state below — Stripe refuses a transfer to anything but an account the platform opened, so there is nothing here a vendor could usefully type. */ @endphp
@php /* A CONNECTION, NOT A FIELD. PayPal's destination is something the owner types, so it is a form field in the grid above. Stripe's is not typed at all — the platform opens the account and Stripe decides whether it may receive money. Rendering that state inside a grid cell forced a status with two buttons into the shape of a text input: it never matched the height of the select beside it, and "Unlink this account" ended up floating at the far edge of the card, detached from the account it removes. It is a band instead — the same one Accept cash above and the device rows use. What it IS sits on the left, what you can DO about it sits on the right, and the destructive action is beside the thing it destroys. */ @endphp
@php /* Name, then state, on one line — the pill qualifies the account, so it belongs beside its name rather than adrift at the other end of the row. The reference sits under it, and the only thing left for the trailing edge is what you can DO. */ @endphp {{ t_raw('vendor.settings.payee_stripe_label') }}@if ($scAcct !== '') {!! $scReady ? t('vendor.settings.payee_stripe_ready') : t('vendor.settings.payee_stripe_pending') !!}@endif @if ($scAcct === '') {{ t_raw('vendor.settings.payee_stripe_intro') }} @else {{ t_raw('vendor.settings.payee_stripe_ref', [':acct' => $scAcct]) }} @if (!$scRead) {{ t_raw('vendor.settings.payee_stripe_unreadable', [':error' => (string) ($sc['error'] ?? '')]) }} @elseif (!$scReady) {{ t_raw('vendor.settings.payee_stripe_pending_hint') }} @if (!empty($sc['requirements_due'])) @php /* Stripe's own field names, shown as they come: they are what the hosted form will ask for, so a paraphrase would make the two screens disagree. */ @endphp {{ t_raw('vendor.settings.payee_stripe_requirements', [':items' => implode(', ', array_slice((array) $sc['requirements_due'], 0, 8))]) }} @endif @elseif (empty($sc['payouts_enabled'])) @php /* Transfers work, so the platform CAN send the share — Stripe is simply not paying it on to the bank yet. A different problem, said separately. */ @endphp {{ t_raw('vendor.settings.payee_stripe_payouts_held') }} @endif @endif
@if ($scAcct === '') @else @if (!$scReady || empty($sc['payouts_enabled'])) @endif @endif
@php /* Same swap the About-media type dropdown does above: the two payees need different things, so the panel follows the selection without a save. */ @endphp
@endif @php /* These fields ARE this restaurant's card terminal in this model, so an empty card is not a blank form — it is a closed till, and it said nothing about itself. The pill states the standing the same way the payee block above does, from Payment::anyGatewayEnabled(): the very call the storefront makes when it decides which tenders to offer, so "Taking card" here and a card option at checkout are the same answer rather than two that can drift. */ @endphp @if ($isFranchise && !$isSplit) @php $gwReady = \App\Services\Payment::anyGatewayEnabled((int) ($r['id'] ?? 0)); @endphp

{{ t_raw('vendor.settings.own_gateway_title') }}

@if ($gwReady){{ t_raw('admin.restaurantdetail.gateway_on_file') }}@else{{ t_raw('admin.restaurantdetail.gateway_missing') }}@endif
@if (!$gwReady)

{{ t_raw('vendor.settings.own_gateway_missing_hint') }}

@endif
@php // The gateway signs its callback with the secret of the account that captured // the charge, so these belong with the keys above. Without them a customer who // closes the tab mid-payment leaves the order unpaid. @endphp
@endif @php /* NAMES WHAT THIS FORM ACTUALLY SAVES. A payee only exists where the platform pays the restaurant - marketplace, or any model with split payments on. Under franchise with split off the tab holds cash and the restaurant's OWN gateway keys and no payee at all, and the button still read "Save payee": the one thing on screen it could not save. Same predicate the payee block above is gated on, so the label can never disagree with the fields. */ @endphp @php $hasPayee = \App\Services\BusinessModel::isMarketplace() || $isSplit; @endphp
@if ($canTax)
@csrf

{{ t_raw('vendor.settings.tax_title') }}

@php $taxMode = in_array($r['tax_mode'] ?? 'none', ['none', 'exclusive', 'inclusive'], true) ? ($r['tax_mode'] ?? 'none') : 'none'; @endphp
{{ t_raw('vendor.settings.tax_name_help') }}
%
{{ t_raw('vendor.settings.tax_rate_help') }}
@php $taxComps = \App\Services\Tax::componentsFor((int) ($r['id'] ?? 0)); @endphp
@php for ($ci = 0; $ci < 4; $ci++): $cc = $taxComps[$ci] ?? ['name' => '', 'rate' => 0.0]; @endphp
%
@endfor
{{ t_raw('vendor.settings.tax_number_help') }}
@endif
@csrf @php $acceptsResv = (int) ($r['accepts_reservations'] ?? 1) === 1; @endphp

{{ t_raw('vendor.settings.reservations_tab') }}

@php /* Inside the reservations block: there is no waiting guest to text on a restaurant that does not take bookings. Off by default because it spends the platform's SMS balance, and it stays inert until an admin has configured the gateway at all. */ @endphp

{{ t_raw('vendor.settings.booking_rules_title') }}

{{ t_raw('vendor.settings.min_ahead_unit') }}
{{ t_raw('vendor.settings.lead_time_hint2') }}
{{ t_raw('common.min') }}
{{ t_raw('common.hours') }}
{{ t_raw('vendor.settings.cancel_window_hint') }}
@php $tzCur = (string) ($r['timezone'] ?? 'UTC'); @endphp
@php /* Its own row, like the switch above it. Wedged into a third grid column it was bottom-aligned against two input groups, and its explanation was stranded under the grid — nowhere near the control it described. The band carries the sentence itself, which is what every other switch here does. */ $reqApproval = (int) ($r['reservation_requires_approval'] ?? 0) === 1; @endphp

@php $lockSecs = (int) ($r['staff_lock_seconds'] ?? 0); @endphp
{{ t_raw('vendor.settings.seconds_idle_unit') }}

{!! t_raw('vendor.settings.staff_lock_help') !!}

@if ($canLoyalty)
@php $currency = setting('currency_symbol', '$'); $loyEnabled = (int) ($r['loyalty_enabled'] ?? 0) === 1; $loyRate = (float) ($r['loyalty_earn_rate'] ?? 1); $loyLabel = (string) ($r['loyalty_points_label'] ?? 'points') ?: 'points'; $loyBonus = (int) ($r['loyalty_signup_bonus'] ?? 0); $loyOnWeb = (int) ($r['loyalty_on_website'] ?? 1) === 1; $loyOnPos = (int) ($r['loyalty_on_pos'] ?? 1) === 1; $loyOnKiosk = (int) ($r['loyalty_on_kiosk'] ?? 1) === 1; $loyBlended = (float) (($blended['blended'] ?? 0)); $loyPer = $blended['per_reward'] ?? []; $loyCheapest = $cheapestReward ?? null; $loyCheapCost = $loyCheapest ? (int) $loyCheapest['points_cost'] : 0; $loyCheapPct = ($loyCheapest && isset($loyPer[(int) $loyCheapest['id']])) ? (float) $loyPer[(int) $loyCheapest['id']] : 0.0; $loyCheapSpend = ($loyRate > 0 && $loyCheapCost > 0) ? ($loyCheapCost / $loyRate) : 0.0; @endphp
@csrf

{{ t_raw('vendor.settings.loyalty_title') }}

@php /* WHERE THE PROGRAMME RUNS. One switch per channel, all on by default: a venue may want points on its website but not at the till, or the reverse. Off means no earning and no redeeming on that channel; balances are untouched. */ @endphp
{{ t_raw('vendor.settings.loyalty_channels_label') }}

{{ t_raw('vendor.settings.loyalty_channels_hint') }}

{{ t_raw('vendor.settings.loyalty_bonus_hint') }}

{{ t_raw('vendor.settings.loyalty_blended_label') }} {{ number_format($loyBlended, 1) }}%

@if ($loyCheapest) @php $rewardTitleMarker = '@@qm_reward_title@@'; $meterNote = t_raw('vendor.settings.loyalty_meter_note', [ ':amount' => '' . money($loyCheapSpend) . '', ':title' => $rewardTitleMarker, ':cost' => (int) $loyCheapCost, ':pct' => '' . number_format($loyCheapPct, 1) . '%', ]); echo str_replace($rewardTitleMarker, e($loyCheapest['title']), $meterNote); @endphp @else {{ t_raw('vendor.settings.loyalty_meter_empty') }} @endif

@foreach ($activeRewards ?? [] as $rw) @php $rwVal = \App\Services\Loyalty::rewardValue($rw); $rwCost = (int) $rw['points_cost']; $rwPct = (float) ($loyPer[(int) $rw['id']] ?? 0); @endphp
{{ $rw['title'] }} {{ (int) $rwCost }} pts {{ number_format($rwPct, 1) }}%
@endforeach
{{ t_raw('vendor.settings.manage_rewards_link') }}
@endif
@php /* /.qm-d2 */ @endphp @endsection