@extends('layouts.dashboard') @section('content') @php /* .qm-d2: the dashboard density standard (app.css §35) */ @endphp
@php /* General sits OUTSIDE the shared settings form below because it carries the Branding fields, which upload files — so this tab needs its own multipart form. Branding + platform settings share ONE form and ONE Save button. */ @endphp
@csrf @partial('branding-form')

{{ t_raw('admin.settings.sec_languages') }}

@php $defLang = setting('default_language', 'en'); @endphp
@foreach (available_languages() as $code => $info) @php $f = lang_flag($code); @endphp @endforeach
@php $enabledLangs = array_keys(available_languages()); @endphp
@foreach (all_languages() as $code => $info) @php $f = lang_flag($code); $on = in_array($code, $enabledLangs, true); @endphp @endforeach

{{ t_raw('admin.settings.sec_formats') }}

@php // One sample moment, rendered in each candidate pattern, so the // operator picks by what they will actually see. $fmtSample = strtotime('2026-07-19 15:40'); $curTime = setting('time_format', '12'); $curDate = setting('date_format', 'M j, Y'); $curUnit = setting('distance_unit', 'km'); @endphp

{{ t_raw('admin.settings.sec_layout') }}

@php $homeLayout = setting('homepage_layout', 'marketplace'); @endphp @php /* Picking a homepage from six names asked the operator to already know what each one looks like. Same treatment as the business-model chooser lower down: one thumbnail per option in .qm-form-grid-3, a native radio wearing .qm-switch-row so the browser still enforces the single choice, and the product's existing lightbox for a full-size look. The window pans down on hover because these captures run up to nine screens tall and every layout's hero looks much like the next — the difference is below it. */ @endphp
{{ t_raw('admin.settings.field_homepage_layout') }}
@foreach (['marketplace', 'cuisine', 'restaurant', 'hungry', 'classic', 'order_builder'] as $lay) @php $layLabel = t_raw('admin.settings.opt_layout_' . $lay); $layShot = asset('assets/admin/homepages/' . $lay . '.png'); /* Every card should pan at the SAME speed, not for the same length of time — a fixed duration made the tallest page race past at 117px/s while the shortest crawled at 17. Duration comes from the capture's own aspect ratio, which is width-independent, against a nominal card width; the clamp keeps a very short page from snapping and a very long one from outlasting anyone's patience. */ $laySize = @getimagesize(public_path() . '/assets/admin/homepages/' . $lay . '.png'); $layTrav = $laySize ? (260 * ($laySize[1] / max(1, $laySize[0]))) - 190 : 2000; $layPan = max(4, min(30, (int) round($layTrav / 90))); @endphp
@php /* The label is written "Name (what it shows)" in all three languages, so the parenthetical is split off and shown as the product's existing .qm-tip beside the name. The card then reads as one short title, and the detail is a hover away instead of wrapping to three lines in a 260px card. Falls back to the whole string if a translation ever drops the brackets. */ @endphp @php $layName = $layLabel; $layHint = ''; if (preg_match('/^(.*?)\s*\((.+)\)\s*$/u', $layLabel, $mLay) === 1) { $layName = trim($mLay[1]); $layHint = trim($mLay[2]); } @endphp
@endforeach
@php $blogStyle = setting('blog_style', 'modern'); $blogSidebars = setting('blog_sidebars', 'two'); /* Paired, not stacked. These held a full row each, running a label across the card and leaving the rest of the width empty. Same two-column row the Identity card uses, which is what the rest of this screen reads like. */ @endphp
@php /* Stays here, unlike restaurants-per-page which moved to the Search tab. This one governs the "similar restaurants" rail on a restaurant's own page — not the listing and not search — so no card on that tab honestly describes it. Zero is no limit, which is how the rail has always behaved; above zero a restaurant out of range drops out of it. Typed in the operator's distance unit, stored as kilometres. Written by saveGeneral(), which is the form it posts with. Drawn with inputValue(), not number(): the value has to sit on the grid the step below declares, or a Save that touched nothing else posts back a rounded copy and rewrites a radius nobody edited. */ $nearbyKm = (float) setting('nearby_rail_radius_km', '0'); @endphp
@php // (Recruiting new restaurants moved to Settings → Business model, beside the // services it advertises — whether the platform takes applications belongs // with what the platform is, not with its name and logo.) @endphp
@php /* Payment tab — one form, one Save: currency + COD + the Stripe and PayPal integrations persist together through savePayment(). Each integration's body still collapses on its own enable toggle; its keys are echoed into the fields and written only while it is enabled. */ @endphp @php $bizMode = \App\Services\BusinessModel::current(); @endphp
@csrf

{{ t_raw('admin.business.title') }}

{{ t_raw('admin.business.intro') }}

@php // The three modes side by side, each showing how money actually moves before // the operator commits to one — the diagram answers "who collects, who pays // whom" far faster than the sentence under it can. Bundled art, addressed by // the mode key, so adding a mode is one file and no view change. // .qm-form-grid-3 is the existing three-up (2 cols under 900px, 1 under 600), // and the selector is a native radio wearing the standard .qm-switch-row // toggle, so the browser still enforces the exclusive choice. @endphp
@foreach (\App\Services\BusinessModel::ALL as $m) @php $label = t_raw('admin.business.mode_' . $m); @endphp
@php // Three side by side means each diagram renders at about a sixth of the // width its text was drawn for, so the thumbnail shows the SHAPE of the // money flow and a click opens it full size. data-lightbox-src is the // product's existing overlay (qm-lightbox.js, already on every dashboard // page) — the same one the storefront galleries use. @endphp
@endforeach
@php /* What this install actually runs. Orthogonal to the money model above: any of the three can be sold with or without a public storefront, so these sit once under all three rather than inside a card. Turning the front end off leaves a services install — the dashboard plus the per-venue guest surfaces (table QR, kiosk, pay links), which stay reachable because they ARE the service. */ @endphp @php $cap = \App\Services\Capability::class; @endphp

