@php /** * The printable folded MENU sheet - one SIDE (front or back) of an unfolded sheet: N fold panels, * each a COVER or a stack of MENU sections. The double-sided doc renders this partial once per side. * * Renders a structure already resolved by App\Services\PrintMenuRenderer against a MenuContext, so this * view never touches the database and serves a vendor's live menu and an admin's sample preview alike. * The whole visual language is a SKIN (App\Services\PrintMenuSkins): its palette + display/body font * pairing + heading treatment arrive pre-resolved on $sheet['skin'] and are applied as CSS custom * properties, so the renderer stays data-driven (add a skin = one config row, no code here). The * order/pay QR is drawn client-side into [data-qr-url]. * * @var array $sheet fold/orientation/paper/fontScale/leaders/poweredBy/doubleSided + skin{palette,fonts,treatment} * @var array $panels this side's resolved panels (cover | menu with sections + live items) * @var bool $sample true = a data-less preview (the QR is a non-functional sample) */ use App\Models\QrCard; $sheet = $sheet ?? []; $panels = $panels ?? []; $skin = is_array($sheet['skin'] ?? null) ? $sheet['skin'] : []; /* TEMPLATE MODE. A template is a DESIGN, never a restaurant's content: it renders against the bundled SampleContext, and the three slots that would otherwise need tenant data — each dish photo, the brand logo and the QR — draw as labelled OUTLINES instead. That keeps a starter free of any restaurant's images and makes the empty slots legible to whoever adopts it. A vendor's own sheet never shows an outline: it prints their real photo (or a clean text row), their logo (or their monogram) and a live QR. */ $tplMode = !empty($sample); /* The glyph is INLINE SVG, not a font icon: this sheet is also printed, rasterised to PNG and turned into a PDF, and the print document does not load an icon font — an would come out blank in every one of those. Inline paths always draw. */ $phBox = static function (string $cls, string $glyph, string $label): string { $paths = [ // picture frame with a mountain + sun 'image' => '' . '' . '', // QR finder pattern 'qr' => '' . '', ]; return '' . '' . ''; }; $hex = static fn($v, $fb) => preg_match('/^#[0-9a-fA-F]{6}$/', (string) $v) ? strtolower((string) $v) : $fb; $accent = $hex($skin['accent'] ?? '', '#b0883a'); $dark = !empty($skin['dark']); $treatment = in_array($skin['treatment'] ?? 'bar', ['bar', 'centered', 'rule', 'block'], true) ? $skin['treatment'] : 'bar'; $display = (string) ($skin['display'] ?? "Georgia, 'Times New Roman', serif"); $bodyFont = (string) ($skin['body'] ?? 'system-ui, sans-serif'); $leaders = !empty($sheet['leaders']); $skinNumbered = !empty($skin['numbered']); // a skin can declare itself numbered → items number by default // Menu-body measure: full (edge-to-edge) | boxed (a centred column) | custom (a set em width). Bites on // single-page sheets where a panel is the full sheet width; landscape folds are already narrow columns. $bodyWidth = in_array($sheet['bodyWidth'] ?? 'full', ['full', 'boxed', 'custom'], true) ? $sheet['bodyWidth'] : 'full'; $bodyMax = $bodyWidth === 'custom' ? max(24, min(72, (int) ($sheet['bodyWidthEm'] ?? 40))) : 42; $logoShape = in_array($sheet['logoShape'] ?? 'square', ['square', 'rounded', 'circle'], true) ? $sheet['logoShape'] : 'square'; $vAlign = in_array($sheet['vAlign'] ?? 'center', ['top', 'center', 'bottom', 'fill'], true) ? $sheet['vAlign'] : 'center'; $spacing = in_array($sheet['spacing'] ?? 'comfortable', ['cozy', 'comfortable', 'roomy', 'fill'], true) ? $sheet['spacing'] : 'comfortable'; $horizontal = ($sheet['orientation'] ?? 'horizontal') === 'horizontal'; $paper = QrCard::PAPERS[$sheet['paper'] ?? 'a4'] ?? QrCard::PAPERS['a4']; $ar = $horizontal ? ($paper['h'] . ' / ' . $paper['w']) : ($paper['w'] . ' / ' . $paper['h']); $scale = max(50, min(130, (int) ($sheet['fontScale'] ?? 100))) / 100; $panelCount = max(1, count($panels)); // A landscape sheet with 4+ folds makes each cover panel ~1/4 sheet-width or less — too narrow to sit the // contact block and a scannable QR side by side, so the cover footer STACKS there (info full-width, QR below) // instead of the inline split used on wider covers. $coverNarrow = $horizontal && $panelCount >= 4; $grid = $horizontal ? 'grid-template-columns: repeat(' . $panelCount . ', 1fr);' : 'grid-template-rows: repeat(' . $panelCount . ', 1fr);'; // The skin palette + font pairing → CSS custom properties. Colours are hex/rgba; font stacks are our // own single-quoted config data, so they drop into the double-quoted style attribute safely. $vars = '--pm-bg:' . e($hex($skin['bg'] ?? '', '#f4f1ec')) . ';'; $vars .= '--pm-ink:' . e($hex($skin['ink'] ?? '', '#1a1614')) . ';'; $vars .= '--pm-ink-soft:' . e($hex($skin['inkSoft'] ?? '', '#6b6259')) . ';'; $vars .= '--pm-line:' . e((string) ($skin['line'] ?? 'rgba(0,0,0,.16)')) . ';'; $vars .= '--pm-accent:' . e($accent) . ';'; $vars .= '--pm-panel:' . e($hex($skin['panel'] ?? '', $hex($skin['bg'] ?? '', '#ffffff'))) . ';'; $vars .= '--pm-display:' . $display . ';'; $vars .= '--pm-body:' . $bodyFont . ';'; $vars .= '--pm-scale:' . $scale . ';'; $vars .= '--pm-body-max:' . $bodyMax . 'em;'; $vars .= 'aspect-ratio:' . $ar . ';' . $grid; // Label maps (t() so the i18n parity check enforces every locale carries them). $cLabel = [ 'phone' => t('vendor.print_menu.contact_phone'), 'email' => t('vendor.print_menu.contact_email'), 'website' => t('vendor.print_menu.contact_website'), 'address' => t('vendor.print_menu.contact_address'), ]; $mLabel = [ 'dinein' => t('vendor.print_menu.mode_dinein'), 'takeaway' => t('vendor.print_menu.mode_takeaway'), 'delivery' => t('vendor.print_menu.mode_delivery'), ]; /** The badge pills for one item, from its real menu flags only (no invented data). */ $itemBadges = static function (array $it): array { $b = []; if (!empty($it['is_veg'])) { $b[] = ['veg', t('vendor.print_menu.badge_veg')]; } if ((int) ($it['spice_level'] ?? 0) > 0) { $b[] = ['spicy', t('vendor.print_menu.badge_spicy')]; } if (!empty($it['is_popular'])) { $b[] = ['popular', t('vendor.print_menu.badge_popular')]; } if (!empty($it['chef_recommended'])) { $b[] = ['chef', t('vendor.print_menu.badge_chef')]; } return $b; }; @endphp @php $guides = !empty($sheet['foldGuides']); $border = in_array($sheet['border'] ?? 'none', ['none', 'thin', 'double', 'ornate'], true) ? $sheet['border'] : 'none'; $divider = in_array($sheet['divider'] ?? 'none', ['none', 'solid', 'dashed', 'dotted'], true) ? $sheet['divider'] : 'none'; $texture = in_array($sheet['texture'] ?? 'none', ['none', 'paper', 'linen', 'grain'], true) ? $sheet['texture'] : 'none'; $pi = 0; // A panel's decorative background image layer (behind the content) — kept low-opacity + blended so text // stays readable while empty space reads as designed, not blank. $bgLayer = static function (?array $bg, string $cls = 'pm-panel-bg'): void { if (!$bg || ($bg['image'] ?? '') === '') { return; } $blend = in_array($bg['blend'] ?? 'normal', ['normal', 'multiply', 'soft-light', 'overlay', 'luminosity'], true) ? $bg['blend'] : 'normal'; $op = max(0, min(100, (int) ($bg['opacity'] ?? 14))) / 100; echo ''; }; $showCount = !empty($sheet['showCount']); @endphp
@php $bgLayer($sheet['bg'] ?? null, 'pm-sheet-bg'); @endphp @foreach ($panels as $panel) @php $pi++; @endphp @if (($panel['role'] ?? 'menu') === 'cover') @php $brand = $panel['brand'] ?? []; @endphp
@php $bgLayer($panel['bg'] ?? null); @endphp @if ($guides){{ t('vendor.print_menu.panel_label', [':n' => $pi]) }}@endif @php // The cover reads as HEADER (brand + service modes under the subtitle) / BODY (hero image) / // FOOTER (contact details + QR, inline). "boxed" contact style wraps the contact in a bordered // info-card. On a NARROW cover (a 4+ fold landscape panel) the mode pills are too bulky to sit // in the footer alongside the contact + QR, so they always move up under the subtitle and the // boxed card is dropped — leaving the footer a clean contact | QR line. $boxed = ($panel['contactStyle'] ?? 'plain') === 'boxed'; $modes = !empty($panel['serviceModes']) ? $panel['serviceModes'] : []; $footerBoxed = $boxed && !$coverNarrow; // no info-card on a narrow cover $modesInHeader = $modes && (!$boxed || $coverNarrow); // header when plain OR narrow; else footer card $coverImg = trim((string) ($panel['coverImage'] ?? '')); $coverFit = in_array($panel['coverFit'] ?? 'cover', ['cover', 'contain', 'fill'], true) ? $panel['coverFit'] : 'cover'; $coverName = ($panel['heading'] ?? '') !== '' ? $panel['heading'] : (!empty($panel['showName']) ? ($brand['name'] ?? '') : ''); $brandName = trim((string) ($brand['name'] ?? '')); $hasFooter = !empty($panel['contact']) || !empty($panel['qr']) || !empty($panel['badgesLegend']) || ($footerBoxed && $modes); @endphp
@if (!empty($panel['logo'])) @if ($tplMode) {!! $phBox('pm-logo-ph', 'image', t_raw('vendor.print_menu.logo_ph')) !!} @elseif (($brand['logo'] ?? '') !== '') @elseif (($brand['name'] ?? '') !== '') @php // No logo in the restaurant's SETTINGS: print their own monogram, never the platform mark. @endphp @endif @endif @if ($coverName !== '')

{{ $coverName }}

@elseif ($brandName !== '') @php // identity always: a small wordmark so a logo-only / name-off cover still names the restaurant; @endphp

{{ $brandName }}

@endif @if (!empty($panel['showTagline']) && ($brand['tagline'] ?? '') !== '')

{{ $brand['tagline'] }}

@endif @if (($panel['blurb'] ?? '') !== '')

{{ $panel['blurb'] }}

@endif
@if ($modesInHeader)
@foreach ($modes as $m) @if (isset($mLabel[$m])){{ $mLabel[$m] }}@endif @endforeach
@endif @if ((int) ($panel['stat'] ?? 0) > 0)
{{ (int) $panel['stat'] }}+ {{ t('vendor.print_menu.stat_items') }}
@endif
@php // BODY: a full-bleed hero image (edge-to-edge inside the cover); it grows to fill the middle. @endphp @if ($coverImg !== '')
@endif @php // FOOTER: the restaurant info and the QR sit INLINE, next to each other, along the bottom. @endphp @if ($hasFooter)
@if (!empty($panel['qr']))
@if ($tplMode) @php // Template mode draws an outline, and emits NO data-qr-url, so nothing scannable // ever ships in a starter — a real code here would just point at a dead sample link. @endphp {!! $phBox('pm-qr-ph', 'qr', t_raw('vendor.print_menu.qr_ph')) !!} @else @endif
@if (($panel['qr']['caption'] ?? '') !== ''){{ $panel['qr']['caption'] }}@endif
@endif
@endif @if (!empty($sheet['poweredBy'])) @php $plogo = $dark ? setting('logo_light', 'assets/admin/logo-light.png') : setting('logo', 'assets/admin/logo.png'); @endphp {{ t_raw('tv.powered_by') }} @endif
@else
@php $bgLayer($panel['bg'] ?? null); @endphp @if ($guides){{ t('vendor.print_menu.panel_label', [':n' => $pi]) }}@endif @php // Optional brand masthead: brand block above the sections, so a single-page sheet carries // the logo/name/tagline + the menu together (no separate cover panel). @endphp @if (!empty($panel['masthead'])) @php $mh = $panel['masthead']; $mb = $mh['brand'] ?? []; @endphp
@if (!empty($mh['logo'])) @if ($tplMode) {!! $phBox('pm-logo-ph pm-logo-ph-sm', 'image', t_raw('vendor.print_menu.logo_ph')) !!} @elseif (($mb['logo'] ?? '') !== '') @elseif (($mb['name'] ?? '') !== '') @endif @endif @if (!empty($mh['name']) && ($mb['name'] ?? '') !== '')

{{ $mb['name'] }}

@endif @if (!empty($mh['tagline']) && ($mb['tagline'] ?? '') !== '')

{{ $mb['tagline'] }}

@endif @if (($mh['blurb'] ?? '') !== '')

{{ $mh['blurb'] }}

@endif
@endif @foreach (($panel['sections'] ?? []) as $sec) @php $secScale = ((int) ($sec['fontScale'] ?? 0) > 0) ? ((int) $sec['fontScale'] / 100) : 1; $f = $sec['flags'] ?? []; $count = count($sec['items'] ?? []); $feature = !empty($sec['feature']); // the first item renders large (a hero dish) // Rendered ROW count (items ÷ columns) + the heading row — the section's proportional weight, // so the "fill" spacing grows each section to its fair share of the column height (no hollow // middles: a 7-row section grows more than a 2-row one, keeping item density even). $secRows = max(1, (int) ceil($count / max(1, (int) $sec['columns']))) + 1; @endphp @php $isFull = ($sec['imgStyle'] ?? 'thumb') === 'full'; // Full-bleed: the section leads with an edge-to-edge banner (the first item that carries a // photo) and the heading is overlaid ON the banner. Degrades to a plain heading when no item // has a photo. The list below drops per-item thumbs — the banner IS the section's photo. $bannerImg = ''; if ($isFull && !empty($f['image'])) { foreach (($sec['items'] ?? []) as $bi) { if (($bi['image'] ?? '') !== '') { $bannerImg = (string) $bi['image']; break; } } } ob_start(); @endphp @if (($sec['heading'] ?? '') !== '')

{{ $sec['heading'] }}

@if ($showCount && $count > 0){{ $count === 1 ? t('vendor.print_menu.item_count_one') : t('vendor.print_menu.item_count', [':n' => $count]) }}@endif @if (($sec['subnote'] ?? '') !== '')

{{ $sec['subnote'] }}

@endif
@endif @php $headHtml = ob_get_clean(); @endphp @php // Per-section spacing: a concrete value overrides; 'inherit' (default) uses the sheet's spacing. @endphp
@if ($isFull && $bannerImg !== '') @php // The full-bleed banner takes its photo from the section's first DISH, so in template // mode it draws as an outline like every other photo slot — a starter carries no dish // photography, only the shape of where it goes. @endphp
@if ($tplMode) {!! $phBox('pm-banner-ph', 'image', t_raw('vendor.print_menu.img_ph')) !!} @else @endif {!! $headHtml !!}
@else {!! $headHtml !!} @endif
    @php $n = 0; @endphp @foreach (($sec['items'] ?? []) as $it) @php $n++; @endphp
  • @php // TEMPLATE mode shows the photo SLOT as an outline, so a starter carries no restaurant's // image yet still reads as photo-forward. A VENDOR's sheet shows the real photo, and an // imageless dish renders a clean text row — a printed sheet never shows a dashed box. @endphp @if (!empty($f['image']) && $tplMode) {!! $phBox('pm-item-thumb pm-item-thumb-ph', 'image', t_raw('vendor.print_menu.img_ph')) !!} @elseif (!empty($f['image']) && ($it['image'] ?? '') !== '') @endif
    @if (!empty($f['number']) || $skinNumbered){{ $n }}. @endif @if (!empty($f['name'])){{ $it['name'] ?? '' }}@endif @if (!empty($f['price'])){{ money($it['price'] ?? 0) }}@endif
    @if (!empty($f['desc']) && ($it['description'] ?? '') !== '')

    {{ $it['description'] }}

    @endif @php // Add-on / combo box: rendered only when the item carries live option data. @endphp @if (!empty($it['options']) && is_array($it['options']))
    {{ t('vendor.print_menu.addons') }} {{ implode(' · ', array_map(static fn($o) => (string) ($o['name'] ?? ''), array_slice($it['options'], 0, 6))) }}
    @endif @php $bs = !empty($f['badges']) ? $itemBadges($it) : []; $cal = !empty($f['calories']) ? (int) ($it['calories'] ?? 0) : 0; @endphp @if ($bs || $cal > 0)
    @foreach ($bs as $bd) {{ $bd[1] }} @endforeach @if ($cal > 0){{ $cal }} {{ t('vendor.print_menu.kcal') }}@endif
    @endif
  • @endforeach
@endforeach @php // An empty panel (more fold panels than the menu fills) renders BLANK — the rendered sheet // carries menu content only, never editor guidance (section controls live in the builder rail). @endphp
@endif @endforeach