@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

{{ t_raw('restaurant.offers_title') }}

{{ t_raw('restaurant.offers_sub') }}
@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 }; // Reviews summary card with average + rating-breakdown bars. $renderReviewSummary = function () use ($rating, $restaurant, $ratingBreakdown, $reviewTotal, $__env): void { @endphp
{{ number_format($rating, 1) }} @partial('cards/stars', ['rate' => $rating]) {{ number_format((int) $restaurant['rating_count']) }} {{ t_raw('restaurant.ratings') }}
@php for ($s = 5; $s >= 1; $s--): $n = (int) ($ratingBreakdown[$s] ?? 0); $pct = $reviewTotal > 0 ? round($n / $reviewTotal * 100) : 0; @endphp
{!! $s !!} {{ $pct }}%
@endfor
@php }; // Similar / nearby restaurants cards. $renderSimilar = function () use ($similar, $cuisineName, $__env): void { if (empty($similar)) { return; } @endphp
{{ t_raw('restaurant.similar_eyebrow') }}

{!! $cuisineName ? t('restaurant.similar_title_cuisine', [':cuisine' => (string) $cuisineName]) : t('restaurant.similar_title') !!}

@foreach ($similar as $s)
@partial('cards/restaurant', ['r' => $s])
@endforeach
@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

{!! str_excerpt(e($restaurant['description']), 36) !!}

@endif

{{ t_raw('restaurant.opening_hours_title') }}

@if (!empty($hours)) @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

{{ t_raw('common.delivery') }}

{{ t_raw('common.contact') }}

@php }; // The promo card - reused by every layout. Truthful: it surfaces a REAL active // coupon when one exists; otherwise it states the actual delivery facts (free // delivery if the fee is genuinely 0, else the real fee + minimum order) — never // a fabricated "free delivery over $X" threshold. $renderPromo = function () use ($restaurant, $coupons, $__env): void { $deal = $coupons[0] ?? null; $deliveryFee = (float) $restaurant['delivery_fee']; if ($deal) { $dealVal = $deal['type'] === 'percent' ? t_raw('restaurant.deal_percent_off', [':value' => rtrim(rtrim(number_format((float) $deal['value'], 2), '0'), '.')]) : t_raw('restaurant.deal_amount_off', [':amount' => money($deal['value'])]); $title = $deal['title'] ?: $dealVal; $text = $deal['description'] ?: t_raw('restaurant.promo_deal_text', [':code' => (string) $deal['code'], ':value' => $dealVal]); $badge = t_raw('restaurant.promo_badge_deal'); } elseif ($deliveryFee <= 0) { $title = t_raw('restaurant.promo_free_title'); $text = t_raw('restaurant.promo_free_text', [':amount' => money($restaurant['min_order'])]); $badge = t_raw('common.delivery'); } else { $title = t_raw('restaurant.delivery_amount', [':amount' => money($deliveryFee)]); $text = t_raw('restaurant.promo_fee_text', [':fee' => money($deliveryFee), ':min' => money($restaurant['min_order'])]); $badge = t_raw('common.delivery'); } @endphp
{{ $badge }}

{{ $title }}

{{ $text }}

{{ t_raw('restaurant.browse_menu_btn') }}
@php }; // The order/cart sidebar - reused by every layout (one instance per page). $renderCart = function () use ($restaurant, $__env): void { @endphp

{{ t_raw('checkout.your_order') }}

{{ t_raw('cart.empty') }} {{ t_raw('restaurant.cart_empty_hint') }}

@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
{{ t_raw('restaurant.find_us') }} {{ $addr }}
@php } else { @endphp
{{ t_raw('restaurant.find_us') }} {{ $addr }}
@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
{{ $restaurant['name'] }} @if (!empty($restaurant['address']) || !empty($restaurant['city']))

{{ trim(($restaurant['address'] ?? '') . (!empty($restaurant['city']) ? ', ' . $restaurant['city'] : ''), ', ') }}

@endif @if ($radius > 0) {{ t_raw('restaurant.delivers_within', [':km' => \App\Services\Distance::number($radius), ':unit' => \App\Services\Distance::unitLabel()]) }}@endif
@php }; $renderMap = function () use ($restaurant, $mapLive, $mapCard, $mapMarkers, $__env): void { if ($mapLive) { @endphp
@php $mapCard(); @endphp
@php } else { @endphp
@php $mapCard(); @endphp
@php } }; // Loyalty rewards strip - shown above the menu when loyalty is enabled here. $renderLoyaltyStrip = function () use ($loyaltyEnabled, $loyaltyBalance, $loyaltyLabel, $loyIsCustomer, $loyIsGuest, $loyCheapest, $loyPct, $loyToGo, $__env): void { if (!$loyaltyEnabled || (!$loyIsCustomer && !$loyIsGuest)) { return; } @endphp
@if ($loyIsCustomer) {{ t_raw('restaurant.loyalty_balance_prefix') }} {{ loyalty_points($loyaltyBalance, $loyaltyLabel) }} {{ t_raw('restaurant.loyalty_balance_suffix') }} @if ($loyCheapest)
@if ($loyToGo <= 0) {{ t_raw('restaurant.loyalty_reward_unlocked', [':title' => (string) $loyCheapest['title']]) }} @else {{ t_raw('restaurant.loyalty_reward_progress', [':count' => number_format((int) $loyToGo), ':label' => $loyaltyLabel, ':title' => (string) $loyCheapest['title']]) }} @endif @endif @else {{ t_raw('restaurant.loyalty_signin_prefix', [':label' => $loyaltyLabel]) }} {{ t_raw('common.sign_in') }} {{ t_raw('restaurant.loyalty_signin_suffix') }} @endif
@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)
{{ t_raw('restaurant.closed_banner_title') }} {!! setting('scheduling_enabled', '1') === '1' ? t('restaurant.closed_banner_text_scheduled') : t('restaurant.closed_banner_text') !!}
@endif @php $renderLoyaltyStrip(); $hasMenu = false; ob_start(); @endphp @foreach ($categories as $cat) @php if (empty($itemsByCat[(int) $cat['id']])) { continue; } $hasMenu = true; @endphp