{{ t_raw('admin.business.frontend_title') }}

{{ t_raw('admin.business.frontend_sub') }}

@php /* Whether this install delivers, or only takes collection. Its own card rather than a row among the services below: those are surfaces staff operate, each with a page and a sidebar row of its own, while this is how an order REACHES the customer. Off, and there is nothing left to switch — the storefront stops offering delivery, the header's delivery/pickup control disappears, no restaurant can accept a delivery, and every ordering surface takes collection only. Independent of the storefront switch above: the till takes phone orders and delivers them, so a services install with no public front end still needs this. */ @endphp

{{ t_raw('admin.business.delivery_title') }}

{{ t_raw('admin.business.delivery_sub') }}

@php /* No presence marker: this card is always rendered and always submitted with the rest of the form, so an absent delivery_enabled genuinely means unchecked — the same rule every other switch on this form is written by. The marker that used to sit here was read by nothing. */ @endphp @php $dlvOn = setting('delivery_enabled', '1') === '1'; @endphp @php /* The partner page's plans and owners switches used to sit here too. They now live on that page's own content editor, beside its other sections — two controls for one setting is a screen that argues with itself, and whichever the operator touched last silently won. */ @endphp
@php /* The two storefront sections that exist only because this install delivers: the "why us" trio and the delivery band under it. They are written here rather than in a page editor because turning delivery off is what removes them, and an operator should not have to hunt elsewhere for the sections that advertise a service they just switched off — which is also why they collapse with the switch. Hidden fields still submit, so the wording survives the collapse and comes back with the switch. What is typed here IS the section: a field left blank renders nothing rather than restoring the wording the product shipped with. Clear a whole card and the card goes; clear a whole section and the section goes. */ @endphp @php /* * One storefront section, drawn from its declared field set. * * Three cards on this tab are the same shape — a list of fields, each a picture, a * glyph or words — so they share one renderer rather than three hand-built copies * that would drift apart the moment a field is added to any of them. Every field * posts under band[]; each save writes only the keys its own set * declares, so the three cannot write over one another. */ /* * One control, by field kind. Declared once and called from both halves of a card so * a field reads the same whether it sits in the header or inside a folded block. */ $pcControl = function (array $f, string $fid): void { $pc = \App\Services\PageContent::class; $name = 'band[' . $f['key'] . ']'; if ($f['kind'] === $pc::IMAGE || $f['kind'] === $pc::VIDEO) { // VIDEO accepts either, so the backdrop can be a still or a clip. echo media_field_html($name, $f['value'], [ 'accept' => $f['kind'] === $pc::VIDEO ? 'both' : 'image', 'allowClear' => true, 'id' => $fid, ]); return; } if ($f['kind'] === $pc::LINK) { echo partial('pc-link-field', ['name' => $name, 'value' => $f['value'], 'id' => $fid, 'label' => $f['label']]); return; } if ($f['kind'] === $pc::ICON) { // One picker, written once — see partials/pc-icon-field.php. It leads with // "no icon", which this screen's own copy of the markup never offered. echo partial('pc-icon-field', ['name' => $name, 'value' => $f['value'], 'id' => $fid, 'label' => $f['label']]); return; } if ($f['kind'] === $pc::BOOL) { // Stored as "1"/"" so it round-trips through the same text column as every // other content value. The hidden input is what makes "off" reach the server: // an unchecked box posts nothing at all. @endphp@php return; } // The shipped wording as a placeholder: it appears once the box is cleared, so an // emptied field still says what belongs in it. No new strings to translate. $ph = (string) ($f['placeholder'] ?? ''); if ($f['kind'] === $pc::PARA) { @endphp@php return; } @endphp@php }; /* * One storefront section as a card. * * The section's own eyebrow, heading and subheading stack full width at the top — * they are one short line each and a two-column grid only made the card wider than * its content. Everything that repeats (a step, a feature card) is DECLARED with a * group in PageContent and folded into a
, so a nine-field section is three * closed rows instead of a wall, and the two cards fit side by side. * * Grouping is declared, never inferred from key names — the same rule the rest of * PageContent follows. A card with no groups (the delivery band) is all header. */ $pcSection = function (string $id, string $title, string $sub, array $fields, bool $wide = false) use ($pcControl, $__env): void { $pc = \App\Services\PageContent::class; $head = []; $groups = []; foreach ($fields as $i => $f) { $g = (string) ($f['opts']['group'] ?? ''); if ($g === '') { $head[$i] = $f; } else { $groups[$g][$i] = $f; } } @endphp

{{ $title }}

{{ $sub }}

@foreach ($head as $i => $f) @php $fid = $id . '-' . $i; @endphp
@php $pcControl($f, $fid); @endphp
@endforeach @foreach ($groups as $gKey => $gFields) @php // The glyph leads its own block's summary, so a card is recognised by its // icon with the block still closed. It is a preview of what the picker // inside holds, not a second control. $gIcon = ''; foreach ($gFields as $gf) { if ($gf['kind'] === $pc::ICON) { $gIcon = $gf['value']; break; } } @endphp
@if ($gIcon !== '')@endif {{ t_raw($gKey) }}
@foreach ($gFields as $i => $f) @php $fid = $id . '-' . $i; @endphp
@php $pcControl($f, $fid); @endphp
@endforeach
@endforeach
@php }; @endphp @php /* Two columns. The delivery-gated pair stack in the first, so ONE wrapper collapses both with the switch; the customer's ordering steps sit beside them in the second, because they are not delivery copy — they describe how an order is placed and stay true of an install that only takes collection. Switch delivery off and the first column goes, leaving the steps the width. The steps were six settings edited through placeholder-only boxes, so their card showed nothing while the storefront showed words, and one value served all three languages the moment anyone typed. */ @endphp
@php $pcSection('dlv-feat', t('admin.business.features_title'), t('admin.business.features_sub'), $features); @endphp @php // Full width: its four fields include two media pickers, which a half // column can only stack into a tall thin ribbon. @endphp @php $pcSection('dlv-band', t('admin.business.delivery_band_title'), t('admin.business.delivery_band_sub'), $band, true); @endphp
@php $pcSection('how', t('admin.business.steps_title'), t('admin.business.steps_sub'), $steps); @endphp

{{ t_raw('admin.business.services_title') }}

{{ t_raw('admin.business.services_sub') }}

@foreach ($cap::SERVICES as $svc) @php $key = $cap::settingKey($svc); @endphp @endforeach
@php /* Whether a till or a kiosk may keep working when the line drops. Its own card for the same reason the upsell switch below has one: the grid above lists SURFACES, each with a page and a sidebar row, while this is a BEHAVIOUR of hardware that already exists. A restaurant runs both terminals online-only perfectly well, so this is not a sub-setting of either. Ships OFF. It changes how a machine behaves during an outage — the till holds sales locally, and a signed-in screen is cached to disk — and that is a decision an operator makes, not one that arrives with an update. */ @endphp

{{ t_raw('admin.settings.sec_offline_terminals') }}

@php // Marker, as the partner card below uses: an unchecked box posts // nothing, so without it a save would always read as "switch it off". @endphp
@php /* Whether an order is allowed to suggest what else to add. Its own card rather than a row among the services above: those are surfaces staff operate, each with a page and a sidebar row of its own, while this is a behaviour of an order that is already being placed. Off is a real choice, not a broken install: the engine recommends from the vendor's own pairings and, where none are written, from the cheapest dish outside the categories the basket already draws on, and an operator selling a fixed menu may want neither. Turning it off stops the band appearing; it never deletes a pairing a restaurant wrote, which is why the vendor page stays reachable either way. */ @endphp @php /* Whether the platform is taking applications right now. It sits here rather than on General because it is the same question as the switches above — what this install offers — and because the partner page it opens advertises exactly the services listed there. Turning it off closes the sign-up page and every route into it, so applications stop without a page being unpublished by hand. Hidden entirely on an owned estate, which has no third party to recruit and therefore no partner page at all. */ @endphp @if (\App\Services\Capability::has(\App\Services\Capability::PARTNERS))

{{ t_raw('admin.settings.sec_partner_signup') }}

@php // Marker, the same idea the shared form's sections[] uses: an unchecked // box posts nothing, so without it a save made while this card was not // on screen would read as "stop taking applications". @endphp
@endif
@php /* Plan prices, allowances and the free trial. Configuration, in the same place as tax and currency — it used to be a page of its own, which meant the operator set what a plan costs somewhere other than every other thing that costs money. */ @endphp
@partial('plans-catalogue', compact('rows', 'pager')) @php /* Annual billing: one switch and one discount, applying to every monthly-base tier at once. Beside the catalogue because it changes what those plans SELL AS; the tax card next door owns what any price grosses to. */ @endphp
@csrf

{{ t_raw('admin.subscriptions.annual_title') }}

%
{{ t_raw('admin.subscriptions.annual_pct_help') }}
@php /* ITS OWN TAB. The card sat inside Plans, which read as "tax on subscriptions" — but platform_tax_* is the tax on everything the platform charges a restaurant, and sponsored placements go through the very same Invoice::grossFor(). Filed under one of the two things it governs, the other looked untaxed. Nothing about how it is stored or applied changes; only where it is edited. */ @endphp
@php /* The platform's own tax, for the invoices it issues to restaurants. It belongs beside the catalogue because the mode and rate decide what a plan COSTS (Invoice::grossFor); it used to sit on the Subscriptions ledger, which records who has paid, not what they are charged. Mirrors the vendor Settings → Tax card exactly — same two mutually-exclusive switches, same rate/label/number fields, same card anatomy — because it is the same form for the other side of the deal. */ $ptMode = in_array($platformTax['tax_mode'] ?? 'none', ['none', 'exclusive', 'inclusive'], true) ? (string) $platformTax['tax_mode'] : 'none'; @endphp
@csrf

{{ t_raw('admin.subscriptions.tax_title') }}

%
{{ t_raw('admin.subscriptions.tax_number_help') }}
@csrf

{{ t_raw('common.currency') }}

@php $curCode = setting('currency_code', 'USD'); @endphp

{{ t_raw('admin.settings.hint_tax_percent') }}

{{ t_raw('admin.settings.sec_payment_cod') }}

@php /* Pay at the counter (`offline_enabled`). Read by Payment::isEnabled() and by the kiosk, defaulted ON, and until now changeable only by editing the database — a shipped payment method with no control anywhere. It belongs beside cash: both are money taken in person rather than through a gateway. */ @endphp
@php /* Split payments: an on/off switch over DISBURSEMENT. It decides whether a vendor's share leaves as the order is captured or waits for the payout run -- it does not decide which gateway anyone pays with, and it is offered only where the platform pays a vendor at all. */ @endphp @php $splitOn = \App\Services\BusinessModel::splitEnabled(); @endphp

{{ t_raw('admin.settings.sec_split') }}

{{ t_raw('admin.settings.sub_split') }}

{{ t_raw('admin.settings.hint_split') }}

@php /* WHAT THE SIGNING SECRET IS DOING **NOW**. Both secret fields ship EMPTY, so on a stock install every callback is rejected — and that happened in total silence: a bare 400 to the gateway and nothing at this end, while renewals quietly never extended a term. So the webhook records each verified receipt and each rejection in the audit trail, and this reports it beside the field that decides it. ONE STATE, NOT A HISTORY. This used to render the newest verified AND the newest rejection together, so a secret that was wrong at lunchtime and corrected at teatime read "verified 4 minutes ago" and "rejected 3 hours ago" side by side — for ever, because a rejection never expires. An operator cannot act on that: it says both "working" and "broken" at once. Only the LATEST of the two is shown, because that is the only one that describes the secret currently in the box. A rejection that has since been superseded is history, and history lives in the audit trail. */ @endphp @php $webhookState = static function (array $seen): string { $ok = $seen['verified'] ?? null; $bad = $seen['rejected'] ?? null; // Whichever happened last IS the current state. $latest = $ok; $isOk = true; if ($bad && (!$ok || strtotime((string) $bad['created_at']) > strtotime((string) $ok['created_at']))) { $latest = $bad; $isOk = false; } $out = '
'; if (!$latest) { $out .= '' . t('admin.settings.webhook_pill_none') . ''; } elseif ($isOk) { $out .= '' . t('admin.settings.webhook_pill_ok', [':when' => time_ago($latest['created_at'])]) . ''; } else { $out .= '' . t('admin.settings.webhook_pill_failed', [':when' => time_ago($latest['created_at'])]) . ''; } $out .= '
'; // Params go in raw: t() substitutes first and escapes the finished sentence, so // escaping here as well would print the entities themselves. if ($latest && $isOk) { $out .= '

' . t('admin.settings.webhook_last_ok', [ ':what' => (string) $latest['detail'], ':when' => fmt_datetime($latest['created_at'], true), ]) . '

'; } elseif ($latest) { $out .= '

' . t('admin.settings.webhook_last_failed', [ ':why' => (string) $latest['detail'], ':when' => fmt_datetime($latest['created_at'], true), ]) . '

'; } return $out; }; @endphp @php $stripeOn = setting('stripe_enabled', '0') === '1'; @endphp

Stripe

{{ t_raw('admin.settings.sub_stripe') }}

{!! $webhookState(\App\Http\Controllers\Public\WebhookController::lastActivity('stripe')) !!}

{{ t_raw('admin.settings.hint_stripe') }}

@php $ppOn = setting('paypal_enabled', '0') === '1'; @endphp

PayPal

{{ t_raw('admin.settings.sub_paypal') }}

{!! $webhookState(\App\Http\Controllers\Public\WebhookController::lastActivity('paypal')) !!}

{{ t_raw('admin.settings.hint_paypal') }}

@csrf

{{ t_raw('admin.settings.tab_checkout') }}

{{ t_raw('admin.settings.sub_checkout') }}

@php /* Email verification on sign-up. `email_verification_required` gates the whole flow — the verification email, the notice, the resend — and NO form has ever written it, so a shipped feature was reachable only by editing the database. Its reader's comment even refers to "the Settings screen's own refusal", which did not exist until this switch. It needs working SMTP: without it EmailVerificationController::required() answers false whatever this says, which is why the hint names that rather than letting the switch quietly lie. */ @endphp

{{ t_raw('admin.settings.sec_delivery_limits') }}

@php // Typed and shown in the operator's unit; App\Services\Distance // converts on the way in and out, and the stored value is always km. $radMin = (float) setting('delivery_radius_min_km', '0.5'); $radMax = (float) setting('delivery_radius_max_km', '50'); $radUnit = \App\Services\Distance::unitLabel(); @endphp

{{ t_raw('admin.checkout.upsell_title') }}

{{ t_raw('admin.checkout.upsell_sub') }}

@php // Marker: an unchecked box posts nothing, so without it a save made // while this card was hidden would read as "stop recommending". @endphp
@csrf @php /* '1', matching App\Services\Loyalty::… and every other consumer. With '0' here, an install carrying no settings row showed this switch OFF while the feature was live — the page contradicted the product. */ @endphp @php $loyOn = setting('loyalty_enabled', '1') === '1'; @endphp

{{ t_raw('admin.loyalty.program_settings_title') }}

{{ t_raw('admin.loyalty.earn_rate_hint') }}

@csrf @php $gdprOn = setting('gdpr_enabled', '0') === '1'; @endphp

{{ t_raw('admin.settings.tab_gdpr') }}

{{ t_raw('admin.settings.sub_gdpr') }}

@csrf

{{ t_raw('admin.settings.tab_notifications') }}

{{ t_raw('admin.settings.sub_notifications') }}

@php /* The two above are in-page behaviour; this one leaves the building, so it ships off and says so. No key field: the VAPID pair is a self-signed identity this install mints for itself on first use — there is no account to open and nothing to paste, so a field could only be a way to get it wrong. */ @endphp
@php /* Contact and SEO share ONE form and ONE Save button — the platform-wide save() — together with the Payment tab's Currency + COD, which bind to this form by its id via form="" above. The sections[] marker still lets save() tell an unchecked COD box (which posts nothing) from a panel the request never carried. */ @endphp
@csrf @php /* The tab the save was made from. A browser never sends a URL fragment, so without this save() cannot put the admin back where they were and every tab sharing this form lands on General. Same return_tab contract the notification preferences card uses on the account pages. Kept current by the qm:tabchange listener at the foot of this page, which fires on load as well as on click. */ @endphp

{{ t_raw('admin.settings.sec_contact_details') }}

{{ t_raw('admin.settings.sec_contact_hours') }}

@php $dayKeys = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']; @endphp @foreach ($dayKeys as $d => $dayKey) @php $dayName = t_raw('common.day_' . $dayKey); $ch = $contactHours[$d] ?? ['open' => '09:00', 'close' => '22:00', 'closed' => false]; @endphp
{{ $dayName }}
@endforeach

{{ t_raw('admin.settings.sec_contact_map') }}

@php // Drag the pin (or click the map) to fill the fields above — same shared map // engine + provider the storefront uses, so what is placed here is what renders. @endphp

{{ t_raw('admin.seo.sec_defaults') }}

@php /* The library picker, as every other image field on the product uses — the same call blog-form.php makes for a post's own sharing image. It was a bare text box asking for a path to be typed from memory, which is why the copy under it had to explain what a valid one looked like. Seo::shareImage() already accepts what the picker stores: a relative assets path, checked for existence. */ @endphp {!! media_field_html('og_image', setting('og_image', ''), ['accept' => 'image', 'allowClear' => true, 'id' => 'seo-og-image']) !!}
@php /* Structured data. Its own card because it is not a default that other pages inherit — it is a whole output the site either publishes or does not. ONE switch, not one per schema: per-type switches would let an install emit a restaurant but not the platform behind it, which is a combination a buyer cannot reason about and we would have to test. Ships ON, unlike a genuinely new feature, because the platform Organization block already publishes today — defaulting it off would silently strip markup from a live site the first time it updated. */ @endphp

{{ t_raw('admin.seo.sec_schema') }}

@php // Marker: an unchecked box posts nothing, so without it a save made // from another tab would read as "switch structured data off". @endphp

{{ t_raw('admin.seo.sec_analytics') }}

{{ t_raw('admin.seo.sec_sitemap') }}

@php $sm = $sitemap ?? ['exists' => false, 'url' => '', 'generated_at' => '', 'urls' => 0, 'writable' => false]; @endphp @if ($sm['exists'])

{{ t_raw('admin.seo.sitemap_ready', [':count' => (int) $sm['urls']]) }}

{{ $sm['url'] }}

@if ($sm['generated_at'] !== '')

{!! t_raw('admin.seo.sitemap_generated', [':when' => fmt_datetime($sm['generated_at'], true)]) !!}

@endif @else

{!! $sm['writable'] ? t('admin.seo.sitemap_not_yet') : t('admin.seo.sitemap_not_writable') !!}

@endif

{{ t_raw('admin.seo.sitemap_refresh_note') }}

@if ($sm['exists']){{ t_raw('admin.seo.sitemap_download') }}@endif
@php /* Typeahead behaviour. Its own tab rather than a card on Search & sharing: that tab is SEO — meta description, share image, analytics — and this governs an interaction, not how the site is indexed. */ @endphp
@php /* The tab asks two separate questions, in this order: 1. WHAT do you use AI for — one card per feature, each with its own provider and model, because a menu reader and a chat assistant do not want the same model and should never have been forced onto one choice. 2. WHICH KEYS do you have — one card per provider, credentials only. App\Services\AiFeature is the registry behind both: it declares the features, the modality each needs, and which providers can serve it. Adding image or video generation later is one entry there, and this markup follows without edits. */ $aiF = \App\Services\AiFeature::class; $aiEnabled = setting('ai_enabled', '0') === '1'; $aiKeyName = ['openai' => 'OpenAI', 'anthropic' => 'Anthropic', 'gemini' => 'Google Gemini', 'openrouter' => 'OpenRouter']; $aiKeyHint = [ 'openai' => 'e.g. gpt-4.1-mini', 'anthropic' => 'e.g. claude-3-5-haiku-latest', 'gemini' => 'e.g. gemini-1.5-pro', 'openrouter' => 'e.g. openai/gpt-4.1-mini', ]; @endphp
@csrf

{{ t_raw('admin.settings.h_ai') }}

{{ t_raw('admin.settings.sub_ai') }}

@php /* The platform-wide master, and the only thing in this card. With it off nothing below runs whatever an individual use says, so everything else hides with it. Settings that belong to ONE use — the assistant's system prompt, for instance — live with that use below, never up here. */ @endphp @php /* No visible text: the card is titled "AI" and its subtitle already says this is the platform-wide switch, so the words beside the control repeated both. The wording moves to the accessible name, which is what a screen reader announces — nothing is lost, only the row it was costing. */ @endphp
@php /* ---- 1. What AI is used for ------------------------------------------- */ @endphp

{{ t_raw('admin.settings.h_ai_features') }}

{{ t_raw('admin.settings.sub_ai_features') }}

@foreach ($aiF::all() as $feat) @php $shipped = $aiF::isShipped($feat); $available = $aiF::availableProviders($feat); $capable = $aiF::capableProviders($feat); $curProv = $aiF::provider($feat); $curModel = $aiF::model($feat); @endphp

{{ $aiF::label($feat) }}

{{ t_raw('admin.settings.ai_feature_' . $feat . '_sub') }}

@if ($shipped) @php /* Same as the master switch: the row already names the feature, so "Use it" beside its toggle said nothing the heading had not. The accessible name keeps the wording AND the feature it belongs to, so four identical toggles stay tellable apart when read aloud. */ @endphp @else @php // Declared, not built. The row exists so the shape of the product is // visible, but it is inert — it never pretends to be a live switch. @endphp {{ t_raw('admin.settings.state_ai_planned') }} @endif
@if ($shipped)
@if (!$available)

{{ t_raw('admin.settings.ai_feature_needs_key', [':providers' => implode(', ', array_map(static fn ($p) => $aiKeyName[$p] ?? $p, $capable))]) }}

@else
@php /* One model block per provider, only the chosen one shown — the same reveal the map-key rows use. A single select repopulated by JS would lose the saved model of every provider but the visible one. */ @endphp @foreach ($available as $p) @php $models = $aiF::models($p); $onThis = $curProv === $p; /* A provider that is not the current one shows what THIS feature was last saved with on it — saveAi() records every posted provider, so the block an operator comes back to holds their own choice. The older ladder still stands behind it for an install that predates the per-provider key: the legacy ai__model, then the provider's default. */ $remembered = $onThis ? '' : trim((string) setting($aiF::settingKey($feat, 'model_' . $p), '')); $modelVal = $onThis ? $curModel : ($remembered ?: (trim((string) setting('ai_' . $p . '_model', '')) ?: $aiF::defaultModel($p))); $isCustom = !array_key_exists($modelVal, $models); @endphp
@endforeach
@endif @php /* Settings belonging to ONE use sit with that use. The assistant is the only shipped feature that has any — who may use it, and the standing instruction it answers under. They lived in the master card while the assistant was the only feature; with a second one that placement read as a second, contradictory assistant switch. */ @endphp @if ($feat === $aiF::ASSISTANT) @endif
@endif
@endforeach
@php /* ---- 2. Provider keys ------------------------------------------------- */ @endphp

{{ t_raw('admin.settings.h_ai_keys') }}

{{ t_raw('admin.settings.sub_ai_keys') }}

@foreach ($aiF::providers() as $p) @php $connected = $aiF::isConnected($p); $keyVal = (string) setting($aiF::keySetting($p), ''); /* GREEN MEANS THE PROVIDER ANSWERED. Nothing else earns it. A stored key proves only that somebody typed something: any string of roughly the right shape is indistinguishable from a live credential until one is actually used, so a badge driven by "is the field filled in" says the same for a typo as for a working key. Four states, because there are four real answers — and "nobody has asked yet" is one of them, not a quiet synonym for "fine". */ $verdict = $aiF::keyVerdict($p); [$pillClass, $pillLabel] = match ($verdict) { 'ok' => ['qm-pill-green', 'admin.settings.state_ai_connected'], 'rejected' => ['qm-pill-red', 'admin.settings.state_ai_rejected'], 'unverified' => ['qm-pill-amber', 'admin.settings.state_ai_unverified'], default => ['qm-pill-grey', 'admin.settings.state_ai_no_key'], }; @endphp

{{ $aiKeyName[$p] ?? $p }}

@php /* State, not a choice. Which provider serves what is decided per feature above; here a provider has answered, or it has not. */ @endphp {{ t_raw($pillLabel) }}
@php /* The button belongs ON the key's row: it tests that key and nothing else, and a row of its own cost four cards a line of height each for a control the width of two words. One test per key, because a single shared button could only ever probe one resolved configuration and would say nothing about the other three. */ @endphp
@php /* An inline span, so it takes no height at all until there is a result to show — the row above must not gain a gap for a message that is not there. */ @endphp
@endforeach
@php /* /#ai-master-body */ @endphp
@csrf
@php $rcOn = setting('recaptcha_enabled', '0') === '1'; /* The switch and the state are different facts, and only the switch may drive the checkbox and the card's collapse — tying those to readiness would fold the card away the moment someone enabled the feature, hiding the very key fields they opened it to fill in. The PILL asks the question a reader is actually asking: is this working? It reads keyVerdict() — what the provider itself said about the stored key — so the badge and the behaviour cannot disagree. */ $rcVersion = setting('recaptcha_version', 'v2') === 'v3' ? 'v3' : 'v2'; @endphp

{!! google_mark(18, 'me-2') !!}{{ t_raw('admin.settings.h_recaptcha') }}

@php [$pc, $pl] = \App\Services\CredentialVerdict::pill(\App\Services\Recaptcha::keyVerdict()); @endphp{{ t_raw($pl) }}

{{ t_raw('admin.settings.hint_recaptcha_version') }}

@php /* ONE PAIR PER VERSION. v2 and v3 are separate products at Google and a key issued for one is refused by the other, so they get separate fields — exactly as each map provider keeps its own key. Only the selected version's pair is shown, because a box that cannot apply should not be sitting there asking to be filled in; the hidden one still POSTs, so switching version never discards the other pair. */ @endphp @foreach (\App\Services\Recaptcha::VERSIONS as $rcV)
@endforeach

{{ t_raw('admin.settings.hint_recaptcha_match', [':version' => $rcVersion === 'v3' ? t_raw('admin.settings.recaptcha_v3_short') : t_raw('admin.settings.recaptcha_v2_short')]) }}

@php /* Switch drives the checkbox and the collapse; the pill reads keyVerdict(), which is what Google itself said about the stored credentials. */ @endphp @php $glOn = setting('google_login_enabled', '0') === '1'; $glMode = \App\Services\GoogleAuth::mode(); @endphp

{!! google_mark(18, 'me-2') !!}{{ t_raw('admin.settings.h_google_login') }}

@php [$pc, $pl] = \App\Services\CredentialVerdict::pill(\App\Services\GoogleAuth::keyVerdict()); @endphp{{ t_raw($pl) }}

{{ t_raw('admin.settings.hint_google_login', [':url' => route('auth.google.callback')]) }}

@php /* Two presentations of the SAME client, never both at once — see App\Services\GoogleAuth. The button needs nothing but the pair above; One Tap additionally needs this site's origin registered on the client as an Authorized JavaScript origin, because it runs in the browser. */ @endphp

{{ t_raw('admin.settings.hint_google_login_mode', [':origin' => \App\Support\App::baseUrl()]) }}

@php /* ---- Google Business Profile menu import ----------------------------- Shares the Client ID / Secret above on purpose: one Google Cloud app can serve both flows, and asking for the same credentials twice would be a second thing to keep in sync. It needs its own redirect URI added there, and — the part no setting can shortcut — Google's approval of the project. Sandbox mode exists precisely because that approval is slow and gated. */ @endphp
@php $gbOn = setting('google_business_enabled', '0') === '1'; $gbSb = setting('google_business_sandbox', '0') === '1'; $gbShared = \App\Services\GoogleBusiness::usesLoginCredentials(); /* The pill reads keyVerdict(); the switch drives everything else. */ @endphp

{!! google_mark(18, 'me-2') !!}{{ t_raw('admin.settings.h_google_business') }}

@php [$pc, $pl] = \App\Services\CredentialVerdict::pill(\App\Services\GoogleBusiness::keyVerdict()); @endphp{{ t_raw($pl) }}

{{ t_raw('admin.settings.hint_google_business', [':url' => route('vendor.google.callback')]) }} {{ t_raw('admin.settings.hint_google_business_signup', [':url' => route('vendor-signup.google-business.callback')]) }}

@php /* One Cloud app can serve Sign-in and Business Profile, so borrowing those credentials is the default. It is a choice rather than a rule because the two features are approved separately — a buyer may hold Business Profile access in a different project entirely, and one shared secret would mean rotating it breaks the other feature. */ @endphp @php /* THREE exclusive modes, so ONE dropdown — not a switch plus a toggle that can be set to contradict each other. Sandbox reads a bundled fixture and calls nothing, so credentials are meaningless to it; as two independent controls that invited the fair question of what "sandbox AND shared credentials" was supposed to mean. It meant sandbox. Now the question cannot be asked. It also stops the credential source reading as an on/off switch for the feature — turning that off to reveal the fields looked like disabling it. */ @endphp @php $gbMode = $gbSb ? 'sandbox' : ($gbShared ? 'shared' : 'own'); @endphp

{{ t_raw('admin.settings.hint_google_business_mode') }}

@php // A client SECRET, so it masks on a read-only preview like every // other secret here. The two client IDs above are public by // design — they travel in the OAuth redirect a user can see — // and are deliberately left readable. @endphp
@php $mapsOn = setting('maps_enabled', '1') === '1'; /* The switch drives the checkbox and the collapse; the pill below reads Maps::keyVerdict(), which is per provider. */ /* Per provider, never shared — see App\Services\Maps. Each keeps its own key and the dropdown switches between them, so one verdict slot would hand a Mapbox result to Google the moment the selection changed. Mapbox IS checked, against its token-introspection endpoint, when the token is saved. Google is not: a Maps JS key is meant to be restricted by HTTP referrer, so a server-side probe is refused for a key that works perfectly in a browser — reporting that as "rejected" would be worse than admitting we do not know. OSM needs no key and is simply on or off. */ $mapsVerdict = \App\Services\Maps::keyVerdict(); @endphp

{{ t_raw('admin.settings.h_maps') }}

@php /* Every provider's verdict travels with the badge, because the dropdown below can change which one is being described before anything is saved. Rendered server-side so the labels stay translated; the script at the foot of this file swaps them on change, the same listener that switches the key rows. */ [$mapsPc, $mapsPl] = \App\Services\CredentialVerdict::pill($mapsVerdict); @endphp {{ t_raw($mapsPl) }}
@php $mapProv = setting('maps_provider', 'osm'); @endphp
@php // Each provider keeps its OWN key so switching never shows the wrong one. Both // are PUBLIC, client-side credentials (exposed in the visitor's browser by // design), so - unlike the reCAPTCHA/Google Sign-in secrets above - they are // shown in full behind a reveal toggle. Only the selected provider's field is // visible; OpenStreetMap needs no key at all. $mapboxToken = trim((string) setting('mapbox_token', '')); $googleKey = trim((string) setting('google_maps_key', '')); @endphp

{{ t_raw('admin.settings.hint_mapbox_token') }}

{{ t_raw('admin.settings.hint_google_maps_key') }}

@php /* Location detection is its OWN card, because it is its own decision. It shared one with the tile layer on the grounds that both concern location, which is true and not enough: reCAPTCHA, Google Sign-in and Business Profile each get a card on this tab, and a reader works out which field belongs to which integration from that boundary. With three subjects under one heading — which tiles to draw, how to locate a visitor, how far to search — the IP endpoint sat under a Google Maps key it has nothing to do with, and looked like part of it. Detection is also usable on its own: it answers "where is this visitor" for search and delivery estimates whether or not a map is ever drawn. */ $geoMode = \App\Services\GeoDetect::mode(); @endphp

{{ t_raw('admin.settings.h_geo_detection') }}

@php /* The card carries a credential, so it reports on it like every other card on this tab. Grey in GPS or Off mode: there is no key in play, so there is nothing to be right or wrong about. */ [$geoPc, $geoPl] = \App\Services\CredentialVerdict::pill(\App\Services\GeoDetect::keyVerdict()); @endphp {{ t_raw($geoPl) }}

{{ t_raw('admin.settings.hint_geo_mode') }}

@php /* Only IP mode uses these two. In GPS or Off mode they are dead controls asking for credentials nothing will ever call, sitting above a switch that already decided they are not needed — the same reason the map provider's key row hides when another provider is chosen. Hidden, not removed: the fields still post, so a stored endpoint and key survive a trip through GPS mode and are still there when IP is chosen again. */ @endphp

{{ t_raw('admin.settings.hint_geo_endpoint') }}

@php /* The key belongs to whichever lookup service the endpoint above points at, so the tip names the step rather than a vendor — this product does not pick the service for the buyer. */ @endphp
@php // Distance search bounds and pin grouping. They belong beside detection // rather than in a card of their own — this is the one place an operator // decides how location behaves, and two fields do not make a card. // // Stored in kilometres like every distance in this product; shown and // typed in the operator's unit, so the pair below reads in the same // units the storefront slider will. // // inputValue(), not number(): the drawn value and $geoStep come from the // same precision, so what the box shows is what the row holds. Drawn one // grid coarser, a Save from this tab that touched only a key or a map // provider would post the rounded copy back and move the storefront's // search bounds the operator never edited. $geoUnit = \App\Services\Distance::unitLabel(); $geoStep = \App\Services\Distance::inputStep(); $geoMinV = \App\Services\Distance::inputValue((float) setting('geo_radius_min', '1')); $geoMaxV = \App\Services\Distance::inputValue((float) setting('geo_radius_max', '25')); @endphp

{{ t_raw('admin.settings.hint_geo_radius') }}

@php $smtpOn = setting('smtp_enabled', '0') === '1'; $smtpEnc = setting('smtp_encryption', 'tls'); $adminEmail = (string) (\App\Support\Auth::user()['email'] ?? ''); @endphp
@csrf
@php /* SMTP records its verdict like every other integration here — the test-send and each real send write it through CredentialVerdict — and it was the one that never showed it, so a rejected login was recorded and never mentioned. Same pill, same helper, same place in the head as reCAPTCHA, Google, Maps, GeoDetect and Mailchimp. */ @endphp

{{ t_raw('admin.settings.h_smtp') }}

@php [$smtpPc, $smtpPl] = \App\Services\CredentialVerdict::pill(\App\Support\Mailer::keyVerdict()); @endphp{{ t_raw($smtpPl) }}

{{ t_raw('admin.settings.sub_email') }}

{{ t_raw('admin.settings.hint_smtp') }}

{{ t_raw('admin.settings.smtp_section_server') }}

{{ t_raw('admin.settings.smtp_section_sender') }}

@php /* On the locked preview the address is bullets like the other secrets here; a text box then, so the browser does not refuse the bullets as "not an email". */ @endphp

{{ t_raw('admin.settings.h_smtp_test') }}

@php /* Text messaging — the sibling of the Email tab for the other channel, and its own tab for the same reason Email has one: it carries provider credentials, a sender identity and a delivery log, which is more than a card inside another panel can hold. One gateway serves the phone-change code, the table-ready alert and the voice agent's payment link; none of them owns it. Everything below is drawn from App\Services\Sms's provider registry rather than written per provider, so a second gateway is one entry there and no edit here. */ @endphp
@php $sms = \App\Services\Sms::class; $smsOn = setting('sms_enabled', '0') === '1'; $smsProvider = $sms::provider(); $smsReady = $sms::isConfigured(); /* WHY it is not ready, in the operator's words. isConfigured() answers three questions at once — switched on, credentials saved, sender set — so a bare "Disabled" pill next to a toggle the operator has already turned on, above credentials already reading "Connected", names the wrong culprit: the usual answer is an empty sender. The sentence is Sms::disabledReason(), the same one the SMS log page and send() itself give, so all three surfaces agree instead of each guessing. */ $smsWhy = $smsReady ? '' : $sms::disabledReason(); @endphp
@csrf

{{ t_raw('admin.settings.h_sms') }}

{{ t_raw('admin.settings.sub_sms') }}

{{ t_raw('admin.settings.sms_section_sender') }}

{{ t_raw('admin.settings.hint_sms_from') }}

{{ t_raw('admin.settings.h_sms_credentials') }}

@php /* One card per provider, credentials only — the same split the AI tab makes between "what is it used for" and "which keys do you have". Every provider's card renders, not only the selected one's, so switching provider never hides an account you still have. */ @endphp @foreach ($sms::providers() as $p) @php $connected = $sms::isConnected($p); @endphp

{{ $sms::providerLabel($p) }}

@php /* Same four states as every other integration — see App\Services\CredentialVerdict. Green is earned by Twilio answering, never by the fields being filled in. A send that fails for a non-credential reason (a trial refusing a custom message body, an unverified recipient) keeps the card green and reports the failure where the failure happened. */ [$smsPillClass, $smsPillLabel] = \App\Services\CredentialVerdict::pill($sms::keyVerdict($p)); @endphp {{ t_raw($smsPillLabel) }}
@foreach ($sms::providerFields($p) as $key => $spec)
@if ($spec['secret'])
@else @endif
@endforeach
@endforeach @php /* What the gateway is allowed to be used FOR. Inside the credentials card and inside its reveal, because neither is worth offering until SMS can actually send — a switch that silently does nothing is the thing this project keeps finding and removing. */ @endphp

{{ t_raw('admin.settings.sms_section_uses') }}

@php /* The ready/not-ready pill belongs to THIS card, not to the section header. isConfigured() answers "could a message go out right now" — which is the question this card asks — and beside the section title, above a toggle the operator had already switched on, it read as "this whole section is off". The reason rides with it, so the pill never states a problem without naming it. */ @endphp

{{ t_raw('admin.settings.h_sms_test') }}

{!! $smsReady ? t('common.enabled') : t('common.disabled') !!}
{{ t_raw('admin.settings.link_sms_log') }}
@csrf
@php /* Newsletter — where the storefront's sign-up forms appear, and which account the ones set to Mailchimp feed. A tab and not a page because it holds no data of its own: the subscribers themselves have their own list at /admin/subscribers, and what is left is two selects and a connection. It sits beside Email and SMS for the obvious reason — the three are the outbound channels. */ @endphp
@php /** Placement provider options, in the order they are offered: collect here, sync to Mailchimp as well, or hide the form entirely. */ $provOptions = static function (string $current): string { $html = ''; foreach (['local', 'mailchimp', 'off'] as $o) { $html .= ''; } return $html; }; @endphp
@csrf

{{ t_raw('admin.newsletter.h_forms') }}

{{ t_raw('admin.newsletter.view_subscribers') }}
@csrf
@php $mcOn = setting('mailchimp_enabled', '0') === '1'; @endphp

{{ t_raw('admin.settings.h_mailchimp') }}

@php /* Four states, same as every other integration. "Load audiences" below is a real authenticated call, so it is what turns this green — pasting a key no longer does. */ [$mcPillClass, $mcPillLabel] = \App\Services\CredentialVerdict::pill(\App\Services\Mailchimp::keyVerdict()); @endphp {{ t_raw($mcPillLabel) }}
@php // demo_hidden(), like every other secret on this page. Without it the // Mailchimp key was the ONE credential a read-only preview handed to // visitors in full, while Stripe, PayPal, SMTP and the SMS token beside // it were all masked. Inherited from the old Newsletter page, where it // sat alone and the inconsistency was invisible. @endphp
@php $savedAudienceId = trim((string) setting('mailchimp_audience_id', '')); $savedAudienceName = trim((string) setting('mailchimp_audience_name', '')); @endphp

{{ t_raw('admin.settings.hint_mailchimp') }}

@php /* /.qm-d2 */ @endphp @endsection