@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

{{ t_raw('vendor.menu.import_done_title') }}

{{ t_raw('vendor.menu.import_done_body', [ ':added' => number_format((int) ($result['added'] ?? 0)), ':updated' => number_format((int) ($result['updated'] ?? 0)), ':skipped' => number_format((int) ($result['skipped'] ?? 0)), ]) }}

@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)

{{ t_raw('vendor.menu.import_done_limit', [ ':n' => number_format((int) $result['over_limit']), ':plan' => (string) ($allowance['plan_name'] ?? ''), ':limit' => number_format((int) ($allowance['limit'] ?? 0)), ]) }} {{ t_raw('vendor.plans.upgrade_pill') }}

@endif
@php /* Newest first, not page 1 of the whole menu. After a 200-dish import the dishes that just arrived were unfindable — they sorted into menu order among everything else, and the owner had no way to check the import's work. */ @endphp {{ t_raw('vendor.menu.import_done_view') }} {{ t_raw('vendor.menu.import_done_again') }}
@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

{{ t_raw('vendor.menu.import_map_title') }}

{{ t_raw('vendor.menu.import_map_sub', [':n' => number_format((int) ($mapping['rows'] ?? 0))]) }}

{{ t_raw('vendor.menu.import_start_over') }}
@csrf
@foreach ($fields as $key => $f)
@endforeach
@php /* The file as it actually is. A mapping chosen without seeing the values is the same guess we are here to stop making. */ @endphp
@foreach ($headers as $i => $h) @endforeach @foreach ($preview as $prow) @foreach ($headers as $i => $h)@endforeach @endforeach
{{ $h !== '' ? $h : t_raw('vendor.menu.import_map_column', [':n' => $i + 1]) }}
{{ (string) ($prow[$i] ?? '') }}
@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

{{ $draftName }}

{{ t_raw('vendor.menu.import_read_from', [':n' => number_format(count($rows))]) }}

@csrf @php /* Full-size, not qm-btn-sm: these three sit on one row beside the rename field, and at the small size they rendered 12px shorter than it and sat 6px lower, reading as stranded rather than aligned. */ @endphp
@csrf
{{ t_raw('vendor.menu.import_start_over') }}
@else

{{ t_raw('vendor.menu.tab_import') }}

@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
@csrf @php /* The name sits at the TOP, once, the way a post title does — not repeated at the foot of every tab. It names the thing being made, so it is asked before the thing, and it does not move or disappear when the source changes. Optional: an unnamed import is called after its source and the date. */ @endphp
@php /* Copying a branch's menu. Shown only to an owner who has somewhere to copy from, and scoped by owner in the query that built this list. */ @endphp @if ($locations)
@php /* The second choice: the whole menu, or one section of it. A location has one menu here, so what there is to choose between is how it is divided — bringing only the food to a site that sells no drinks. Repopulated from the map below when the location changes, so it always matches. */ @endphp
@endif
@php /* A document and a photograph are not the same job, so they are not one tab. A PDF or Word file is text to be READ; a photo of a menu board needs a model that can SEE. They also accept different types and fail for different reasons, which one vague "Upload a file" hid. */ @endphp
@php /* The product's one uploader: FilePond over Core\Upload, exactly as the media and avatar fields use it. A bare file input would validate nothing and look like nothing else in the dashboard. */ @endphp
@php /* The spreadsheet, reachable here as well as from the Dishes card. It had no URL of its own at all, so the one method that works on every install could only be started from a different page. */ @endphp
@php /* Reading takes seconds, not milliseconds. The button states that itself rather than leaving the owner wondering whether the click registered. */ @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)
@if (!$googleConnected)
{!! google_mark(44) !!}

{{ t_raw('vendor.menu.google_connect_title') }}

{{ t_raw('vendor.menu.google_connect_body') }}

{!! google_mark(18, 'me-2') !!} {{ t_raw('vendor.menu.google_connect_btn') }}
@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') }}
@csrf
{{ t_raw('vendor.menu.google_change_location') }} @if ($googleLocation !== '')
@csrf
@endif
@endif
@endif
@php /* /panel column */ @endphp
@php /* /.qm-settings */ @endphp
@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)
{{ t_raw('vendor.menu.import_over_banner', [ ':adds' => number_format($importOverAdds), ':plan' => (string) $allowance['plan_name'], ':remaining' => number_format((int) $allowance['remaining']), ]) }} {{ t_raw('vendor.plans.upgrade_pill') }}
@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

