@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
@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
@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') }}
@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') }}
@csrf
{{ t_raw('common.currency') }}
@php $curCode = setting('currency_code', 'USD');
@endphp
{{ t_raw('common.currency') }}
@foreach (currency_list() as $code => [$symbol, $name])
{{ $name }} ({{ $symbol }} {{ $code }})
@endforeach
@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') }}
@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
{{ t_raw('admin.settings.hint_stripe') }}
@php $ppOn = setting('paypal_enabled', '0') === '1';
@endphp
{{ t_raw('admin.settings.field_paypal_mode') }}
{{ t_raw('admin.settings.opt_paypal_sandbox') }}
{{ t_raw('admin.settings.opt_paypal_live') }}
{{ t_raw('admin.settings.field_paypal_webhook_id') }}
{!! $webhookState(\App\Http\Controllers\Public\WebhookController::lastActivity('paypal')) !!}
{{ t_raw('admin.settings.hint_paypal') }}
{{ t_raw('admin.settings.save_general') }}
@csrf
{{ t_raw('admin.settings.tab_checkout') }}
{{ t_raw('admin.settings.sub_checkout') }}
{{ 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
@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
@csrf
@php $gdprOn = setting('gdpr_enabled', '0') === '1';
@endphp
{{ t_raw('admin.settings.field_gdpr_message') }} {{ setting('gdpr_message', '') }}
@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.seo.sec_defaults') }}
{{ t_raw('admin.settings.field_meta_description') }}
{{ setting('meta_description', '') }}
{{ t_raw('admin.seo.f_default_share_image') }}
@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_analytics') }}
{{ t_raw('admin.seo.f_analytics') }}
{{ setting('analytics_snippet', '') }}
{{ 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') }}
@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
{{ t_raw('admin.suggest.sec_title') }}
@php /* The order of the controls in the /restaurants filter card. The palette is
closed — these are the controls the listing ships with — so this arranges
them and never adds or removes one. Dragged by the grip or moved with the
arrow keys, exactly as the menu sections and the page bands are, and the
arrangement posts as one ordered list with the rest of this tab. */
@endphp
{{ t_raw('admin.settings.sec_listing_filters') }}
{{ t_raw('admin.settings.filter_reorder') }}
{{ t_raw('common.name') }}
@foreach (\App\Services\ListingFilter::order() as $fltId)
{{ \App\Services\ListingFilter::label($fltId) }}
@endforeach
{{ t_raw('admin.settings.save_general') }}
@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
@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
@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)
{{ t_raw('admin.settings.field_ai_vendor') }}
{{ t_raw('admin.settings.field_ai_system_prompt') }}
{{ setting('ai_system_prompt', '') }}
@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) }}
{{ t_raw('admin.settings.field_ai_api_key') }}
@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
{{ t_raw('admin.settings.save_ai') }}
@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
{{ t_raw('admin.settings.field_recaptcha_version') }}
{{ t_raw('admin.settings.opt_recaptcha_v2') }}
{{ t_raw('admin.settings.opt_recaptcha_v3') }}
{{ 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
@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.field_google_login_mode') }}
{{ t_raw('admin.settings.opt_google_button') }}
{{ t_raw('admin.settings.opt_google_one_tap') }}
{{ 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
{{ 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.field_google_business_mode') }}
{{ t_raw('admin.settings.opt_google_business_sandbox') }}
{{ t_raw('admin.settings.opt_google_business_shared') }}
{{ t_raw('admin.settings.opt_google_business_own') }}
{{ t_raw('admin.settings.hint_google_business_mode') }}
@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
@php $mapProv = setting('maps_provider', 'osm');
@endphp
{{ t_raw('admin.settings.field_maps_provider') }}
{{ t_raw('admin.settings.opt_map_osm') }}
Mapbox
Google Maps
@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
@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.field_geo_mode') }}
{{ t_raw('admin.settings.opt_geo_gps') }}
{{ t_raw('admin.settings.opt_geo_ip') }}
{{ t_raw('admin.settings.opt_geo_off') }}
{{ 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.field_trust_proxy') }}
{{ t_raw('admin.settings.field_trust_proxy_sub') }}
@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') }}
{{ t_raw('admin.settings.field_geo_cluster') }}
{{ t_raw('admin.settings.field_geo_cluster_sub') }}
{{ t_raw('admin.settings.save_general') }}
@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.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.sub_sms') }}
{{ t_raw('admin.settings.field_sms_provider') }}
@foreach ($sms::providers() as $p)
{{ $sms::providerLabel($p) }}
@endforeach
@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)
@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.field_sms_phone_otp') }}
{{ t_raw('admin.settings.hint_sms_phone_otp') }}
@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') }}
@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 .= ''
. e(t('admin.newsletter.opt_' . $o)) . ' ';
}
return $html;
};
@endphp
@csrf
@csrf
@php $mcOn = setting('mailchimp_enabled', '0') === '1';
@endphp
{{ t_raw('admin.settings.field_mailchimp_audience_id') }}
@php
$savedAudienceId = trim((string) setting('mailchimp_audience_id', ''));
$savedAudienceName = trim((string) setting('mailchimp_audience_name', ''));
@endphp
@if ($savedAudienceId !== '')
{{ $savedAudienceName !== '' ? $savedAudienceName : $savedAudienceId }}
@else
{{ t_raw('admin.settings.opt_mailchimp_load_first') }}
@endif
{{ t_raw('admin.settings.btn_mailchimp_load') }}
{{ t_raw('admin.settings.hint_mailchimp') }}