*/ function legal_page(string $key): array { $fromCms = app(CmsContentService::class)->get('legal.'.$key); if (is_array($fromCms) && isset($fromCms['content'])) { return $fromCms; } return trans('b2in_legal.'.$key); } } if (! function_exists('cms')) { /** * Resolve a CMS content value by dot-notation key. * * First segment is the group, rest is the key: "home.hero.title" * Falls back to __() if no DB entry exists. * * @param array $replace */ function cms(string $key, array $replace = [], ?string $locale = null): mixed { return app(CmsContentService::class)->get($key, $replace, $locale); } } if (! function_exists('cms_theme_section')) { /** * Theme-Sektion: zuerst CMS (gemäß cms_section_map), sonst Lang b2in.themes.{theme}.{section}. * * @return array|string|mixed */ function cms_theme_section(string $sectionKey, ?string $theme = null): mixed { $theme = $theme ?? config('app.theme', 'b2in'); /** @var array $sections */ $sections = config('cms_section_map.sections', []); if (isset($sections[$sectionKey])) { [$group, $key] = $sections[$sectionKey]; $fromCms = app(CmsContentService::class)->getIfExists("{$group}.{$key}"); if ($fromCms !== null) { return $fromCms; } } return trans("b2in.themes.{$theme}.{$sectionKey}"); } } if (! function_exists('references_page_enabled')) { /** * Ob die Seite /referenzen samt Menüpunkt sichtbar ist – Schalter im CMS-Modul „Referenzen“ * (Einstellung pages.referenzen_enabled). Ohne Eintrag ist die Seite sichtbar. */ function references_page_enabled(): bool { return (bool) Setting::getValue('pages', 'referenzen_enabled', true); } } if (! function_exists('cms_inline')) { /** * CMS-Text für die Ausgabe innerhalb einer Zeile oder eines Absatzes (12.10.2026): Klartext * wird escaped, Editor-HTML auf Inline-Auszeichnung reduziert (fett, kursiv, Hervorhebung, * Links, Zeilenumbrüche) – Absatz-Tags aus dem Editor erscheinen so nicht als Text. */ function cms_inline(mixed $value): \Illuminate\Support\HtmlString { $text = trim((string) $value); if (! str_contains($text, '<')) { return new \Illuminate\Support\HtmlString(e(html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'))); } $html = \App\Services\CmsFluxEditorHtmlTransformer::unwrapParagraphs( \App\Services\CmsFluxEditorHtmlTransformer::fromEditor($text) ); if (! str_contains($html, '<')) { return new \Illuminate\Support\HtmlString(e($html)); } $html = (string) preg_replace('#\s*<\1[^>]*>#i', '

', $html); $html = strip_tags($html, '
'); // Nur harmlose Attribute: keine Event-Handler, keine javascript:-Links. $html = (string) preg_replace('#\s+on[a-z]+\s*=\s*("[^"]*"|\'[^\']*\'|[^\s>]+)#i', '', $html); $html = (string) preg_replace('#href\s*=\s*(["\'])\s*javascript:[^"\']*\1#i', 'href="#"', $html); return new \Illuminate\Support\HtmlString(trim($html)); } } if (! function_exists('whatsapp_link')) { /** * Klick-zum-Chat-Link, sofern im CMS eine Nummer hinterlegt ist (global.contact.whatsapp). * * Liefert einen leeren String, solange keine Nummer bestaetigt ist – der Button erscheint dann * gar nicht erst, statt auf eine erfundene Nummer zu zeigen. */ function whatsapp_link(): string { $service = app(CmsContentService::class); // Die Nummer kann als eigener Eintrag oder als Feld der Sektion `global.contact` liegen. $number = $service->getIfExists('global.contact.whatsapp'); if ($number === null) { $contact = $service->getIfExists('global.contact'); $number = is_array($contact) ? ($contact['whatsapp'] ?? null) : null; } if (! is_string($number)) { return ''; } $digits = preg_replace('/\D+/', '', $number); return $digits === '' ? '' : 'https://wa.me/'.$digits; } } if (! function_exists('theme_image_url')) { /** * Bild-URL für Theme-/CMS-Inhalte: zuerst Medienbibliothek (CmsMedia, oft nur Dateiname z. B. *.webp), * sonst statische Assets unter public/img/assets/ (Legacy-Pfade wie b2in/…). */ function theme_image_url(?string $value): string { if ($value === null) { return ''; } $trimmed = ltrim(trim($value), '/'); if ($trimmed === '') { return ''; } if (str_starts_with($trimmed, 'http://') || str_starts_with($trimmed, 'https://')) { return $trimmed; } if (str_starts_with($trimmed, 'storage/')) { return asset($trimmed); } if (str_starts_with($trimmed, 'immobile/')) { return asset('storage/'.$trimmed); } // Liegt die Datei unter public/img/assets/, kommt sie von dort – auch wenn sie inzwischen in // der Mediathek steht (AssetMediaImportSeeder). Nur so findet die WebP-Fassung // neben JPG/PNG. if (str_contains($trimmed, '/') && is_file(public_path('img/assets/'.$trimmed))) { return asset('img/assets/'.$trimmed); } $candidates = array_values(array_unique(array_filter([$trimmed, basename($trimmed)]))); foreach ($candidates as $candidate) { if (CmsMedia::query()->where('filename', $candidate)->exists()) { return media_url($candidate); } } if (str_contains($trimmed, '/')) { return asset('img/assets/'.$trimmed); } return media_url($trimmed); } } if (! function_exists('tcms')) { /** * Typed CMS – always returns a string. * * @param array $replace */ function tcms(string $key, array $replace = [], ?string $locale = null): string { $text = cms($key, $replace, $locale); return is_string($text) ? $text : (string) $text; } } if (! function_exists('media_url')) { /** * Resolve a CmsMedia filename to its full storage URL. * Cached per request to avoid repeated DB queries. * * Ohne Eintrag in der Mediathek zeigt die URL auf public/img/assets/ – dort legen die Seeder * Marken-, Magazin- und Slideshow-Bilder ab (wie theme_image_url() im Frontend). Der frühere * Rückfall assets/images/ existierte nicht und lieferte im Admin 404 (07.10.2026). */ function media_url(?string $filename, string $profile = ''): string { if (! $filename || $filename === '') { return ''; } // once(): je Request (und je Test) zwischengespeichert, nicht über die ganze Prozesslaufzeit. return once(function () use ($filename, $profile): string { $media = CmsMedia::where('filename', $filename)->first(); if (! $media) { return asset('img/assets/'.ltrim($filename, '/')); } return $profile && $media->hasConversion($profile) ? $media->getConversionUrl($profile) : $media->getUrl(); }); } } if (! function_exists('b2in_world')) { /** * Farbwelt der aktuellen Seite: `interior` (Standard) oder `immobilien`. * * Die Immobilien-Welt umfasst die Immobilien-Routen samt Detailseiten und den * Entwicklungs-Vorschauen. Das Layout setzt den Wert als Body-Klasse * (`world-interior` / `world-immobilien`), theme-b2in.css schaltet daran die Farben um. * Livewire-Komponenten merken sich die Welt in mount(), weil ein späterer * Livewire-Request keine Seitenroute mehr trägt. */ function b2in_world(): string { if (app()->bound('b2in.world')) { return (string) app('b2in.world'); } $routeName = request()->route()?->getName(); if (is_string($routeName) && (str_starts_with($routeName, 'immobilien') || str_starts_with($routeName, 'dev.immobilien'))) { return 'immobilien'; } return 'interior'; } } if (! function_exists('b2in_worlds')) { /** * Die beiden Welten für den Welten-Wechsler in Kopf- und Fußzeile. * * @return array */ function b2in_worlds(?string $current = null): array { $current = $current ?? b2in_world(); return [ 'interior' => [ 'label' => __('ui.world_interior'), 'url' => url('/'), 'active' => $current === 'interior', ], 'immobilien' => [ 'label' => __('ui.world_immobilien'), 'url' => route('immobilien'), 'active' => $current === 'immobilien', ], ]; } } if (! function_exists('b2in_world_set')) { /** * Welt für den laufenden Request festlegen, wenn sie nicht an der Route hängt – * etwa auf der Magazin-Detailseite, die in der Welt ihres Beitrags rendert. * Muss vor dem Rendern von Kopf- und Fußzeile geschehen (beide halten die Welt in mount() fest). */ function b2in_world_set(string $world): void { app()->instance('b2in.world', $world); } }