@extends('layouts.public')
@section('content')
@php
/** @var array $restaurant @var string $cuisineName @var array $categories @var array $itemsByCat @var array $optionsByItem @var array $reviews @var bool $canReview @var array $hours @var array $gallery @var array $coupons @var array $popularItems @var array $similar @var array $ratingBreakdown @var string $layout */
$rid = (int) $restaurant['id'];
$tags = $tags ?? [];
$isOpen = $isOpen ?? true;
$rating = (float) $restaurant['rating_cache'];
$ratingCount = (int) ($restaurant['rating_count'] ?? 0);
/* The delivery promises on this page — the radius pill, the map circle — are shown only when the
restaurant can actually deliver, which is the same question the cart, the checkout and the POS
all ask (Restaurant::deliversNow). A venue with no coordinates on file cannot: the distance every
delivery decision rests on is unmeasurable. Advertising it here and refusing at the last click
was the customer filling in a whole checkout for an order that was never possible. */
$radius = \App\Models\Restaurant::deliversNow($restaurant)
? (float) ($restaurant['delivery_radius'] ?? 0)
: 0.0;
// Hero cover: the restaurant's own image if it has one; otherwise the admin's
// configured general fallback image (Settings -> General), if one is set;
// otherwise the platform's theme colour (never a stock photo) — so removing
// the cover shows a clean branded banner, not someone else's food.
$hasCover = !empty($restaurant['cover_image']) && is_file(public_path() . '/' . ltrim((string) $restaurant['cover_image'], '/'));
$adminFallbackCover = trim((string) setting('fallback_general_image', 'assets/admin/fallback/general-fallback.png'));
$hasAdminFallbackCover = $adminFallbackCover !== '' && is_file(public_path() . '/' . ltrim($adminFallbackCover, '/'));
$cover = $hasCover ? media($restaurant['cover_image']) : ($hasAdminFallbackCover ? asset($adminFallbackCover) : '');
$hasAnyCover = $hasCover || $hasAdminFallbackCover;
// If a stored about-media FILE has gone missing, fall back to the admin's general image
// (Settings → General, bundled default) rather than a broken tag — never the cover or a stray
// stock image. Only reached when the active about media is set but its file is absent.
$videoPost = $hasAdminFallbackCover ? asset($adminFallbackCover) : '';
// Image, video and YouTube are stored independently; the owner's chosen type decides which is live.
$aboutMediaType = in_array($restaurant['about_media_type'] ?? 'image', ['image', 'video', 'youtube'], true) ? ($restaurant['about_media_type'] ?? 'image') : 'image';
$aboutMediaActive = trim((string) ($aboutMediaType === 'video' ? ($restaurant['about_video'] ?? '') : ($aboutMediaType === 'youtube' ? ($restaurant['about_youtube'] ?? '') : ($restaurant['about_image'] ?? ''))));
// When the section is set to VIDEO but the owner hasn't uploaded their own clip, fall
// back to the platform showcase video (admin-set in Settings → Branding, shipped default
// assets/client/video/video.mp4) — so the section still plays instead of vanishing, and a vendor's
// own upload (saved to their folder) always wins over it.
if ($aboutMediaType === 'video' && $aboutMediaActive === '') {
$aboutMediaActive = trim((string) setting('fallback_about_video', 'assets/client/video/video.mp4'));
}
// The about figure shows only when the chosen type has media AND the block isn't switched off. The
// master switch (Settings) hides it without deleting; toggling back on restores it. Hidden → the
// description spans full width.
$showAboutMedia = (int) ($restaurant['about_media_hidden'] ?? 0) === 0 && $aboutMediaActive !== '';
$isFav = can_favorite() && is_favorite($rid);
$dayNames = [t('common.day_sunday'), t('common.day_monday'), t('common.day_tuesday'), t('common.day_wednesday'), t('common.day_thursday'), t('common.day_friday'), t('common.day_saturday')];
$todayIdx = (int) date('w');
// Normalise the layout to a known variant so unexpected values fall back safely.
$layout = in_array($layout ?? 'classic', ['classic', 'style1', 'style2'], true) ? ($layout ?? 'classic') : 'classic';
$coupons = $coupons ?? [];
$popularItems = $popularItems ?? [];
$optionsByItem = $optionsByItem ?? [];
$ingredientsByItem = $ingredientsByItem ?? [];
$similar = $similar ?? [];
$ratingBreakdown = $ratingBreakdown ?? [5 => 0, 4 => 0, 3 => 0, 2 => 0, 1 => 0];
$reviewTotal = array_sum($ratingBreakdown);
// Loyalty context (defensive: page may render without the controller passing it).
$loyaltyEnabled = $loyaltyEnabled ?? false;
$loyaltyBalance = (int) ($loyaltyBalance ?? 0);
$loyaltyRewards = $loyaltyRewards ?? [];
$loyaltyLabel = (string) ($loyaltyLabel ?? 'points');
// Two distinct audiences, not one flag: only a customer account carries a points
// balance worth showing, and only a signed-OUT visitor should be invited to sign in.
// A signed-in admin / vendor / driver is neither, so the strip renders nothing for
// them rather than telling someone who is already signed in to sign in.
$loyIsCustomer = \App\Support\Auth::is('customer');
$loyIsGuest = !\App\Support\Auth::check();
// Cheapest reward + progress toward it (rewards arrive sorted by points_cost ASC).
$loyCheapest = $loyaltyRewards[0] ?? null;
$loyCost = $loyCheapest ? (int) $loyCheapest['points_cost'] : 0;
$loyPct = $loyCost > 0 ? min(100, (int) round($loyaltyBalance / $loyCost * 100)) : 0;
$loyToGo = $loyCost > 0 ? max(0, $loyCost - $loyaltyBalance) : 0;
/* ===== Shared, layout-agnostic widget components =====================
* Each closure echoes one self-contained block. The three layout variants
* call the same closures so data + components stay identical; only the
* wrapping structure/order changes per layout. */
// Offers / coupons strip.
$renderOffers = function () use ($coupons, $__env): void {
if (empty($coupons)) { return; }
@endphp
@php
};
// Build the same data-* attribute contract the menu list uses, so a dish opened
// from the "Popular this week" rail is customizable EXACTLY like the menu list
// (real option groups + removable/extra ingredients), not a stub with data-options="[]".
// Single definition, shared with partials/cards/food-item.php.
$buildItemAttrs = function (array $item, array $options, array $ingredients): string {
return menu_item_modal_data($item, $options, $ingredients)['attrs'];
};
// "Popular this week" horizontal rail of compact item cards. Each card reuses
// the same modal trigger contract (.qm-open-item + data-attrs) so adding to
// cart works exactly like the menu list — including real options/ingredients.
$renderPopular = function () use ($popularItems, $optionsByItem, $ingredientsByItem, $buildItemAttrs, $__env): void {
if (empty($popularItems)) { return; }
@endphp
@php
};
// The info card (opening hours, delivery, contact) - reused by every layout.
$renderInfoCard = function () use ($restaurant, $hours, $todayIdx, $dayNames, $radius, $isOpen, $__env): void {
@endphp
{{ t_raw('restaurant.info_title') }}
@if (!empty($restaurant['description']))
@php // Escape BEFORE excerpting. The description is a plain-text field (a textarea the
// vendor types prose into, rendered verbatim by the About block below), not the
// HTML `excerpt` column str_excerpt() was written for - and its strip_tags() eats
// everything from an unclosed `<` to the end of the string, so ordinary copy like
// "Kids <10 eat free on Sundays" reached the diner as "Kids". With the value already
// escaped there is no `<` left to swallow and only the word limit applies.
@endphp
@endif
@php // The live open/closed status now sits in the sticky tab bar, where it stays
// visible while the visitor browses, instead of being buried in this card.
@endphp
@php
};
// The order/cart sidebar - reused by every layout (one instance per page).
$renderCart = function () use ($restaurant, $__env): void {
@endphp
@php
};
// Location map gate (opt-in): on when the admin enables maps. Default install ships maps
// OFF, so the stylized LOCAL map renders and the page makes ZERO external requests. When
// live, the map uses the shared QuickMunch engine (qm-map.js via [data-qm-map]), so it
// follows the admin's chosen provider (OpenStreetMap / Mapbox / Google) exactly like every
// other map across the site — no hardcoded provider here.
$mapLive = setting('maps_enabled', '1') === '1' && ($restaurant['lat'] ?? null) !== null && ($restaurant['lng'] ?? null) !== null;
// Both maps on this page mark the restaurant with its own storefront photo rather than a
// generic pin — the same framed brandmark marker the dashboard maps use for the logo. A
// one-entry marker list is used instead of the single centre pin because that is the path
// the engine accepts a brandmark on; it keeps the configured centre/zoom, since the engine
// only re-frames the view when there is more than one marker.
$mapMarkers = $mapLive ? (string) json_encode([[
'lat' => (float) $restaurant['lat'],
'lng' => (float) $restaurant['lng'],
'iconHtml' => restaurant_cover_html($restaurant, 'sm'),
]], JSON_UNESCAPED_SLASHES) : '';
// Compact location preview for the order sidebar (rendered UNDER the cart). Real map when
// live, else a styled jump-to-map card.
$renderMiniMap = function () use ($restaurant, $mapLive, $mapMarkers, $__env): void {
$addr = trim(($restaurant['address'] ?? '') . (!empty($restaurant['city']) ? ', ' . $restaurant['city'] : ''), ', ') ?: t_raw('restaurant.view_on_map');
if ($mapLive) {
@endphp
@php
}
};
// Full location map (live OpenStreetMap iframe when maps_enabled + coords, else a
// stylized LOCAL SVG so the default install makes zero external requests) plus the
// address/radius overlay card. Shared by the standalone #map section (classic/style1)
// and, on style2, the highlights row beside the info card.
$mapCard = static function () use ($restaurant, $radius, $__env): void {
@endphp
@php
};
// The menu list (categories + food-item cards) - reused by every layout.
// Categories that actually have items - the jump nav and the menu must agree on
// this, so both derive it from the same list rather than counting separately.
$navCats = array_values(array_filter($categories, static fn ($c) => !empty($itemsByCat[(int) $c['id']])));
// Sticky category jump list (style2). Each entry targets the #cat-{id} anchor the
// menu already emits, so this is navigation over existing markup - no new ids.
// $restaurant is captured because the title names the owner's own menu: without it the closure
// saw nothing, `$restaurant ?? null` quietly resolved to null, and a restaurant that had renamed
// its menu got the generic label here while the menu header beside it showed the real one.
$renderCatNav = function () use ($navCats, $itemsByCat, $restaurant, $__env): void {
@endphp
@php
};
$renderMenu = function () use ($categories, $itemsByCat, $optionsByItem, $ingredientsByItem, $renderLoyaltyStrip, $isOpen, $__env): void {
@endphp
@if (!$isOpen)
@endforeach
@php
$menuHtml = ob_get_clean();
// Both card layouts render once; CSS shows the active one via data-menu-view. The
// list/grid toggle lives in the menu header (built as $menuViewToggle) so it aligns
// with the section title rather than forming its own band below it.
@endphp
@if ($hasMenu)
{!! $menuHtml !!}
@endif
@if (!$hasMenu)
{{ t_raw('restaurant.no_menu_items') }}
@endif
@php };
// List/grid view switch, built once and echoed into whichever layout's menu header
// renders. Reuses .qm-seg (the shared segmented control); menu-view.js wires it to
// .qm-menu-views and persists the choice. Only shown when the menu has items.
$menuHasItems = false;
foreach ($categories as $cat) {
if (!empty($itemsByCat[(int) $cat['id']])) { $menuHasItems = true; break; }
}
$menuViewToggle = '';
if ($menuHasItems) {
ob_start(); @endphp
@php
$menuViewToggle = ob_get_clean();
}
@endphp
@php /* Cuisine flag badge over the logo's lower-left corner — owner-toggleable via Settings -> General. */
$cuisineFlag = (!empty($cuisineSlug) && (int) ($restaurant['show_cuisine_flag'] ?? 1) === 1)
? cuisine_flag((string) $cuisineSlug) : null;
@endphp
@elseif ($layout === 'style2')
@php /* ---------- STYLE 2: highlights/info-first, wide 2-col menu+cart, reviews summary up top ---------- */; @endphp
@php $renderOffers();
@endphp
@php $renderPopular();
@endphp
@php // Category jump list + menu + sticky cart. The nav only earns its column
// when there are at least two sections to jump between; with one (or none)
// the menu keeps the full width instead of showing a one-entry list.
@endphp
@php $showCatNav = count($navCats) > 1;
@endphp
@php // Location map moved here (from the standalone section) to fill the empty space beside the tall info card, below the review summary + active deal.
@endphp
@php // No section heading here: the map card already names the restaurant and
// its address, so a title only pushed the map down and made this column
// outgrow the info card beside it. The standalone map section used by the
// other layouts keeps its heading - there it introduces a full-width band.
@endphp
@php $renderMap();
@endphp
@else
@php
/* ---------- CLASSIC: original 3-col arrangement (info / menu / cart) ---------- */
@endphp
@if ($layout !== 'style2' && $reviewTotal > 0)
@php /* style2 already shows the summary in its highlights row */; @endphp
@php $renderReviewSummary();
@endphp
@endif
@if ($reviews)
@php // Only the first few are visible so a long review list does not push the
// rest of the page down; "Load more" reveals the next batch. Every review
// is already in the DOM, so this degrades gracefully without JS.
@endphp
@php $revStep = 4;
@endphp