Umsetzung von AP0-AP6 aus dev/2026-08-28/entwicklungsplan.md sowie der vorab gefundenen Befunde T1 und B1-B13. Befunde - CmsContentSeeder überschrieb bei jedem Lauf alle redaktionellen Inhalte; jetzt Redaktionsschutz plus gezielter Relaunch-Seeder (B1) - Magazin-CMS war nicht ans Frontend angebunden, Artikel jetzt über Slugs mit 301 von den alten numerischen URLs (B2) - Vier veröffentlichte Display-Playlists zeigten auf b2in.test und wären live nicht ladbar gewesen (B13) - Info-Tablet speicherte geleerte Öffnungszeiten nicht (T1) - /service und /portfolio lieferten seit Februar 500 (B12) Website - Startseite neu strukturiert und umgetextet, eigene Seite /marken mit Detailseite je Kernmarke, /netzwerk und Altrouten leiten dorthin - Galerie je Marke, getrennt nach realisierten Projekten und Visualisierungen; ohne gesetzte Kategorie gilt ein Bild als Entwurf - Seite /referenzen, gespeist aus den Markengalerien - Showroom-Display im 9:16-Rahmen über eine schreibgeschützte Embed-Route ohne Preview-Token - Terminanfrage-Widget auf allen Seiten, Markenauswahl aus dem CMS, WhatsApp per Klick-zum-Chat-Link mit vorbereitetem Text - FAQ, Über-uns, Kontakt und Magazin inhaltlich auf Interior umgestellt Sonstiges - Vorschau-Instanzen liefern noindex und verlinken sich bei unbekanntem Host auf sich selbst statt auf eine fremde Domain - Seeder-Zugangsdaten aus dem Code in die Konfiguration (B8) - Ablauf für die Vorschau: dev/2026-08-28/preview-deployment.md 833 Tests grün (3084 Assertions), Pint sauber. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
208 lines
6 KiB
PHP
208 lines
6 KiB
PHP
<?php
|
||
|
||
use FluxCms\Core\Models\CmsMedia;
|
||
use FluxCms\Core\Services\CmsContentService;
|
||
|
||
if (! function_exists('legal_page')) {
|
||
/**
|
||
* Rechtstexte: zuerst CMS (Gruppe „legal“), sonst Lang-Datei b2in_legal.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
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<string, string> $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>|string|mixed
|
||
*/
|
||
function cms_theme_section(string $sectionKey, ?string $theme = null): mixed
|
||
{
|
||
$theme = $theme ?? config('app.theme', 'b2in');
|
||
|
||
/** @var array<string, array{0: string, 1: string}> $sections */
|
||
$sections = config('cms_section_map.sections', []);
|
||
/** @var array<string, string> $langKeys */
|
||
$langKeys = config('cms_section_map.lang_keys', []);
|
||
|
||
if (isset($sections[$sectionKey])) {
|
||
[$group, $key] = $sections[$sectionKey];
|
||
$fromCms = app(CmsContentService::class)->getIfExists("{$group}.{$key}");
|
||
|
||
if ($fromCms !== null) {
|
||
return $fromCms;
|
||
}
|
||
}
|
||
|
||
$langKey = $langKeys[$sectionKey] ?? $sectionKey;
|
||
|
||
return trans("b2in.themes.{$theme}.{$langKey}");
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
$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<string, string> $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('cms_media_url')) {
|
||
/**
|
||
* Resolve a CMS content key (type=image) to a full media URL.
|
||
* Falls back to asset('assets/images/...') if not found in media library.
|
||
*/
|
||
function cms_media_url(string $key, string $profile = ''): string
|
||
{
|
||
$filename = cms($key);
|
||
|
||
if (! $filename || ! is_string($filename) || $filename === $key) {
|
||
$fallback = str_replace('.', '/', $key);
|
||
|
||
return asset('assets/images/'.basename($fallback));
|
||
}
|
||
|
||
return media_url($filename, $profile);
|
||
}
|
||
}
|
||
|
||
if (! function_exists('media_url')) {
|
||
/**
|
||
* Resolve a CmsMedia filename to its full storage URL.
|
||
* Uses in-memory cache to avoid repeated DB queries.
|
||
*/
|
||
function media_url(?string $filename, string $profile = ''): string
|
||
{
|
||
if (! $filename || $filename === '') {
|
||
return '';
|
||
}
|
||
|
||
static $resolved = [];
|
||
$cacheKey = $filename.'|'.$profile;
|
||
|
||
if (isset($resolved[$cacheKey])) {
|
||
return $resolved[$cacheKey];
|
||
}
|
||
|
||
$media = CmsMedia::where('filename', $filename)->first();
|
||
|
||
if (! $media) {
|
||
$resolved[$cacheKey] = asset('assets/images/'.$filename);
|
||
|
||
return $resolved[$cacheKey];
|
||
}
|
||
|
||
if ($profile && $media->hasConversion($profile)) {
|
||
$resolved[$cacheKey] = $media->getConversionUrl($profile);
|
||
} else {
|
||
$resolved[$cacheKey] = $media->getUrl();
|
||
}
|
||
|
||
return $resolved[$cacheKey];
|
||
}
|
||
}
|