{{ $cat['name'] }} {{ count($itemsByCat[(int) $cat['id']]) }}

@foreach ($itemsByCat[(int) $cat['id']] as $item) @partial('cards/food-item', ['item' => $item, 'options' => $optionsByItem[(int) $item['id']] ?? [], 'ingredients' => $ingredientsByItem[(int) $item['id']] ?? []]) @endforeach
@foreach ($itemsByCat[(int) $cat['id']] as $item)
@partial('cards/food-item', ['item' => $item, 'options' => $optionsByItem[(int) $item['id']] ?? [], 'ingredients' => $ingredientsByItem[(int) $item['id']] ?? [], 'layout' => 'grid'])
@endforeach
@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
{!! restaurant_logo_html($restaurant, 'xl', 'qm-rd-hero-logo') !!} @if ($cuisineFlag) @endif

{{ $restaurant['name'] }}

@if (!empty($restaurant['tagline']))

{{ $restaurant['tagline'] }}

@endif
@if ($ratingCount > 0){{ number_format($rating, 1) }} ({{ $ratingCount }})@else{{ t_raw('restaurant.no_ratings_yet') }}@endif @if ($cuisineName) {{ $cuisineName }}@endif {{ (int) $restaurant['avg_prep_time'] }} {{ t_raw('common.prep_min_suffix') }} @if ($radius > 0) {{ t_raw('restaurant.delivers_within', [':km' => \App\Services\Distance::number($radius), ':unit' => \App\Services\Distance::unitLabel()]) }}@endif {{ money($restaurant['min_order']) }} {{ t_raw('restaurant.min_order') }} {!! (float) $restaurant['delivery_fee'] > 0 ? t('restaurant.delivery_amount', [':amount' => money($restaurant['delivery_fee'])]) : t('restaurant.promo_free_title') !!} {{ t_raw('restaurant.pickup_available') }}
@if ($tags)
@php $tagTaken = []; @endphp @foreach ($tags as $t){{ $t['name'] }}@endforeach
@endif
@if ((int) ($restaurant['accepts_reservations'] ?? 0) === 1) {{ t_raw('reservation.crumb') }} @endif @if (can_favorite())
@csrf
@endif
@if ($layout === 'style1') @php /* ---------- STYLE 1: menu-first, offers + popular rail on top, 3-col with left ad/offers + right cart ---------- */; @endphp
@php $renderOffers(); @endphp @php $renderPopular(); @endphp
@php $renderCart(); @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
@if ($showCatNav)
@php $renderCatNav(); @endphp
@endif
@php $renderCart(); @endphp
@php $renderInfoCard(); @endphp
@php $renderReviewSummary(); @endphp
@php $renderPromo(); @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
@php $renderOffers(); @endphp
@php $renderCart(); @endphp
@php $renderPopular(); @endphp
@endif
{{ t_raw('restaurant.about') }}

{{ t_raw('restaurant.about_heading', [':name' => (string) $restaurant['name']]) }}

@if (!empty($restaurant['description']))

{{ $restaurant['description'] }}

@endif
{{ number_format($rating, 1) }} {{ t_raw('restaurant.stat_avg_rating') }}
{{ (int) $restaurant['avg_prep_time'] }}{{ t_raw('common.prep_min_suffix') }} {{ t_raw('restaurant.prep_time_label') }}
{{ number_format((int) $restaurant['rating_count']) }} {{ t_raw('restaurant.stat_happy_reviews') }}
@if ($showAboutMedia)
@php // Owner-managed media: an uploaded image, an MP4, or a YouTube link. @endphp {!! restaurant_media_html( $aboutMediaActive, 'qm-rd-figure-img', t_raw('restaurant.figure_alt', [':name' => (string) $restaurant['name']]), $videoPost ) !!}
{{ t_raw('restaurant.figure_caption', [':name' => (string) $restaurant['name']]) }}
@endif
@if ($layout !== 'style2') @php // style2 renders the map inside the highlights row, next to the info card; @endphp
{{ t_raw('restaurant.location_label') }}

{{ t_raw('restaurant.location_heading') }}

@php $renderMap(); @endphp
@endif @if (!empty($gallery)) @endif
{{ t_raw('restaurant.reviews') }}

{{ t_raw('restaurant.reviews_heading') }}

@php $renderSimilar(); @endphp
@partial('item-modal') @endsection