B2in Interior: Relaunch der Website, Kernmarken-CMS und Terminanfrage

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>
This commit is contained in:
Kevin Adametz 2026-08-28 15:31:19 +00:00
parent 193d37b1ce
commit 6d53243ea6
132 changed files with 8672 additions and 335 deletions

View file

@ -13,6 +13,22 @@ class CmsArticle extends Model
protected $table = 'cms_articles';
/**
* Alte numerische Artikel-IDs (resources/lang/{locale}/b2in.php) → Slug.
*
* Wird an drei Stellen gebraucht: beim Anlegen durch den CmsArticleSeeder, für den
* Lang-Fallback und für die 301-Weiterleitung der alten /magazin/{id}-URLs.
*
* @var array<int, string>
*/
public const LEGACY_ID_TO_SLUG = [
1 => 'escrow-system-dubai-investoren',
2 => 'spotlight-al-jaddaf-hotspot',
3 => 'turnkey-investments-moeblierung-rendite',
4 => 'supply-chain-management-vertragssicherheit',
5 => 'local-for-local-regionaler-moebelhandel',
];
protected $fillable = [
'slug',
'title',
@ -53,6 +69,86 @@ class CmsArticle extends Model
return $query->orderBy('order')->orderByDesc('created_at');
}
/**
* Veröffentlichte Artikel fürs Frontend, nach Slug indiziert.
*
* Das CMS gewinnt. Solange keine Artikel gepflegt sind, greift die Lang-Datei als Fallback –
* dieselbe Reihenfolge wie bei cms_theme_section(), damit das Magazin auf einer frischen
* Installation nicht leer ist.
*
* @return array<string, array<string, mixed>>
*/
public static function forFrontend(): array
{
$articles = static::query()->published()->ordered()->get();
if ($articles->isNotEmpty()) {
return $articles
->mapWithKeys(fn (self $article) => [$article->slug => $article->toFrontendArray()])
->all();
}
return static::fallbackFromLang();
}
/**
* Slug zu einer alten numerischen Magazin-ID, falls bekannt.
*/
public static function slugForLegacyId(int|string $id): ?string
{
return self::LEGACY_ID_TO_SLUG[(int) $id] ?? null;
}
/**
* Slug eines Artikels aus der Lang-Datei.
*
* Neue Artikel bringen ihren Slug selbst mit (`'slug' => …`). Die fünf ursprünglichen
* Artikel hatten keinen und werden weiter über {@see LEGACY_ID_TO_SLUG} aufgelöst – die
* Konstante bleibt damit das, was sie ist: die Karte für die alten `/magazin/{id}`-URLs.
*
* @param array<string, mixed> $article
*/
public static function slugForLangArticle(int|string $id, array $article): ?string
{
$slug = $article['slug'] ?? null;
if (is_string($slug) && $slug !== '') {
return $slug;
}
return self::slugForLegacyId($id);
}
/**
* @return array<string, array<string, mixed>>
*/
private static function fallbackFromLang(): array
{
$articles = trans('b2in.articles');
if (! is_array($articles)) {
return [];
}
$out = [];
foreach ($articles as $id => $article) {
if (! is_array($article)) {
continue;
}
$slug = self::slugForLangArticle($id, $article);
if ($slug === null) {
continue;
}
$out[$slug] = $article + ['slug' => $slug];
}
return $out;
}
/**
* @return array<string, mixed>
*/

225
app/Models/CmsBrand.php Normal file
View file

