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:
parent
193d37b1ce
commit
6d53243ea6
132 changed files with 8672 additions and 335 deletions
|
|
@ -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
225
app/Models/CmsBrand.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue