@extends('layouts.dashboard')
@section('content')
@php
/**
* Vendor → Menu → Import with AI.
*
* One page, three states, because "read it again" and "fix a price" are the same journey:
*
* source pick where the menu is coming from, and read it
* review what was read, EACH ROW MATCHED against the menu this restaurant already has,
* so the owner decides per dish whether it is added, updated or left alone
* done what actually changed
*
* Nothing is written until the review step is saved. A model reading a photographed menu
* will get prices and names wrong, and a restaurant that already has "Margherita" must not
* silently end up with two — so matching is shown, not hidden, and the default for an
* already-present dish is to leave it alone.
*
* @var string $step source|review|done
* @var array $categories existing menu categories (id, name)
* @var array $rows the parsed draft: name, category, price, description,
* status (new|existing), match_id, match_name, match_price
* @var array $result after a save: added, updated, skipped, sections
* @var array $allowance plan ceiling (limit, used, remaining, unlimited, reached, plan_name)
*/
$step = $step ?? 'source';
$categories = $categories ?? [];
$rows = $rows ?? [];
$result = $result ?? [];
// The Google source only exists when the platform has configured it; everything below it is
// only meaningful once this restaurant has actually linked an account.
// Not whitelisted here any more. This list had drifted behind the registry — it did not know
// about csv or location, so arriving at ?src=csv silently opened the paste box instead. The
// entries built below ARE the valid set, and $activeSrc is validated against them once, there.
$activeSrc = (string) ($activeSrc ?? 'text');
$drafts = $drafts ?? [];
$draftId = (int) ($draftId ?? 0);
$draftName = (string) ($draftName ?? '');
$googleOn = !empty($googleOn);
$googleConnected = !empty($googleConnected);
$googleLocation = (string) ($googleLocation ?? '');
$googleSynced = (string) ($googleSynced ?? '');
$locations = $locations ?? [];
$allowance = $allowance ?? ['limit' => 0, 'used' => 0, 'unlimited' => true, 'remaining' => 0, 'reached' => false, 'plan_name' => ''];
$newCount = $existingCount = 0;
foreach ($rows as $r) {
if (($r['status'] ?? 'new') === 'existing') { $existingCount++; } else { $newCount++; }
}
$sections = array_unique(array_filter(array_map(static fn ($r) => (string) ($r['category'] ?? ''), $rows)));
@endphp
@php /* .qm-d2: the dashboard density standard (app.css §35) */
@endphp
@if ($step === 'done')
@php /* ---- State 3: what actually changed -------------------------------------------
The counts are the whole point of this screen: an import that silently did nothing, or
quietly overwrote 40 prices, is the thing an owner needs to be told about immediately. */
@endphp
@php /* Rows the plan ceiling cut are named as such — "left alone" must never be the
only word an owner gets for dishes the PLAN dropped. */
@endphp
@if ((int) ($result['over_limit'] ?? 0) > 0)
@elseif ($step === 'map')
@php /* ---- State 2a: name the columns ---------------------------------------------------
Only reached when the header row did not name the two fields a dish cannot exist without.
The alternative was guessing, which filed a supplier's food cost as the menu price and
imported a Spanish header row as a dish — both silently. Our best guess is pre-selected, and
three real rows sit underneath so the choice can be checked rather than trusted. */
@endphp
@php
$mapping = $mapping ?? [];
$headers = $mapping['headers'] ?? [];
$guess = $mapping['map'] ?? [];
$preview = $mapping['preview'] ?? [];
$fields = [
'name' => ['label' => t_raw('vendor.menu.col_item'), 'required' => true],
'price' => ['label' => t_raw('common.price'), 'required' => true],
'category' => ['label' => t_raw('vendor.menu.category_label'), 'required' => false],
'description' => ['label' => t_raw('vendor.menu.import_col_description'), 'required' => false],
// Offered here too: a file whose header the reader could not place still has an ingredient
// column, and without this it could only be mapped into the description — which is exactly
// the folding that lost the link to the library in the first place.
'ingredients' => ['label' => t_raw('vendor.menu.import_col_ingredients'), 'required' => false],
];
@endphp
@else
@php /* ---- State 1: the source ------------------------------------------------------
Once a draft exists this card collapses to a single line. Leaving a ten-row textarea
open above the review table would bury the thing the owner is actually here to check. */
@endphp
@if ($step === 'review')
@php /* Reviewing a saved draft: its name is editable in place, because a draft the owner
comes back to next week has to say what it is. */
@endphp
@php /* Vertical tabs, the same .qm-settings grid the settings pages use: pick an input on the
left, its fields appear beside it. What this replaces was four peer toggles that also
duplicated the method choice already made on the Dishes page — so the method is gone
from here, and only the AI inputs remain, because AI is the one method with a choice
of input at all. */
@endphp
@php
/* The nav is BUILT FROM THE REGISTRY, not hand-listed. It used to be a fixed array of the
four AI inputs, which is why this page opened on a paste box on installs where AI was
switched off, why Google existed as a panel but never as a choice, and why the one
method that always works had no entry at all. A method that cannot run is never drawn.
Each entry names the input it opens; a method with no inputs is its own entry. */
$icons = [
'text' => 'fa-align-left',
'file' => 'fa-file-alt',
'csv' => 'fa-file-csv',
'image' => 'fa-image',
'url' => 'fa-link',
];
$entries = [];
foreach (\App\Services\MenuImportMethod::listed() as $entry) {
$method = $entry['method'];
$live = $entry['available'];
$inputs = \App\Services\MenuImportMethod::inputs($method);
if (!$inputs) {
$entries[$method] = [
'label' => \App\Services\MenuImportMethod::label($method),
'icon' => '',
'live' => $live,
];
continue;
}
foreach ($inputs as $in) {
$entries[$in] = [
// One input means the entry IS the method, so it carries the method's name.
'label' => count($inputs) === 1
? \App\Services\MenuImportMethod::label($method)
: t_raw('vendor.menu.import_src_' . $in),
'icon' => $icons[$in] ?? 'fa-file',
'live' => $live,
];
}
}
// Land on something that WORKS: an unavailable source in the URL must not open a panel
// the vendor cannot use, and a greyed entry must never be what the page opens on.
if (!isset($entries[$activeSrc]) || !$entries[$activeSrc]['live']) {
$activeSrc = '';
foreach ($entries as $k => $e) {
if ($e['live']) { $activeSrc = (string) $k; break; }
}
}
@endphp
@php /* Google is a link, not a paste: connect once, choose the location once, then read
whenever. It therefore has its own controls rather than the shared Read button —
and its own forms, since disconnecting must not submit the text area. */
@endphp
@if ($googleOn)
@else
@php /* Plain, not tinted. `is-highlight` paints a row with 7% of the brand colour —
it marks the option you have CHOSEN among several, which is not what a
connected account is. Stating a fact in the selection colour made this the
loudest thing on the page. Constrained to part of the row as well: a single
line of status stretched across the full panel put its own status pill a
thousand pixels from the name it describes. */
@endphp
{!! $googleLocation !== '' ? e($googleLocation) : t('vendor.menu.google_no_location') !!}
{!! $googleSynced !== '' ? t('vendor.menu.google_last_synced', [':when' => fmt_datetime($googleSynced)]) : t('vendor.menu.google_never_synced') !!}
@php /* flex-grow-0, because `.qm-switch-row > span` is `flex: 1 1 auto` (app.css:2768)
— written for the label span that should fill the row, and the status tag is
a span too, so it grew to 463px for the word "Connected". A tag sizes to its
text or it is not a tag. */
@endphp
{{ t_raw('admin.settings.state_ai_connected') }}
@endif
@if ($step === 'source')
@php /* The saved menus, on the page whose badge counts them. They used to be displayed on
Dishes while the Import tab carried the number — so the count and the thing it counted
were one click apart. Export sits with them: the file this produces is the file the
spreadsheet import reads back. */
@endphp
@partial('menu-transfer', ['drafts' => $drafts, 'exportQs' => (string) ($exportQs ?? ''), 'methodsOnly' => false, 'listOnly' => true])
@endif
@if ($step === 'review' && $rows)
@php /* ---- State 2: what was found, against what is already there --------------------- */
@endphp
{{ t_raw('vendor.menu.import_found_title') }}
{{ t_raw('vendor.menu.import_found_sub') }}
@php /* THE CEILING CHOICE, made before any row is read: this draft holds more new
dishes than the plan has room for, so the owner picks a path now — carry on
(the import fills the remaining allowance and leaves the rest) or upgrade.
Continue uses the product's own dismiss control; the save button below stays
the only thing that writes. */
@endphp
@php $importOverAdds = (int) ($importOverAdds ?? 0);
@endphp
@if ($importOverAdds > 0)
@endif
@php /* The fact strip the item-analytics page uses. Four numbers answer the question
the owner asks before reading a single row: how much of this is actually new. */
@endphp
{{ t_raw('vendor.menu.import_stat_found') }}
{{ number_format(count($rows)) }}
{{ t_raw('vendor.menu.import_stat_new') }}
{{ number_format($newCount) }}
{{ t_raw('vendor.menu.import_stat_existing') }}
{{ number_format($existingCount) }}
{{ t_raw('vendor.menu.import_stat_sections') }}
{{ number_format(count($sections)) }}
{{ t_raw('vendor.menu.import_review_title') }}
@php /* When a menu is bigger than one screen, say so here rather than letting the
owner discover it by saving and finding more waiting. */
@endphp
@php /* The template, not the rendered sentence, so the live count can re-substitute it
without the JS having to know the phrasing of any language. */
@endphp