@ -0,0 +1,225 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
/**
* Kernmarke mit eigener Detailseite (CABINET, Rotpunkt Küchen, Febrü …).
*
* Nicht zu verwechseln mit {@see Brand} – das ist die Produkt-/Partner-Marke.
*/
class CmsBrand extends Model
{
use HasFactory, HasTranslations;
/** Tatsaechlich ausgefuehrtes Projekt – ein Foto aus einem echten Auftrag. */
public const GALLERY_PROJECT = 'project';
/** Entwurf, Rendering oder KI-gestuetzte Darstellung – nicht realisiert. */
public const GALLERY_INSPIRATION = 'inspiration';
/** @var list<string> */
public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION];
protected $table = 'cms_brands';
protected $fillable = [
'slug',
'name',
'tagline',
'teaser',
'summary',
'highlights',
'description',
'logo',
'image',
'services',
'gallery',
'website_url',
'showroom',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = [
'name',
'tagline',
'teaser',
'summary',
'description',
];
protected function casts(): array
{
return [
'highlights' => 'array',
'services' => 'array',
'gallery' => 'array',
'is_published' => 'boolean',
'order' => 'integer',
];
}
public function scopePublished(Builder $query): Builder
{
return $query->where('is_published', true);
}
public function scopeOrdered(Builder $query): Builder
{
return $query->orderBy('order')->orderBy('id');
}
/**
* Veröffentlichte Kernmarken fürs Frontend, nach Slug indiziert.
*
* Das CMS gewinnt. Solange keine Marke gepflegt ist, greift die Lang-Sektion `core_brands`
* als Fallback – dieselbe Reihenfolge wie bei cms_theme_section(), damit die Startseite auf
* einer frischen Installation nicht ohne Kernmarken dasteht.
*
* @return array<string, array<string, mixed>>
*/
public static function forFrontend(): array
{
$brands = static::query()->published()->ordered()->get();
if ($brands->isNotEmpty()) {
return $brands
->mapWithKeys(fn (self $brand) => [$brand->slug => $brand->toFrontendArray()])
->all();
}
return static::fallbackFromLang();
}
/**
* @return array<string, array<string, mixed>>
*/
private static function fallbackFromLang(): array
{
$section = cms_theme_section('core_brands');
$brands = is_array($section) ? ($section['brands'] ?? []) : [];
$out = [];
foreach ($brands as $brand) {
if (! is_array($brand) || empty($brand['name'])) {
continue;
}
$slug = $brand['slug'] ?? \Illuminate\Support\Str::slug((string) $brand['name']);
$entry = array_merge([
'slug' => $slug,
'tagline' => '',
'teaser' => '',
'summary' => '',
'highlights' => [],
'description' => '',
'logo' => '',
'image' => '',
'services' => [],
'gallery' => [],
'website_url' => '',
'showroom' => '',
'has_detail_page' => false,
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
$out[$slug] = $entry;
}
return $out;
}
/**
* Galerie-Eintraege in eine feste Form bringen.
*
* Ein Eintrag ohne (oder mit unbekannter) Kategorie gilt als Inspiration, nicht als Projekt:
* Ein Entwurf, der versehentlich als Referenz durchgeht, waere die schaedlichere Verwechslung.
*
* @param mixed $gallery
* @return list<array{image: string, category: string, caption: string}>
*/
public static function normalizeGallery($gallery): array
{
if (! is_array($gallery)) {
return [];
}
$out = [];
foreach ($gallery as $item) {
$image = is_array($item) ? (string) ($item['image'] ?? '') : (string) $item;
$image = trim($image);
if ($image === '') {
continue;
}
$category = is_array($item) ? (string) ($item['category'] ?? '') : '';
$out[] = [
'image' => $image,
'category' => in_array($category, self::GALLERY_CATEGORIES, true)
? $category
: self::GALLERY_INSPIRATION,
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
];
}
return $out;
}
/**
* Galerie nach realisierten Projekten und Inspirationen getrennt.
*
* @param mixed $gallery
* @return array{projects: list<array{image: string, category: string, caption: string}>, inspirations: list<array{image: string, category: string, caption: string}>}
*/
public static function groupGallery($gallery): array
{
$items = self::normalizeGallery($gallery);
return [
'projects' => array_values(array_filter(
$items,
fn (array $item) => $item['category'] === self::GALLERY_PROJECT,
)),
'inspirations' => array_values(array_filter(
$items,
fn (array $item) => $item['category'] === self::GALLERY_INSPIRATION,
)),
];
}
/**
* @return array<string, mixed>
*/
public function toFrontendArray(): array
{
return [
'id' => $this->id,
'slug' => $this->slug,
'name' => $this->name,
'tagline' => $this->tagline,
'teaser' => $this->teaser,
'summary' => $this->summary,
'highlights' => $this->highlights ?? [],
'description' => $this->description,
'logo' => $this->logo,
'image' => $this->image,
'services' => $this->services ?? [],
'gallery' => self::normalizeGallery($this->gallery),
'website_url' => $this->website_url,
'showroom' => $this->showroom,
'has_detail_page' => true,
];
}
}