@if ((int) ($rowsTotal ?? 0) > count($rows)){{ t_raw('vendor.menu.import_review_page', [ ':n' => number_format(count($rows)), ':total' => number_format((int) $rowsTotal), ]) }}@else{{ t_raw('vendor.menu.import_review_sub') }}@endif

@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
@csrf @php /* Which draft this save belongs to, so it can be marked applied with what it changed rather than silently vanishing. */ @endphp
@foreach ($rows as $i => $row) @php $exists = ($row['status'] ?? 'new') === 'existing'; @endphp @php /* The ingredient line, and what the library could not place. A term with no entry is DROPPED on apply — the admin owns that vocabulary and the importer will not invent one — so it is named here rather than lost between the file and the menu. Editing the box to a term the library knows is enough to rescue it. */ @endphp @endforeach
{{ t_raw('vendor.menu.col_item') }} {{ t_raw('vendor.menu.category_label') }} {{ t_raw('common.price') }} {{ t_raw('vendor.menu.import_col_description') }} {{ t_raw('vendor.menu.import_col_ingredients') }} {{ t_raw('vendor.menu.import_col_status') }} {{ t_raw('vendor.menu.import_col_action') }} {{ t_raw('common.actions') }}
@php /* The model proposes a section; the owner files it. A section this restaurant does not have yet is offered as "new" and created on save, so the menu does not have to be reorganised by hand first. */ @endphp @php $choice = \App\Services\MenuImport::categoryChoice((string) ($row['category'] ?? ''), $categories); @endphp @php /* An empty price means the source did not state one — NOT that the dish is free. It is left empty and flagged here, and a row still empty on save is skipped rather than published at zero. */ @endphp @php $noPrice = trim((string) ($row['price'] ?? '')) === ''; @endphp @if ($noPrice) {{ t_raw('vendor.menu.import_price_missing') }} @endif @php /* What it costs today, when that is not what was read. Without this the owner cannot tell an intended price rise from a misread digit. */ @endphp @if ($exists && ($row['match_price'] ?? '') !== '' && (string) $row['match_price'] !== (string) ($row['price'] ?? '')) {{ t_raw('vendor.menu.import_price_now', [':price' => money((float) $row['match_price'])]) }} @endif @php $ingLine = (string) ($row['ingredients'] ?? ''); @endphp @php $ingMatch = \App\Services\MenuImport::matchIngredients($ingLine); @endphp @if ($ingMatch['unmatched']) @php /* Raw, like every other t() argument: t() substitutes first and escapes the whole string afterwards, so pre-escaping the list escaped it twice — a term read out of the file as "salt & pepper" printed as "salt & pepper". */ @endphp {{ t_raw('vendor.menu.import_ing_unmatched', [':list' => implode(', ', $ingMatch['unmatched'])]) }} @endif @if (($row['status'] ?? '') === 'duplicate') @php /* The same dish appears earlier in this very file. Saying which row it repeats is the difference between a warning and a puzzle. */ @endphp {{ t_raw('vendor.menu.import_status_duplicate', [':n' => (int) ($row['duplicate'] ?? 0)]) }} @elseif ($exists) {{ t_raw('vendor.menu.import_status_existing') }} @else {{ t_raw('vendor.menu.import_status_new') }} @endif @php /* One control per row, and only the choices that make sense for it: a dish that is not on the menu cannot be updated, and a dish that is defaults to being LEFT ALONE — an import must never overwrite a price the owner did not look at. */ @endphp @php /* Carried through the review only when the file actually supplied them, so the round trip keeps the cost that drives every margin figure — and the form stays as small as the ceiling allows. */ @endphp @if (isset($row['cost']))@endif @if (isset($row['available']))@endif @php /* The dish this row matched, opened in its own tab. "Update the one I have" is a decision about a real dish, and until now the only thing shown about that dish was its name and its price — not its photo, its section, its options or whether it is even switched on. A new tab is the point: this form holds unsaved edits to every row on the page, and navigating away throws them away. A row that is not on the menu yet has nothing to open — the dish is created on save, and the archive links to what each import produced. */ @endphp @if ($exists && (int) ($row['match_id'] ?? 0) > 0) @endif
@php /* The ceiling is stated where the decision is made — but only once it is CLOSE (five slots or fewer): a plan with plenty of room has nothing to warn about, and a permanent notice reads as nagging. */ @endphp @if (!$allowance['unlimited'] && (int) $allowance['remaining'] <= 5)

{{ t_raw('vendor.menu.import_allowance', [ ':remaining' => number_format((int) $allowance['remaining']), ':plan' => (string) $allowance['plan_name'], ]) }}

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