Compare commits

..

24 commits

Author SHA1 Message Date
451e738c8b CMS: Absatz-Tags aus dem Rich-Text-Editor erscheinen nicht mehr als Text
Der Editor packt eingefügten Text in <p>; Felder wie die FAQ-Antwort gibt die Website als Text
aus, die Tags waren sichtbar. Beim Speichern entfernt unwrapParagraphs() die Absätze (ein
Absatz ohne <p>, mehrere mit <br><br>, Blockinhalte unverändert). cms_inline() gibt die
Felder als bereinigtes Inline-HTML aus – auch schon gespeicherte Texte mit <p> erscheinen
sauber, Fett und Hervorhebung werden dargestellt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:58:48 +00:00
07816dce19 Kontaktdaten: Telefon +49 521 986 20 100, E-Mail interior@b2in.eu
Lang-Dateien (Kontaktformular, Impressum, Datenschutz, de/en), Support-Link im Partnerportal,
WhatsApp-Schreibweise und Platzhalter im Admin. ContactDetailsSeeder ersetzt die alten Werte in
den gespeicherten CMS-Einträgen (beide Sprachen), Marken und Magazin; Display-Folien und
Info-Tablet (CABINET-Showroom) bleiben unberührt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:29:34 +00:00
17acb35231 Projektdokumentation im Admin auf den Stand v2.0.6 gebracht
dev/entwicklung.md neu: Welten Interior/Immobilien, Seiten und Bausteine der Website, CMS
im Portal, Store Displays mit Playlists, Betrieb (Git-Deploy, Monitoring), Datenbank, Tests,
offene Punkte. Die Doku-Seite stellt Tabellen jetzt lesbar dar.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:43:12 +00:00
061f004013 Admin: Monitoring-Seite (Systemstatus) und Navigation neu sortiert
- /admin/monitoring (nur Admin): Prüfungen wie /health, Version und Deploy-Zeit, geplante
  Aufgaben mit nächstem Lauf, letzte Fehler aus dem Log (ohne E-Mail-Adressen), Alarme,
  fehlgeschlagene Jobs, Links zu GlitchTip/Uptime Kuma (MONITORING_STATUS_URL), Test-Alarm
- Navigation: CMS, Cabinet, Produkte, Regionen, Admin, ganz unten Monitoring

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:27:04 +00:00
8ddc85422e Englische Fassung der Markenseiten: Merkmale, Leistungen, Abschnitte, Showroom, Bildunterschriften
Diese Markenfelder waren nur einsprachig gespeichert und erschienen auf der englischen Seite
deutsch. Neue Spalte cms_brands.field_translations (additiv) trägt die Übersetzungen je Sprache,
die bisherigen Spalten bleiben die deutsche Fassung; ohne Übersetzung gilt weiter Deutsch.
Marken-Editor bearbeitet die Felder über den DE/EN-Umschalter (Bilder und Rubriken bleiben
sprachübergreifend). BrandTranslationsSeeder spielt die englischen Texte der vier Kernmarken
ein, ohne gepflegte Übersetzungen zu überschreiben. php artisan cms:untranslated listet
Inhalte, die in einer Sprache noch fehlen oder deutsch sind.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 10:08:03 +00:00
dc31cdb401 FAQ im Inhalte-Editor vollständig bearbeitbar: Bereich je Frage statt verschachtelter sections
Die Bereiche der FAQ-Seite lagen als verschachtelte Listen in sections, der Editor zeigte
davon nur JSON-Text. Jetzt stehen alle Fragen in questions mit dem Feld category, Überschrift
und Symbol der Bereiche in categories. FAQ::groups baut daraus die Seite (allgemeine Fragen
zuerst), alte Einträge werden weiter dargestellt. FaqCategoriesSeeder stellt gespeicherte
Einträge um (FAQ und Immobilien-FAQ), Texte bleiben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:50:44 +00:00
69d50eed0f Hotfix: Vorschau im Inhalte-Editor und Seitennavigation der Medienbibliothek
- AllowPortalFraming: frame-ancestors 'self' + Portal statt X-Frame-Options SAMEORIGIN,
  damit portal.b2in.eu die Website im Vorschau-iframe zeigen darf
- Medienbibliothek und Hub-Verwaltung: WithPagination – ohne zeigten die Seitenlinks nach
  dem ersten Livewire-Klick auf den Update-Endpunkt (405); Suche/Filter springen auf Seite 1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:36:16 +00:00
a661a68913 Hotfix: Livewire-UI-Komponenten im Ordner Ui (wie der Namespace)
Der Ordner hieß im Repository UI, der Namespace App\Livewire\Web\Components\Ui. Auf dem
Mac fällt das nicht auf, auf Linux findet der Autoloader die Klassen nicht: Header,
Footer usw. rendern ohne Klasse (500, Undefined variable $logoPath). Wächtertest prüft
Pfad und Namespace auf gleiche Schreibweise.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:11:39 +00:00
cbf04e1f9c Git-Deploy: Laufzeitdateien des Display-Players nicht versionieren
clicks.log und die Logs unter public/_cabinet schreibt der Player im Betrieb – versioniert
hätte der Checkout die Klickstatistik überschrieben und jeder spätere Deploy wäre an der
Abweichung gescheitert. Nur auf dem Server liegende Player-Dateien (offers/player.html,
offers/logo-cabinet-300.png) und public/_flux bleiben per .gitignore an Ort und Stelle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:04:09 +00:00
b5ed756d67 Git-Deploy per Tag, Betriebsüberwachung und Release-Seeder für den Livegang
- dev/betrieb/deploy.sh: Deploy nur per Tag, mit --seed (Seeder im Wartungsmodus)
  und --initial (Vorschau/Live einmalig an Ort und Stelle auf Git umstellen)
- dev/betrieb/vorschau-auffrischen.sh: Live-DB und Live-Medien in die Vorschau (Generalprobe)
- InteriorReleaseSeeder: alle Inhaltsschritte des Relaunchs in einem Lauf, idempotent;
  Displays, Playlists und Mediathek bleiben unberührt
- DisplayInteriorBrandingSeeder überschreibt keinen inzwischen gepflegten Claim mehr
- Monitoring: GlitchTip (Server + Browser), /health mit Token, Scheduler-Herzschlag,
  Alarme per Mail und ntfy, Version in about und /health
- public/build versioniert; .gitignore: macOS-Muster Icon traf resources/views/flux/icon/
- Runbook: dev/betrieb/README.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:56:23 +00:00
fcb10be120 Interior-Feinschliff: Hero-Slideshow, Lektorat, Rechtstexte, Aufräumen und Korrekturen 08.10.
Startseite und Heroes
- Hero-Slideshow (11 Motive inkl. Febrü) mit Infokarte, Laufzeit-Linie, größeren Klickflächen,
  Goldrahmen und mobiler Karte unter dem Bild; Zeile über der Überschrift, neue Vertrauenspunkte
- Hero-Stil auf Marken, Über uns und Kontakt übertragen, Breadcrumb entfernt
- Gründer-Leiste, Vision-Box auf Ocker, Showroom-Display in Box mit „Größer ansehen“/Vollbild
- Akzentfarbe #a8741f, größere Serif-Überschriften in der Interior-Welt
- Marken: Bildpause mit Zitat; Vereine aktualisiert und verlinkt

Inhalte
- Lektorat aller angezeigten Texte, /about-Korrekturen, Rechtstexte (DDG, TDDDG, Hosting Hetzner)
- Ablauf Maßanfertigung überall einheitlich, Magazin-Beitrag neu, alte Beiträge offline,
  deutsches Datum/Lesezeit, FAQ Projektausstattung, Otterpohl
- Referenzen-Seite per CMS-Schalter ausblendbar

CMS und Technik
- Tote Sektionen, Felder, Seiten und Komponenten entfernt und archiviert (dev/2026-08-28/archiv-*)
- Entwurf /entwurf, Farb-Schalter und /welcome entfernt; Test-Routen nur noch lokal
- Seitenbaum-Beschreibungen geprüft, fehlende CMS-Felder ergänzt, Markenliste aus „Kernmarken“
- Admin-Bildvorschauen repariert, verwendete Bilder in die Mediathek übernommen
- Seeder für das Deployment siehe dev/2026-08-28/entwicklungsplan.md

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:00:15 +00:00
4b07bbdcae Startseite ruhiger, Otterpohl mit Montage, Marken-Bilder und Farbwelt-Entwurf
- Hinweisleiste per B2IN_ANNOUNCEMENT_BAR ausgeblendet (Standard aus)
- Hero: „Räume aus einer Hand.“ als neue Überschrift, zweiter Button entfernt
- Projekttext mit zentralem deutschen Vertragspartner (Punkt statt Gedankenstrich)
- Otterpohl als vierte Kernmarke: Text v2, Abschnitt „Montage“, Titelbild getauscht
- Markenabschnitte: Absätze mit „- “ als Liste mit fetter Einleitung
- Titelbilder und Galerien für CABINET, Rotpunkt, Febrü und Otterpohl (WebP)
- Feedback 17.09. in den Markentexten, Demobilder entfernt, WhatsApp-Nummer
- Palette „Modern“ fest ohne Schalter, Entwurf /entwurf mit Hero-Slideshow
- Seeder-Liste fürs Deployment im Entwicklungsplan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 10:34:21 +00:00
3211569fee Store Display: Bilddauer mit Nachkommastelle (1,5 Sekunden)
Das Feld „Dauer (Sekunden)“ im b2in-Display nimmt Komma und Punkt an,
erlaubt 0,5 bis 120 Sekunden und speichert eine Zahl; der Player rechnet
unverändert mal 1000. Anzeige in der Liste mit Komma. Nachtrag 14.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 09:52:33 +00:00
90aa079cf7 CMS: Standorte an der Marke, Seitenbaum mit Vorschau, Referenzen-Modul
Nachtrag 11: Herkunfts-Sektion liest Ort, Entfernung und Rolle aus der
Markenverwaltung (drei neue Felder an cms_brands, BrandOriginSeeder),
Kartenpositionen im Code, „Marken“ in der Sidebar.

Nachtrag 12: Inhalte-Editor zeigt die Seiten der Website mit ihren
Sektionen in Reihenfolge (config/cms_pages.php, CmsPageTree), Felder in
Klartext, fehlende Sektionen aus der Sprachdatei anlegbar, Vorschau der
echten Seite im iframe mit Sprung zur Sektion; Anker an allen lebenden
Sektionen nachgezogen.

Nachtrag 13: Referenzen als eigenes Modul (CmsReference, Portal-Seite,
/referenzen mit Fallback auf die Markengalerien); Immobilien-Projekte
aus Sidebar und CMS-Übersicht genommen, Route bleibt.

Dazu .gitignore für _b2in_interior_shop/ und docs/.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 14:58:45 +00:00
7953a8c9a1 Interior-Farbwelt, Welten-Trennung, Tischlerei und Herkunfts-Sektion
Arbeitsplan 14.09.2026, Punkte 1.1 bis 1.4 (Nachträge 6 bis 10 im Entwicklungsplan):

- Farbwelt Interior (Tanne, Creme, Sand, Ocker, Clay) als Standard, Immobilien-Welt
  behält das bisherige Blau über body.world-immobilien; Logo je Welt, Favicons neu,
  Welten-Wechsler in Kopf- und Fußzeile.
- Saubere Trennung der Welten: eigene Navigation, Ankündigungsleiste, Magazinliste,
  Über-uns-Seite, FAQ, Kontaktseite und Abschluss-CTA für Immobilien; Beiträge tragen
  eine Welt (Migration + Admin-Feld). Abschluss-CTA Ton in Ton statt Grün-zu-Ocker.
- Kontakt: Betreffe in Gruppen Interior / Immobilien / B2in, Marken darin.
  Terminanfrage-Buttons öffnen das Widget mit vorgewähltem Anliegen; Select-Felder
  einheitlich gestaltet.
- Tischlerei als vierte Kernmarke (Platzhalter), Kachelraster 2×2.
- Herkunfts-Sektion „Aus der Region gefertigt“ mit schematischer Standortkarte und
  Ablauf CABINET (Beratung Bielefeld, Fertigung Köln, Aufbau mit Tischlerei).

Deployment: migrate, WorldSplitCmsSeeder, CmsBrandSeeder, OriginSectionCmsSeeder
(Abschnitt 8 im Entwicklungsplan).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-15 12:46:41 +00:00
1242d4bce8 Markenlogos optisch aufeinander abstimmen
Gewuenscht: CABINET 15 Prozent kleiner, Rotpunkt 20 Prozent groesser, Februe
unveraendert. Rotpunkt fuellte die Box bereits ganz aus und konnte nur ueber
eine groessere Box wachsen.

- Alle vier Logo-Boxen 20 Prozent hoeher (Seitenkopf 96 → 115 px, Kacheln
  44 → 53 px, /marken 56 → 67 px). Rotpunkt waechst dadurch um genau 20 %.
- Februe gleicht das ueber 20 Prozent Luft oben und unten in der Datei aus und
  rendert unveraendert.
- CABINET bekommt 18 Prozent Luft rechts – die Box begrenzt den 4:1-Schriftzug
  ueber die Breite, nicht ueber die Hoehe. Die Luft sitzt nur rechts, damit das
  Logo links buendig zum Text darunter bleibt.

Nachgerechnet an allen drei Stellen: CABINET 85 %, Rotpunkt 120 %, Februe 100 %.

Die Abstimmung steckt in den Zeichenflaechen der Dateien, nicht im Blade – so
gilt sie an allen vier Stellen gleichzeitig. Der Merksatz fuer neue Logos steht
im Kopf von BrandLogoSeeder.

Tests: 873 gruen (3217 Assertions), Pint sauber, alle Themes gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 12:47:41 +00:00
3958a1fe89 Markenlogos auf ihren Inhalt beschneiden
CABINET und Rotpunkt wirkten neben Februe zu klein. Ursache war nicht die Box,
sondern leerer Rand in den Dateien: CABINET nutzte nur 29 Prozent der Hoehe
seiner Zeichenflaeche, Rotpunkt 70 Prozent. CABINET ist ein 4:1-Schriftzug in
einer fast quadratischen Flaeche – im Seitenkopf blieben davon rund 28 px
sichtbare Hoehe.

Bei beiden SVGs auf die Tuschekante beschnitten (viewBox auf den Inhalt
gesetzt, plus 1 Prozent Luft; die Pfade selbst unveraendert). Beide nutzen ihre
Flaeche jetzt zu 98 Prozent. Im Seitenkopf ist CABINET damit rund zweieinhalb-
mal so gross wie vorher, Rotpunkt gut anderthalbmal und auf derselben Hoehe
wie Februe.

Die Boxgroessen im Frontend bleiben unveraendert – Februe sass bereits richtig,
eine groessere Box haette Februe mitvergroessert.

Ein Test haelt fest, dass unter public/ die beschnittenen Fassungen liegen und
nicht die Zulieferungen.

Tests: 873 gruen (3217 Assertions), Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:33:12 +00:00
c8899e2d7d Markenlogos einbinden: Seitenkopf und alle Kacheln
Zulieferung dev/2026-08-28/markenlogos/ (CABINET und Rotpunkt als SVG, Februe
als PNG). Die Dateien liegen unter public/img/assets/marken/ und haengen als
`logo` an der jeweiligen Marke.

- Kopf der Markenseite: Logo anstelle des Schriftzugs, im <h1>, damit die
  Ueberschrift erhalten bleibt – den Text liefert das alt-Attribut. Ohne Logo
  erscheint weiterhin der Schriftzug.
- Logo zusaetzlich auf der Startseiten-Kachel, im Block auf /marken und in den
  „Weitere Marken“-Kacheln am Seitenfuss.
- Feste Box (Hoehe + Maximalbreite + object-contain) statt fester Hoehe: die
  Seitenverhaeltnisse gehen weit auseinander (Februe 2,3:1, Rotpunkt 1,2:1),
  mit reiner Hoehenangabe waere Februe doppelt so breit erschienen. Konvention
  aus der Sponsoring-Sektion.
- Februe-Logo auf seinen Inhalt beschnitten: im Original waren knapp drei
  Viertel der Flaeche leerer Rand, in einer Box haette es winzig gewirkt.
  53 KB → 15 KB.
- BrandLogoSeeder traegt die Logos auf bestehenden Installationen nach –
  CmsBrandSeeder ueberspringt vorhandene Marken. Setzt nur `logo` und nur bei
  leerem Feld, ein im Admin hochgeladenes Logo bleibt stehen.

Tests: 871 gruen (3215 Assertions), Pint sauber, alle Themes gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 11:16:41 +00:00
093e0ece51 Abschluss-CTA: Ausnahme vom Glass-Flow-Sektionshintergrund
Nach dem Build stand der Abschluss hellgrau mit weisser Schrift da.
`.variante-glass-flow section` faerbt jede Sektion in diesem Wrapper hell ein
und ist mit (0,1,1) spezifischer als `.cta-band` mit (0,1,0). Fuer
`.bg-secondary` und `.bg-accent` gibt es dort laengst Ausnahmen – die neue
Klasse brauchte dieselbe. /marken/{slug} haengt in genau diesem Wrapper.

Der Verlauf steht jetzt in --cta-band-gradient und wird von beiden Regeln
benutzt, damit er nicht doppelt gepflegt werden muss. Ein Test haelt die
Ausnahme fest.

Nebenbei dokumentiert: `npm run build:web` baut nur die vier Frontend-Themes,
nicht das Portal. Ohne `npm run build:portal` liefert das Admin-Portal 500 –
in Deployment-Checkliste und Vorschau-Runbook ergaenzt, samt Pruefschleife
ueber alle fuenf Manifeste.

Tests: 863 gruen (3194 Assertions), Pint sauber, alle Themes gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:36:02 +00:00
3ee7b44a77 Abschluss-CTA: durchgehender Verlauf statt weicher Kreise
Die beiden blur-Kreise ueber dem Hellblau wirkten als Flecken – der dunkle
rechts unten am staerksten. Ersetzt durch .cta-band in shared-styles.css: ein
durchgehender Verlauf ueber die volle Breite, von Anthrazit diagonal in das
Dynamic Blue, beides aus den Theme-Variablen.

- Tiefe kommt aus dem Verlauf, nicht aus ueberlagerten Flaechen.
- Zwei Haarlinien als Kontur: eine oben ueber die Breite, eine als Auftakt
  ueber der Ueberschrift, beide zu den Seiten auslaufend.
- Buttons filigraner: duennerer Rahmen beim Outline-Button, weicherer Schatten
  beim Hauptbutton.

Der Verlauf liegt als CSS-Klasse vor und nicht als Utility-Kette im Blade,
damit er mit den Theme-Variablen arbeitet und ueber alle Themes stimmt.

Tests: 862 gruen (3192 Assertions), Pint sauber, npm run build:b2in gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:12:20 +00:00
f971f416db Akkordeons weicher aufklappen, starken Hover entfernen
Die Klappelemente sprangen auf und zu: x-show gibt dem Element sofort die
volle Hoehe und blendet nur den Inhalt ein. Ersetzt durch eine Animation von
grid-template-rows 0fr → 1fr ueber 500 ms mit cubic-bezier(0.33, 1, 0.68, 1),
die die tatsaechliche Hoehe bewegt – ohne das Alpine-Collapse-Plugin, das in
diesem Projekt nicht registriert ist.

- Der zugeklappte Zustand steht auch statisch im class-Attribut, damit vor dem
  Start von Alpine nichts aufgeklappt aufblitzt.
- aria-hidden haengt am Zustand, weil der Inhalt jetzt dauerhaft im DOM steht.
- Hover: die Flaechenaufhellung (hover:bg-muted/30) ist raus, geblieben ist ein
  feiner Farbwechsel der Ueberschrift. Im FAQ entfaellt zusaetzlich der
  Hintergrundwechsel am Icon-Kreis.
- Der Pfeil dreht in derselben Dauer und Kurve wie das Aufklappen.

Betrifft beide Akkordeons – die Vertiefungen der Markenseiten und das FAQ.

Tests: 861 gruen (3189 Assertions), Pint sauber, npm run build:b2in gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 10:00:39 +00:00
a52347845e Abstimmungsrunde 03.09.: CABINET-Seite, korrigierte Markentexte, Galerie-Hinweise, Abschluss-CTA
Setzt die vier Punkte aus dev/2026-08-28/umsetzung-03-09-2026.md um.

1. Markenseiten
   - Rotpunkt und Febrü auf die korrigierten Fassungen (*_FINAL.md) gebracht:
     bei Rotpunkt sind die beiden Nachhaltigkeitsabschnitte zusammengefuehrt,
     bei Febrü behaelt der Blaue-Engel-Satz seinen Produktbezug ohne den
     erklaerenden Zusatz.
   - CABINET nach demselben Muster neu verfasst: 8 Abschnitte, 3 Vertiefungen,
     eigener Abschlussblock und eigene Widget-Beschriftung. Bewusst ohne Zahlen,
     Zertifizierungen oder Herstelleraussagen – dafuer gibt es keine Quelle.
     Braucht Marcels Freigabe.
   - Febrü-Claim korrigiert: „Arbeiten, wo man gerne bleibt.“

2. „BEISPIEL“-Vermerk aus den Bildunterschriften entfernt. Weil die Demobilder
   auf Vorschau und Live bereits in der Datenbank stehen, entfernt
   BrandGalleryCaptionNormalizerSeeder ihn dort in-place – nur der Text, Bild,
   Kategorie und Reihenfolge bleiben unberuehrt.

3. Hinweise zu Visualisierungen neu formuliert (Markengalerie und /referenzen).
   Die Trennung bleibt, sie laeuft nur nicht mehr ueber eine Verneinung.

4. Abschluss-CTA der Markenseiten ueberarbeitet: weiche Lichtflaechen statt
   Vollton, gesetzter Hauptbutton mit Pfeil, Herstellerseite als gleichwertiger
   Outline-Button, Showroom als Fusszeile.

Ein Pruefskript hat jeden veroeffentlichten Satz gegen die abgestimmten
Markdown-Fassungen gehalten: keine Abweichung.

Tests: 859 gruen (3180 Assertions), Pint sauber, npm run build:b2in gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 09:17:16 +00:00
4ef3a4d8f1 Markenseiten Rotpunkt und Febrü aus der Zulieferung vom 31.08.
Umsetzung der beiden finalen Webseitentexte aus dev/2026-08-28/.
Die angekündigte Aufgabenliste umsetzung-03-09-2026.md war leer, Grundlage
sind daher die Umsetzungshinweise in den PDFs selbst.

- Neue Felder in cms_brands: sections, widget_label, cta_title, cta_text.
  Einleitung und Leistungskacheln reichten für diese Texte nicht aus.
- Die als Vertiefung gelieferten Blöcke werden aufklappbar dargestellt,
  mit aria-expanded und aria-controls. Bewusst x-transition statt
  x-collapse, weil das Collapse-Plugin hier nicht registriert ist.
- Kein zweiter CTA-Button: das schwebende Terminanfrage-Widget trägt auf
  der Markenseite die Beschriftung der Marke und liest den Kontext aus
  der Route, da es im Layout und nicht je Seite eingebunden ist.
- Footer bekommt unten Platz und lange Beschriftungen brechen um, damit
  das Widget auf dem Smartphone nichts verdeckt.
- Abschnitte sind im Admin vollständig pflegbar, inklusive Sortierung.
- Interne Quellenhinweise der PDFs sind nicht übernommen; ein Test prüft,
  dass sie nicht im Ausgabe-HTML stehen.
- BrandPageTextSeeder fasst nur die Textfelder an, Bilder, Galerie und
  Leistungen bleiben unangetastet.

Vor der Veröffentlichung: Herstellerfreigabe einholen und die Aussage zur
erhaltenen Waldfläche nur mit ausdrücklicher Bestätigung verwenden. Beides
steht in der Deployment-Checkliste.

854 Tests grün (3150 Assertions), Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-03 08:52:36 +00:00
6d53243ea6 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>
2026-08-28 15:31:19 +00:00
803 changed files with 48638 additions and 4938 deletions

View file

@ -80,3 +80,33 @@ DISPLAY_PLAYER_URL=https://portal.b2in.test/_cabinet/display
# Nützlich in der Entwicklung, um Seed-Daten und manuelle Testeinträge zu erhalten.
# Hinweis: Migrationen müssen mindestens einmal für die Test-DB ausgeführt werden: DB_DATABASE=testing sail artisan migrate
# TESTING_PRESERVE_DATABASE=false
# Seed-Admin (DatabaseSeeder)
SEED_ADMIN_NAME="Admin"
SEED_ADMIN_EMAIL=admin@b2in.test
SEED_ADMIN_PASSWORD=
# --- Version (leer lassen: kommt aus der Datei RELEASE, die dev/betrieb/deploy.sh schreibt)
APP_VERSION=
# --- GlitchTip (errors.adametz.media, Projekte server-b2in / browser-b2in) – siehe dev/betrieb/README.md
SENTRY_LARAVEL_DSN=
SENTRY_BROWSER_DSN=
SENTRY_ENVIRONMENT=production # Vorschau: preview, lokal: local
SENTRY_RELEASE=
# --- Betriebs-Alerts (Mail und ntfy-Push)
MONITORING_ALERTS_ENABLED=true
MONITORING_ALERT_EMAIL=
MONITORING_NTFY_URL=https://push.adametz.media
MONITORING_NTFY_TOPIC=b2in-ops
MONITORING_NTFY_TOKEN=
MONITORING_NTFY_PRIORITY=4
# --- Tiefer Health-Check /health (Bearer-Token für Uptime Kuma) – je Umgebung ein eigener Wert
MONITORING_HEALTH_TOKEN=
MONITORING_QUEUE_BACKLOG_SECONDS=300
MONITORING_DISK_MIN_FREE_PERCENT=10
# --- Nur lokal: Lesezugriff auf die GlitchTip-API (project:read, event:read) – nie committen
GLITCHTIP_API_TOKEN=

31
.gitignore vendored
View file

@ -1,12 +1,17 @@
# Laravel
/.phpunit.cache
/node_modules
/public/build
# public/build ist versioniert (Git-Deploy: auf dem Server gibt es kein Node). Nur die
# Ausgabe des alten Standard-Builds (`npm run build`) liegt direkt darunter und wird nicht gebraucht.
/public/build/assets/
/public/build/manifest.json
/public/hot
/public/storage
/public/vendor
/public/flux
/storage/*.key
# schreibt dev/betrieb/deploy.sh beim Deploy (Version des ausgelieferten Tags)
/RELEASE
/storage/app
/storage/framework
/storage/language
@ -50,11 +55,31 @@ yarn-error.log
Network Trash Folder
Temporary Items
.apdisk
Icon
# macOS-Ordnersymbol heißt „Icon\r“ – ohne [\r] träfe das Muster bei core.ignorecase auch resources/views/flux/icon/
Icon[\r]
# Project specific
_static/
_work/
_storage/
dev/immobilien 07-05-2026/AZIZI SharePoint
dev/immobilien 07-05-2026/AZIZI SharePoint
# Deployment-Archive werden bei Bedarf neu erzeugt
dev/**/deploy/*.tar.gz
# Referenzmaterial und Projektdokumente ausserhalb der Versionierung
/_b2in_interior_shop/
/docs/
# Original-Bildzulieferungen (mehrere MB je Datei) bleiben ausserhalb der Versionierung;
# die optimierten WebP-Fassungen liegen unter public/img/assets/marken/.
/dev/2026-08-28/images/
# Display-Player (public/_cabinet): Laufzeitdateien und Dateien, die nur auf dem Server liegen.
# Sie dürfen beim Git-Deploy weder überschrieben noch als Abweichung gemeldet werden.
/public/_cabinet/clicks.log
/public/_cabinet/logs/*.log
/public/_cabinet/offers/player.html
/public/_cabinet/offers/logo-cabinet-300.png
# Statisch veröffentlichte Flux-Skripte (alter Pfad), nur auf dem Server
/public/_flux/

View file

@ -128,7 +128,7 @@ Domains are configured in `config/domains.php` with environment variables:
Domain-specific configurations include:
- **portal.b2in.test**: Admin backend with Flux UI theme
- **b2in.test**: Anthracite/Dynamic Blue color scheme, Inter/IBM Plex Sans fonts
- **b2in.test**: two color worlds on one theme – Interior (default: Tanne `#202c2b`, Creme `#f7f4ef`, Sand `#d9cbbf`, Ocker `#d39e46`, Clay `#a38f82`, Anthrazit `#2d2926`; Inter/EB Garamond) and Immobilien (`body.world-immobilien`: Anthracite/Dynamic Blue, Inter/IBM Plex Sans). The world comes from `b2in_world()` (app/helpers.php), the logo per world from `ThemeHelper::getLogoPath()`
- **b2a.test**: Azur Blue/Liberty Red color scheme, Inter/Merriweather fonts
- **stileigentum.test**: Style Blue/Style Sun color scheme, Inter/Ephesis fonts
- **style2own.test**: Imperial Blue/Sand Gold color scheme, Inter/EB Garamond fonts
@ -142,6 +142,11 @@ For local development, add domains to your hosts file:
127.0.0.1 style2own.test
```
## Deployment & Monitoring
- Deploys run via Git tags only: `dev/betrieb/deploy.sh vX.Y.Z` on the server; remotes: `origin` (forge.adametz.media, deploy source), `NAS` (backup only). Runbook: `dev/betrieb/README.md`.
- `public/build` is versioned: build locally (`npm run build:web && npm run build:portal`) and commit before tagging.
- Errors go to GlitchTip (`errors.adametz.media`, org `adametz-media`, projects `server-b2in` = 8, `browser-b2in` = 7); read-only API token as `GLITCHTIP_API_TOKEN` in `.env`. `/health` (token) for Uptime Kuma, alerts via ntfy topic `b2in-ops`.
## Important Files
- `DOMAINS-CONFIG.md`: Detailed domain configuration guide
- `FORTIFY-SANCTUM-SETUP.md`: Authentication setup guide
@ -160,7 +165,7 @@ The Laravel Boost guidelines are specifically curated by Laravel maintainers for
## Foundational Context
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
- php - 8.4.18
- php - 8.4.26
- laravel/fortify (FORTIFY) - v1
- laravel/framework (LARAVEL) - v12
- laravel/prompts (PROMPTS) - v0

View file

@ -0,0 +1,105 @@
<?php
namespace App\Console\Commands;
use App\Models\CmsArticle;
use App\Models\CmsBrand;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Console\Command;
/**
* Findet Inhalte, die auf der englischen Seite deutsch erscheinen (09.10.2026): CMS-Sektionen,
* Marken (auch Merkmale, Leistungen, Abschnitte, Showroom, Bildunterschriften) und Magazin.
* Erkennung über typische deutsche Wörter – Eigennamen wie „Möbelservice“ allein schlagen nicht an.
*/
class CmsUntranslated extends Command
{
protected $signature = 'cms:untranslated {--locale=en : Zielsprache}';
protected $description = 'Listet CMS-Inhalte, die in der Zielsprache fehlen oder noch deutsch sind';
private const GermanWords = '/\b(und|der|die|das|für|mit|nicht|wir|Sie|Ihr|Ihre|Ihnen|ist|auf|ein|eine|vom|zum|zur|bei|aus|oder|auch|sich|werden|wird|unser|unsere|dem|den|des|im|sind|alle|mehr|über|wie|nach|nur|noch)\b/u';
public function handle(): int
{
$locale = (string) $this->option('locale');
$findings = [];
foreach (CmsContent::query()->orderBy('group')->orderBy('key')->get() as $row) {
$translations = $row->getTranslations('value');
$label = "CMS {$row->group}.{$row->key}";
if (! array_key_exists($locale, $translations)) {
$findings[] = [$label, 'keine Übersetzung'];
continue;
}
$this->collect($translations[$locale], $label, $findings);
}
foreach (CmsBrand::query()->ordered()->get() as $brand) {
foreach ($brand->translatable as $attribute) {
$translations = $brand->getTranslations($attribute);
if (filled($translations[CmsBrand::BASE_LOCALE] ?? null) && blank($translations[$locale] ?? null)) {
$findings[] = ["Marke {$brand->slug}.{$attribute}", 'keine Übersetzung'];
} else {
$this->collect($translations[$locale] ?? null, "Marke {$brand->slug}.{$attribute}", $findings);
}
}
foreach (CmsBrand::LOCALIZED_FIELDS as $field) {
$this->collect($brand->localized($field, $locale), "Marke {$brand->slug}.{$field}", $findings);
}
}
foreach (CmsArticle::query()->get() as $article) {
foreach ($article->translatable as $attribute) {
$translations = $article->getTranslations($attribute);
$label = "Magazin {$article->slug}.{$attribute}";
if (filled($translations[CmsBrand::BASE_LOCALE] ?? null) && blank($translations[$locale] ?? null)) {
$findings[] = [$label, 'keine Übersetzung'];
} else {
$this->collect($translations[$locale] ?? null, $label, $findings);
}
}
}
if ($findings === []) {
$this->info("Keine deutschen oder fehlenden Texte für „{$locale}“ gefunden.");
return self::SUCCESS;
}
$this->table(['Stelle', 'Text'], $findings);
$this->warn(count($findings).' Stellen.');
return self::FAILURE;
}
/**
* @param list<array{0: string, 1: string}> $findings
*/
private function collect(mixed $value, string $path, array &$findings): void
{
if (is_array($value)) {
foreach ($value as $key => $item) {
$this->collect($item, "{$path}.{$key}", $findings);
}
return;
}
if (! is_string($value)) {
return;
}
$text = trim(html_entity_decode(strip_tags($value)));
if ($text !== '' && preg_match_all(self::GermanWords, $text) >= 2) {
$findings[] = [$path, mb_strimwidth($text, 0, 90, '…')];
}
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Console\Commands;
use App\Services\Monitoring\HealthProbe;
use Illuminate\Console\Command;
/**
* Minütlicher Herzschlag des Schedulers. Bleibt er aus,
* melden `/health` und der Systemstatus den Scheduler als ausgefallen —
* `onFailure` der einzelnen Tasks greift in diesem Fall nicht mehr.
*/
class MonitoringHeartbeat extends Command
{
protected $signature = 'monitoring:heartbeat';
protected $description = 'Schreibt den Herzschlag des Schedulers (Grundlage für /health und den Systemstatus).';
public function handle(HealthProbe $probe): int
{
$probe->recordHeartbeat();
return self::SUCCESS;
}
}

View file

@ -0,0 +1,50 @@
<?php
namespace App\Console\Commands;
use App\Jobs\Monitoring\QueueProbe;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Sleep;
use Illuminate\Support\Str;
/**
* Prüft, ob ein Queue-Worker läuft: stellt einen Test-Job ein und wartet,
* bis er abgeholt ist (Aufruf am Ende von dev/betrieb/deploy.sh).
*
* Hintergrund: `queue:restart` beendet den Worker, neu starten muss ihn
* Supervisor. Läuft Supervisor nicht, bleibt die Queue still stehen.
*/
class MonitoringQueueProbe extends Command
{
protected $signature = 'monitoring:queue-probe
{--wait=60 : Sekunden, die auf den Worker gewartet wird}';
protected $description = 'Prüft mit einem Test-Job, ob ein Queue-Worker läuft.';
public function handle(): int
{
$token = Str::random(16);
$wait = max(0, (int) $this->option('wait'));
QueueProbe::dispatch($token);
for ($second = 0; $second <= $wait; $second++) {
if (Cache::has(QueueProbe::cacheKey($token))) {
Cache::forget(QueueProbe::cacheKey($token));
$this->info(sprintf('Queue-Worker läuft (Test-Job nach %d s abgeholt).', $second));
return self::SUCCESS;
}
if ($second < $wait) {
Sleep::for(1)->second();
}
}
$this->error(sprintf('Kein Queue-Worker hat den Test-Job innerhalb von %d s abgeholt.', $wait));
$this->line('Worker prüfen: läuft Supervisor (bzw. der Worker im Server-Panel)? Notfalls: nohup php artisan queue:work --tries=3 --max-time=3600 &');
return self::FAILURE;
}
}

View file

@ -5,11 +5,20 @@ namespace App\Helpers;
class ThemeHelper
{
/**
* Get the logo path based on the current theme
* Logo-Pfad für das aktuelle Theme.
*
* Für b2in hängt die Datei zusätzlich an der Farbwelt: die Interior-Welt trägt das Logo
* mit dem Zusatz „Interior“ und ockerfarbenem Bogen, die Immobilien-Welt das Logo ohne
* Zusatz mit blauem Bogen. Ohne Angabe wird die Welt aus der aktuellen Route gelesen.
*/
public static function getLogoPath(string $type = 'positive'): string
public static function getLogoPath(string $type = 'positive', ?string $world = null): string
{
$theme = config('app.theme', 'b2in');
if ($theme === 'b2in' && ($world ?? b2in_world()) === 'immobilien') {
return self::getLogoPathForBrand('b2in-immobilien', $type);
}
return self::getLogoPathForBrand($theme, $type);
}
@ -22,21 +31,25 @@ class ThemeHelper
$logoMap = [
'b2in' => [
'positive' => 'img/logos/b2in-interior-logo-positive.svg',
'negative' => 'img/logos/b2in-interior-logo-negative.svg',
],
'b2in-immobilien' => [
'positive' => 'img/logos/b2in-logo-positive.svg',
'negative' => 'img/logos/b2in-logo-negative.svg'
'negative' => 'img/logos/b2in-logo-negative.svg',
],
'b2a' => [
'positive' => 'img/logos/b2a-logo-positiv.svg',
'negative' => 'img/logos/b2a-logo-negativ.svg'
'negative' => 'img/logos/b2a-logo-negativ.svg',
],
'stileigentum' => [
'positive' => 'img/logos/stileigentum-logo-positiv.svg',
'negative' => 'img/logos/stileigentum-logo-negativ.svg'
'negative' => 'img/logos/stileigentum-logo-negativ.svg',
],
'style2own' => [
'positive' => 'img/logos/style2own-logo-positiv.svg',
'negative' => 'img/logos/style2own-logo-negativ.svg'
]
'negative' => 'img/logos/style2own-logo-negativ.svg',
],
];
return $logoMap[$brand][$type] ?? $logoMap['b2in'][$type];
@ -48,6 +61,7 @@ class ThemeHelper
public static function getFaviconPath(): string
{
$theme = config('app.theme', 'b2in');
return "img/favicons/{$theme}-favicon.ico";
}
@ -57,6 +71,7 @@ class ThemeHelper
public static function getThemeCssPath(): string
{
$theme = config('app.theme', 'b2in');
return "resources/css/web/theme-{$theme}.css";
}
@ -66,6 +81,7 @@ class ThemeHelper
public static function getDomainConfig(): array
{
$theme = config('app.theme', 'b2in');
return config("domains.domains.{$theme}", []);
}
@ -75,6 +91,7 @@ class ThemeHelper
public static function getPrimaryColor(): string
{
$config = self::getDomainConfig();
return $config['color_scheme']['primary'] ?? '#2b3f51';
}
@ -84,6 +101,7 @@ class ThemeHelper
public static function getSecondaryColor(): string
{
$config = self::getDomainConfig();
return $config['color_scheme']['secondary'] ?? '#20a0da';
}
@ -93,6 +111,7 @@ class ThemeHelper
public static function getPrimaryFont(): string
{
$config = self::getDomainConfig();
return $config['font_family']['primary'] ?? 'Inter';
}
@ -102,6 +121,7 @@ class ThemeHelper
public static function getSecondaryFont(): string
{
$config = self::getDomainConfig();
return $config['font_family']['secondary'] ?? 'IBM Plex Sans';
}
@ -111,12 +131,14 @@ class ThemeHelper
public static function getDomainName(): string
{
$config = self::getDomainConfig();
return $config['domain_name'] ?? 'B2IN';
}
public static function getDomainUrl(): string
{
$config = self::getDomainConfig();
return $config['url'] ?? config('app.url');
}
@ -127,7 +149,7 @@ class ThemeHelper
{
$brand = $brand ?? config('app.theme', 'b2in');
$config = config("domains.domains.{$brand}", []);
return [
'primary' => $config['color_scheme']['primary'] ?? '#2b3f51',
'secondary' => $config['color_scheme']['secondary'] ?? '#20a0da',
@ -141,14 +163,14 @@ class ThemeHelper
public static function getBrandName(?string $brand = null): string
{
$brand = $brand ?? config('app.theme', 'b2in');
$brandNames = [
'b2in' => 'B2IN',
'b2a' => 'B2A',
'stileigentum' => 'StilEigentum',
'style2own' => 'Style2Own',
];
return $brandNames[$brand] ?? strtoupper($brand);
}
@ -158,6 +180,7 @@ class ThemeHelper
public static function getBrandConfig(?string $brand = null): array
{
$brand = $brand ?? config('app.theme', 'b2in');
return config("domains.domains.{$brand}", []);
}
}

View file

@ -0,0 +1,34 @@
<?php
namespace App\Http\Controllers;
use App\Models\Display;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* Öffentliche, schreibgeschützte Einbettung eines Displays auf der Website.
*
* Bewusst getrennt von {@see \App\Http\Controllers\Api\DisplayPreviewController}: Die
* Preview-Routen arbeiten mit `preview_token` und zeigen den **Entwurf**. Nach außen darf weder
* ein Token noch ein unveröffentlichter Stand gelangen. Diese Route kennt nur die numerische ID
* und liefert ausschließlich veröffentlichte Playlists aktiver Displays.
*/
class DisplayEmbedController extends Controller
{
public function show(Display $display): BinaryFileResponse
{
abort_unless($display->is_active, 404);
$playlist = $display->livePlaylist()->withCount('modules')->first();
abort_if($playlist === null || $playlist->modules_count === 0, 404);
// Der Player liest die ID aus dem Pfad (/display/{id}) und holt sich die Konfiguration
// über die öffentliche Route /api/display/{display}/config – dieselbe Quelle wie das
// physische Display. Keine doppelte Pflege.
return response()->file(public_path('_cabinet/display/index.html'), [
'Cache-Control' => 'no-cache, must-revalidate',
'X-Robots-Tag' => 'noindex',
]);
}
}

View file

@ -0,0 +1,39 @@
<?php
namespace App\Http\Controllers;
use App\Services\Monitoring\HealthProbe;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Tiefer Health-Check für den externen Monitor.
*
* `/up` zeigt nur, dass die App bootet. `/health` prüft zusätzlich DB,
* Cache, Queue-Rückstau, Scheduler-Herzschlag und Plattenplatz: 200 solange
* nichts kritisch ist, sonst 503. Zugang nur mit MONITORING_HEALTH_TOKEN
* (Bearer oder `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht.
*/
class HealthController extends Controller
{
public function __invoke(Request $request, HealthProbe $probe): JsonResponse
{
$token = (string) config('monitoring.health.token');
$given = (string) ($request->bearerToken() ?? $request->query('token', ''));
abort_if($token === '' || ! hash_equals($token, $given), 404);
$checks = $probe->checks();
$status = HealthProbe::worstStatus($checks);
return response()->json([
'status' => $status,
'version' => config('app.version'),
'checked_at' => now()->toIso8601String(),
'checks' => collect($checks)->mapWithKeys(fn (array $check): array => [
$check['key'] => ['status' => $check['status'], 'detail' => $check['detail']],
]),
], $status === HealthProbe::StatusCritical ? 503 : 200)
->header('Cache-Control', 'no-store');
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
/**
* Erlaubt die Live-Vorschau im Inhalte-Editor des Portals (09.10.2026).
*
* Der Editor auf portal.b2in.eu zeigt die Website in einem iframe. Der Webserver schickt
* `X-Frame-Options: SAMEORIGIN`, das jede andere Domain ausschließt – also auch das Portal.
* `Content-Security-Policy: frame-ancestors` löst X-Frame-Options ab: Browser werten dann nur
* noch diese Liste aus. Erlaubt sind die eigene Domain und das Portal, alle anderen Seiten
* dürfen die Website weiterhin nicht einbetten.
*/
class AllowPortalFraming
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if ($response->headers->has('Content-Security-Policy')) {
return $response;
}
$portal = rtrim((string) config('domains.domain_portal_url'), '/');
$ancestors = trim("'self' {$portal}");
$response->headers->set('Content-Security-Policy', "frame-ancestors {$ancestors}");
$response->headers->remove('X-Frame-Options');
return $response;
}
}

View file

@ -32,6 +32,12 @@ class BasicAuthMiddleware
return $next($request);
}
// Skip Basic Auth für den Health-Check: Uptime Kuma schickt seinen eigenen
// Authorization-Header (Bearer-Token), /health prüft ihn selbst.
if ($path === 'health' || $path === 'up') {
return $next($request);
}
// Skip Basic Auth für Flux UI Assets (flux.js, flux.min.js, editor.js, etc.)
if (str_starts_with($path, 'flux/')) {
return $next($request);

View file

@ -0,0 +1,96 @@
<?php
namespace App\Http\Middleware;
use App\Support\Monitoring\ErrorReportScrubber;
use App\Support\Monitoring\ErrorSignal;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
use function Illuminate\Support\defer;
/**
* Meldet Antworten, hinter denen ein Bedien- oder Programmierfehler steckt,
* obwohl keine Exception gemeldet wird:
*
* - 404 mit Referrer der eigenen Domain — irgendwo bei uns steht ein toter Link
* - 403/429 für angemeldete Nutzer — die Oberfläche führt sie dorthin
* - 419 — Formular oder Livewire-Komponente abgelaufen, Eingaben sind verloren
*
* Gesendet wird nach der Antwort (`defer()`), der Nutzer wartet nicht auf GlitchTip.
*/
class ReportErrorSignals
{
public function __construct(private readonly ErrorSignal $errorSignal) {}
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$signal = $this->signalFor($request, $response->getStatusCode());
if ($signal !== null) {
$location = $this->locationOf($request);
$context = [
'status' => $response->getStatusCode(),
'method' => $request->method(),
'path' => '/'.ltrim($request->path(), '/'),
'referer' => $this->refererPath($request),
'livewire' => $request->hasHeader('X-Livewire'),
];
defer(fn () => $this->errorSignal->report($signal, $location, $context));
}
return $response;
}
private function signalFor(Request $request, int $status): ?string
{
return match (true) {
$status === 404 && $this->hasInternalReferer($request) => ErrorSignal::DeadInternalLink,
$status === 403 && $request->user() !== null => ErrorSignal::AccessDenied,
$status === 419 => ErrorSignal::PageExpired,
$status === 429 && $request->user() !== null => ErrorSignal::Throttled,
default => null,
};
}
/**
* Livewire-Requests laufen alle über denselben Update-Endpunkt — der Ort
* ist dort die Seite, auf der der Nutzer stand.
*/
private function locationOf(Request $request): string
{
if ($request->hasHeader('X-Livewire')) {
return 'livewire '.($this->refererPath($request) ?? '/');
}
$routeName = $request->route()?->getName();
return $routeName !== null
? $routeName
: '/'.ltrim($request->path(), '/');
}
private function hasInternalReferer(Request $request): bool
{
$referer = $request->headers->get('referer');
return is_string($referer) && parse_url($referer, PHP_URL_HOST) === $request->getHost();
}
private function refererPath(Request $request): ?string
{
$referer = $request->headers->get('referer');
if (! is_string($referer) || $referer === '') {
return null;
}
$path = parse_url($referer, PHP_URL_PATH);
return is_string($path) ? ErrorReportScrubber::scrubPath($path) : null;
}
}

View file

@ -0,0 +1,31 @@
<?php
namespace App\Jobs\Monitoring;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Support\Facades\Cache;
/**
* Test-Job für `monitoring:queue-probe`: tut nichts außer zu quittieren,
* dass ein Worker ihn abgeholt hat. Läuft auf der Standard-Queue —
* der Worker muss sie bedienen.
*/
class QueueProbe implements ShouldQueue
{
use Queueable;
public int $tries = 1;
public function __construct(public string $token) {}
public static function cacheKey(string $token): string
{
return 'monitoring:queue-probe:'.$token;
}
public function handle(): void
{
Cache::put(self::cacheKey($this->token), now()->toIso8601String(), now()->addMinutes(10));
}
}

View file

@ -98,13 +98,35 @@ class CabinetInfoTablet extends Component
}
/**
* @param array<string, string> $hours Optional: Time picker values from DOM (bypasses wire:model sync issues)
* Zeit-Properties, die beim Speichern aus dem DOM uebernommen werden duerfen.
*
* @return array<int, string>
*/
private function hourProperties(): array
{
return [
'hoursMondayOpen', 'hoursMondayClose',
'hoursTuesdayOpen', 'hoursTuesdayClose',
'hoursWednesdayOpen', 'hoursWednesdayClose',
'hoursThursdayOpen', 'hoursThursdayClose',
'hoursFridayOpen', 'hoursFridayClose',
'hoursSaturdayOpen', 'hoursSaturdayClose',
'hoursSundayOpen', 'hoursSundayClose',
];
}
/**
* Der Flux-Zeit-Picker meldet das Leeren per X nicht zuverlaessig an wire:model.
* Deshalb liest das Formular die Werte beim Absenden aus dem DOM und uebergibt sie hier.
* Nur die Zeitfelder duerfen so gesetzt werden – alles andere kommt aus wire:model.
*
* @param array<string, string|null> $hours Zeit-Picker-Werte aus dem DOM
*/
public function save(array $hours = []): void
{
foreach ($hours as $prop => $value) {
if (property_exists($this, $prop)) {
$this->{$prop} = $value ?? '';
foreach ($this->hourProperties() as $prop) {
if (array_key_exists($prop, $hours)) {
$this->{$prop} = is_string($hours[$prop]) ? $hours[$prop] : '';
}
}

View file

@ -53,7 +53,8 @@ class DisplayVersionEditor extends Component
public string $mediaSubline = '';
public int $mediaDuration = 10;
/** Anzeigedauer eines Bildes in Sekunden als Eingabetext, Komma oder Punkt erlaubt („1,5“). */
public string $mediaDuration = '10';
public bool $mediaIsActive = true;
@ -247,6 +248,12 @@ class DisplayVersionEditor extends Component
public function saveItem(): void
{
if ($this->itemType === 'media' && $this->mediaType === 'image' && self::parseDuration($this->mediaDuration) === null) {
$this->addError('mediaDuration', 'Bitte eine Dauer zwischen 0,5 und 120 Sekunden angeben, z. B. 1,5.');
return;
}
$content = $this->buildItemContent();
$isActive = $this->getActiveFlag();
@ -279,6 +286,41 @@ class DisplayVersionEditor extends Component
$this->refreshModulePreview();
}
/**
* Sekunden aus der Eingabe lesen: „1,5“, „1.5“ und „10“ sind gültig, gerundet auf Zehntel.
* Erlaubt sind 0,5 bis 120 Sekunden; alles andere ergibt null.
*/
public static function parseDuration(string|int|float $input): ?float
{
$normalized = str_replace(',', '.', trim((string) $input));
if ($normalized === '' || ! is_numeric($normalized)) {
return null;
}
$seconds = round((float) $normalized, 1);
if ($seconds < 0.5 || $seconds > 120) {
return null;
}
return $seconds;
}
/**
* Sekunden für die Anzeige: ganze Zahlen ohne Nachkommastelle, sonst mit Komma („1,5“).
*/
public static function formatDuration(int|float|string|null $seconds): string
{
$seconds = (float) str_replace(',', '.', (string) ($seconds ?? 10));
if (floor($seconds) === $seconds) {
return (string) (int) $seconds;
}
return str_replace('.', ',', (string) round($seconds, 1));
}
public function deleteItem(int $id): void
{
DisplayVersionItem::findOrFail($id)->delete();
@ -437,7 +479,7 @@ class DisplayVersionEditor extends Component
$this->mediaUrl = $content['media_url'] ?? '';
$this->mediaHeadline = $content['headline'] ?? '';
$this->mediaSubline = $content['subline'] ?? '';
$this->mediaDuration = $content['duration_seconds'] ?? 10;
$this->mediaDuration = self::formatDuration($content['duration_seconds'] ?? 10);
$this->mediaIsActive = $isActive;
}
@ -501,7 +543,7 @@ class DisplayVersionEditor extends Component
'media_url' => $this->mediaUrl,
'headline' => $this->mediaHeadline,
'subline' => $this->mediaSubline,
'duration_seconds' => $this->mediaDuration,
'duration_seconds' => self::parseDuration($this->mediaDuration) ?? 10,
],
'slide' => [
'type' => 'detail',
@ -663,7 +705,7 @@ class DisplayVersionEditor extends Component
$this->mediaUrl = '';
$this->mediaHeadline = '';
$this->mediaSubline = '';
$this->mediaDuration = 10;
$this->mediaDuration = '10';
$this->mediaIsActive = true;
$this->slideShowLogo = true;
$this->slideLogoUrl = '';

View file

@ -8,9 +8,13 @@ class AboutHero extends Component
{
public $content = [];
public function mount()
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_about_hero). */
public string $section = 'about_hero';
public function mount(string $section = 'about_hero')
{
$this->content = cms_theme_section('about_hero');
$this->section = $section;
$this->content = cms_theme_section($this->section);
}
public function render()

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
class AudienceSwitch extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('audience_switch');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.audience-switch');
}
}

View file

@ -0,0 +1,89 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use Livewire\Component;
class BrandDetail extends Component
{
public string $slug = '';
/** @var array<string, mixed> */
public array $brand = [];
/** @var array<int, array<string, mixed>> */
public array $otherBrands = [];
/**
* Galerie getrennt nach realisierten Projekten und Inspirationen.
*
* @var array{projects: array<int, array<string, mixed>>, inspirations: array<int, array<string, mixed>>}
*/
public array $gallery = ['projects' => [], 'inspirations' => []];
public function mount(string $slug): void
{
$brands = CmsBrand::forFrontend();
abort_unless(isset($brands[$slug]), 404);
$this->slug = $slug;
$this->brand = $brands[$slug];
$this->gallery = CmsBrand::groupGallery($this->brand['gallery'] ?? []);
$this->otherBrands = collect($brands)
->reject(fn (array $brand, string $key) => $key === $slug)
->values()
->all();
$craft = $this->craftSection($slug, $brands);
if ($craft !== null) {
$this->brand['sections'][] = $craft;
}
}
/**
* Gemeinsamer Handwerk-Abschluss (Lang-Sektion `brand_craft`, Nachtrag 15): steht als letzter
* Abschnitt auf jeder Markenseite – außer auf der Seite der Handwerksmarke selbst. Der Link
* dorthin erscheint nur, wenn diese Marke veröffentlicht ist und eine Detailseite hat.
*
* @param array<string, array<string, mixed>> $brands
* @return array<string, mixed>|null
*/
private function craftSection(string $slug, array $brands): ?array
{
$content = cms_theme_section('brand_craft');
if (! is_array($content)) {
return null;
}
$craftSlug = (string) ($content['brand_slug'] ?? '');
if ($craftSlug !== '' && $craftSlug === $slug) {
return null;
}
$sections = CmsBrand::normalizeSections([$content]);
if ($sections === []) {
return null;
}
$section = $sections[0];
$section['anchor'] = 'handwerk';
if ($craftSlug !== '' && ! empty($brands[$craftSlug]['has_detail_page'])) {
$section['link'] = route('marken.show', ['slug' => $craftSlug]);
$section['link_text'] = (string) ($content['link_text'] ?? $brands[$craftSlug]['name'] ?? '');
}
return $section;
}
public function render()
{
return view('livewire.web.components.sections.brand-detail');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use Livewire\Component;
class BrandOverview extends Component
{
/** @var array<int, array<string, mixed>> */
public array $brands = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$this->brands = array_values(CmsBrand::forFrontend());
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.brand-overview');
}
}

View file

@ -12,8 +12,13 @@ class CTASection extends Component
public $section = '';
public function mount($bg = 'bg-secondary', $section = 'cta_section')
public function mount($bg = 'cta-band', $section = 'cta_section')
{
// Der Standard-Abschluss hat je Welt eigene Texte und fuehrt zur Kontaktseite der Welt.
if ($section === 'cta_section' && b2in_world() === 'immobilien') {
$section = 'cta_section_immobilien';
}
$this->section = $section;
$this->content = cms_theme_section($this->section);
$this->bg = $bg;

View file

@ -0,0 +1,30 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use Livewire\Component;
class CoreBrands extends Component
{
/** @var array<string, mixed> */
public array $content = [];
/** @var array<int, array<string, mixed>> */
public array $brands = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('core_brands');
$this->content = is_array($section) ? $section : [];
$this->brands = array_values(CmsBrand::forFrontend());
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.core-brands');
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\Display;
use App\Models\DisplayPlaylist;
use Livewire\Component;
/**
* Spiegelt ein Showroom-Display im Hochformat (9:16) auf der Website.
*
* Quelle ist dasselbe veröffentlichte Playlist wie beim physischen Display – Marcel pflegt
* weiterhin nur im Display-CMS, hier entsteht keine zweite Pflegestelle.
*/
class DisplayFeed extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public ?int $displayId = null;
public string $bg = '';
public bool $showLink = true;
public function mount(string $bg = '', bool $showLink = true): void
{
$section = cms_theme_section('display_feed');
$content = is_array($section) ? $section : [];
$this->bg = $bg;
$this->showLink = $showLink && $this->linkTargetVisible($content);
$this->displayId = $this->resolveDisplayId($content);
// Ohne Display bleiben auch die Texte leer – sie hätten sonst nichts zu beschriften und
// stünden trotzdem im Livewire-Snapshot der Seite.
$this->content = $this->displayId === null ? [] : $content;
}
/**
* Im CMS hinterlegtes Display, sonst das erste aktive mit veröffentlichter Playlist.
*
* Ohne passendes Display rendert die Sektion nichts – lieber keine Sektion als ein
* Rahmen, in dem sich ewig ein Ladebalken dreht.
*
* @param array<string, mixed> $content
*/
private function resolveDisplayId(array $content): ?int
{
$configured = $content['display_id'] ?? null;
$query = Display::query()
->where('is_active', true)
->whereHas('playlists', fn ($q) => $q->where('status', DisplayPlaylist::STATUS_PUBLISHED)
->whereHas('modules'));
if (is_numeric($configured) && (int) $configured > 0) {
$display = (clone $query)->whereKey((int) $configured)->first();
if ($display !== null) {
return $display->id;
}
}
return $query->orderBy('id')->value('id');
}
public function render()
{
return view('livewire.web.components.sections.display-feed');
}
/**
* Der Button führt standardmäßig auf /referenzen – ist die Seite im CMS ausgeblendet, entfällt er.
*
* @param array<string, mixed> $content
*/
private function linkTargetVisible(array $content): bool
{
$path = rtrim(parse_url((string) ($content['link_url'] ?? '/referenzen'), PHP_URL_PATH) ?? '', '/');
return $path !== '/referenzen' || references_page_enabled();
}
}

View file

@ -20,8 +20,84 @@ class FAQ extends Component
$this->content = cms_theme_section($this->section);
}
/**
* Fragen nach Bereich gruppiert, in Seitenreihenfolge: zuerst die allgemeinen Fragen ohne
* Bereich, dann die Bereiche aus `categories` (Überschrift + Symbol), zuletzt Bereiche, die nur
* an Fragen stehen. Leere Bereiche entfallen.
*
* @param array<string, mixed> $content
* @return list<array{title: ?string, icon: ?string, questions: list<array<string, mixed>>}>
*/
public static function groups(array $content): array
{
$content = self::flatten($content);
$groups = ['' => ['title' => null, 'icon' => null, 'questions' => []]];
foreach ($content['categories'] ?? [] as $category) {
$title = trim((string) ($category['title'] ?? ''));
if ($title !== '') {
$groups[$title] = ['title' => $title, 'icon' => ($category['icon'] ?? '') ?: 'question-mark-circle', 'questions' => []];
}
}
foreach ($content['questions'] ?? [] as $question) {
if (! is_array($question) || trim((string) ($question['question'] ?? '')) === '') {
continue;
}
$title = trim((string) ($question['category'] ?? ''));
$groups[$title] ??= ['title' => $title, 'icon' => 'question-mark-circle', 'questions' => []];
$groups[$title]['questions'][] = $question;
}
return array_values(array_filter($groups, fn (array $group): bool => $group['questions'] !== []));
}
/**
* Wandelt die frühere Form mit verschachtelten `sections` (Bereich mit eigener Fragenliste) in
* die flache Form um, die der Inhalte-Editor bearbeiten kann (09.10.2026): jede Frage trägt
* ihren Bereich in `category`, Überschrift und Symbol stehen in `categories`.
*
* @param array<string, mixed> $content
* @return array<string, mixed>
*/
public static function flatten(array $content): array
{
if (! isset($content['sections']) || ! is_array($content['sections'])) {
return $content;
}
$questions = array_map(
fn (mixed $question): mixed => is_array($question) ? [...$question, 'category' => $question['category'] ?? ''] : $question,
is_array($content['questions'] ?? null) ? $content['questions'] : [],
);
$categories = is_array($content['categories'] ?? null) ? $content['categories'] : [];
foreach ($content['sections'] as $section) {
if (! is_array($section)) {
continue;
}
$title = trim((string) ($section['title'] ?? ''));
$categories[] = ['title' => $title, 'icon' => (string) ($section['icon'] ?? '')];
foreach ($section['questions'] ?? [] as $question) {
if (is_array($question)) {
$questions[] = [...$question, 'category' => $title];
}
}
}
unset($content['sections']);
$content['questions'] = $questions;
$content['categories'] = $categories;
return $content;
}
public function render()
{
return view('livewire.web.components.sections.f-a-q');
return view('livewire.web.components.sections.f-a-q', ['groups' => self::groups($this->content)]);
}
}

View file

@ -8,8 +8,12 @@ class FounderBar extends Component
{
public array $content = [];
public function mount(): void
/** Hintergrundklasse der Sektion; unter den Interior-Heroes (Startseite, Marken, Über uns) `bg-accent`. */
public string $bg = 'bg-background';
public function mount(string $bg = 'bg-background'): void
{
$this->bg = $bg;
$this->content = cms_theme_section('founder_bar');
}

View file

@ -4,16 +4,86 @@ namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
/**
* Hero der Startseite: Text links, rechts eine ruhige Slideshow als Hauptelement des Rahmens
* (Abstimmung 07.10.2026, Vorbild dev/2026-08-28/Farbwelt und Stil.png).
*
* Die Bilder kommen aus der Liste `slides` der Sektion `home/hero` (je `image`, `label`, `caption`,
* `alt`, `active`) und laufen in dieser Reihenfolge. Die Infokarte über dem Bild zeigt je Slide
* Marke (`label`) und Motiv (`caption`), ohne Angaben die festen Texte `card_title`/`card_text`.
* Ist die Liste leer oder alles deaktiviert, steht das einzelne Bild `image` da wie früher.
*/
class Hero extends Component
{
public $content = [];
/** @var array<string, mixed> */
public array $content = [];
public function mount()
/** @var array<int, array{image: string, alt: string, label: string, caption: string}> */
public array $slides = [];
public function mount(): void
{
$this->content = cms_theme_section('hero');
$section = cms_theme_section('hero');
$this->content = is_array($section) ? $section : [];
$this->slides = self::activeSlides($this->content['slides'] ?? []) ?: $this->singleImage();
}
public function render()
/**
* Gepflegte Slides ohne Bild oder mit `active` = aus fallen weg.
*
* @return array<int, array{image: string, alt: string, label: string, caption: string}>
*/
public static function activeSlides(mixed $list): array
{
$slides = [];
foreach ((array) $list as $slide) {
if (! is_array($slide) || trim((string) ($slide['image'] ?? '')) === '') {
continue;
}
if (array_key_exists('active', $slide) && ! filter_var($slide['active'], FILTER_VALIDATE_BOOLEAN)) {
continue;
}
$label = trim((string) ($slide['label'] ?? $slide['title'] ?? ''));
$caption = trim((string) ($slide['caption'] ?? ''));
$slides[] = [
'image' => (string) $slide['image'],
'alt' => trim((string) ($slide['alt'] ?? '')) ?: implode(' – ', array_filter([$label, $caption])),
'label' => $label,
'caption' => $caption,
];
}
return $slides;
}
/**
* @return array<int, array{image: string, alt: string, label: string, caption: string}>
*/
private function singleImage(): array
{
$image = trim((string) ($this->content['image'] ?? ''));
return $image === '' ? [] : [[
'image' => $image,
'alt' => (string) ($this->content['image_alt'] ?? ''),
'label' => '',
'caption' => '',
]];
}
/** Wechsel alle `interval` Sekunden, begrenzt auf 3–30 s (Standard 7 s). */
public function getIntervalMsProperty(): int
{
$seconds = (float) ($this->content['interval'] ?? 7);
return (int) round(max(3, min(30, $seconds)) * 1000);
}
public function render(): \Illuminate\View\View
{
return view('livewire.web.components.sections.hero');
}

View file

@ -4,6 +4,10 @@ namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
/**
* Hero der Themes b2a, Stileigentum und Style2own (Stand vor dem B2in-Entwurf vom 24.09.2026).
* Ohne gepflegte Sektion `hero_slider` bleibt der Bereich leer.
*/
class HeroSlider extends Component
{
public $content = [];
@ -12,7 +16,8 @@ class HeroSlider extends Component
public function mount()
{
$this->content = cms_theme_section('hero_slider');
$section = cms_theme_section('hero_slider');
$this->content = is_array($section) ? $section : [];
}
public function nextSlide()

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
class ImmobilienTeaser extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('immobilien_teaser');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.immobilien-teaser');
}
}

View file

@ -2,11 +2,12 @@
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsArticle;
use Livewire\Component;
class MagazinDetail extends Component
{
public $articleId;
public $slug;
public $article;
@ -14,25 +15,41 @@ class MagazinDetail extends Component
public $content;
public function mount($id = 1)
/** Rueckweg zur Magazinliste der Welt, zu der der Beitrag gehoert. */
public string $listUrl = '';
public function mount($slug)
{
$this->articleId = (int) $id;
$this->slug = (string) $slug;
$this->loadArticle();
$this->listUrl = $this->articleWorld() === CmsArticle::WORLD_IMMOBILIEN
? route('immobilien.magazin')
: route('magazin');
$this->loadRelatedArticles();
$this->loadThemeContent();
}
private function articleWorld(): string
{
return (string) ($this->article['world'] ?? CmsArticle::WORLD_INTERIOR);
}
private function loadArticle()
{
$articles = $this->getArticlesData();
$this->article = $articles[$this->articleId] ?? $articles[1];
// Unbekannte Slugs haben frueher stillschweigend Artikel 1 ausgeliefert – mit Status 200.
// Das erzeugt Duplicate Content unter beliebig vielen URLs.
abort_unless(isset($articles[$this->slug]), 404);
$this->article = $articles[$this->slug];
}
private function loadRelatedArticles()
{
$articles = $this->getArticlesData();
$this->relatedArticles = collect($articles)
->filter(fn ($article, $key) => $key != $this->articleId)
$this->relatedArticles = collect($this->getArticlesData())
->filter(fn (array $article) => ($article['world'] ?? CmsArticle::WORLD_INTERIOR) === $this->articleWorld())
->reject(fn (array $article, string $slug) => $slug === $this->slug)
->take(2)
->values()
->toArray();
@ -43,9 +60,18 @@ class MagazinDetail extends Component
$this->content = cms_theme_section('magazin_detail');
}
private function getArticlesData()
/** @var array<string, array<string, mixed>>|null */
private ?array $articlesCache = null;
/**
* Wird beim Rendern zweimal gebraucht (Artikel + verwandte Artikel) – deshalb gemerkt,
* damit die Detailseite nur eine Abfrage auslöst.
*
* @return array<string, array<string, mixed>>
*/
private function getArticlesData(): array
{
return trans('b2in.articles');
return $this->articlesCache ??= CmsArticle::forFrontend();
}
public function render()

View file

@ -2,6 +2,7 @@
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsArticle;
use Livewire\Component;
class MagazinList extends Component
@ -10,34 +11,41 @@ class MagazinList extends Component
public $content = [];
public function mount()
/** Farbwelt der Liste: `interior` (/magazin) oder `immobilien` (/immobilien/magazin). */
public string $world = CmsArticle::WORLD_INTERIOR;
public function mount(string $world = CmsArticle::WORLD_INTERIOR)
{
$this->world = in_array($world, CmsArticle::WORLDS, true) ? $world : CmsArticle::WORLD_INTERIOR;
$this->loadPosts();
$this->loadThemeContent();
}
private function loadPosts()
{
$articles = trans('b2in.articles');
$this->posts = collect($articles)->map(function ($article) {
$this->posts = collect(CmsArticle::forFrontend($this->world))->map(function (array $article) {
return [
'id' => $article['id'],
'slug' => $article['slug'],
'title' => $article['title'],
'excerpt' => $article['content']['intro'], // Using intro as excerpt
'excerpt' => $article['content']['intro'] ?? '',
'image' => $article['image'],
'date' => $article['date'],
'readTime' => $article['readTime'],
];
})->all();
})->values()->all();
}
private function loadThemeContent()
{
$this->content = cms_theme_section('magazin_list');
$this->content = cms_theme_section(
$this->world === CmsArticle::WORLD_IMMOBILIEN ? 'magazin_list_immobilien' : 'magazin_list'
);
}
public function render()
{
return view('livewire.web.components.sections.magazin-list');
return view('livewire.web.components.sections.magazin-list', [
'posts' => $this->posts,
]);
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
class MarkenCta extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('marken_cta');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.marken-cta');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
class MarkenGrowth extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('marken_growth');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.marken-growth');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
class MarkenNetwork extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('marken_network');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.marken-network');
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use Livewire\Component;
/**
* „Aus der Region gefertigt“: die Standorte der Kernmarken rund um Bielefeld
* (Arbeitsplan 14.09.2026, Punkt 1.3, Nachtrag 11).
*
* Überschrift, Karte, Drehscheibe und Ablauf-Block kommen aus dem CMS (Fallback Lang-Datei).
* Die Standorte kommen aus den veröffentlichten Marken: Ort, Entfernung und Rolle pflegt
* Marcel in der Markenverwaltung. Die Positionen in der schematischen Karte gehören zur
* Zeichnung und bleiben hier im Code.
*/
class OriginSection extends Component
{
/**
* Position je Marke in der Karte (viewBox 640×400) und Seite der Beschriftung.
*
* Eine Marke ohne Eintrag erscheint in den Kacheln, aber nicht in der Karte.
*
* @var array<string, array{x: int, y: int, label: string}>
*/
public const MAP_POSITIONS = [
'rotpunkt' => ['x' => 330, 'y' => 55, 'label' => 'left'],
'februe' => ['x' => 480, 'y' => 90, 'label' => 'right'],
'tischlerei' => ['x' => 295, 'y' => 285, 'label' => 'left'],
'cabinet' => ['x' => 80, 'y' => 345, 'label' => 'right'],
];
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('origin_section');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
}
/**
* Veröffentlichte Marken mit gepflegtem Fertigungsort, in der Reihenfolge der Marken.
*
* Eine unveröffentlichte Marke fällt heraus, eine Marke ohne Ort ebenfalls. Der Link zur
* Markenseite hängt daran, ob die Marke eine Detailseite hat (Lang-Fallback: keine).
*
* @return list<array{slug: string, city: string, brand: string, role: string, distance: string, link: ?string, map: ?array{x: int, y: int, label: string}}>
*/
public function getLocationsProperty(): array
{
$out = [];
foreach (CmsBrand::forFrontend() as $slug => $brand) {
$city = trim((string) ($brand['origin_city'] ?? ''));
$name = trim((string) ($brand['name'] ?? ''));
if ($city === '' || $name === '') {
continue;
}
$out[] = [
'slug' => (string) $slug,
'city' => $city,
'brand' => $name,
'role' => trim((string) ($brand['origin_role'] ?? '')),
'distance' => trim((string) ($brand['origin_distance'] ?? '')),
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
'map' => self::MAP_POSITIONS[$slug] ?? null,
];
}
return $out;
}
public function render()
{
return view('livewire.web.components.sections.origin-section');
}
}

View file

@ -8,9 +8,13 @@ class OurStory extends Component
{
public $content = [];
public function mount()
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_story). */
public string $section = 'our_story';
public function mount(string $section = 'our_story')
{
$this->content = cms_theme_section('our_story');
$this->section = $section;
$this->content = cms_theme_section($this->section);
}
public function render()

View file

@ -8,9 +8,13 @@ class OurValues extends Component
{
public $content = [];
public function mount()
/** Sektionsschluessel – die Immobilien-Welt nutzt eigene Inhalte (immobilien_our_values). */
public string $section = 'our_values';
public function mount(string $section = 'our_values')
{
$this->content = cms_theme_section('our_values');
$this->section = $section;
$this->content = cms_theme_section($this->section);
}
public function render()

View file

@ -0,0 +1,32 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
/**
* Seitenkopf für Unterseiten: Zeile über der Überschrift, Überschrift, Text und optional ein Bild
* daneben (Breadcrumb seit 07.10.2026 entfernt).
*
* Liegt auf derselben abgesetzten grauen Fläche wie der Hero der Startseite, damit sich der
* Kopf klar von den Inhaltsblöcken darunter abhebt.
*/
class PageHero extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $section = 'marken_hero', string $bg = ''): void
{
$content = cms_theme_section($section);
$this->content = is_array($content) ? $content : [];
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.page-hero');
}
}

View file

@ -0,0 +1,113 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use App\Models\CmsBrand;
use App\Models\CmsReference;
use Livewire\Component;
/**
* Realisierte Projekte auf /referenzen.
*
* Seit Nachtrag 13 pflegt Marcel Referenzen als eigene Einträge im Portal (Titelbild, weitere
* Bilder, beteiligte Marken). Solange dort nichts veröffentlicht ist, greift der Stand aus AP5:
* die als „Realisiertes Projekt“ markierten Bilder aus den Markengalerien, über alle Marken
* hinweg gesammelt. Visualisierungen und Entwürfe bleiben in beiden Fällen draußen.
*/
class ReferenceProjects extends Component
{
/** @var array<string, mixed> */
public array $content = [];
/** @var array<int, array<string, mixed>> */
public array $projects = [];
public string $bg = '';
public int $limit = 0;
public function mount(string $bg = '', int $limit = 0): void
{
$section = cms_theme_section('reference_projects');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
$this->limit = $limit;
$projects = $this->fromReferences();
if ($projects === []) {
$projects = $this->fromBrandGalleries();
}
$this->projects = $this->limit > 0 ? array_slice($projects, 0, $this->limit) : $projects;
}
/**
* @return array<int, array<string, mixed>>
*/
private function fromReferences(): array
{
$references = CmsReference::query()->published()->ordered()->get();
if ($references->isEmpty()) {
return [];
}
$brands = CmsBrand::forFrontend();
$projects = [];
foreach ($references as $reference) {
$gallery = CmsReference::normalizeGallery($reference->gallery);
$image = (string) ($reference->image ?: ($gallery[0]['image'] ?? ''));
if ($image === '') {
continue;
}
$projects[] = [
'key' => 'ref-'.$reference->id,
'image' => $image,
'caption' => (string) $reference->title,
'meta' => implode(' · ', array_filter([$reference->location, $reference->year])),
'description' => (string) $reference->description,
'gallery' => array_values(array_filter($gallery, fn (array $item) => $item['image'] !== $image)),
'brands' => $reference->resolveBrands($brands),
];
}
return $projects;
}
/**
* @return array<int, array<string, mixed>>
*/
private function fromBrandGalleries(): array
{
$projects = [];
foreach (CmsBrand::forFrontend() as $slug => $brand) {
foreach (CmsBrand::groupGallery($brand['gallery'] ?? [])['projects'] as $index => $item) {
$projects[] = [
'key' => $slug.'-'.$index,
'image' => $item['image'],
'caption' => $item['caption'],
'meta' => '',
'description' => '',
'gallery' => [],
'brands' => [[
'slug' => $slug,
'name' => (string) ($brand['name'] ?? ''),
'link' => ! empty($brand['has_detail_page']) ? '/marken/'.$slug : null,
]],
];
}
}
return $projects;
}
public function render()
{
return view('livewire.web.components.sections.reference-projects');
}
}

View file

@ -0,0 +1,25 @@
<?php
namespace App\Livewire\Web\Components\Sections;
use Livewire\Component;
class Sponsoring extends Component
{
/** @var array<string, mixed> */
public array $content = [];
public string $bg = '';
public function mount(string $bg = ''): void
{
$section = cms_theme_section('sponsoring');
$this->content = is_array($section) ? $section : [];
$this->bg = $bg;
}
public function render()
{
return view('livewire.web.components.sections.sponsoring');
}
}

View file

@ -10,9 +10,13 @@ class VisionSection extends Component
public $bg = 'bg-background';
public function mount($bg = 'bg-background')
/** Inhalt in weißer Box mit kleinerem Bild (Startseite, 07.10.2026); die anderen Themes bleiben offen. */
public bool $boxed = false;
public function mount($bg = 'bg-background', bool $boxed = false)
{
$this->bg = $bg;
$this->boxed = $boxed;
$this->content = cms_theme_section('vision_section');
}

View file

@ -1,20 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class AnnouncementBar extends Component
{
public array $content = [];
public function mount(): void
{
$this->content = cms_theme_section('announcement_bar');
}
public function render()
{
return view('livewire.web.components.ui.announcement-bar');
}
}

View file

@ -1,115 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
use Livewire\Component;
class ContactForm extends Component
{
public string $firstName = '';
public string $lastName = '';
public string $company = '';
public string $email = '';
public string $phone = '';
public string $subject = '';
public string $message = '';
public bool $privacy = false;
public bool $success = false;
/** @var string Hidden honeypot field */
public string $website = '';
public ?int $formLoadedAt = null;
/** @var array<string, mixed> */
public array $content = [];
public function mount(): void
{
$this->content = cms_theme_section('contact_form');
$this->formLoadedAt = time();
}
/** @return array<string, string> */
public function getSubjectsProperty(): array
{
return $this->content['form']['subjects'] ?? [];
}
/** @return array<int, array<string, mixed>> */
public function getContactInfoProperty(): array
{
return $this->content['contact_info'] ?? [];
}
/** @return array<int, array<string, mixed>> */
public function getSocialMediaProperty(): array
{
return $this->content['social_media']['platforms'] ?? [];
}
public function submit(ContactFormService $service): void
{
$this->validate([
'firstName' => ['required', 'string', 'max:255'],
'lastName' => ['required', 'string', 'max:255'],
'email' => ['required', 'email:rfc', 'max:255'],
'subject' => ['required', 'string'],
'message' => ['required', 'string', 'max:2000'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:255'],
'privacy' => ['accepted'],
'website' => ['nullable', 'string', 'max:0'],
]);
$spamDetector = SpamDetector::fromConfig();
$payload = [
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'email' => $this->email,
'subject' => $this->subject,
'message' => $this->message,
'company' => $this->company,
'phone' => $this->phone,
'website' => $this->website,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'is_spam' => false,
];
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
$subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject;
$subjectLine = $subjectLabel
? 'Kontaktanfrage B2in: ' . $subjectLabel
: __('contact-form::contact-form.default_subject');
$service->handle($payload, $subjectLine, 'contact-form');
$this->success = true;
$this->firstName = '';
$this->lastName = '';
$this->company = '';
$this->email = '';
$this->phone = '';
$this->subject = '';
$this->message = '';
$this->privacy = false;
$this->website = '';
$this->formLoadedAt = time();
}
public function render(): \Illuminate\View\View
{
return view('livewire.web.components.ui.contact-form');
}
}

View file

@ -1,13 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class Footer extends Component
{
public function render()
{
return view('livewire.web.components.ui.footer');
}
}

View file

@ -1,86 +0,0 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class Header extends Component
{
public $isMobileMenuOpen = false;
public $domainName;
public $domainUrl;
public $content = [];
public $currentLocale;
public $availableLocales = [
'de' => 'DE',
'en' => 'EN',
];
public function mount(): void
{
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
$this->currentLocale = app()->getLocale();
$this->content = cms_theme_section('header');
// Ensure required keys exist
if (!isset($this->content['portal_login'])) {
$this->content['portal_login'] = 'Portal Login';
}
if (!isset($this->content['navigation']) || !is_array($this->content['navigation'])) {
$this->content['navigation'] = [];
}
}
public function switchLanguage(string $locale): mixed
{
if (array_key_exists($locale, $this->availableLocales)) {
app()->setLocale($locale);
session(['locale' => $locale]);
$this->currentLocale = $locale;
$referer = request()->header('Referer') ?? '/';
return redirect()->to($referer);
}
return null;
}
public function toggleMobileMenu(): void
{
$this->isMobileMenuOpen = ! $this->isMobileMenuOpen;
}
public function closeMobileMenu(): void
{
$this->isMobileMenuOpen = false;
}
public function isActiveRoute($url)
{
$currentPath = request()->path();
$currentPath = '/' . ltrim($currentPath, '/');
// Exact match
if ($currentPath === $url) {
return true;
}
// Special handling for home route
if ($url === '/' && ($currentPath === '/' || $currentPath === '')) {
return true;
}
// Check if current path starts with the navigation URL (for sub-pages)
if ($url !== '/' && str_starts_with($currentPath, rtrim($url, '/'))) {
return true;
}
return false;
}
public function render()
{
return view('livewire.web.components.ui.header');
}
}

View file

@ -0,0 +1,46 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class AnnouncementBar extends Component
{
public array $content = [];
/** Farbwelt der Seite – jede Welt hat ihre eigene Meldung. */
public string $world = 'interior';
public function mount(): void
{
$this->world = b2in_world();
// Interior: global.announcement_bar, Immobilien: global.announcement_bar_immobilien.
$section = cms_theme_section($this->world === 'immobilien' ? 'announcement_bar_immobilien' : 'announcement_bar');
$this->content = is_array($section) ? $section : [];
}
/**
* Schluessel, unter dem sich der Browser das Wegklicken merkt.
*
* Leitet sich aus dem Inhalt ab, nicht nur aus der gepflegten `id`: Wird die Leiste im CMS
* neu belegt, ohne die `id` zu aendern, bekaemen Wiederkehrer die neue Meldung sonst nie
* zu sehen, weil ihr alter Dismiss-Eintrag im localStorage weitergilt.
*/
public function getDismissKeyProperty(): string
{
$fingerprint = implode('|', [
$this->content['id'] ?? 'default',
$this->content['badge'] ?? '',
$this->content['text'] ?? '',
$this->content['link_url'] ?? '',
]);
return 'announcement_dismissed_'.substr(md5($fingerprint), 0, 12);
}
public function render()
{
return view('livewire.web.components.ui.announcement-bar');
}
}

View file

@ -0,0 +1,166 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
use App\Models\CmsBrand;
use Livewire\Component;
/**
* Schwebende Terminanfrage – auf allen Seiten unten rechts.
*
* Setzt wie die übrigen Formulare der Seite auf {@see ContactFormService} und {@see SpamDetector}
* auf, statt auf Acme\ContactForm\Livewire\ContactForm: dessen mitgelieferte View bringt ein
* eigenes (blaues) Layout mit, das nicht zum Auftritt passt. Honeypot, Rate-Limit und
* Spam-Erkennung kommen so trotzdem aus dem Paket.
*/
class AppointmentWidget extends Component
{
/** Feste Anliegen neben den gepflegten Marken. */
public const EXTRA_TOPICS = [
'projektausstattung' => 'Projektausstattung',
'immobilien' => 'Immobilien',
'sonstiges' => 'Sonstiges',
];
/** Farbwelt der Seite – in der Immobilien-Welt ist das Anliegen vorbelegt. */
public string $world = 'interior';
public string $name = '';
public string $email = '';
public string $phone = '';
public string $topic = '';
public string $preferredDate = '';
public string $message = '';
public bool $privacy = false;
public bool $success = false;
/** @var string Honeypot – muss leer bleiben. */
public string $website = '';
public ?int $formLoadedAt = null;
/** @var array<string, mixed> */
public array $content = [];
public function mount(): void
{
$section = cms_theme_section('appointment_widget');
$this->content = is_array($section) ? $section : [];
$this->formLoadedAt = time();
$this->world = b2in_world();
if ($this->world === 'immobilien') {
$this->topic = 'immobilien';
}
$this->applyBrandContext();
}
/**
* Auf einer Markenseite trägt der Button die Beschriftung dieser Marke.
*
* Die Zulieferung sieht ausdrücklich keinen zweiten CTA-Button vor, sondern eine
* kontextbezogene Beschriftung des vorhandenen Widgets. Da das Widget im Layout steckt und
* nicht je Seite eingebunden wird, liest es den Kontext aus der Route.
*/
private function applyBrandContext(): void
{
$route = request()->route();
if ($route?->getName() !== 'marken.show') {
return;
}
$slug = (string) $route->parameter('slug');
$brand = CmsBrand::forFrontend()[$slug] ?? null;
$label = is_array($brand) ? trim((string) ($brand['widget_label'] ?? '')) : '';
if ($label !== '') {
$this->content['button_label'] = $label;
}
}
/**
* Auswahl „Anliegen": gepflegte Kernmarken zuerst, danach die festen Einträge.
*
* @return array<string, string>
*/
public function getTopicsProperty(): array
{
$brands = [];
foreach (CmsBrand::forFrontend() as $slug => $brand) {
$brands[$slug] = (string) ($brand['name'] ?? $slug);
}
return $brands + self::EXTRA_TOPICS;
}
public function getWhatsappUrlProperty(): string
{
return whatsapp_link();
}
public function submit(ContactFormService $service): void
{
$this->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'email:rfc', 'max:255'],
'phone' => ['nullable', 'string', 'max:80'],
'topic' => ['nullable', 'string', 'max:255'],
'preferredDate' => ['nullable', 'date', 'after_or_equal:today'],
'message' => ['nullable', 'string', 'max:2000'],
'privacy' => ['accepted'],
'website' => ['nullable', 'string', 'max:0'],
], [
'preferredDate.after_or_equal' => __('ui.appointment.date_in_past'),
]);
$topicLabel = $this->topics[$this->topic] ?? '';
$payload = [
'name' => $this->name,
'email' => $this->email,
'phone' => $this->phone,
'topic' => $topicLabel,
'preferred_date' => $this->preferredDate,
'message' => $this->message,
'website' => $this->website,
'source' => request()->path(),
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'is_spam' => false,
];
$payload['is_spam'] = SpamDetector::fromConfig()->detect($payload, $this->formLoadedAt);
$subject = ($this->world === 'immobilien' ? 'Terminanfrage B2in Immobilien' : 'Terminanfrage B2in Interior')
.($topicLabel !== '' ? ': '.$topicLabel : '');
$service->handle($payload, $subject, 'appointment-widget');
$this->success = true;
$this->reset(['name', 'email', 'phone', 'topic', 'preferredDate', 'message', 'privacy', 'website']);
$this->topic = $this->world === 'immobilien' ? 'immobilien' : '';
$this->formLoadedAt = time();
}
public function startOver(): void
{
$this->success = false;
$this->formLoadedAt = time();
}
public function render(): \Illuminate\View\View
{
return view('livewire.web.components.ui.appointment-widget');
}
}

View file

@ -0,0 +1,205 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Acme\ContactForm\ContactFormService;
use Acme\ContactForm\SpamDetector;
use App\Models\CmsBrand;
use Illuminate\Validation\Rule;
use Livewire\Component;
class ContactForm extends Component
{
public string $firstName = '';
public string $lastName = '';
public string $company = '';
public string $email = '';
public string $phone = '';
public string $subject = '';
public string $message = '';
public bool $privacy = false;
public bool $success = false;
/** @var string Hidden honeypot field */
public string $website = '';
public ?int $formLoadedAt = null;
/** @var array<string, mixed> */
public array $content = [];
/** Farbwelt der Seite (`interior` | `immobilien`). */
public string $world = 'interior';
public function mount(string $world = 'interior'): void
{
$this->world = $world === 'immobilien' ? 'immobilien' : 'interior';
$this->content = $this->loadContent();
$this->formLoadedAt = time();
}
/**
* Inhalte der Welt. Die Immobilien-Welt liegt als Overlay über der Interior-Sektion:
* `contact_form_immobilien` trägt nur, was abweicht (Einstieg, Betreffe) – Adresse,
* Beschriftungen und Social Media erbt sie von `contact_form`.
*
* @return array<string, mixed>
*/
private function loadContent(): array
{
$base = cms_theme_section('contact_form');
$base = is_array($base) ? $base : [];
if ($this->world !== 'immobilien') {
return $base;
}
$overlay = cms_theme_section('contact_form_immobilien');
if (! is_array($overlay)) {
return $base;
}
return array_replace_recursive($base, $overlay);
}
/**
* Betreffe in Gruppen: Interior (mit den gepflegten Kernmarken vorneweg), Immobilien, B2in.
* Die Welt der Seite bestimmt die Reihenfolge – ihre Gruppe steht oben.
*
* Ohne `subject_groups` (aeltere CMS-Staende) faellt das Formular auf die flache Liste zurueck.
*
* @return array<string, array{label: string, subjects: array<string, string>}>
*/
public function getSubjectGroupsProperty(): array
{
$groups = $this->content['form']['subject_groups'] ?? null;
if (! is_array($groups) || $groups === []) {
$flat = $this->content['form']['subjects'] ?? [];
unset($flat['']);
return $flat === [] ? [] : ['all' => ['label' => '', 'subjects' => $flat]];
}
$brands = [];
foreach (CmsBrand::forFrontend() as $slug => $brand) {
$brands[$slug] = (string) ($brand['name'] ?? $slug);
}
$out = [];
foreach ($groups as $key => $group) {
$subjects = is_array($group['subjects'] ?? null) ? $group['subjects'] : [];
if ($key === 'interior') {
$subjects = $brands + $subjects;
}
$out[$key] = [
'label' => (string) ($group['label'] ?? ''),
'subjects' => $subjects,
];
}
$first = $this->world === 'immobilien' ? 'immobilien' : 'interior';
if (isset($out[$first])) {
$out = [$first => $out[$first]] + $out;
}
return $out;
}
/**
* Alle waehlbaren Betreffe als flache Liste (Schluessel → Beschriftung).
*
* @return array<string, string>
*/
public function getSubjectsProperty(): array
{
$flat = [];
foreach ($this->getSubjectGroupsProperty() as $group) {
$flat += $group['subjects'];
}
return $flat;
}
public function getSubjectPlaceholderProperty(): string
{
return (string) ($this->content['form']['subjects'][''] ?? $this->content['form']['subject_placeholder'] ?? __('ui.contact_form.subject_placeholder'));
}
/** @return array<int, array<string, mixed>> */
public function getContactInfoProperty(): array
{
return $this->content['contact_info'] ?? [];
}
public function submit(ContactFormService $service): void
{
$this->validate([
'firstName' => ['required', 'string', 'max:255'],
'lastName' => ['required', 'string', 'max:255'],
'email' => ['required', 'email:rfc', 'max:255'],
'subject' => ['required', 'string', Rule::in(array_keys($this->subjects))],
'message' => ['required', 'string', 'max:2000'],
'company' => ['nullable', 'string', 'max:255'],
'phone' => ['nullable', 'string', 'max:255'],
'privacy' => ['accepted'],
'website' => ['nullable', 'string', 'max:0'],
]);
$spamDetector = SpamDetector::fromConfig();
$payload = [
'first_name' => $this->firstName,
'last_name' => $this->lastName,
'email' => $this->email,
'subject' => $this->subject,
'message' => $this->message,
'company' => $this->company,
'phone' => $this->phone,
'website' => $this->website,
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'is_spam' => false,
];
$payload['is_spam'] = $spamDetector->detect($payload, $this->formLoadedAt);
$subjectLabel = $this->getSubjectsProperty()[$this->subject] ?? $this->subject;
$prefix = $this->world === 'immobilien' ? 'Kontaktanfrage B2in Immobilien' : 'Kontaktanfrage B2in';
$subjectLine = $subjectLabel
? $prefix.': '.$subjectLabel
: __('contact-form::contact-form.default_subject');
$service->handle($payload, $subjectLine, 'contact-form');
$this->success = true;
$this->firstName = '';
$this->lastName = '';
$this->company = '';
$this->email = '';
$this->phone = '';
$this->subject = '';
$this->message = '';
$this->privacy = false;
$this->website = '';
$this->formLoadedAt = time();
}
public function render(): \Illuminate\View\View
{
return view('livewire.web.components.ui.contact-form');
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class Footer extends Component
{
/** @var array<string, mixed> */
public array $content = [];
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
public string $world = 'interior';
/** @var array<string, array{label: string, url: string, active: bool}> */
public array $worlds = [];
public string $logoPath = '';
/**
* Negatives Logo für die Farbvariante „Modern“ (Nachtrag 18): dort liegt der Interior-Footer
* dunkel in Tanne. Die Palette schaltet im Browser, deshalb liegen beide Logos im Markup und
* das Stylesheet blendet das passende ein. Leer in der Immobilien-Welt, die ohnehin das
* negative Logo trägt.
*/
public string $logoPathDark = '';
/**
* Fuer die Immobilien-Welt zeigen die gemeinsamen Footer-Links auf die Seiten dieser Welt.
*
* @var array<string, string>
*/
private const IMMOBILIEN_LINKS = [
'/faq' => '/immobilien/faq',
'/about' => '/immobilien/ueber-b2in',
'/magazin' => '/immobilien/magazin',
];
public function mount(): void
{
$section = cms_theme_section('footer');
$this->content = is_array($section) ? $section : [];
$this->world = b2in_world();
if ($this->world === 'immobilien' && is_array($this->content['links'] ?? null)) {
$this->content['links'] = array_map(function ($link) {
if (is_array($link) && isset(self::IMMOBILIEN_LINKS[$link['url'] ?? ''])) {
$link['url'] = self::IMMOBILIEN_LINKS[$link['url']];
}
return $link;
}, $this->content['links']);
}
$this->worlds = b2in_worlds($this->world);
// Der Interior-Footer liegt auf Sand (helle Fläche) und trägt deshalb das positive
// Logo; der Immobilien-Footer bleibt dunkel und nutzt die negative Variante.
$variant = $this->world === 'immobilien' ? 'negative' : 'positive';
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath($variant, $this->world);
$this->logoPathDark = $this->world === 'immobilien'
? ''
: \App\Helpers\ThemeHelper::getLogoPath('negative', $this->world);
}
public function render()
{
return view('livewire.web.components.ui.footer');
}
}

View file

@ -0,0 +1,130 @@
<?php
namespace App\Livewire\Web\Components\Ui;
use Livewire\Component;
class Header extends Component
{
public $isMobileMenuOpen = false;
public $domainName;
public $domainUrl;
public $content = [];
public $currentLocale;
public $availableLocales = [
'de' => 'DE',
'en' => 'EN',
];
/** Farbwelt der Seite (`interior` | `immobilien`), in mount() festgehalten. */
public string $world = 'interior';
/** @var array<string, array{label: string, url: string, active: bool}> */
public array $worlds = [];
public string $logoPath = '';
/** Pfad der Seite, in mount() festgehalten – ein Livewire-Request traegt ihn nicht mehr. */
public string $currentPath = '/';
/** Ziel des Kontakt-Buttons: /contact (Interior) bzw. /immobilien/kontakt (Immobilien). */
public string $contactUrl = '';
public function mount(): void
{
$this->domainName = \App\Helpers\ThemeHelper::getDomainName();
$this->domainUrl = \App\Helpers\ThemeHelper::getDomainUrl();
$this->currentLocale = app()->getLocale();
$this->world = b2in_world();
$this->worlds = b2in_worlds($this->world);
$this->logoPath = \App\Helpers\ThemeHelper::getLogoPath('positive', $this->world);
$this->currentPath = '/'.ltrim(request()->path(), '/');
$this->contactUrl = route($this->world === 'immobilien' ? 'immobilien.contact' : 'contact');
// Jede Welt hat ihre eigene Navigation (CMS: global.header bzw. global.header_immobilien).
$this->content = cms_theme_section($this->world === 'immobilien' ? 'header_immobilien' : 'header');
// Ensure required keys exist
if (!isset($this->content['portal_login'])) {
$this->content['portal_login'] = 'Portal Login';
}
if (!isset($this->content['navigation']) || !is_array($this->content['navigation'])) {
$this->content['navigation'] = [];
}
// Referenzen lassen sich im CMS ausblenden; dann verschwindet auch der Menüpunkt.
if (! references_page_enabled()) {
$this->content['navigation'] = array_values(array_filter(
$this->content['navigation'],
fn ($item) => rtrim(parse_url((string) ($item['url'] ?? ''), PHP_URL_PATH) ?? '', '/') !== '/referenzen',
));
}
}
public function switchLanguage(string $locale): mixed
{
if (array_key_exists($locale, $this->availableLocales)) {
app()->setLocale($locale);
session(['locale' => $locale]);
$this->currentLocale = $locale;
$referer = request()->header('Referer') ?? '/';
return redirect()->to($referer);
}
return null;
}
public function toggleMobileMenu(): void
{
$this->isMobileMenuOpen = ! $this->isMobileMenuOpen;
}
public function closeMobileMenu(): void
{
$this->isMobileMenuOpen = false;
}
/**
* Aktiver Navigationspunkt: exakter Treffer, sonst der laengste Eintrag, dessen Pfad ein
* Praefix der Seite ist. So markiert /immobilien/magazin nur „Magazin“, nicht auch
* „Immobilien“. Anker (#…) zaehlen nicht zum Pfad.
*/
public function isActiveRoute($url): bool
{
$path = $this->navPath((string) $url);
if ($this->currentPath === $path) {
return true;
}
if ($path === '/' || ! str_starts_with($this->currentPath, rtrim($path, '/').'/')) {
return false;
}
foreach ($this->content['navigation'] ?? [] as $navItem) {
$other = $this->navPath((string) ($navItem['url'] ?? ''));
if ($other === $this->currentPath) {
return false;
}
if (strlen($other) > strlen($path) && str_starts_with($this->currentPath, rtrim($other, '/').'/')) {
return false;
}
}
return true;
}
private function navPath(string $url): string
{
$path = (string) (parse_url($url, PHP_URL_PATH) ?? '/');
return '/'.ltrim($path, '/');
}
public function render()
{
return view('livewire.web.components.ui.header');
}
}

View file

@ -13,12 +13,47 @@ 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',
];
public const WORLD_INTERIOR = 'interior';
public const WORLD_IMMOBILIEN = 'immobilien';
/** @var array<int, string> */
public const WORLDS = [self::WORLD_INTERIOR, self::WORLD_IMMOBILIEN];
/**
* Beiträge aus dem Bestand, die zur Immobilien-Welt gehören (Dubai-Themen).
*
* @var array<int, string>
*/
public const LEGACY_IMMOBILIEN_SLUGS = [
'escrow-system-dubai-investoren',
'spotlight-al-jaddaf-hotspot',
'turnkey-investments-moeblierung-rendite',
];
protected $fillable = [
'slug',
'title',
'subtitle',
'image',
'category',
'world',
'date_label',
'read_time',
'author',
@ -53,6 +88,104 @@ class CmsArticle extends Model
return $query->orderBy('order')->orderByDesc('created_at');
}
public function scopeForWorld(Builder $query, string $world): Builder
{
return $query->where('world', $world);
}
/**
* 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.
*
* Mit `$world` nur die Beiträge dieser Farbwelt (Interior oder Immobilien) – die beiden
* Magazinlisten sind getrennt, die Detailseite rendert in der Welt ihres Beitrags.
*
* @return array<string, array<string, mixed>>
*/
public static function forFrontend(?string $world = null): array
{
$maintained = static::query()->published()->exists();
if ($maintained) {
return static::query()
->published()
->when($world !== null, fn (Builder $query) => $query->forWorld($world))
->ordered()
->get()
->mapWithKeys(fn (self $article) => [$article->slug => $article->toFrontendArray()])
->all();
}
$articles = static::fallbackFromLang();
if ($world === null) {
return $articles;
}
return array_filter($articles, fn (array $article) => ($article['world'] ?? self::WORLD_INTERIOR) === $world);
}
/**
* 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) || ($article['published'] ?? true) === false) {
continue;
}
$slug = self::slugForLangArticle($id, $article);
if ($slug === null) {
continue;
}
$out[$slug] = $article + ['slug' => $slug, 'world' => self::WORLD_INTERIOR];
}
return $out;
}
/**
* @return array<string, mixed>
*/
@ -65,6 +198,7 @@ class CmsArticle extends Model
'subtitle' => $this->subtitle,
'image' => $this->image,
'category' => $this->category,
'world' => $this->world ?? self::WORLD_INTERIOR,
'date' => $this->date_label,
'readTime' => $this->read_time,
'author' => $this->author ?? [],

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

@ -0,0 +1,481 @@
<?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';
/** Einblick in Werkstatt, Fertigung oder Montage des Herstellers – weder Projekt noch Entwurf (Nachtrag 19). */
public const GALLERY_COMPANY = 'company';
/** @var list<string> */
public const GALLERY_CATEGORIES = [self::GALLERY_PROJECT, self::GALLERY_INSPIRATION, self::GALLERY_COMPANY];
/** Sprache der Spalten highlights, services, sections, showroom und der Bildunterschriften. */
public const BASE_LOCALE = 'de';
/**
* Felder, die nur einsprachig als Spalte existieren und ihre Übersetzungen in `field_translations`
* tragen (09.10.2026). `gallery_captions` sind die Bildunterschriften, nach Bild-Datei.
*/
public const LOCALIZED_FIELDS = ['highlights', 'services', 'sections', 'showroom', 'gallery_captions'];
protected $table = 'cms_brands';
protected $fillable = [
'slug',
'name',
'tagline',
'teaser',
'summary',
'highlights',
'description',
'logo',
'image',
'services',
'sections',
'widget_label',
'cta_title',
'cta_text',
'gallery',
'field_translations',
'website_url',
'showroom',
'origin_city',
'origin_distance',
'origin_role',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = [
'name',
'tagline',
'teaser',
'summary',
'description',
'widget_label',
'cta_title',
'cta_text',
'origin_city',
'origin_distance',
'origin_role',
];
protected function casts(): array
{
return [
'highlights' => 'array',
'services' => 'array',
'sections' => 'array',
'gallery' => 'array',
'field_translations' => '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 Liste `core_brands.brands`
* der Lang-Datei als Fallback, 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
{
// Direkt aus der Lang-Datei: Im CMS-Eintrag `home.core_brands` steht die Liste nicht
// (mehr), dort pflegt man nur Überschrift und Untertitel – die Marken in der Markenverwaltung.
$brands = trans('b2in.themes.'.config('app.theme', 'b2in').'.core_brands.brands');
$brands = is_array($brands) ? $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' => [],
'sections' => [],
'widget_label' => '',
'cta_title' => '',
'cta_text' => '',
'gallery' => [],
'website_url' => '',
'showroom' => '',
'origin_city' => '',
'origin_distance' => '',
'origin_role' => '',
'has_detail_page' => false,
], $brand, ['slug' => $slug, 'has_detail_page' => false]);
$entry['gallery'] = self::normalizeGallery($entry['gallery']);
$entry['sections'] = self::normalizeSections($entry['sections']);
$out[$slug] = $entry;
}
return $out;
}
/**
* Seitenabschnitte in eine feste Form bringen.
*
* Jeder Abschnitt hat einen Titel und Absätze. Optional dazu:
* - `claim` hervorgehobene Schlusszeile
* - `quote` Zitat mit `quote_author`
* - `detail` Vertiefung, die im Frontend aufklappbar dargestellt wird
*
* @param mixed $sections
* @return list<array{title: string, paragraphs: list<string>, claim: string, quote: string, quote_author: string, detail: array{title: string, paragraphs: list<string>}|null}>
*/
public static function normalizeSections($sections): array
{
if (! is_array($sections)) {
return [];
}
$out = [];
foreach ($sections as $section) {
if (! is_array($section)) {
continue;
}
$paragraphs = self::paragraphList($section['paragraphs'] ?? []);
$title = trim((string) ($section['title'] ?? ''));
if ($title === '' && $paragraphs === []) {
continue;
}
$detail = null;
if (is_array($section['detail'] ?? null)) {
$detailParagraphs = self::paragraphList($section['detail']['paragraphs'] ?? []);
$detailTitle = trim((string) ($section['detail']['title'] ?? ''));
if ($detailTitle !== '' || $detailParagraphs !== []) {
$detail = ['title' => $detailTitle, 'paragraphs' => $detailParagraphs];
}
}
$out[] = [
'title' => $title,
'paragraphs' => $paragraphs,
'claim' => trim((string) ($section['claim'] ?? '')),
'quote' => trim((string) ($section['quote'] ?? '')),
'quote_author' => trim((string) ($section['quote_author'] ?? '')),
'detail' => $detail,
];
}
return $out;
}
/**
* Absätze eines Abschnitts für die Ausgabe gruppieren.
*
* Ein Absatz, der mit „- “ beginnt, ist ein Listenpunkt; aufeinanderfolgende Punkte bilden
* eine Liste. Steht im Punkt ein Doppelpunkt, wird der Teil davor als fette Einleitung
* ausgegeben („- Sichere Prüfung & Terminierung: Wir vereinbaren …“). So lässt sich eine
* Aufzählung im Backend im normalen Textfeld pflegen.
*
* @param list<string> $paragraphs
* @return list<array{type: 'paragraph', text: string}|array{type: 'list', items: list<array{lead: string, text: string}>}>
*/
public static function paragraphBlocks(array $paragraphs): array
{
$blocks = [];
foreach ($paragraphs as $paragraph) {
if (! preg_match('/^[-–•]\s+(.+)$/su', $paragraph, $match)) {
$blocks[] = ['type' => 'paragraph', 'text' => $paragraph];
continue;
}
[$lead, $text] = str_contains($match[1], ': ')
? array_map('trim', explode(': ', $match[1], 2))
: ['', trim($match[1])];
$last = array_key_last($blocks);
if ($last === null || $blocks[$last]['type'] !== 'list') {
$blocks[] = ['type' => 'list', 'items' => []];
$last = array_key_last($blocks);
}
$blocks[$last]['items'][] = ['lead' => $lead, 'text' => $text];
}
return $blocks;
}
/**
* @param mixed $paragraphs
* @return list<string>
*/
private static function paragraphList($paragraphs): array
{
if (is_string($paragraphs)) {
$paragraphs = preg_split('/\R{2,}/', $paragraphs) ?: [];
}
if (! is_array($paragraphs)) {
return [];
}
return array_values(array_filter(array_map(
fn ($p) => trim((string) (is_array($p) ? ($p['text'] ?? '') : $p)),
$paragraphs,
)));
}
/**
* 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, Einblicken in die Werkstatt und Inspirationen getrennt.
*
* @param mixed $gallery
* @return array{projects: list<array{image: string, category: string, caption: string}>, company: 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,
)),
'company' => array_values(array_filter(
$items,
fn (array $item) => $item['category'] === self::GALLERY_COMPANY,
)),
'inspirations' => array_values(array_filter(
$items,
fn (array $item) => $item['category'] === self::GALLERY_INSPIRATION,
)),
];
}
/**
* Beschriftung des Badges auf dem Galeriebild („Realisiertes Projekt“, „Aus der Werkstatt“, „Visualisierung“).
*/
public static function galleryBadge(string $category): string
{
return match ($category) {
self::GALLERY_PROJECT => __('ui.brand_gallery_badge_project'),
self::GALLERY_COMPANY => __('ui.brand_gallery_badge_company'),
default => __('ui.brand_gallery_badge_inspiration'),
};
}
/**
* @return array<string, mixed>
*/
/**
* Ein einsprachiges Feld in der gewünschten Sprache: die Übersetzung aus `field_translations`,
* sonst die deutsche Spalte. `gallery_captions` liefert Bild-Datei => Unterschrift.
*/
public function localized(string $field, ?string $locale = null): mixed
{
$locale ??= app()->getLocale();
$base = $field === 'gallery_captions'
? collect(self::normalizeGallery($this->gallery))->mapWithKeys(fn (array $item) => [$item['image'] => $item['caption']])->all()
: $this->getAttribute($field);
if ($locale === self::BASE_LOCALE) {
return $base;
}
$translated = $this->field_translations[$locale][$field] ?? null;
if ($field === 'gallery_captions') {
foreach ($base as $image => $caption) {
if (filled($translated[$image] ?? null)) {
$base[$image] = (string) $translated[$image];
}
}
return $base;
}
return filled($translated) ? $translated : $base;
}
/**
* Schreibt ein einsprachiges Feld: Deutsch in die Spalte, andere Sprachen nach `field_translations`.
* Eine Übersetzung, die genau dem deutschen Stand entspricht, wird nicht gespeichert – sonst
* gälte deutscher Text als übersetzt.
*/
public function setLocalized(string $field, string $locale, mixed $value): void
{
if ($locale === self::BASE_LOCALE) {
if ($field !== 'gallery_captions') {
$this->setAttribute($field, $value);
}
return;
}
$translations = $this->field_translations ?? [];
$base = $this->localized($field, self::BASE_LOCALE);
if ($field === 'gallery_captions') {
$value = array_filter(
(array) $value,
fn (mixed $caption, string $image): bool => filled($caption) && $caption !== ($base[$image] ?? null),
ARRAY_FILTER_USE_BOTH,
);
}
if (blank($value) || $value === $base) {
unset($translations[$locale][$field]);
} else {
$translations[$locale][$field] = $value;
}
$translations = array_filter($translations, fn (mixed $fields): bool => filled($fields));
$this->field_translations = $translations === [] ? null : $translations;
}
/**
* Bildergalerie in der gewünschten Sprache (Bilder und Rubriken sind sprachunabhängig).
*
* @return list<array{image: string, category: string, caption: string}>
*/
public function localizedGallery(?string $locale = null): array
{
$captions = $this->localized('gallery_captions', $locale);
return array_map(
fn (array $item): array => [...$item, 'caption' => (string) ($captions[$item['image']] ?? $item['caption'])],
self::normalizeGallery($this->gallery),
);
}
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->localized('highlights') ?? [],
'description' => $this->description,
'logo' => $this->logo,
'image' => $this->image,
'services' => $this->localized('services') ?? [],
'sections' => self::normalizeSections($this->localized('sections')),
'widget_label' => (string) $this->widget_label,
'cta_title' => (string) $this->cta_title,
'cta_text' => (string) $this->cta_text,
'gallery' => $this->localizedGallery(),
'website_url' => $this->website_url,
'showroom' => $this->localized('showroom'),
'origin_city' => (string) $this->origin_city,
'origin_distance' => (string) $this->origin_distance,
'origin_role' => (string) $this->origin_role,
'has_detail_page' => true,
];
}
}

115
app/Models/CmsReference.php Normal file
View file

@ -0,0 +1,115 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Spatie\Translatable\HasTranslations;
/**
* Referenzprojekt der Interior-Welt: ein realisierter Auftrag mit Titelbild, weiteren Bildern
* und den beteiligten Kernmarken (Nachtrag 13). Erscheint auf /referenzen.
*
* Nicht zu verwechseln mit {@see CmsProject} – das sind die Immobilienprojekte.
*/
class CmsReference extends Model
{
use HasFactory, HasTranslations;
protected $table = 'cms_references';
protected $fillable = [
'title',
'location',
'year',
'description',
'image',
'gallery',
'brand_slugs',
'is_published',
'order',
];
/** @var array<string> */
public array $translatable = ['title', 'description'];
protected function casts(): array
{
return [
'gallery' => 'array',
'brand_slugs' => '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');
}
/**
* Galerie in eine feste Form bringen: nur Einträge mit Bild, Bildunterschrift getrimmt.
*
* @param mixed $gallery
* @return list<array{image: string, caption: string}>
*/
public static function normalizeGallery($gallery): array
{
if (! is_array($gallery)) {
return [];
}
$out = [];
foreach ($gallery as $item) {
$image = trim((string) (is_array($item) ? ($item['image'] ?? '') : $item));
if ($image === '') {
continue;
}
$out[] = [
'image' => $image,
'caption' => is_array($item) ? trim((string) ($item['caption'] ?? '')) : '',
];
}
return $out;
}
/**
* Beteiligte Marken als Liste mit Name und Link, aufgelöst gegen die veröffentlichten Marken.
*
* Eine unveröffentlichte oder gelöschte Marke fällt heraus.
*
* @param array<string, array<string, mixed>> $brands Ergebnis von CmsBrand::forFrontend()
* @return list<array{slug: string, name: string, link: ?string}>
*/
public function resolveBrands(array $brands): array
{
$out = [];
foreach ($this->brand_slugs ?? [] as $slug) {
$slug = (string) $slug;
if (! isset($brands[$slug])) {
continue;
}
$out[] = [
'slug' => $slug,
'name' => (string) ($brands[$slug]['name'] ?? $slug),
'link' => ! empty($brands[$slug]['has_detail_page']) ? '/marken/'.$slug : null,
];
}
return $out;
}
}

View file

@ -2,6 +2,10 @@
namespace App\Providers;
use App\Support\OperationalAlert;
use Illuminate\Foundation\Console\AboutCommand;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
@ -29,5 +33,17 @@ class AppServiceProvider extends ServiceProvider
if ($scheme === 'https') {
URL::forceScheme('https');
}
// Betriebs-Alert bei endgültig fehlgeschlagenem Queue-Job
Queue::failing(function (JobFailed $event): void {
OperationalAlert::send("Queue-Job fehlgeschlagen: {$event->job->resolveName()}", [
'connection' => $event->connectionName,
'queue' => $event->job->getQueue(),
'exception' => $event->exception->getMessage(),
]);
});
// Ausgelieferte Version in `php artisan about` (ABOUT_SECTION in dev/betrieb/deploy.sh)
AboutCommand::add('B2in', fn (): array => ['Version' => config('app.version') ?? '—']);
}
}

View file

@ -57,6 +57,18 @@ class ThemeServiceProvider extends ServiceProvider
$domainConfig = array_merge($domainConfig, $confiDomains[$themeOverride]);
}
// Unbekannter Host (Vorschau-, Staging- oder IP-Aufruf): Bis hierher stünde in `url` noch
// APP_URL bzw. der Vorgabewert aus config/domains.php – also eine *fremde* Domain. Alle
// Assets und Links würden dorthin zeigen, obwohl die Instanz unter einem anderen Namen
// läuft. Außerhalb von `production` gilt deshalb der tatsächliche Request-Origin.
//
// In `production` bleibt es bewusst bei der konfigurierten URL: dort darf ein gefälschter
// Host-Header keine Links auf eine fremde Domain erzeugen.
if ($domainConfig['domain_name'] !== $host && config('app.env') !== 'production') {
$domainConfig['domain_name'] = $host;
$domainConfig['url'] = Request::getSchemeAndHttpHost();
}
// Dynamische ASSET_URL basierend auf der aktuellen Domain setzen
// Verhindert CORS-Probleme, da Assets immer von derselben Domain geladen werden
$assetUrl = $domainConfig['url'];

View file

@ -48,6 +48,30 @@ final class CmsFluxEditorHtmlTransformer
return self::marksToSecondarySpans($html);
}
/**
* Der Editor packt jeden Text in Absätze – auch eingefügten Klartext (12.10.2026). Für Felder,
* die die Website innerhalb einer Zeile oder eines eigenen Absatzes ausgibt, wird daraus
* Inline-HTML: ein Absatz ohne `<p>`, mehrere durch `<br><br>` getrennt, reiner Text als
* Klartext. Enthält der Text Blockelemente (Überschriften, Listen …), bleibt er unverändert.
*/
public static function unwrapParagraphs(string $html): string
{
$html = trim((string) preg_replace('#<p[^>]*>(\s|&nbsp;|<br\s*/?>)*</p>#i', '', $html));
if ($html === '' || ! str_contains($html, '<p')) {
return $html;
}
if (preg_match('#<(?!/?(?:p|strong|b|em|i|mark|span|br|a)\b)[a-z]#i', $html) === 1) {
return $html;
}
$html = (string) preg_replace('#</p>\s*<p[^>]*>#i', '<br><br>', $html);
$html = trim((string) preg_replace('#^<p[^>]*>|</p>$#i', '', $html));
return str_contains($html, '<') ? $html : html_entity_decode($html, ENT_QUOTES | ENT_HTML5, 'UTF-8');
}
/**
* @param array<int, array<string, mixed>|array{_value: string}> $items
* @return array<int, array<string, mixed>|array{_value: string}>
@ -92,7 +116,7 @@ final class CmsFluxEditorHtmlTransformer
$out = [];
foreach ($item as $key => $value) {
if (in_array($key, self::RICH_TEXT_JSON_FIELDS, true) && is_string($value)) {
$out[$key] = self::fromEditor($value);
$out[$key] = self::unwrapParagraphs(self::fromEditor($value));
} else {
$out[$key] = $value;
}

View file

@ -0,0 +1,187 @@
<?php
namespace App\Services;
use FluxCms\Core\Models\CmsContent;
use Illuminate\Support\Str;
/**
* Seitenbaum für den Inhalte-Editor (config/cms_pages.php, Nachtrag 12).
*
* Verbindet die Registry mit dem Datenbestand: je Sektion, ob sie in flux_cms_contents liegt
* (dann mit `content_id`), und was sonst noch in der Datenbank steht, aber in keiner Seite
* eingetragen ist („Nicht mehr verwendet“).
*/
class CmsPageTree
{
/** Schlüssel des Sammelpunkts für nicht eingetragene Sektionen. */
public const UNLISTED = 'unlisted';
/**
* Alle Seiten mit ihren Sektionen in Website-Reihenfolge.
*
* @return array<string, array{title: string, path: ?string, sections: list<array<string, mixed>>}>
*/
public function pages(): array
{
$rows = CmsContent::query()
->get(['id', 'group', 'key'])
->keyBy(fn (CmsContent $row) => $row->group.'/'.$row->key);
$pages = [];
foreach (config('cms_pages.pages', []) as $pageKey => $page) {
$sections = [];
foreach ($page['sections'] ?? [] as $section) {
$id = $section['group'].'/'.$section['key'];
$sections[] = array_merge($section, [
'page' => $pageKey,
'path' => $section['path'] ?? $page['path'] ?? null,
'anchor' => $section['anchor'] ?? null,
'content_id' => $rows[$id]->id ?? null,
'theme_key' => $this->themeKey($section['group'], $section['key']),
]);
}
$pages[$pageKey] = [
'title' => $page['title'],
'path' => $page['path'] ?? null,
'sections' => $sections,
];
}
$unlisted = $this->unlistedSections($rows->keys()->all(), $rows);
if ($unlisted !== []) {
$pages[self::UNLISTED] = [
'title' => config('cms_pages.unlisted.title', 'Nicht mehr verwendet'),
'path' => null,
'sections' => $unlisted,
];
}
return $pages;
}
/**
* Eine Sektion aus der Registry samt Datenbankbezug, oder null.
*
* @return array<string, mixed>|null
*/
public function section(string $group, string $key): ?array
{
foreach ($this->pages() as $page) {
foreach ($page['sections'] as $section) {
if ($section['group'] === $group && $section['key'] === $key) {
return $section;
}
}
}
return null;
}
/**
* Klartext-Beschriftung eines Feldes: Sektions-Override, dann Wörterbuch, sonst lesbar gemacht.
*/
public function fieldLabel(string $field, ?string $group = null, ?string $key = null): string
{
if ($group !== null && $key !== null) {
foreach (config('cms_pages.pages', []) as $page) {
foreach ($page['sections'] ?? [] as $section) {
if ($section['group'] === $group && $section['key'] === $key && isset($section['fields'][$field])) {
return $section['fields'][$field];
}
}
}
}
$labels = config('cms_pages.field_labels', []);
if (isset($labels[$field])) {
return $labels[$field];
}
return Str::ucfirst(str_replace('_', ' ', $field));
}
/**
* Adresse der Vorschau für eine Sektion: Frontend-Domain, Pfad, Cache-Brecher und Sprungmarke.
*/
public function previewUrl(?array $section, int $version = 0): ?string
{
$path = $section['path'] ?? null;
if ($path === null) {
return null;
}
$url = rtrim(config('domains.protocol', 'https://').config('domains.domain_b2in'), '/')
.'/'.ltrim($path, '/')
.'?cms-preview='.$version;
if (! empty($section['anchor'])) {
$url .= '#'.$section['anchor'];
}
return $url;
}
/**
* Theme-Section-Schlüssel (Lang-Datei) zu Gruppe/Key, für „Aus Vorlage anlegen“.
*/
public function themeKey(string $group, string $key): ?string
{
foreach (config('cms_section_map.sections', []) as $themeKey => $target) {
if ($target === [$group, $key]) {
return $themeKey;
}
}
return null;
}
/**
* @param list<string> $ids
* @param \Illuminate\Support\Collection<string, CmsContent> $rows
* @return list<array<string, mixed>>
*/
private function unlistedSections(array $ids, $rows): array
{
$listed = [];
foreach (config('cms_pages.pages', []) as $page) {
foreach ($page['sections'] ?? [] as $section) {
$listed[$section['group'].'/'.$section['key']] = true;
}
}
$out = [];
foreach ($ids as $id) {
if (isset($listed[$id])) {
continue;
}
$row = $rows[$id];
$out[] = [
'group' => $row->group,
'key' => $row->key,
'page' => self::UNLISTED,
'path' => null,
'anchor' => null,
'title' => $row->group.' / '.$row->key,
'description' => null,
'content_id' => $row->id,
'theme_key' => null,
];
}
usort($out, fn (array $a, array $b) => [$a['group'], $a['key']] <=> [$b['group'], $b['key']]);
return $out;
}
}

View file

@ -0,0 +1,198 @@
<?php
namespace App\Services\Monitoring;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
/**
* Die Prüfungen, die ein externer Monitor jede Minute abfragt
*: Datenbank, Cache, Queue-Rückstau,
* Scheduler-Herzschlag, Plattenplatz. Bewusst billig — keine Zählungen über
* große Tabellen, damit `/health` selbst keine Last erzeugt.
*
* Ein Admin-Systemstatus kann `scheduler()` und `disk()` mitbenutzen.
*/
class HealthProbe
{
public const StatusOk = 'ok';
public const StatusWarn = 'warn';
public const StatusCritical = 'critical';
/**
* Datei statt Datenbank/Cache: übersteht `cache:clear`, braucht keine
* Migration. Bei mehreren App-Servern stattdessen eine DB-Zeile nehmen.
*/
public static function heartbeatPath(): string
{
return (string) (config('monitoring.scheduler.heartbeat_path') ?: storage_path('framework/scheduler-heartbeat'));
}
public function recordHeartbeat(): void
{
file_put_contents(self::heartbeatPath(), now()->toIso8601String(), LOCK_EX);
}
public function lastHeartbeat(): ?Carbon
{
$value = @file_get_contents(self::heartbeatPath());
return is_string($value) && trim($value) !== '' ? Carbon::parse(trim($value)) : null;
}
/**
* @return list<array{key: string, label: string, status: string, detail: string}>
*/
public function checks(): array
{
return [
$this->database(),
$this->cache(),
$this->queue(),
$this->scheduler(),
$this->disk(),
];
}
/**
* @param list<array{status: string}> $checks
*/
public static function worstStatus(array $checks): string
{
$statuses = array_column($checks, 'status');
return match (true) {
in_array(self::StatusCritical, $statuses, true) => self::StatusCritical,
in_array(self::StatusWarn, $statuses, true) => self::StatusWarn,
default => self::StatusOk,
};
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
public function scheduler(): array
{
$lastBeat = $this->lastHeartbeat();
$warnMinutes = (int) config('monitoring.scheduler.heartbeat_warn_minutes');
$criticalMinutes = (int) config('monitoring.scheduler.heartbeat_critical_minutes');
$minutesAgo = $lastBeat !== null ? (int) $lastBeat->diffInMinutes(now(), absolute: true) : null;
return $this->check(
'scheduler',
'Scheduler',
match (true) {
$minutesAgo === null, $minutesAgo >= $criticalMinutes => self::StatusCritical,
$minutesAgo >= $warnMinutes => self::StatusWarn,
default => self::StatusOk,
},
$lastBeat === null
? 'Noch kein Herzschlag — läuft `schedule:run` per Cron (bzw. `schedule:work`)?'
: 'Letzter Herzschlag '.$lastBeat->format('d.m.Y H:i:s').' (vor '.$minutesAgo.' Min.).',
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
public function disk(): array
{
$path = storage_path();
$free = @disk_free_space($path);
$total = @disk_total_space($path);
if ($free === false || $total === false || $total <= 0) {
return $this->check('disk', 'Plattenplatz', self::StatusWarn, 'Plattenplatz nicht ermittelbar.');
}
$freePercent = round($free / $total * 100, 1);
$minPercent = (float) config('monitoring.health.disk_min_free_percent');
return $this->check(
'disk',
'Plattenplatz',
match (true) {
$freePercent < $minPercent => self::StatusCritical,
$freePercent < $minPercent * 2 => self::StatusWarn,
default => self::StatusOk,
},
sprintf('%s %% frei (%s GB von %s GB).', $freePercent, round($free / 1024 ** 3, 1), round($total / 1024 ** 3, 1)),
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function database(): array
{
try {
DB::select('select 1');
return $this->check('database', 'Datenbank', self::StatusOk, 'Verbindung steht.');
} catch (Throwable $exception) {
return $this->check('database', 'Datenbank', self::StatusCritical, 'Keine Verbindung: '.Str::limit($exception->getMessage(), 120));
}
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function cache(): array
{
try {
$value = Str::random(8);
Cache::put('monitoring:health-probe', $value, 60);
return Cache::get('monitoring:health-probe') === $value
? $this->check('cache', 'Cache', self::StatusOk, 'Schreiben und Lesen funktionieren.')
: $this->check('cache', 'Cache', self::StatusCritical, 'Geschriebener Wert kommt nicht zurück.');
} catch (Throwable $exception) {
return $this->check('cache', 'Cache', self::StatusCritical, 'Nicht erreichbar: '.Str::limit($exception->getMessage(), 120));
}
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function queue(): array
{
if (config('queue.default') !== 'database') {
return $this->check('queue', 'Queue', self::StatusOk, 'Treiber '.config('queue.default').' — Rückstau hier nicht prüfbar.');
}
try {
$now = now()->getTimestamp();
$oldestAvailable = DB::table('jobs')
->whereNull('reserved_at')
->where('available_at', '<=', $now)
->min('available_at');
} catch (Throwable $exception) {
return $this->check('queue', 'Queue', self::StatusCritical, 'Jobs-Tabelle nicht lesbar: '.Str::limit($exception->getMessage(), 120));
}
$waitingSeconds = $oldestAvailable !== null ? max(0, $now - (int) $oldestAvailable) : 0;
$criticalSeconds = (int) config('monitoring.health.queue_backlog_critical_seconds');
return $this->check(
'queue',
'Queue',
$waitingSeconds >= $criticalSeconds ? self::StatusCritical : self::StatusOk,
$oldestAvailable === null
? 'Kein wartender Job.'
: 'Ältester wartender Job seit '.$waitingSeconds.' s.'.($waitingSeconds >= $criticalSeconds ? ' Worker gestoppt?' : ''),
);
}
/**
* @return array{key: string, label: string, status: string, detail: string}
*/
private function check(string $key, string $label, string $status, string $detail): array
{
return ['key' => $key, 'label' => $label, 'status' => $status, 'detail' => $detail];
}
}

View file

@ -0,0 +1,170 @@
<?php
namespace App\Services\Monitoring;
use App\Support\Monitoring\ErrorReportScrubber;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Throwable;
/**
* Daten für die Monitoring-Seite im Admin (09.10.2026): dieselben Prüfungen wie `/health`, dazu
* Version, geplante Aufgaben, fehlgeschlagene Jobs und die letzten Fehler und Alarme aus den
* Logs. Liest nur, ändert nichts; Logs nur das Ende der Datei, damit die Seite schnell bleibt.
*/
class SystemStatus
{
/** Zeitzone für die Anzeige – die App rechnet in UTC. */
public const DisplayTimezone = 'Europe/Berlin';
private const LogTailBytes = 262144;
private const LogLinePattern = '/^\[(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2})[^\]]*\] \w+\.(\w+): (.*)$/';
public function __construct(private readonly HealthProbe $probe) {}
/**
* @return array{status: string, checks: list<array{key: string, label: string, status: string, detail: string}>}
*/
public function health(): array
{
$checks = $this->probe->checks();
return ['status' => HealthProbe::worstStatus($checks), 'checks' => $checks];
}
/**
* @return array{version: string, environment: string, debug: bool, php: string, laravel: string, deployed_at: ?Carbon}
*/
public function application(): array
{
$release = base_path('RELEASE');
return [
'version' => (string) (config('app.version') ?? '—'),
'environment' => app()->environment(),
'debug' => (bool) config('app.debug'),
'php' => PHP_VERSION,
'laravel' => app()->version(),
'deployed_at' => is_file($release) ? Carbon::createFromTimestamp((int) filemtime($release))->timezone(self::DisplayTimezone) : null,
];
}
/**
* Geplante Aufgaben mit nächstem Lauf (über `schedule:list`, das die Konsolen-Routen lädt).
*
* @return list<array{command: string, expression: string, next: ?Carbon, description: string}>
*/
public function scheduledTasks(): array
{
try {
Artisan::call('schedule:list', ['--json' => true]);
$tasks = json_decode(Artisan::output(), true);
} catch (Throwable) {
return [];
}
return collect(is_array($tasks) ? $tasks : [])
->map(fn (array $task): array => [
'command' => trim((string) preg_replace('/^.*?artisan\s+/', '', (string) ($task['command'] ?? ''))),
'expression' => (string) ($task['expression'] ?? ''),
'next' => filled($task['next_due_date'] ?? null) ? Carbon::parse($task['next_due_date'])->timezone(self::DisplayTimezone) : null,
'description' => (string) ($task['description'] ?? ''),
])
->values()
->all();
}
/**
* @return array{count: int, latest: list<array{job: string, queue: string, failed_at: Carbon, error: string}>}
*/
public function failedJobs(int $limit = 5): array
{
try {
$table = DB::table((string) config('queue.failed.table', 'failed_jobs'));
return [
'count' => $table->count(),
'latest' => $table->orderByDesc('failed_at')->limit($limit)->get()
->map(fn (object $job): array => [
'job' => (string) (json_decode((string) $job->payload, true)['displayName'] ?? 'Job'),
'queue' => (string) $job->queue,
'failed_at' => Carbon::parse($job->failed_at)->timezone(self::DisplayTimezone),
'error' => ErrorReportScrubber::scrubText(Str::limit(strtok((string) $job->exception, "\n") ?: '', 180)),
])
->all(),
];
} catch (Throwable) {
return ['count' => 0, 'latest' => []];
}
}
/**
* Letzte Einträge ab einer Stufe aus dem jüngsten Log einer Art (`laravel`, `monitoring`).
*
* @param list<string> $levels
* @return list<array{at: Carbon, level: string, message: string}>
*/
public function recentLogEntries(string $prefix, array $levels, int $limit = 10): array
{
$files = glob(storage_path("logs/{$prefix}*.log")) ?: [];
usort($files, fn (string $a, string $b): int => filemtime($b) <=> filemtime($a));
$file = $files[0] ?? null;
if ($file === null) {
return [];
}
$handle = @fopen($file, 'rb');
if ($handle === false) {
return [];
}
$size = (int) filesize($file);
fseek($handle, max(0, $size - self::LogTailBytes));
$tail = (string) stream_get_contents($handle);
fclose($handle);
$entries = [];
foreach (preg_split('/\R/', $tail) ?: [] as $line) {
if (preg_match(self::LogLinePattern, $line, $match) !== 1 || ! in_array(strtoupper($match[2]), $levels, true)) {
continue;
}
$message = (string) preg_replace('/\s*\{"(exception|userId)".*$/s', '', $match[3]);
$entries[] = [
'at' => Carbon::parse($match[1])->timezone(self::DisplayTimezone),
'level' => strtolower($match[2]),
'message' => ErrorReportScrubber::scrubText(Str::limit($message, 220)),
];
}
return array_slice(array_reverse($entries), 0, $limit);
}
/**
* Links zu den Diensten außerhalb der App, soweit konfiguriert.
*
* @return array<string, string>
*/
public function links(): array
{
$links = [];
$dsn = (string) config('sentry.dsn');
if (preg_match('#^https?://[^@]+@([^/]+)/(\d+)#', $dsn, $match) === 1) {
$links['GlitchTip'] = "https://{$match[1]}/";
}
if (filled(config('monitoring.status_url'))) {
$links['Uptime Kuma'] = (string) config('monitoring.status_url');
}
return $links;
}
}

View file

@ -27,8 +27,10 @@ class DisplayModuleSettings
],
DisplayVersionType::B2in->value => [
'theme' => 'dark',
'header_logo_url' => '/_cabinet/assets/b2in-logo-positive.svg',
'header_claim' => 'Connecting Design & Property',
// Standard-Theme ist dunkel, das Logo liegt dort auf einem dunklen Scrim –
// deshalb die negative (weisse) Variante.
'header_logo_url' => '/_cabinet/assets/b2in-interior-logo-negative.svg',
'header_claim' => 'Premium-Einrichtung. Persönlich gemacht.',
'logo_position' => 'top-left',
'claim_position' => 'top-right',
'show_logo' => true,

View file

@ -0,0 +1,159 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Str;
use Sentry\Event;
use Sentry\EventHint;
use Sentry\UserDataBag;
use Throwable;
/**
* Letzte Station vor GlitchTip (`before_send` in config/sentry.php).
*
* Personenbezug nur über die interne User-ID. Tokens und Signaturen stecken
* in Query-Strings (signierte Links) und in Pfaden
* (`/reset-password/{token}`, Magic Links) — beides wird ersetzt, bevor das
* Ereignis den Server verlässt. E-Mail-Adressen in Fehlermeldungen (z. B.
* QueryException mit eingesetzten Bindings) ebenso.
*/
class ErrorReportScrubber
{
public const Filtered = '[Filtered]';
/**
* Query-Parameter, die unverändert mitgehen dürfen (`utm_*` immer).
* Je Projekt anpassen — dieselbe Liste steht in error-reporting.js.
*
* @var list<string>
*/
public const AllowedQueryParameters = ['page', 'q', 'tab', 'sort'];
private const EmailPattern = '/[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}/i';
/** Pfadsegmente, die wie ein Token aussehen (Magic Links, Reset-Links). */
private const TokenSegmentPattern = '#(?<=/)[A-Za-z0-9_\-]{32,}(?=/|$)#';
public static function beforeSend(Event $event, ?EventHint $hint = null): ?Event
{
$userId = self::authenticatedUserId();
$event->setUser($userId !== null ? UserDataBag::createFromUserIdentifier($userId) : null);
$event->setRequest(self::scrubRequest($event->getRequest()));
foreach ($event->getExceptions() as $exception) {
$exception->setValue(self::scrubText($exception->getValue()));
}
if ($event->getMessage() !== null) {
$formatted = $event->getMessageFormatted();
$event->setMessage(
self::scrubText($event->getMessage()),
$event->getMessageParams(),
$formatted !== null ? self::scrubText($formatted) : null,
);
}
return $event;
}
/**
* @param array<string, mixed> $request
* @return array<string, mixed>
*/
public static function scrubRequest(array $request): array
{
if (isset($request['url']) && is_string($request['url'])) {
$request['url'] = self::scrubUrl($request['url']);
}
if (isset($request['query_string']) && is_string($request['query_string'])) {
$request['query_string'] = self::scrubQuery($request['query_string']);
}
unset($request['data'], $request['cookies']);
foreach ($request['headers'] ?? [] as $name => $values) {
if (strtolower((string) $name) === 'referer') {
$request['headers'][$name] = array_map(
static fn (mixed $value): string => self::scrubUrl((string) $value),
(array) $values,
);
}
}
return $request;
}
public static function scrubUrl(string $url): string
{
$query = parse_url($url, PHP_URL_QUERY);
$withoutQuery = $query !== null && $query !== false ? Str::before($url, '?') : $url;
$scrubbedPath = self::scrubPath(self::scrubPathTokens($withoutQuery));
return $query !== null && $query !== false && $query !== ''
? $scrubbedPath.'?'.self::scrubQuery($query)
: $scrubbedPath;
}
public static function scrubQuery(string $query): string
{
parse_str($query, $parameters);
$scrubbed = [];
foreach ($parameters as $name => $value) {
$isAllowed = in_array((string) $name, self::AllowedQueryParameters, true)
|| str_starts_with((string) $name, 'utm_');
$scrubbed[$name] = $isAllowed && is_string($value) ? $value : self::Filtered;
}
return http_build_query($scrubbed, '', '&', PHP_QUERY_RFC3986);
}
public static function scrubPath(string $path): string
{
return self::scrubText((string) preg_replace(self::TokenSegmentPattern, self::Filtered, $path));
}
public static function scrubText(string $text): string
{
return (string) preg_replace(self::EmailPattern, '[email]', $text);
}
/**
* Ersetzt Routenparameter, deren Name auf ein Geheimnis hindeutet
* (`token`, `hash`, `signature`), im Pfad der aktuellen Route.
*/
private static function scrubPathTokens(string $url): string
{
try {
$route = request()->route();
} catch (Throwable) {
return $url;
}
if ($route === null) {
return $url;
}
foreach ($route->parameters() as $name => $value) {
if (is_string($value) && $value !== '' && Str::contains(strtolower((string) $name), ['token', 'hash', 'signature'])) {
$url = str_replace(rawurlencode($value), self::Filtered, str_replace($value, self::Filtered, $url));
}
}
return $url;
}
private static function authenticatedUserId(): int|string|null
{
try {
return Auth::id();
} catch (Throwable) {
return null;
}
}
}

View file

@ -0,0 +1,68 @@
<?php
namespace App\Support\Monitoring;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\Str;
use Sentry\Severity;
use Sentry\State\Scope;
use function Sentry\captureMessage;
use function Sentry\withScope;
/**
* Fehler ohne Exception als GlitchTip-Warnung: tote
* interne Links, verweigerte Zugriffe, abgelaufene Seiten. Gruppiert wird je
* Signal und Ort; je Gruppe gehen höchstens fünf Meldungen in zehn Minuten
* raus, damit ein Crawler mit gefälschtem Referrer GlitchTip nicht flutet.
*/
class ErrorSignal
{
public const DeadInternalLink = 'dead_internal_link';
public const AccessDenied = 'access_denied';
public const PageExpired = 'page_expired';
public const Throttled = 'throttled';
private const Labels = [
self::DeadInternalLink => 'Toter interner Link',
self::AccessDenied => 'Zugriff verweigert (angemeldet)',
self::PageExpired => 'Seite abgelaufen (419)',
self::Throttled => 'Drosselung trifft angemeldeten Nutzer',
];
private const MaxReportsPerWindow = 5;
private const WindowSeconds = 600;
/**
* @param array<string, mixed> $context
*/
public function report(string $signal, string $location, array $context = []): bool
{
$location = Str::limit($location, 200, '');
$reported = RateLimiter::attempt(
'error-signal:'.sha1($signal.'|'.$location),
self::MaxReportsPerWindow,
static fn (): bool => true,
self::WindowSeconds,
);
if ($reported === false) {
return false;
}
withScope(function (Scope $scope) use ($signal, $location, $context): void {
$scope->setFingerprint(['error-signal', $signal, $location]);
$scope->setTag('signal', $signal);
$scope->setContext('signal', $context);
captureMessage((self::Labels[$signal] ?? $signal).': '.$location, Severity::warning());
});
return true;
}
}

View file

@ -0,0 +1,36 @@
<?php
namespace App\Support\Monitoring;
/**
* Versionskennung des ausgelieferten Stands — `config('app.version')` und
* Release in GlitchTip. Vorrang hat der konfigurierte Wert (APP_VERSION bzw.
* SENTRY_RELEASE); sonst die Datei `RELEASE` im Projektwurzelverzeichnis, die
* beim Deploy entsteht (`git describe --tags --always --dirty > RELEASE`) —
* der Live-Server hat kein Git, solange per SFTP deployt wird.
*
* Versionen sind Git-Tags nach SemVer: Feature-Deploy erhöht die mittlere,
* Hotfix die letzte Stelle. Auf dem Tag ergibt das „v1.1.0“, mit späteren
* Commits „v1.1.0-2-gabc1234“, mit uncommitteten Änderungen „…-dirty“.
*/
class Release
{
public static function current(?string $configured, ?string $basePath = null): ?string
{
$configured = trim((string) $configured);
if ($configured !== '') {
return $configured;
}
$file = rtrim($basePath ?? base_path(), '/').'/RELEASE';
if (! is_file($file)) {
return null;
}
$release = trim((string) file_get_contents($file));
return preg_match('/^[A-Za-z0-9._\-]{1,64}$/', $release) === 1 ? $release : null;
}
}

View file

@ -0,0 +1,96 @@
<?php
namespace App\Support;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Str;
use Throwable;
/**
* Zentrale Betriebs-Alerts: meldet fehlgeschlagene Scheduler-Tasks und
* Queue-Jobs (Verdrahtung: siehe 03-betriebsueberwachung.md). Der Log-Eintrag (`critical`) ist die verlässliche Ebene und läuft
* immer; Mail und ntfy-Push sind Best-Effort, unabhängig
* voneinander, und dürfen den auslösenden Prozess nie mit in den Abgrund
* reißen (Mailserver down, ntfy nicht erreichbar o. ä.).
*/
class OperationalAlert
{
/**
* @param array<string, mixed> $context
*/
public static function send(string $summary, array $context = []): void
{
Log::channel('monitoring')->critical('[monitoring] '.$summary, $context);
if (! config('monitoring.alerts.enabled')) {
return;
}
self::mail($summary, $context);
self::push($summary, $context);
}
/**
* @param array<string, mixed> $context
*/
private static function mail(string $summary, array $context): void
{
$recipient = config('monitoring.alerts.mail_to');
if (empty($recipient)) {
return;
}
try {
Mail::raw(
$summary."\n\n".json_encode($context, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
fn ($message) => $message->to($recipient)->subject('['.config('app.name').'] Betriebs-Alert: '.Str::limit($summary, 80)),
);
} catch (Throwable $e) {
Log::channel('monitoring')->error('[monitoring] Alert-Mail konnte nicht versendet werden.', [
'summary' => $summary,
'error' => $e->getMessage(),
]);
}
}
/**
* JSON-Publish an die ntfy-Basis-URL (UTF-8-sicher, anders als Header).
*
* @param array<string, mixed> $context
*/
private static function push(string $summary, array $context): void
{
$url = (string) config('monitoring.alerts.ntfy.url');
$topic = (string) config('monitoring.alerts.ntfy.topic');
if ($url === '' || $topic === '') {
return;
}
$details = collect($context)
->map(fn (mixed $value, string $key): string => $key.': '.(is_scalar($value) || $value === null ? (string) $value : json_encode($value)))
->implode("\n");
try {
Http::timeout(5)
->connectTimeout(3)
->when(filled(config('monitoring.alerts.ntfy.token')), fn ($request) => $request->withToken((string) config('monitoring.alerts.ntfy.token')))
->post(rtrim($url, '/'), [
'topic' => $topic,
'title' => config('app.name').' ('.app()->environment().'): Betriebs-Alert',
'message' => Str::limit(trim($summary."\n\n".$details), 3500),
'priority' => (int) config('monitoring.alerts.ntfy.priority'),
'tags' => ['rotating_light'],
])
->throw();
} catch (Throwable $e) {
Log::channel('monitoring')->error('[monitoring] ntfy-Push konnte nicht versendet werden.', [
'summary' => $summary,
'error' => $e->getMessage(),
]);
}
}
}

View file

@ -1,5 +1,6 @@
<?php
use App\Models\Setting;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\CmsContentService;
@ -48,8 +49,6 @@ if (! function_exists('cms_theme_section')) {
/** @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];
@ -60,9 +59,78 @@ if (! function_exists('cms_theme_section')) {
}
}
$langKey = $langKeys[$sectionKey] ?? $sectionKey;
return trans("b2in.themes.{$theme}.{$sectionKey}");
}
}
return trans("b2in.themes.{$theme}.{$langKey}");
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('#</(p|div|li|h[1-6])>\s*<\1[^>]*>#i', '<br><br>', $html);
$html = strip_tags($html, '<strong><b><em><i><span><br><a>');
// 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;
}
}
@ -94,6 +162,13 @@ if (! function_exists('theme_image_url')) {
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 <x-web-picture> 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()) {
@ -123,29 +198,14 @@ if (! function_exists('tcms')) {
}
}
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.
* 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
{
@ -153,27 +213,80 @@ if (! function_exists('media_url')) {
return '';
}
static $resolved = [];
$cacheKey = $filename.'|'.$profile;
// 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 (isset($resolved[$cacheKey])) {
return $resolved[$cacheKey];
}
if (! $media) {
return asset('img/assets/'.ltrim($filename, '/'));
}
$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];
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<string, array{label: string, url: string, active: bool}>
*/
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);
}
}

View file

@ -1,14 +1,25 @@
<?php
use App\Http\Controllers\HealthController;
use App\Http\Middleware\ReportErrorSignals;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
use Illuminate\Support\Facades\Route;
use Sentry\Laravel\Integration;
use Symfony\Component\Console\Exception\CommandNotFoundException;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/domains.php',
commands: __DIR__.'/../routes/console.php',
health: '/up',
then: function (): void {
// Tiefer Health-Check für den externen Monitor (Uptime Kuma), auf jeder Domain
Route::get('/health', HealthController::class)
->middleware('throttle:30,1')
->name('health.deep');
},
)
->withMiddleware(function (Middleware $middleware) {
// Traefik Reverse-Proxy vertraut die X-Forwarded-Proto/For Headers
@ -27,11 +38,23 @@ return Application::configure(basePath: dirname(__DIR__))
'partner.setup' => \App\Http\Middleware\EnsurePartnerSetupCompleted::class,
]);
// Fehler ohne Exception (toter interner Link, 403/429 für Angemeldete, 419) –
// global, weil 404 vor dem Routing entsteht
$middleware->append(ReportErrorSignals::class);
// Inhalte-Editor im Portal zeigt die Website im iframe: frame-ancestors statt SAMEORIGIN
$middleware->append(\App\Http\Middleware\AllowPortalFraming::class);
// BasicAuth ganz am Ende, nach Session-Middleware
if (env('BASIC_AUTH_ENABLED', true)) {
$middleware->append(\App\Http\Middleware\BasicAuthMiddleware::class);
}
})
->withExceptions(function (Exceptions $exceptions) {
//
// Gemeldete Exceptions an GlitchTip (ohne DSN ein No-op);
// Laravels dontReport (404, Validierung, Auth) gilt weiter
Integration::handles($exceptions);
// Vertippte artisan-Befehle sind kein Anwendungsfehler
$exceptions->dontReport(CommandNotFoundException::class);
})->create();

View file

@ -22,6 +22,7 @@
"livewire/flux": "^2.1.1",
"livewire/flux-pro": "^2.6",
"livewire/volt": "^1.7.0",
"sentry/sentry-laravel": "^4.29",
"spatie/laravel-permission": "^6.17"
},
"require-dev": {

553
composer.lock generated
View file

@ -4,11 +4,11 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "9e07d437a063526b426b920d400db8b8",
"content-hash": "f2a8196dfd6ef735f3e827805a8a1f94",
"packages": [
{
"name": "acme/contact-form",
"version": "dev-master",
"version": "dev-feature/b2in-interior",
"dist": {
"type": "path",
"url": "packages/acme/contact-form",
@ -55,7 +55,7 @@
},
{
"name": "acme/cookie-consent",
"version": "dev-master",
"version": "dev-feature/b2in-interior",
"dist": {
"type": "path",
"url": "packages/acme/CookieConsent",
@ -860,7 +860,7 @@
},
{
"name": "flux-cms/core",
"version": "dev-master",
"version": "dev-feature/b2in-interior",
"dist": {
"type": "path",
"url": "packages/flux-cms/core",
@ -1603,6 +1603,66 @@
],
"time": "2026-02-19T13:11:17+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "laravel/fortify",
"version": "v1.35.0",
@ -3493,6 +3553,84 @@
],
"time": "2026-02-16T23:10:27+00:00"
},
{
"name": "nyholm/psr7",
"version": "1.8.2",
"source": {
"type": "git",
"url": "https://github.com/Nyholm/psr7.git",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Nyholm/psr7/zipball/a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"reference": "a71f2b11690f4b24d099d6b16690a90ae14fc6f3",
"shasum": ""
},
"require": {
"php": ">=7.2",
"psr/http-factory": "^1.0",
"psr/http-message": "^1.1 || ^2.0"
},
"provide": {
"php-http/message-factory-implementation": "1.0",
"psr/http-factory-implementation": "1.0",
"psr/http-message-implementation": "1.0"
},
"require-dev": {
"http-interop/http-factory-tests": "^0.9",
"php-http/message-factory": "^1.0",
"php-http/psr7-integration-tests": "^1.0",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.4",
"symfony/error-handler": "^4.4"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.8-dev"
}
},
"autoload": {
"psr-4": {
"Nyholm\\Psr7\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Tobias Nyholm",
"email": "tobias.nyholm@gmail.com"
},
{
"name": "Martijn van der Ven",
"email": "martijn@vanderven.se"
}
],
"description": "A fast PHP7 implementation of PSR-7",
"homepage": "https://tnyholm.se",
"keywords": [
"psr-17",
"psr-7"
],
"support": {
"issues": "https://github.com/Nyholm/psr7/issues",
"source": "https://github.com/Nyholm/psr7/tree/1.8.2"
},
"funding": [
{
"url": "https://github.com/Zegnat",
"type": "github"
},
{
"url": "https://github.com/nyholm",
"type": "github"
}
],
"time": "2024-09-09T07:06:30+00:00"
},
{
"name": "paragonie/constant_time_encoding",
"version": "v3.1.3",
@ -4378,6 +4516,195 @@
},
"time": "2025-12-14T04:43:48+00:00"
},
{
"name": "sentry/sentry",
"version": "4.34.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-php.git",
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-php/zipball/3d8f4e56becb10ede64e2ac6f8077f6434715461",
"reference": "3d8f4e56becb10ede64e2ac6f8077f6434715461",
"shasum": ""
},
"require": {
"ext-curl": "*",
"ext-json": "*",
"ext-mbstring": "*",
"guzzlehttp/psr7": "^1.8.4|^2.1.1|^3.0.0",
"jean85/pretty-package-versions": "^1.5|^2.0.4",
"php": "^7.2|^8.0",
"psr/log": "^1.0|^2.0|^3.0",
"symfony/options-resolver": "^4.4.30|^5.0.11|^6.0|^7.0|^8.0"
},
"conflict": {
"raven/raven": "*"
},
"require-dev": {
"carthage-software/mago": "1.30.0",
"friendsofphp/php-cs-fixer": "^3.4",
"guzzlehttp/guzzle": "^7.0|^8.0",
"guzzlehttp/promises": "^2.0.3|^3.0.0",
"monolog/monolog": "^1.6|^2.0|^3.0",
"nyholm/psr7": "^1.8",
"open-telemetry/api": "^1.1",
"open-telemetry/context": "^1.1",
"open-telemetry/exporter-otlp": "^1.1",
"open-telemetry/gen-otlp-protobuf": "^1.2.1",
"open-telemetry/sdk": "^1.1",
"open-telemetry/sem-conv": "^1.27",
"phpstan/phpstan": "^2.2",
"phpunit/phpunit": "^8.5.52|^9.6.34",
"spiral/roadrunner-http": "^3.6",
"spiral/roadrunner-worker": "^3.6"
},
"suggest": {
"ext-excimer": "Enable Sentry profiling with the Excimer PHP extension.",
"monolog/monolog": "Allow sending log messages to Sentry by using the included Monolog handler."
},
"type": "library",
"autoload": {
"files": [
"src/functions.php"
],
"psr-4": {
"Sentry\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "PHP SDK for Sentry (http://sentry.io)",
"homepage": "http://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-php/issues",
"source": "https://github.com/getsentry/sentry-php/tree/4.34.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-10-08T11:25:58+00:00"
},
{
"name": "sentry/sentry-laravel",
"version": "4.29.0",
"source": {
"type": "git",
"url": "https://github.com/getsentry/sentry-laravel.git",
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/getsentry/sentry-laravel/zipball/4b3deab3d8818490220e1da8e2d7cca971f74410",
"reference": "4b3deab3d8818490220e1da8e2d7cca971f74410",
"shasum": ""
},
"require": {
"illuminate/support": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"nyholm/psr7": "^1.0",
"php": "^7.2 | ^8.0",
"sentry/sentry": "^4.34.0",
"symfony/psr-http-message-bridge": "^1.0 | ^2.0 | ^6.0 | ^7.0 | ^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.11",
"guzzlehttp/guzzle": "^7.2",
"laravel/ai": "^0.1.5 | ^1.0",
"laravel/folio": "^1.1",
"laravel/framework": "^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0 | ^12.0 | ^13.0",
"laravel/octane": "^2.15",
"laravel/pennant": "^1.0",
"livewire/livewire": "^2.0 | ^3.0 | ^4.0",
"mockery/mockery": "^1.3",
"orchestra/testbench": "^4.7 | ^5.1 | ^6.0 | ^7.0 | ^8.0 | ^9.0 | ^10.0 | ^11.0",
"phpstan/phpstan": "^1.10 | ^2.2",
"phpunit/phpunit": "^8.5 | ^9.6 | ^10.4 | ^11.5"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Sentry": "Sentry\\Laravel\\Facade"
},
"providers": [
"Sentry\\Laravel\\ServiceProvider",
"Sentry\\Laravel\\Tracing\\ServiceProvider"
]
}
},
"autoload": {
"psr-0": {
"Sentry\\Laravel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Sentry",
"email": "accounts@sentry.io"
}
],
"description": "Laravel SDK for Sentry (https://sentry.io)",
"homepage": "https://sentry.io",
"keywords": [
"crash-reporting",
"crash-reports",
"error-handler",
"error-monitoring",
"laravel",
"log",
"logging",
"profiling",
"sentry",
"tracing"
],
"support": {
"issues": "https://github.com/getsentry/sentry-laravel/issues",
"source": "https://github.com/getsentry/sentry-laravel/tree/4.29.0"
},
"funding": [
{
"url": "https://sentry.io/",
"type": "custom"
},
{
"url": "https://sentry.io/pricing/",
"type": "custom"
}
],
"time": "2026-10-08T12:31:48+00:00"
},
{
"name": "spatie/laravel-package-tools",
"version": "1.93.0",
@ -5601,6 +5928,77 @@
],
"time": "2026-02-05T15:57:06+00:00"
},
{
"name": "symfony/options-resolver",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/options-resolver.git",
"reference": "88f9c561f678a02d54b897014049fa839e33ff82"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/options-resolver/zipball/88f9c561f678a02d54b897014049fa839e33ff82",
"reference": "88f9c561f678a02d54b897014049fa839e33ff82",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"symfony/deprecation-contracts": "^2.5|^3"
},
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\OptionsResolver\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Provides an improved replacement for the array_replace PHP function",
"homepage": "https://symfony.com",
"keywords": [
"config",
"configuration",
"options"
],
"support": {
"source": "https://github.com/symfony/options-resolver/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/polyfill-ctype",
"version": "v1.33.0",
@ -6495,6 +6893,93 @@
],
"time": "2026-01-26T15:07:59+00:00"
},
{
"name": "symfony/psr-http-message-bridge",
"version": "v8.1.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/psr-http-message-bridge.git",
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/psr-http-message-bridge/zipball/67fd34de15ded1763aa1e330fe345f080a94022c",
"reference": "67fd34de15ded1763aa1e330fe345f080a94022c",
"shasum": ""
},
"require": {
"php": ">=8.4.1",
"psr/http-message": "^1.0|^2.0",
"symfony/http-foundation": "^7.4|^8.0"
},
"conflict": {
"php-http/discovery": "<1.15"
},
"require-dev": {
"nyholm/psr7": "^1.1",
"php-http/discovery": "^1.15",
"psr/log": "^1.1.4|^2|^3",
"symfony/browser-kit": "^7.4|^8.0",
"symfony/config": "^7.4|^8.0",
"symfony/event-dispatcher": "^7.4|^8.0",
"symfony/framework-bundle": "^7.4|^8.0",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/runtime": "^7.4|^8.0"
},
"type": "symfony-bridge",
"autoload": {
"psr-4": {
"Symfony\\Bridge\\PsrHttpMessage\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "PSR HTTP message bridge",
"homepage": "https://symfony.com",
"keywords": [
"http",
"http-message",
"psr-17",
"psr-7"
],
"support": {
"source": "https://github.com/symfony/psr-http-message-bridge/tree/v8.1.0"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2026-05-29T05:06:50+00:00"
},
{
"name": "symfony/routing",
"version": "v7.4.6",
@ -7967,66 +8452,6 @@
},
"time": "2025-04-30T06:54:44+00:00"
},
{
"name": "jean85/pretty-package-versions",
"version": "2.1.1",
"source": {
"type": "git",
"url": "https://github.com/Jean85/pretty-package-versions.git",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/Jean85/pretty-package-versions/zipball/4d7aa5dab42e2a76d99559706022885de0e18e1a",
"reference": "4d7aa5dab42e2a76d99559706022885de0e18e1a",
"shasum": ""
},
"require": {
"composer-runtime-api": "^2.1.0",
"php": "^7.4|^8.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.2",
"jean85/composer-provided-replaced-stub-package": "^1.0",
"phpstan/phpstan": "^2.0",
"phpunit/phpunit": "^7.5|^8.5|^9.6",
"rector/rector": "^2.0",
"vimeo/psalm": "^4.3 || ^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"psr-4": {
"Jean85\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alessandro Lai",
"email": "alessandro.lai85@gmail.com"
}
],
"description": "A library to get pretty versions strings of installed dependencies",
"keywords": [
"composer",
"package",
"release",
"versions"
],
"support": {
"issues": "https://github.com/Jean85/pretty-package-versions/issues",
"source": "https://github.com/Jean85/pretty-package-versions/tree/2.1.1"
},
"time": "2025-03-19T14:43:43+00:00"
},
{
"name": "laravel/boost",
"version": "v1.8.11",

View file

@ -15,6 +15,18 @@ return [
'name' => env('APP_NAME', 'Laravel'),
/*
|--------------------------------------------------------------------------
| Ausgelieferte Version
|--------------------------------------------------------------------------
|
| Git-Tag des Deploys (Datei RELEASE, schreibt dev/betrieb/deploy.sh) oder
| APP_VERSION. Erscheint in /health, `php artisan about` und GlitchTip.
|
*/
'version' => App\Support\Monitoring\Release::current(env('APP_VERSION')),
/*
|--------------------------------------------------------------------------
| Application Environment
@ -41,6 +53,34 @@ return [
'debug' => (bool) env('APP_DEBUG', false),
/*
|--------------------------------------------------------------------------
| Indexierung durch Suchmaschinen
|--------------------------------------------------------------------------
|
| Nur die Produktivumgebung darf indexiert werden. Vorschau- und Testinstanzen
| liefern damit automatisch `noindex, nofollow` – sonst konkurriert eine Kopie
| der Website mit dem Original um dieselben Suchergebnisse.
|
| Bewusst als eigener Schalter und nicht nur an APP_ENV gekoppelt, damit eine
| Vorschau notfalls auch mit APP_ENV=production noindex bleiben kann.
|
*/
'indexable' => (bool) env('SITE_INDEXABLE', env('APP_ENV', 'production') === 'production'),
/*
|--------------------------------------------------------------------------
| Obere Hinweisleiste (Ankündigungsleiste)
|--------------------------------------------------------------------------
|
| Seit der Abstimmung vom 01.10.2026 ausgeblendet – die Seite wirkt ohne die
| Leiste ruhiger. Inhalt und CMS-Pflege bleiben erhalten; mit
| B2IN_ANNOUNCEMENT_BAR=true ist sie auf allen Seiten wieder da.
|
*/
'announcement_bar' => (bool) env('B2IN_ANNOUNCEMENT_BAR', false),
/*
|--------------------------------------------------------------------------
| Application URL

298
config/cms_pages.php Normal file
View file

@ -0,0 +1,298 @@
<?php
declare(strict_types=1);
/**
* Seitenbaum für den Inhalte-Editor im Portal (Nachtrag 12).
*
* Der Editor zeigt die CMS-Sektionen nicht mehr nach Datenbank-Gruppe, sondern so, wie sie auf
* der Website stehen: je Seite in der Reihenfolge von oben nach unten, mit Klartext-Titel und
* einem Satz, der sagt, was die Sektion ist. `anchor` ist die Sprungmarke in der Vorschau
* (das `id` des Abschnitts im Frontend).
*
* Eine Sektion, die auf mehreren Seiten steht, ist einmal eingetragen – dort, wo sie zuerst
* auftaucht – und nennt die anderen Seiten in der Beschreibung.
*
* `group`/`key` sind Gruppe und Schlüssel in flux_cms_contents (siehe cms_section_map.php).
* Sektionen, die in der Datenbank liegen, aber hier nicht eingetragen sind, sammelt der Editor
* unter „Nicht mehr verwendet“ – sie gehen also nicht verloren, stehen aber auch nicht im Weg.
*
* `fields` überschreibt Feldbeschriftungen je Sektion; alles andere kommt aus `field_labels`.
*/
return [
/*
|--------------------------------------------------------------------------
| Feldbeschriftungen
|--------------------------------------------------------------------------
| Gilt für Felder auf Sektionsebene und für Felder in Listeneinträgen.
| Unbekannte Schlüssel werden lesbar gemacht (button_text → „Button text“).
*/
'field_labels' => [
'title' => 'Überschrift',
'subtitle' => 'Untertitel',
'headline' => 'Überschrift',
'tagline' => 'Claim',
'text' => 'Text',
'intro' => 'Einleitung',
'description' => 'Beschreibung',
'content' => 'Inhalt',
'lead' => 'Vorspann',
'note' => 'Hinweis',
'label' => 'Beschriftung',
'badge' => 'Kennzeichen',
'eyebrow' => 'Überzeile',
'quote' => 'Zitat',
'author' => 'Zitatgeber',
'name' => 'Name',
'role' => 'Rolle / Funktion',
'city' => 'Ort',
'value' => 'Wert',
'number' => 'Zahl',
'suffix' => 'Zusatz',
'icon' => 'Icon',
'image' => 'Bild',
'image_alt' => 'Bildbeschreibung',
'alt' => 'Bildbeschreibung',
'logo' => 'Logo',
'link' => 'Link',
'url' => 'Adresse (URL)',
'href' => 'Link',
'link_text' => 'Linktext',
'button_text' => 'Button-Beschriftung',
'button_link' => 'Button-Link',
'primary_button' => 'Erster Button',
'secondary_button' => 'Zweiter Button',
'cta_title' => 'Überschrift Abschluss',
'cta_text' => 'Text Abschluss',
'cta_link' => 'Link Abschluss',
'items' => 'Einträge',
'steps' => 'Schritte',
'cards' => 'Karten',
'stats' => 'Kennzahlen',
'features' => 'Merkmale',
'benefits' => 'Vorteile',
'values' => 'Werte',
'links' => 'Links',
'columns' => 'Spalten',
'menu' => 'Menüpunkte',
'navigation' => 'Navigation',
'topics' => 'Themen',
'options' => 'Auswahlmöglichkeiten',
'placeholder' => 'Platzhaltertext',
'meta_title' => 'Seitentitel (Browser, Google)',
'meta_description' => 'Beschreibung für Google',
'email' => 'E-Mail',
'phone' => 'Telefon',
'whatsapp' => 'WhatsApp-Nummer',
'address' => 'Adresse',
'opening_hours' => 'Öffnungszeiten',
'copyright' => 'Copyright-Zeile',
'map_alt' => 'Beschreibung der Karte',
'region_label' => 'Beschriftung der Region',
'distance_suffix' => 'Zusatz hinter der Entfernung',
'hub' => 'Drehscheibe (Bielefeld)',
'steps_title' => 'Überschrift Ablauf-Block',
'steps_text' => 'Einleitung Ablauf-Block',
'x' => 'Position waagerecht (Entwickler)',
'y' => 'Position senkrecht (Entwickler)',
'id' => 'Sprungmarke (Entwickler)',
'slug' => 'Adresskürzel (Entwickler)',
'teaser' => 'Kurztext',
'summary' => 'Zusammenfassung',
'brands' => 'Marken',
'projects' => 'Projekte',
'partners' => 'Partner',
'questions' => 'Fragen',
'question' => 'Frage',
'answer' => 'Antwort',
'read_more' => 'Weiterlesen-Text',
'empty_text' => 'Text, wenn nichts vorhanden ist',
'success_text' => 'Text nach dem Absenden',
'submit_text' => 'Beschriftung Absenden-Button',
'privacy_text' => 'Datenschutz-Hinweis',
'subjects' => 'Betreffe',
'subject' => 'Betreff',
],
/*
|--------------------------------------------------------------------------
| Seiten in der Reihenfolge des Menüs
|--------------------------------------------------------------------------
*/
'pages' => [
'home' => [
'title' => 'Startseite',
'path' => '/',
'sections' => [
['group' => 'home', 'key' => 'hero', 'anchor' => 'hero', 'title' => 'Hero',
'description' => 'Der große Einstieg ganz oben: Zeile über der Überschrift (eyebrow), Überschrift, Text, Button, Häkchen-Punkte (stats) und die Slideshow. Unter „slides“ je Bild: image, label (Marke) und caption (Motiv) – beides erscheint in der Karte über dem Bild und wechselt mit –, alt (Bildbeschreibung für Screenreader) und active (aus = ausgeblendet); die Reihenfolge der Liste ist die Reihenfolge der Slideshow. „interval“ = Sekunden pro Bild (3–30). card_title/card_text stehen in der Karte, wenn eine Slide weder label noch caption hat; image/image_alt sind das Ersatzbild, wenn keine Slide aktiv ist.'],
['group' => 'home', 'key' => 'founder_bar', 'anchor' => 'gruender', 'title' => 'Gründer-Leiste',
'description' => 'Schmale Zeile mit Marcel Scheibe und Zitat. Steht auch auf /marken, „Über uns“ und in der Immobilien-Welt.'],
['group' => 'home', 'key' => 'audience_switch', 'anchor' => 'wege', 'title' => 'Zielgruppen-Wahl',
'description' => 'Zwei Wege: Privat einrichten oder Projekt ausstatten.'],
['group' => 'home', 'key' => 'core_brands', 'anchor' => 'marken', 'title' => 'Kernmarken',
'description' => 'Überschrift und Untertitel des Marken-Rasters. Die Kacheln selbst kommen aus der Markenverwaltung.'],
['group' => 'home', 'key' => 'origin_section', 'anchor' => 'herkunft', 'title' => 'Aus der Region gefertigt',
'description' => 'Karte mit Bielefeld als Drehscheibe und der Ablauf-Block am Beispiel CABINET. Die Standorte pflegst du bei den Marken. Steht auch auf /marken.'],
['group' => 'home', 'key' => 'vision_section', 'anchor' => 'vision', 'title' => 'Gebaut auf Expertise',
'description' => 'Weiße Box auf Ocker: Überschrift, Absätze (paragraphs) und Porträt mit Bildunterschrift (image_caption).'],
['group' => 'home', 'key' => 'sponsoring', 'anchor' => 'engagement', 'title' => 'Regional verbunden',
'description' => 'Vereine, die B2in unterstützt: je Verein name, image (Logo), text und optional url – mit url öffnet die Kachel die Vereinsseite in einem neuen Tab.'],
['group' => 'referenzen', 'key' => 'display_feed', 'anchor' => 'displays', 'title' => 'Aktuelles aus dem Showroom',
'description' => '„Aktuelles aus unserem Showroom“: Überschrift, Text, Hinweis (note) und Link in der weißen Box. Das Display rechts kommt aus den Store Displays (display_id leer = das erste veröffentlichte), Besucher können es über „Größer ansehen“ vergrößern. Steht auch auf /referenzen.'],
['group' => 'home', 'key' => 'immobilien_teaser', 'anchor' => 'immobilien', 'title' => 'Immobilien-Teaser',
'description' => 'Der Übergang in die Immobilien-Welt am Ende der Startseite.'],
['group' => 'global', 'key' => 'cta_section', 'anchor' => 'cta', 'title' => 'Abschluss: Termin anfragen',
'description' => 'Der letzte Block vor dem Fuß auf der Startseite und auf /referenzen. Marken und „Über uns“ haben eigene Abschlüsse.'],
],
],
'marken' => [
'title' => 'Marken',
'path' => '/marken',
'sections' => [
['group' => 'marken', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Zeile über der Überschrift (eyebrow), Überschrift, Einleitung (text), Bild und die Häkchen-Punkte (stats) oben auf /marken. Die Markenblöcke darunter kommen aus der Markenverwaltung.'],
['group' => 'marken', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
'description' => 'Großes Bild zwischen den Markenblöcken und „Aus der Region gefertigt“, damit sich die beiden hellen Sektionen absetzen. Mit Zitat (quote) und Autor (author); ohne Zitat steht nur das Bild.'],
['group' => 'marken', 'key' => 'network', 'anchor' => 'netzwerk', 'title' => 'Mehr als Marken: unser Netzwerk',
'description' => 'Partner und Zulieferer jenseits der Kernmarken.'],
['group' => 'marken', 'key' => 'growth', 'anchor' => 'wachstum', 'title' => 'Die Markenwelt wächst',
'description' => 'Hinweis, dass weitere Marken folgen.'],
['group' => 'marken', 'key' => 'cta', 'anchor' => 'marken-cta', 'title' => 'Abschluss auf /marken',
'description' => 'Der Abschlussblock „Lernen Sie unsere Marken kennen“ mit Terminanfrage und WhatsApp.'],
['group' => 'marken', 'key' => 'craft', 'anchor' => 'handwerk', 'title' => 'Handwerk-Abschluss auf jeder Markenseite',
'description' => 'Der letzte Abschnitt auf den Seiten von CABINET, Rotpunkt und Febrü: „Planung plus Handwerk“ mit Link zu Otterpohl. Einmal gepflegt, überall gleich.', 'path' => '/marken/cabinet'],
],
],
'referenzen' => [
'title' => 'Referenzen',
'path' => '/referenzen',
'sections' => [
['group' => 'referenzen', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Überschrift, Einleitung und Bild der Referenzen-Seite. Ob die Seite (samt Menüpunkt) erscheint, schaltest du unter „Referenzen“.'],
['group' => 'referenzen', 'key' => 'projects', 'anchor' => 'projekte', 'title' => 'Realisierte Projekte',
'description' => 'Überschrift, Text und Leerzustand über den Projekten. Die Projekte selbst pflegst du unter „Referenzen“.'],
],
],
'magazin' => [
'title' => 'Magazin',
'path' => '/magazin',
'sections' => [
['group' => 'magazin', 'key' => 'list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht',
'description' => 'Überschrift und Einleitung der Übersicht. Die Beiträge pflegst du unter „Magazin“.'],
['group' => 'magazin', 'key' => 'detail', 'anchor' => null, 'title' => 'Beitragsseite',
'description' => 'Feste Texte auf jeder Beitragsseite, etwa „Zurück zur Übersicht“ und „Weitere Beiträge“.'],
],
],
'about' => [
'title' => 'Über uns',
'path' => '/about',
'sections' => [
['group' => 'about', 'key' => 'hero', 'anchor' => 'seitenkopf', 'title' => 'Seitenkopf',
'description' => 'Zeile über der Überschrift (eyebrow), Überschrift, Zitat, Name und Funktion sowie Bild mit Infokarte (card_title, card_text).'],
['group' => 'about', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Unsere Geschichte',
'description' => 'Überschrift, Text (summary) und die Zeitleiste (timeline) zur Entstehung von B2in.'],
['group' => 'about', 'key' => 'image_break', 'anchor' => 'bild', 'title' => 'Bildpause',
'description' => 'Großes Bild zwischen Geschichte und Werten, mit Zitat (quote) und Autor (author).'],
['group' => 'about', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Unsere Werte',
'description' => 'Die Werte-Kacheln.'],
['group' => 'about', 'key' => 'partner_cta', 'anchor' => 'cta', 'title' => 'Abschluss auf „Über uns“',
'description' => 'Der Abschlussblock am Ende von „Über uns“.'],
],
],
'contact' => [
'title' => 'Kontakt',
'path' => '/contact',
'sections' => [
['group' => 'contact', 'key' => 'contact_form', 'anchor' => 'kontakt', 'title' => 'Kontaktformular',
'description' => 'Kopf des Formulars (hero: eyebrow, title, subtitle), Betreffe, Feldbeschriftungen und Datenschutz-Hinweis.'],
['group' => 'global', 'key' => 'contact', 'anchor' => 'kontakt', 'title' => 'Kontaktdaten',
'description' => 'Telefon, E-Mail, Adresse und die WhatsApp-Nummer. Ohne WhatsApp-Nummer erscheint kein WhatsApp-Button.'],
],
],
'faq' => [
'title' => 'FAQ',
'path' => '/faq',
'sections' => [
['group' => 'faq', 'key' => 'faq', 'anchor' => 'faq', 'title' => 'Fragen und Antworten',
'description' => 'Überschrift und alle Fragen der Seite. „category“ ordnet eine Frage einem Bereich zu (leer = allgemeine Fragen oben); Überschrift und Symbol der Bereiche stehen in „categories“, ihre Reihenfolge ist die auf der Seite.'],
],
],
'global' => [
'title' => 'Auf jeder Seite',
'path' => '/',
'sections' => [
['group' => 'global', 'key' => 'announcement_bar', 'anchor' => 'header', 'title' => 'Hinweisleiste',
'description' => 'Schmale Zeile ganz oben über dem Menü (text, badge, Link). „enabled“ schaltet sie ein. Zurzeit ist die Leiste für die ganze Website über B2IN_ANNOUNCEMENT_BAR in der .env aus – Änderungen hier erscheinen erst, wenn sie dort eingeschaltet ist.'],
['group' => 'global', 'key' => 'header', 'anchor' => 'header', 'title' => 'Hauptmenü',
'description' => 'Menüpunkte und Buttons im Kopf der Interior-Welt. Der Punkt „Referenzen“ erscheint nur, solange die Referenzen-Seite eingeschaltet ist.'],
['group' => 'global', 'key' => 'appointment_widget', 'anchor' => null, 'title' => 'Terminanfrage-Widget',
'description' => 'Das schwebende Widget rechts unten: Texte, Themen und Zeitfenster.'],
['group' => 'global', 'key' => 'footer', 'anchor' => 'footer', 'title' => 'Fußbereich',
'description' => 'Claim, Zeile unter dem Claim (byline) und die Linkliste (links) im Fuß.'],
],
],
'immobilien' => [
'title' => 'Immobilien-Welt',
'path' => '/immobilien',
'sections' => [
['group' => 'global', 'key' => 'announcement_bar_immobilien', 'anchor' => 'header', 'title' => 'Hinweisleiste (Immobilien)',
'description' => 'Hinweisleiste über dem Menü der Immobilien-Welt. Wie die Interior-Leiste zurzeit über B2IN_ANNOUNCEMENT_BAR ausgeschaltet.'],
['group' => 'global', 'key' => 'header_immobilien', 'anchor' => 'header', 'title' => 'Hauptmenü (Immobilien)',
'description' => 'Menüpunkte und Buttons im Kopf der Immobilien-Welt.'],
['group' => 'global', 'key' => 'cta_section_immobilien', 'anchor' => 'cta', 'title' => 'Abschluss (Immobilien)',
'description' => 'Der letzte Block vor dem Fuß auf allen Immobilien-Seiten.'],
['group' => 'immobilien', 'key' => 'magazin_list', 'anchor' => 'magazin', 'title' => 'Magazin-Übersicht (Immobilien)',
'description' => 'Überschrift und Einleitung unter /immobilien/magazin.', 'path' => '/immobilien/magazin'],
['group' => 'immobilien', 'key' => 'about_hero', 'anchor' => 'seitenkopf', 'title' => 'Über B2in: Seitenkopf',
'description' => 'Überschrift, Zitat, Name und Funktion sowie Bild mit Infokarte unter /immobilien/ueber-b2in.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'our_story', 'anchor' => 'geschichte', 'title' => 'Über B2in: Geschichte',
'description' => 'Der Geschichte-Block der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'about_image_break', 'anchor' => 'bild', 'title' => 'Über B2in: Bildpause',
'description' => 'Bild zwischen Geschichte und Werten.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'immobilien', 'key' => 'our_values', 'anchor' => 'werte', 'title' => 'Über B2in: Werte',
'description' => 'Die Werte-Kacheln der Immobilien-Welt.', 'path' => '/immobilien/ueber-b2in'],
['group' => 'faq', 'key' => 'faq_immobilien', 'anchor' => 'faq', 'title' => 'FAQ (Immobilien)',
'description' => 'Fragen und Antworten unter /immobilien/faq.', 'path' => '/immobilien/faq'],
['group' => 'contact', 'key' => 'contact_form_immobilien', 'anchor' => 'kontakt', 'title' => 'Kontaktformular (Immobilien)',
'description' => 'Abweichende Texte und Betreffe unter /immobilien/kontakt. Leere Felder übernehmen den Interior-Wert.', 'path' => '/immobilien/kontakt'],
],
],
'legal' => [
'title' => 'Rechtliches',
'path' => '/impressum',
'sections' => [
['group' => 'legal', 'key' => 'impressum', 'anchor' => null, 'title' => 'Impressum',
'description' => 'Pflichtangaben. Der Inhalt ist HTML.', 'path' => '/impressum'],
['group' => 'legal', 'key' => 'privacy', 'anchor' => null, 'title' => 'Datenschutz',
'description' => 'Datenschutzerklärung. Der Inhalt ist HTML.', 'path' => '/privacy'],
['group' => 'legal', 'key' => 'terms', 'anchor' => null, 'title' => 'Nutzungsbedingungen',
'description' => 'Nutzungsbedingungen der Website. Der Inhalt ist HTML.', 'path' => '/terms'],
['group' => 'legal', 'key' => 'cookie_policy', 'anchor' => null, 'title' => 'Cookie-Hinweise',
'description' => 'Cookie-Richtlinie. Der Inhalt ist HTML.', 'path' => '/cookie-policy'],
],
],
],
/*
|--------------------------------------------------------------------------
| Sammelpunkt für alles, was in der Datenbank liegt, aber oben nicht steht
|--------------------------------------------------------------------------
*/
'unlisted' => [
'title' => 'Nicht mehr verwendet',
'description' => 'Einträge in der Datenbank, die keine Seite anzeigt. Normalerweise leer – der UnusedCmsContentCleanupSeeder räumt solche Einträge ab.',
],
];

View file

@ -8,84 +8,55 @@ declare(strict_types=1);
* Wird von cms_theme_section() und CmsContentSeeder verwendet.
*/
return [
'lang_keys' => [
'card_section' => 'partner_card_section',
],
'sections' => [
'announcement_bar' => ['global', 'announcement_bar'],
'announcement_bar_immobilien' => ['global', 'announcement_bar_immobilien'],
'header' => ['global', 'header'],
'footer' => ['global', 'footer'],
'cta_section' => ['global', 'cta_section'],
'cta_section_portfolio' => ['global', 'cta_section_portfolio'],
'cta_section_immobilien' => ['global', 'cta_section_immobilien'],
'appointment_widget' => ['global', 'appointment_widget'],
'hero' => ['home', 'hero'],
'audience_switch' => ['home', 'audience_switch'],
'core_brands' => ['home', 'core_brands'],
'origin_section' => ['home', 'origin_section'],
'marken_hero' => ['marken', 'hero'],
'marken_image_break' => ['marken', 'image_break'],
'marken_network' => ['marken', 'network'],
'marken_growth' => ['marken', 'growth'],
'marken_cta' => ['marken', 'cta'],
'brand_craft' => ['marken', 'craft'],
'referenzen_hero' => ['referenzen', 'hero'],
'reference_projects' => ['referenzen', 'projects'],
'display_feed' => ['referenzen', 'display_feed'],
'sponsoring' => ['home', 'sponsoring'],
'immobilien_teaser' => ['home', 'immobilien_teaser'],
'founder_bar' => ['home', 'founder_bar'],
'synergie_section' => ['home', 'synergie_section'],
'ecosystem_core' => ['home', 'ecosystem_core'],
'vision_section' => ['home', 'vision_section'],
'brand_worlds' => ['home', 'brand_worlds'],
'integriertes_modell_b2in' => ['home', 'integriertes_modell_b2in'],
'immobilien_hero' => ['immobilien', 'hero'],
'immobilien_hero_v2' => ['immobilien', 'hero_v2'],
'immobilien_projects' => ['immobilien', 'projects_meta'],
'immobilien_moebel_vorteil' => ['immobilien', 'moebel_vorteil'],
'immobilien_trust' => ['immobilien', 'trust'],
'immobilien_warum_dubai' => ['immobilien', 'warum_dubai'],
'immobilien_image_break' => ['immobilien', 'image_break'],
'immobilien_kaufprozess' => ['immobilien', 'kaufprozess'],
'immobilien_bruecke' => ['immobilien', 'bruecke'],
'immobilien_mindset' => ['immobilien', 'mindset'],
'portfolio' => ['immobilien', 'portfolio'],
'netzwerk_hero' => ['netzwerk', 'hero'],
'netzwerk_image_break' => ['netzwerk', 'image_break'],
'netzwerk_teasers' => ['netzwerk', 'teasers'],
'netzwerk_cta' => ['netzwerk', 'cta'],
'netzwerk_cabinet_partner' => ['netzwerk', 'cabinet_partner'],
'netzwerk_immobilien_hint' => ['netzwerk', 'immobilien_hint'],
'interior_hero' => ['netzwerk', 'interior_hero'],
'interior_concept' => ['netzwerk', 'interior_concept'],
'interior_brands' => ['netzwerk', 'interior_brands'],
'interior_zielgruppen' => ['netzwerk', 'interior_zielgruppen'],
'interior_process' => ['netzwerk', 'interior_process'],
'interior_trust' => ['netzwerk', 'interior_trust'],
'faq' => ['faq', 'faq'],
'faq_immobilien' => ['faq', 'faq_immobilien'],
// Immobilien-Welt: eigene Navigation, eigenes Magazin, eigene Ueber-uns-Seite
'header_immobilien' => ['global', 'header_immobilien'],
'magazin_list_immobilien' => ['immobilien', 'magazin_list'],
'immobilien_about_hero' => ['immobilien', 'about_hero'],
'immobilien_our_story' => ['immobilien', 'our_story'],
'immobilien_about_image_break' => ['immobilien', 'about_image_break'],
'immobilien_our_values' => ['immobilien', 'our_values'],
'contact_form' => ['contact', 'contact_form'],
'contact_form_immobilien' => ['contact', 'contact_form_immobilien'],
'about_hero' => ['about', 'hero'],
'our_story' => ['about', 'our_story'],
'about_image_break' => ['about', 'image_break'],
'our_values' => ['about', 'our_values'],
'leadership_team' => ['about', 'leadership_team'],
'broker_section' => ['netzwerk', 'broker_section'],
'commitment_section' => ['netzwerk', 'commitment_section'],
'dark_stats_section' => ['netzwerk', 'dark_stats_section'],
'ecosystem_hero' => ['netzwerk', 'ecosystem_hero'],
'ecosystem_stats' => ['netzwerk', 'ecosystem_stats'],
'ecosystem_start' => ['netzwerk', 'ecosystem_start'],
'ecosystem_hub' => ['netzwerk', 'ecosystem_hub'],
'ecosystem_result' => ['netzwerk', 'ecosystem_result'],
'end_customer_section' => ['netzwerk', 'end_customer_section'],
'final_commitment' => ['netzwerk', 'final_commitment'],
'digital_core' => ['netzwerk', 'digital_core'],
'supply_chain_intro' => ['netzwerk', 'supply_chain_intro'],
'partner_hero' => ['netzwerk', 'partner_hero'],
'partner_benefits' => ['netzwerk', 'partner_benefits'],
'partner_cta' => ['netzwerk', 'partner_cta'],
'partner_card_section' => ['netzwerk', 'partner_card_section'],
'card_section' => ['netzwerk', 'partner_card_section'],
'partner_benefits_developer' => ['netzwerk', 'partner_benefits_developer'],
'partner_benefits_retailer' => ['netzwerk', 'partner_benefits_retailer'],
'partner_benefits_supplier' => ['netzwerk', 'partner_benefits_supplier'],
'partner_benefits_broker' => ['netzwerk', 'partner_benefits_broker'],
'partner_process' => ['netzwerk', 'partner_process'],
'supplier_section' => ['netzwerk', 'supplier_section'],
'partner_cta' => ['about', 'partner_cta'],
'magazin_detail' => ['magazin', 'detail'],
'magazin_list' => ['magazin', 'list'],

View file

@ -71,13 +71,29 @@ return [
'view_prefix' => 'b2in',
'assets_dir' => 'build/b2in',
'description' => 'Haupt-Landingpage mit Standard-Theme',
// Interior-Welt (Standard). Die Immobilien-Welt behält das bisherige Blau,
// siehe `worlds` und resources/css/web/theme-b2in.css.
'color_scheme' => [
'primary' => '#2b3f51', // Anthracite
'secondary' => '#20a0da', // Dynamic Blue
'primary' => '#202c2b', // Tanne
'secondary' => '#a8741f', // Ocker
],
'font_family' => [
'primary' => 'Inter',
'secondary' => 'IBM Plex Sans',
'secondary' => 'EB Garamond',
],
'worlds' => [
'interior' => [
'primary' => '#202c2b', // Tanne – Hauptton dunkel, Wortmarke
'background' => '#f7f4ef', // Creme
'surface' => '#d9cbbf', // Sand – Flächen, Footer
'accent' => '#a8741f', // Ocker – Akzent, Logo-Bogen
'secondary' => '#a38f82', // Clay – Sekundär, Produkte
'text' => '#2d2926', // Anthrazit
],
'immobilien' => [
'primary' => '#2b3f51', // Anthracite
'accent' => '#20a0da', // Dynamic Blue
],
],
],

View file

@ -52,6 +52,13 @@ return [
'channels' => [
'monitoring' => [
'driver' => 'daily',
'path' => storage_path('logs/monitoring.log'),
'level' => 'debug',
'days' => 14,
],
'stack' => [
'driver' => 'stack',
'channels' => explode(',', env('LOG_STACK', 'single')),

87
config/monitoring.php Normal file
View file

@ -0,0 +1,87 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Betriebs-Alerts
|--------------------------------------------------------------------------
|
| Fehlgeschlagene Scheduler-Tasks und Queue-Jobs werden immer als
| `critical` ins Log geschrieben (rotierter `daily`-Channel) und – wenn
| aktiviert und eine Adresse gesetzt ist – zusätzlich per Best-Effort-Mail
| gemeldet. Die Mail ist bewusst nur die zweite Ebene: schlägt der Versand
| fehl, bleibt der Log-Eintrag als verlässliche Spur bestehen.
|
*/
'alerts' => [
'enabled' => (bool) env('MONITORING_ALERTS_ENABLED', true),
// Ops-Postfach. Leer = keine Alert-Mail (Log und ntfy laufen trotzdem).
'mail_to' => env('MONITORING_ALERT_EMAIL'),
// Zweiter Alarmweg: Push über ntfy, JSON-POST
// an die Basis-URL. Ohne URL oder Topic aus.
'ntfy' => [
'url' => env('MONITORING_NTFY_URL'),
'topic' => env('MONITORING_NTFY_TOPIC'),
'token' => env('MONITORING_NTFY_TOKEN'),
'priority' => (int) env('MONITORING_NTFY_PRIORITY', 4),
],
],
/*
|--------------------------------------------------------------------------
| Browserfehler
|--------------------------------------------------------------------------
|
| Eigenes GlitchTip-Projekt für JavaScript-Fehler. Die DSN ist öffentlich
| (steht im HTML), sie erlaubt nur das Einliefern von Ereignissen. Ohne
| DSN wird kein Skript eingebunden.
|
*/
'browser' => [
'dsn' => env('SENTRY_BROWSER_DSN'),
'sample_rate' => (float) env('SENTRY_BROWSER_SAMPLE_RATE', 1.0),
],
/*
|--------------------------------------------------------------------------
| Health-Endpunkt und Scheduler-Herzschlag
|--------------------------------------------------------------------------
|
| `/health` antwortet nur mit gültigem Token (Bearer-Header oder
| `?token=`); ohne konfiguriertes Token gibt es den Endpunkt nicht (404).
| Der Scheduler schreibt jede Minute einen Herzschlag; bleibt er aus, ist
| der Cron-Eintrag weg oder der Server hängt.
|
*/
'health' => [
'token' => env('MONITORING_HEALTH_TOKEN'),
'queue_backlog_critical_seconds' => (int) env('MONITORING_QUEUE_BACKLOG_SECONDS', 300),
'disk_min_free_percent' => (float) env('MONITORING_DISK_MIN_FREE_PERCENT', 10),
],
'scheduler' => [
// Leer = storage/framework/scheduler-heartbeat. Die Tests setzen je Prozess eine eigene Datei.
'heartbeat_path' => env('MONITORING_HEARTBEAT_PATH'),
'heartbeat_warn_minutes' => 3,
'heartbeat_critical_minutes' => 10,
],
/*
|--------------------------------------------------------------------------
| Monitoring-Seite im Admin
|--------------------------------------------------------------------------
|
| Link zur Statusseite von Uptime Kuma (optional). GlitchTip wird aus der
| DSN abgeleitet.
|
*/
'status_url' => env('MONITORING_STATUS_URL'),
];

21
config/seeding.php Normal file
View file

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
return [
/*
|--------------------------------------------------------------------------
| Seed-Admin
|--------------------------------------------------------------------------
|
| Zugangsdaten für den Admin-Benutzer, den der DatabaseSeeder anlegt.
| Gehören nicht ins Repository – über die .env setzen.
|
*/
'admin_name' => env('SEED_ADMIN_NAME', 'Admin'),
'admin_email' => env('SEED_ADMIN_EMAIL', 'admin@b2in.test'),
'admin_password' => env('SEED_ADMIN_PASSWORD', 'password'),
];

180
config/sentry.php Normal file
View file

@ -0,0 +1,180 @@
<?php
use App\Support\Monitoring\ErrorReportScrubber;
use App\Support\Monitoring\Release;
/**
* Sentry Laravel SDK configuration file.
*
* Ziel ist das selbst gehostete GlitchTip, das das
* Sentry-Protokoll spricht. Ohne DSN ist das SDK ein No-op. Abweichend vom
* Paket-Standard: kein Personenbezug außer der User-ID, keine
* Request-Bodys, keine Log-/Cache-Breadcrumbs (Lognachrichten tragen bei uns
* E-Mail-Adressen), keine Metriken (versteht GlitchTip nicht).
*
* @see https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/
*/
return [
// @see https://docs.sentry.io/concepts/key-terms/dsn-explainer/
'dsn' => env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN')),
// @see https://spotlightjs.com/
// 'spotlight' => env('SENTRY_SPOTLIGHT', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#logger
// 'logger' => Sentry\Logger\DebugFileLogger::class, // By default this will log to `storage_path('logs/sentry.log')`
// The release version of your application
// Example with dynamic git hash: trim(exec('git --git-dir ' . base_path('.git') . ' log --pretty="%h" -n1 HEAD'))
// Gleiche Kennung wie config('app.version'): SENTRY_RELEASE bzw.
// APP_VERSION, sonst die beim Deploy geschriebene Datei RELEASE.
'release' => Release::current(env('SENTRY_RELEASE', env('APP_VERSION'))),
// When left empty or `null` the Laravel environment will be used (usually discovered from `APP_ENV` in your `.env`)
'environment' => env('SENTRY_ENVIRONMENT'),
// Override the organization ID used for trace continuation checks.
'org_id' => env('SENTRY_ORG_ID') === null ? null : (int) env('SENTRY_ORG_ID'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#sample_rate
'sample_rate' => env('SENTRY_SAMPLE_RATE') === null ? 1.0 : (float) env('SENTRY_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#traces_sample_rate
'traces_sample_rate' => env('SENTRY_TRACES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_TRACES_SAMPLE_RATE'),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#profiles_sample_rate
'profiles_sample_rate' => env('SENTRY_PROFILES_SAMPLE_RATE') === null ? null : (float) env('SENTRY_PROFILES_SAMPLE_RATE'),
// Only continue incoming traces when the organization IDs are compatible with this SDK instance.
'strict_trace_continuation' => env('SENTRY_STRICT_TRACE_CONTINUATION', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_logs
'enable_logs' => env('SENTRY_ENABLE_LOGS', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#enable_metrics
'enable_metrics' => env('SENTRY_ENABLE_METRICS', false),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#log_flush_threshold
'log_flush_threshold' => env('SENTRY_LOG_FLUSH_THRESHOLD') === null ? null : (int) env('SENTRY_LOG_FLUSH_THRESHOLD'),
// The minimum log level that will be sent to Sentry as logs using the `sentry_logs` logging channel
'logs_channel_level' => env('SENTRY_LOG_LEVEL', env('SENTRY_LOGS_LEVEL', env('LOG_LEVEL', 'debug'))),
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#send_default_pii
'send_default_pii' => false,
// Formular- und JSON-Inhalte verlassen den Server nie.
'max_request_body_size' => 'never',
// User-ID statt Person, Tokens aus URL/Query/Referer, E-Mails aus Meldungen.
'before_send' => [ErrorReportScrubber::class, 'beforeSend'],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_exceptions
// 'ignore_exceptions' => [],
// @see: https://docs.sentry.io/platforms/php/guides/laravel/configuration/options/#ignore_transactions
'ignore_transactions' => [
// Ignore Laravel's default health URL
'/up',
'/health',
],
// Breadcrumb specific configuration
'breadcrumbs' => [
// Capture Laravel logs as breadcrumbs
'logs' => env('SENTRY_BREADCRUMBS_LOGS_ENABLED', false),
// Capture Laravel cache events (hits, writes etc.) as breadcrumbs
'cache' => env('SENTRY_BREADCRUMBS_CACHE_ENABLED', false),
// Capture Livewire components like routes as breadcrumbs
'livewire' => env('SENTRY_BREADCRUMBS_LIVEWIRE_ENABLED', true),
// Capture SQL queries as breadcrumbs
'sql_queries' => env('SENTRY_BREADCRUMBS_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query breadcrumbs
'sql_bindings' => env('SENTRY_BREADCRUMBS_SQL_BINDINGS_ENABLED', false),
// Capture queue job information as breadcrumbs
'queue_info' => env('SENTRY_BREADCRUMBS_QUEUE_INFO_ENABLED', true),
// Capture command information as breadcrumbs
'command_info' => env('SENTRY_BREADCRUMBS_COMMAND_JOBS_ENABLED', true),
// Capture HTTP client request information as breadcrumbs
'http_client_requests' => env('SENTRY_BREADCRUMBS_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture send notifications as breadcrumbs
'notifications' => env('SENTRY_BREADCRUMBS_NOTIFICATIONS_ENABLED', true),
],
// Performance monitoring specific configuration
'tracing' => [
// Trace queue jobs as their own transactions (this enables tracing for queue jobs)
'queue_job_transactions' => env('SENTRY_TRACE_QUEUE_ENABLED', true),
// Capture queue jobs as spans when executed on the sync driver
'queue_jobs' => env('SENTRY_TRACE_QUEUE_JOBS_ENABLED', true),
// Capture SQL queries as spans
'sql_queries' => env('SENTRY_TRACE_SQL_QUERIES_ENABLED', true),
// Capture SQL query bindings (parameters) in SQL query spans
'sql_bindings' => env('SENTRY_TRACE_SQL_BINDINGS_ENABLED', false),
// Capture where the SQL query originated from on the SQL query spans
'sql_origin' => env('SENTRY_TRACE_SQL_ORIGIN_ENABLED', true),
// Define a threshold in milliseconds for SQL queries to resolve their origin
'sql_origin_threshold_ms' => env('SENTRY_TRACE_SQL_ORIGIN_THRESHOLD_MS', 100),
// Capture views rendered as spans
'views' => env('SENTRY_TRACE_VIEWS_ENABLED', true),
// Capture Livewire components as spans
'livewire' => env('SENTRY_TRACE_LIVEWIRE_ENABLED', true),
// Capture HTTP client requests as spans
'http_client_requests' => env('SENTRY_TRACE_HTTP_CLIENT_REQUESTS_ENABLED', true),
// Capture Laravel cache events (hits, writes etc.) as spans
'cache' => env('SENTRY_TRACE_CACHE_ENABLED', true),
// Capture Redis operations as spans (this enables Redis events in Laravel)
'redis_commands' => env('SENTRY_TRACE_REDIS_COMMANDS', false),
// Capture where the Redis command originated from on the Redis command spans
'redis_origin' => env('SENTRY_TRACE_REDIS_ORIGIN_ENABLED', true),
// Capture send notifications as spans
'notifications' => env('SENTRY_TRACE_NOTIFICATIONS_ENABLED', true),
// Enable tracing for requests without a matching route (404's)
'missing_routes' => env('SENTRY_TRACE_MISSING_ROUTES_ENABLED', false),
// Configures if the performance trace should continue after the response has been sent to the user until the application terminates
// This is required to capture any spans that are created after the response has been sent like queue jobs dispatched using `dispatch(...)->afterResponse()` for example
'continue_after_response' => env('SENTRY_TRACE_CONTINUE_AFTER_RESPONSE', true),
// Capture AI agent interactions as spans (requires laravel/ai)
'gen_ai' => env('SENTRY_TRACE_GEN_AI_ENABLED', true),
// Capture AI invoke_agent spans
'gen_ai_invoke_agent' => env('SENTRY_TRACE_GEN_AI_INVOKE_AGENT_ENABLED', true),
// Capture AI chat spans
'gen_ai_chat' => env('SENTRY_TRACE_GEN_AI_CHAT_ENABLED', true),
// Capture AI execute_tool spans
'gen_ai_execute_tool' => env('SENTRY_TRACE_GEN_AI_EXECUTE_TOOL_ENABLED', true),
// Capture AI embeddings spans
'gen_ai_embeddings' => env('SENTRY_TRACE_GEN_AI_EMBEDDINGS_ENABLED', true),
// Enable the tracing integrations supplied by Sentry (recommended)
'default_integrations' => env('SENTRY_TRACE_DEFAULT_INTEGRATIONS_ENABLED', true),
],
];

View file

@ -23,6 +23,7 @@ class CmsArticleFactory extends Factory
'subtitle' => fake()->sentence(12),
'image' => 'b2in/magazin-'.fake()->numberBetween(1, 5).'.jpg',
'category' => fake()->randomElement(['Dubai Investment', 'Rendite & Einrichtung', 'B2B & Partner', 'Einrichtung & Netzwerk']),
'world' => CmsArticle::WORLD_INTERIOR,
'date_label' => fake()->date('F j, Y'),
'read_time' => fake()->numberBetween(3, 10).' min read',
'author' => [

View file

@ -0,0 +1,70 @@
<?php
namespace Database\Factories;
use App\Models\CmsBrand;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends Factory<CmsBrand>
*/
class CmsBrandFactory extends Factory
{
protected $model = CmsBrand::class;
public function definition(): array
{
$name = fake()->unique()->company();
return [
'slug' => Str::slug($name).'-'.fake()->unique()->numberBetween(100, 999),
'name' => ['de' => $name, 'en' => $name],
'tagline' => ['de' => fake()->sentence(4), 'en' => fake()->sentence(4)],
'teaser' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
'description' => ['de' => '<p>'.fake()->paragraph(4).'</p>', 'en' => '<p>'.fake()->paragraph(4).'</p>'],
'logo' => '',
'image' => '',
'services' => [
['title' => fake()->words(3, true), 'description' => fake()->sentence(10)],
['title' => fake()->words(3, true), 'description' => fake()->sentence(10)],
],
'gallery' => [],
'website_url' => fake()->url(),
'showroom' => 'Bielefeld',
'is_published' => true,
'order' => fake()->numberBetween(1, 20),
];
}
/**
* Galerie mit realisierten Projekten und Inspirationen.
*/
public function withGallery(int $projects = 2, int $inspirations = 2): static
{
$items = [];
for ($i = 1; $i <= $projects; $i++) {
$items[] = [
'image' => 'projekt-'.$i.'.webp',
'category' => CmsBrand::GALLERY_PROJECT,
'caption' => 'Projekt '.$i,
];
}
for ($i = 1; $i <= $inspirations; $i++) {
$items[] = [
'image' => 'inspiration-'.$i.'.webp',
'category' => CmsBrand::GALLERY_INSPIRATION,
'caption' => 'Inspiration '.$i,
];
}
return $this->state(fn () => ['gallery' => $items]);
}
public function unpublished(): static
{
return $this->state(fn () => ['is_published' => false]);
}
}

View file

@ -0,0 +1,62 @@
<?php
namespace Database\Factories;
use App\Models\CmsReference;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<CmsReference>
*/
class CmsReferenceFactory extends Factory
{
protected $model = CmsReference::class;
public function definition(): array
{
$title = fake()->unique()->words(3, true);
return [
'title' => ['de' => ucfirst($title), 'en' => ucfirst($title)],
'location' => fake()->city(),
'year' => (string) fake()->numberBetween(2022, 2026),
'description' => ['de' => fake()->paragraph(2), 'en' => fake()->paragraph(2)],
'image' => 'referenzen/'.fake()->unique()->slug(2).'.webp',
'gallery' => [],
'brand_slugs' => [],
'is_published' => true,
'order' => fake()->numberBetween(1, 20),
];
}
public function published(): static
{
return $this->state(fn () => ['is_published' => true]);
}
public function unpublished(): static
{
return $this->state(fn () => ['is_published' => false]);
}
/**
* @param list<string> $slugs
*/
public function forBrands(array $slugs): static
{
return $this->state(fn () => ['brand_slugs' => $slugs]);
}
public function withGallery(int $count = 2): static
{
return $this->state(function () use ($count) {
$items = [];
for ($i = 1; $i <= $count; $i++) {
$items[] = ['image' => 'referenzen/bild-'.$i.'.webp', 'caption' => 'Bild '.$i];
}
return ['gallery' => $items];
});
}
}

View file

@ -0,0 +1,40 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Kernmarken (CABINET, Rotpunkt Küchen, Febrü) mit eigener Detailseite.
*
* Bewusst eine eigene Tabelle statt der bestehenden `brands`: die gehört zum
* Produkt-/Partner-Kontext (Partner::brand(), Product::brand()) und hat eine andere Bedeutung.
*/
public function up(): void
{
Schema::create('cms_brands', function (Blueprint $table) {
$table->id();
$table->string('slug')->unique();
$table->json('name');
$table->json('tagline')->nullable();
$table->json('teaser')->nullable();
$table->json('description')->nullable();
$table->string('logo')->nullable();
$table->string('image')->nullable();
$table->json('services')->nullable();
$table->json('gallery')->nullable();
$table->string('website_url')->nullable();
$table->string('showroom')->nullable();
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('cms_brands');
}
};

View file

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Die Übersichtsseite /marken zeigt je Marke einen mittellangen Text plus drei Merkmale.
*
* Abgrenzung zu den vorhandenen Feldern:
* teaser – Kurztext für die Kachel auf der Startseite
* summary – Text auf der Übersichtsseite /marken (neu)
* highlights – drei Stichpunkte auf /marken (neu)
* description – Einleitung auf der Detailseite
* services – Leistungen auf der Detailseite
*/
public function up(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->json('summary')->nullable()->after('teaser');
$table->json('highlights')->nullable()->after('summary');
});
}
public function down(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->dropColumn(['summary', 'highlights']);
});
}
};

View file

@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Ausführliche Markenseiten nach den Zulieferungen vom 31.08.2026.
*
* Die gelieferten Texte (Rotpunkt, Febrü) bestehen aus mehreren Abschnitten, von denen einige
* eine Vertiefung mitbringen, die laut Umsetzungshinweis aufklappbar dargestellt werden soll.
* Dafür reichen `description` (Einleitung) und `services` (Kacheln) nicht aus.
*
* Ergänzt werden:
* - `sections` Abschnitte der Seite, je mit Titel, Absätzen und optionaler Vertiefung
* - `widget_label` Beschriftung des schwebenden Terminanfrage-Widgets auf dieser Markenseite
* - `cta_title` Überschrift des Abschlussblocks
* - `cta_text` Text des Abschlussblocks
*
* `sections` ist – wie `services`, `highlights` und `gallery` – bewusst nicht übersetzbar:
* Die Texte liegen nur auf Deutsch vor. Die drei Textfelder folgen den übrigen Textfeldern
* und sind übersetzbar.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->json('sections')->nullable()->after('services');
$table->json('widget_label')->nullable()->after('sections');
$table->json('cta_title')->nullable()->after('widget_label');
$table->json('cta_text')->nullable()->after('cta_title');
});
}
public function down(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->dropColumn(['sections', 'widget_label', 'cta_title', 'cta_text']);
});
}
};

View file

@ -0,0 +1,34 @@
<?php
use App\Models\CmsArticle;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Magazinbeiträge gehören zu einer Farbwelt (Interior oder Immobilien).
*
* Die drei Dubai-Beiträge aus dem Bestand werden direkt der Immobilien-Welt zugeordnet,
* damit sie nach der Trennung nicht in der Interior-Liste stehen.
*/
public function up(): void
{
Schema::table('cms_articles', function (Blueprint $table) {
$table->string('world', 20)->default(CmsArticle::WORLD_INTERIOR)->index()->after('category');
});
CmsArticle::query()
->whereIn('slug', CmsArticle::LEGACY_IMMOBILIEN_SLUGS)
->update(['world' => CmsArticle::WORLD_IMMOBILIEN]);
}
public function down(): void
{
Schema::table('cms_articles', function (Blueprint $table) {
$table->dropIndex(['world']);
$table->dropColumn('world');
});
}
};

View file

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Fertigungsort je Kernmarke (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
*
* Die Herkunfts-Sektion „Aus der Region gefertigt“ holt ihre Standorte nicht mehr aus einem
* JSON-Eintrag im generischen Inhalte-Editor, sondern aus der Markenverwaltung: Marcel pflegt
* Ort, Entfernung und Rolle direkt bei der Marke. Die Kartenpositionen bleiben im Code.
*
* - `origin_city` Fertigungsort, z. B. „Bünde“ – leer bedeutet: Marke erscheint nicht in der Sektion
* - `origin_distance` Entfernung ab Bielefeld als Text, z. B. „rund 30 km“
* - `origin_role` Ein Satz zur Rolle des Standorts
*
* Alle drei sind übersetzbar wie die übrigen Textfelder der Marke.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->json('origin_city')->nullable()->after('showroom');
$table->json('origin_distance')->nullable()->after('origin_city');
$table->json('origin_role')->nullable()->after('origin_distance');
});
}
public function down(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->dropColumn(['origin_city', 'origin_distance', 'origin_role']);
});
}
};

View file

@ -0,0 +1,44 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Referenzprojekte der Interior-Welt (Nachtrag 13).
*
* AP5 hatte die Referenzen aus den Markengalerien zusammengesammelt, um keine zweite Pflegestelle
* zu schaffen. Abstimmung 15.09.: Marcel soll Referenzen als eigene Einträge pflegen – ein
* realisiertes Projekt hat mehr als ein Bild und mehr als eine Marke. Solange kein Eintrag
* existiert, greift das Frontend weiter auf die Markengalerien zurück.
*
* - `title`, `description` übersetzbar
* - `location`, `year` Ort und Jahr als Text („Bielefeld“, „2025“)
* - `image` Titelbild (Dateiname aus der Medienbibliothek)
* - `gallery` weitere Bilder mit Bildunterschrift
* - `brand_slugs` beteiligte Kernmarken (Slugs aus cms_brands), verlinkt auf die Markenseiten
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('cms_references', function (Blueprint $table) {
$table->id();
$table->json('title');
$table->string('location')->nullable();
$table->string('year', 20)->nullable();
$table->json('description')->nullable();
$table->string('image')->nullable();
$table->json('gallery')->nullable();
$table->json('brand_slugs')->nullable();
$table->boolean('is_published')->default(true);
$table->integer('order')->default(0);
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('cms_references');
}
};

View file

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Übersetzungen der Markenfelder, die bisher nur einsprachig gespeichert waren (Merkmale,
* Leistungen, Abschnitte, Showroom, Bildunterschriften), je Sprache:
* {"en": {"highlights": [...], "services": [...], "sections": [...], "showroom": "...",
* "gallery_captions": {"<bild>": "..."}}}. Die bisherigen Spalten bleiben die deutsche Fassung.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->json('field_translations')->nullable()->after('gallery');
});
}
public function down(): void
{
Schema::table('cms_brands', function (Blueprint $table) {
$table->dropColumn('field_translations');
});
}
};

View file

@ -0,0 +1,104 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Textkorrekturen auf /about (Abstimmung 06.10.2026, Nachtrag 21).
*
* „… am Ende auch dafür geradesteht“, dazu Flüchtigkeits- und Formulierungsfehler: Komma bei
* „wenigen ausgewählten“, deutsche Anführungszeichen im Zitat, „Wort halten“, Otterpohl als
* vierte Kernmarke in der Zeitleiste, „ungenutzt“ statt „liegen lässt“, „B2in-Partner“ und
* ein Alternativtext für die Bildpause, der das Bild beschreibt (nur Interior-Welt).
* Der Seeder tauscht nur genau diese Textstellen in den betroffenen CMS-Sektionen; alles andere
* bleibt. Steht eine Stelle nicht mehr drin, passiert dort nichts. Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=AboutPageTextSeeder
*/
class AboutPageTextSeeder extends Seeder
{
/** @var list<array{0: string, 1: string}> Gruppe und Schlüssel der Sektionen */
public const SECTIONS = [
['about', 'hero'],
['about', 'image_break'],
['about', 'our_story'],
['about', 'our_values'],
['about', 'partner_cta'],
['netzwerk', 'partner_cta'],
];
/** @var array<string, string> alte Stelle → neue Stelle */
public const REPLACEMENTS = [
'aufmisst und am Ende geradesteht.' => 'aufmisst und am Ende auch dafür geradesteht.',
'mit wenigen, ausgewählten Herstellern' => 'mit wenigen ausgewählten Herstellern',
'"Gute Einrichtung entsteht' => '„Gute Einrichtung entsteht',
'bis nach der Montage."' => 'bis nach der Montage.“',
'verbunden durch dieselbe Grundhaltung, dass Qualität und Wort halten zusammengehören.' => 'verbunden durch dieselbe Grundhaltung: Zu Qualität gehört, Wort zu halten.',
'2026 rückt das Interior in den Mittelpunkt: Mit CABINET, Rotpunkt Küchen und Febrü stehen starke Kernmarken im Zentrum, ergänzt um ein Netzwerk regionaler Fachexperten. Internationale Immobilien bleiben als ergänzender Bereich bestehen.' => '2026 rückt die Einrichtung in den Mittelpunkt: CABINET, Rotpunkt Küchen, Febrü und Otterpohl Möbelservice bilden die Kernmarken, ergänzt um ein Netzwerk regionaler Fachexperten. Internationale Immobilien bleiben als zweiter Bereich bestehen.',
'Wir planen den Raum, den ein Standardmöbel liegen lässt.' => 'Wir planen auch den Raum, den ein Standardmöbel ungenutzt lässt.',
'Werden Sie B2in Partner' => 'Werden Sie B2in-Partner',
'B2in – Immobilien und Einrichtung verbinden' => 'Wohnraum halb als Planungsskizze, halb fertig eingerichtet',
'B2in – Connecting Real Estate and Interior' => 'Living space, half planning sketch, half fully furnished',
'In 2026 interiors move to the centre: CABINET, Rotpunkt Küchen and Febrü form the core, complemented by a network of regional specialists. International real estate remains as a complementary area.' => 'In 2026 interiors move to the centre: CABINET, Rotpunkt Küchen, Febrü and Otterpohl Möbelservice form the core, complemented by a network of regional specialists. International real estate remains as a second area.',
];
public function run(): void
{
$total = 0;
foreach (self::SECTIONS as [$group, $key]) {
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
if (! $row) {
continue;
}
$count = 0;
$value = $this->replaceDeep($row->getTranslations('value'), $count);
if ($count === 0) {
continue;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache($group);
$total += $count;
}
$this->command?->info($total > 0
? "About-Texte korrigiert ({$total} Stelle(n))."
: 'About-Texte: bereits aktuell oder nicht im CMS – nichts geändert.');
}
/**
* @param array<mixed> $data
* @return array<mixed>
*/
private function replaceDeep(array $data, int &$count): array
{
foreach ($data as $key => $item) {
if (is_array($item)) {
$data[$key] = $this->replaceDeep($item, $count);
continue;
}
if (! is_string($item)) {
continue;
}
$replaced = strtr($item, self::REPLACEMENTS);
if ($replaced !== $item) {
$data[$key] = $replaced;
$count++;
}
}
return $data;
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace Database\Seeders;
use App\Models\CmsArticle;
use App\Models\CmsBrand;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Models\CmsMedia;
use FluxCms\Core\Services\MediaConversionService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Übernimmt die Bilder, die Seeder unter public/img/assets/ abgelegt haben (Marken, Magazin,
* Slideshow …), in die Mediathek (07.10.2026) – damit sie im Bildauswahl-Dialog erscheinen.
*
* Name in der Mediathek ist der bisherige Pfad (z. B. `marken/cabinet/ankleide-solitaer-eiche-tano.webp`).
* Die Editoren speichern beim Auswählen genau diesen Namen, also bleiben alle Verweise gültig; der
* Admin (media_url()) zeigt das Bild aus der Mediathek. Die Datei wird kopiert, das Original unter
* public/img/assets/ bleibt liegen – die Website liefert weiter von dort (WebP-Fassungen).
*
* Übernommen wird nur, was Marken, Magazin oder CMS-Einträge (samt Standardtexten) tatsächlich
* verwenden. Bereits vorhandene Einträge werden übersprungen – mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=AssetMediaImportSeeder
*/
class AssetMediaImportSeeder extends Seeder
{
public function run(): void
{
$disk = config('flux-cms.media.disk', 'public');
$originals = config('flux-cms.media.originals_path', 'cms/media/originals');
$service = app(MediaConversionService::class);
$imported = 0;
foreach ($this->referencedAssets() as $value => $collection) {
if (CmsMedia::query()->where('filename', $value)->exists()) {
continue;
}
$source = public_path('img/assets/'.$value);
$extension = strtolower(pathinfo($source, PATHINFO_EXTENSION));
$path = $originals.'/'.Str::random(40).'.'.$extension;
Storage::disk($disk)->put($path, file_get_contents($source));
$isSvg = $extension === 'svg';
$dimensions = $isSvg ? false : @getimagesize($source);
$media = CmsMedia::create([
'filename' => $value,
'disk' => $disk,
'path' => $path,
'type' => 'image',
'mime_type' => $isSvg ? 'image/svg+xml' : (mime_content_type($source) ?: 'image/'.$extension),
'file_size' => filesize($source),
'original_width' => $dimensions ? $dimensions[0] : null,
'original_height' => $dimensions ? $dimensions[1] : null,
'collection' => $collection,
'conversions' => [],
]);
if (! $isSvg) {
$service->generateThumbnail($media);
}
$imported++;
}
$this->command?->info($imported === 0
? 'Mediathek: alle verwendeten Bilder sind bereits übernommen.'
: "Mediathek: {$imported} Bilder aus public/img/assets übernommen.");
}
/**
* Bildpfade, die Marken, Magazin oder CMS verwenden und die unter public/img/assets/ liegen,
* mit der Sammlung, in die sie kommen (die erste Quelle zählt: Marken, Magazin, Website).
*
* @return array<string, string>
*/
public function referencedAssets(): array
{
$values = [];
$collect = function (mixed $value, string $collection) use (&$collect, &$values): void {
if (is_array($value)) {
array_walk($value, fn ($item) => $collect($item, $collection));
return;
}
if (! is_string($value) || ! preg_match('/\.(jpe?g|png|webp|svg|gif)$/i', $value)) {
return;
}
$value = ltrim(trim($value), '/');
if (str_contains($value, '://') || str_starts_with($value, 'storage/') || str_starts_with($value, 'immobile/')) {
return;
}
if (is_file(public_path('img/assets/'.$value))) {
$values[$value] ??= $collection;
}
};
foreach (CmsBrand::query()->get() as $brand) {
$collect([$brand->logo, $brand->image, $brand->gallery, $brand->sections], 'Marken');
}
foreach (CmsArticle::query()->get() as $article) {
$collect($article->toArray(), 'Magazin');
}
foreach (CmsContent::query()->get() as $row) {
$collect($row->getTranslations('value'), 'Website');
}
$collect(trans('b2in.themes.'.config('app.theme', 'b2in'), [], 'de'), 'Website');
ksort($values);
return $values;
}
}

View file

@ -0,0 +1,84 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Projekttext auf der Startseite (Briefing Marcel 24.09.2026, Punkt 7; Nachtrag 19).
*
* In der Karte „Ausstattung für Ihr Projekt“ (home/audience_switch) wird „Mit deutscher
* Vertragssicherheit …“ durch „Mit einem zentralen deutschen Vertragspartner …“ ersetzt.
* Abstimmung 01.10.2026: Nach „Projektentwicklungen“ steht ein Punkt statt des Gedankenstrichs.
* Der Seeder tauscht nur genau diesen Satz, egal wo er im Sektions-JSON steht; alles andere
* bleibt. Steht der Satz nicht mehr drin, passiert nichts. Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=AudienceSwitchProjectTextSeeder
*/
class AudienceSwitchProjectTextSeeder extends Seeder
{
/** @var array<string, string> alter Satz → neuer Satz */
public const REPLACEMENTS = [
'Mit deutscher Vertragssicherheit und einem eingespielten Herstellernetzwerk.' => 'Mit einem zentralen deutschen Vertragspartner und einem eingespielten Herstellernetzwerk.',
'With German contractual reliability and an established manufacturer network.' => 'With a single German contract partner and an established manufacturer network.',
'Projektentwicklungen – koordiniert aus einer Hand' => 'Projektentwicklungen. Koordiniert aus einer Hand',
'developments – coordinated from a single source' => 'developments. Coordinated from a single source',
];
public function run(): void
{
$row = CmsContent::query()->where('group', 'home')->where('key', 'audience_switch')->first();
if (! $row) {
$this->command?->info('Projekttext: Sektion home/audience_switch nicht im CMS – die Lang-Datei gilt.');
return;
}
$value = $row->getTranslations('value');
$count = 0;
$value = $this->replaceDeep($value, $count);
if ($count === 0) {
$this->command?->info('Projekttext: bereits aktuell, nichts geändert.');
return;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache('home');
$this->command?->info("Projekttext ersetzt ({$count} Stelle(n)).");
}
/**
* @param array<mixed> $data
* @return array<mixed>
*/
private function replaceDeep(array $data, int &$count): array
{
foreach ($data as $key => $item) {
if (is_array($item)) {
$data[$key] = $this->replaceDeep($item, $count);
continue;
}
if (! is_string($item)) {
continue;
}
$replaced = strtr($item, self::REPLACEMENTS);
if ($replaced !== $item) {
$data[$key] = $replaced;
$count++;
}
}
return $data;
}
}

View file

@ -0,0 +1,64 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Entfernt den Vermerk „BEISPIEL – “ aus vorhandenen Galerie-Bildunterschriften.
*
* Der Vermerk stammt aus {@see DemoBrandGallerySeeder} und ist auf Kundenwunsch entfallen. Weil
* die Bilder auf Vorschau- und Livesystem bereits in der Datenbank stehen, reicht die Änderung am
* Demo-Seeder nicht aus – der wuerde beim erneuten Ausfuehren die gesamte Galerie ueberschreiben
* und damit gepflegte Bilder verlieren.
*
* Dieser Seeder fasst deshalb ausschliesslich den Text der Bildunterschrift an. Bilder, Kategorien
* und Reihenfolge bleiben unveraendert. Mehrfaches Ausfuehren ist unbedenklich.
*
* php artisan db:seed --class=BrandGalleryCaptionNormalizerSeeder
*/
class BrandGalleryCaptionNormalizerSeeder extends Seeder
{
/** Praefixe, die vorne aus der Bildunterschrift entfernt werden. */
private const PREFIXES = ['BEISPIEL – ', 'BEISPIEL - ', 'BEISPIEL: ', 'BEISPIEL '];
public function run(): void
{
$cleaned = 0;
foreach (CmsBrand::query()->whereNotNull('gallery')->get() as $brand) {
$gallery = $brand->gallery ?? [];
$changed = false;
foreach ($gallery as $index => $item) {
$caption = (string) ($item['caption'] ?? '');
$stripped = $this->stripPrefix($caption);
if ($stripped !== $caption) {
$gallery[$index]['caption'] = $stripped;
$changed = true;
$cleaned++;
}
}
if ($changed) {
$brand->gallery = CmsBrand::normalizeGallery($gallery);
$brand->save();
}
}
$this->command?->info("Bildunterschriften bereinigt: {$cleaned}.");
}
private function stripPrefix(string $caption): string
{
foreach (self::PREFIXES as $prefix) {
if (str_starts_with($caption, $prefix)) {
return trim(substr($caption, strlen($prefix)));
}
}
return $caption;
}
}

View file

@ -0,0 +1,162 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Trägt Titelbild und Galerie für CABINET, Rotpunkt, Febrü (Nachtrag 16) und Otterpohl (Nachtrag 19) nach.
*
* Grundlage: dev/2026-08-28/images/ (Zulieferungen vom 22.–24.09.2026). Die Originale sind 1–8 MB
* große JPEGs; unter public/img/assets/marken/{cabinet,rotpunkt,februe,otterpohl}/ liegen sie als
* WebP mit maximal 1920 px Kantenlänge (Imagick, Qualität 82, ohne Metadaten). Die mit `_title_`
* markierte Datei ist das Titelbild (Seitenkopf der Markenseite, Kachel auf der Startseite,
* Markenblock auf /marken). Die Galeriebilder der drei Hersteller stehen als **Visualisierung**:
* es sind Herstellerdarstellungen, keine von uns ausgeführten Projekte – unter „Referenzen“
* erscheinen sie damit nicht. Bei Otterpohl sind die Bilder 2–5 Einblicke in Werkstatt und
* Montage (**Aus der Werkstatt**, dazu das frühere Titelbild), alle übrigen ausgeführte Aufträge (**Realisiertes Projekt**);
* letztere erscheinen unter „Referenzen“, solange dort keine eigenen Einträge gepflegt sind.
*
* Verhalten: Das Titelbild wird nur gesetzt, wenn keins gepflegt ist. Galeriebilder werden
* nur ergänzt, wenn die Datei noch nicht in der Galerie steht; vorhandene Einträge bleiben
* samt Kategorie und Bildunterschrift, wie Marcel sie gepflegt hat. Mehrfaches Ausführen ist
* unbedenklich.
*
* php artisan db:seed --class=BrandImageSeeder
*/
class BrandImageSeeder extends Seeder
{
/**
* Galerie: Dateiname → Bildunterschrift (Kategorie Visualisierung) oder
* Dateiname → ['caption' => …, 'category' => …] für eine andere Kategorie.
*
* @var array<string, array{image: string, gallery: array<string, string|array{caption: string, category: string}>}>
*/
private const IMAGES = [
'cabinet' => [
'image' => 'marken/cabinet/ankleide-solitaer-eiche-tano.webp',
'gallery' => [
'marken/cabinet/dachschraegenschrank-filo-cashmere.webp' => 'Dachschrägenschrank in Filo Cashmere',
'marken/cabinet/schrank-gegen-schraege-marmor.webp' => 'Schrank gegen die Schräge in Marmoroptik',
'marken/cabinet/schlafzimmer-eiche-tano.webp' => 'Schlafzimmerschrank in Eiche Tano',
'marken/cabinet/schlafzimmer-velluto-fango.webp' => 'Schlafzimmerschrank in Velluto Fango',
'marken/cabinet/schlafzimmer-wandschrank-filo-cashmere.webp' => 'Wandschrank im Schlafzimmer in Filo Cashmere',
'marken/cabinet/garderobe-seite-offen.webp' => 'Garderobe mit offener Seite',
'marken/cabinet/garderobe-sitznische-filo-cashmere.webp' => 'Garderobe mit Sitznische in Filo Cashmere',
'marken/cabinet/drehtuerenschrank-marmor.webp' => 'Drehtürenschrank in Marmoroptik',
'marken/cabinet/flur-schrank-eiche-tano.webp' => 'Flurschrank in Eiche Tano',
'marken/cabinet/dachschraege-velluto-fango.webp' => 'Schrank unter der Dachschräge in Velluto Fango',
'marken/cabinet/ankleide-solitaer-eiche-tano-seite.webp' => 'Ankleide Solitär in Eiche Tano, Seitenansicht',
],
],
'rotpunkt' => [
'image' => 'marken/rotpunkt/kueche-01.webp',
'gallery' => [
'marken/rotpunkt/kueche-01-b.webp' => 'Küche 01, zweite Ansicht',
'marken/rotpunkt/kueche-02.webp' => 'Küche 02',
'marken/rotpunkt/kueche-03.webp' => 'Küche 03',
'marken/rotpunkt/kueche-04.webp' => 'Küche 04',
'marken/rotpunkt/kueche-05.webp' => 'Küche 05',
'marken/rotpunkt/kueche-06.webp' => 'Küche 06',
'marken/rotpunkt/kueche-06-b.webp' => 'Küche 06, zweite Ansicht',
'marken/rotpunkt/kueche-07.webp' => 'Küche 07',
'marken/rotpunkt/kueche-08.webp' => 'Küche 08',
'marken/rotpunkt/kueche-09.webp' => 'Küche 09',
'marken/rotpunkt/kueche-10.webp' => 'Küche 10',
'marken/rotpunkt/kueche-11.webp' => 'Küche 11',
'marken/rotpunkt/kueche-12.webp' => 'Küche 12',
],
],
// Bildunterschriften nach den Febrü-Produktlinien aus den Dateinamen.
'februe' => [
'image' => 'marken/februe/base-thekenloesung.webp',
'gallery' => [
'marken/februe/active-2-0-bureo.webp' => 'Active 2.0 mit Bureo',
'marken/februe/active-2-0-soul-grossraum.webp' => 'Großraum mit Active 2.0 und Soul',
'marken/februe/adesso.webp' => 'Adesso',
'marken/februe/arena-stage.webp' => 'Arena Stage',
'marken/februe/base.webp' => 'Base',
'marken/februe/base-sideboard.webp' => 'Base Sideboard',
'marken/februe/bobb-bionics-coworker-container-cube.webp' => 'Bobb, Bionics, CoWorker, Container und Cube',
'marken/februe/bobb.webp' => 'Bobb',
'marken/februe/brandon.webp' => 'Brandon',
'marken/februe/coworker.webp' => 'CoWorker',
'marken/februe/corporate.webp' => 'Corporate',
'marken/februe/curved-ben-basic.webp' => 'Curved, Ben und Basic',
'marken/februe/eline-one-silent-garderobenpaneel.webp' => 'E-Line, One, Silent und Garderobenpaneel',
'marken/februe/elements-soul-grossraum.webp' => 'Großraum mit Elements und Soul',
'marken/februe/vio.webp' => 'Vio',
],
],
// Otterpohl (Zulieferung 24.09.): Werkstattbilder und Referenzen. Seit 01.10. ist die Treppe mit
// Lamellenwand (tittle_otter.png) das Titelbild, das frühere Werkstatt-Titelbild steht in der
// Galerie – {@see OtterpohlBrandSeeder::swapTitleImage()} tauscht bestehende Installationen.
// Unterschriften ohne Kundennamen – die stehen nur in den Dateinamen der Zulieferung.
'tischlerei' => [
'image' => OtterpohlBrandSeeder::TITLE_IMAGE,
'gallery' => [
'marken/otterpohl/referenz-dziuballe-treppe.webp' => ['caption' => 'Treppe mit Holzstufen, Stufenbeleuchtung und Eichenhandlauf', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-luecke-12.webp' => ['caption' => 'Tür und Kommode vor Lamellenwand', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-neugebauer.webp' => ['caption' => 'Gewendelte Treppe mit Eichenstufen', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-luecke-15.webp' => ['caption' => 'Beleuchtete Wandregale über Eck', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-berkemeier-treppe.webp' => ['caption' => 'Treppe mit umlaufender Sockelleiste und Lichtdetail', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-objektbau-luecke-technik.webp' => ['caption' => 'Objektbau: Verkaufsraum mit Theke und Regalwand', 'category' => CmsBrand::GALLERY_PROJECT],
'marken/otterpohl/referenz-otterpohl-11.webp' => ['caption' => 'Sideboard mit Eichenplatte im Büro', 'category' => CmsBrand::GALLERY_PROJECT],
OtterpohlBrandSeeder::FORMER_TITLE_IMAGE => ['caption' => OtterpohlBrandSeeder::FORMER_TITLE_CAPTION, 'category' => CmsBrand::GALLERY_COMPANY],
'marken/otterpohl/werkstatt-02.webp' => ['caption' => 'CNC-Bearbeitungszentrum in der Fertigung', 'category' => CmsBrand::GALLERY_COMPANY],
'marken/otterpohl/werkstatt-03.webp' => ['caption' => 'Einbau einer Tür vor Ort', 'category' => CmsBrand::GALLERY_COMPANY],
'marken/otterpohl/werkstatt-04.webp' => ['caption' => 'Handwerkzeug in der Werkstatt', 'category' => CmsBrand::GALLERY_COMPANY],
'marken/otterpohl/werkstatt-05-lift.webp' => ['caption' => 'Anlieferung per Möbellift – Montage inklusive', 'category' => CmsBrand::GALLERY_COMPANY],
],
],
];
public function run(): void
{
foreach (self::IMAGES as $slug => $data) {
$brand = CmsBrand::query()->where('slug', $slug)->first();
if (! $brand) {
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
continue;
}
$titleSet = false;
if (trim((string) $brand->image) === '') {
$brand->image = $data['image'];
$titleSet = true;
}
$gallery = CmsBrand::normalizeGallery($brand->gallery);
$known = array_column($gallery, 'image');
$added = 0;
foreach ($data['gallery'] as $image => $entry) {
if (in_array($image, $known, true)) {
continue;
}
$gallery[] = [
'image' => $image,
'category' => is_array($entry) ? $entry['category'] : CmsBrand::GALLERY_INSPIRATION,
'caption' => is_array($entry) ? $entry['caption'] : $entry,
];
$added++;
}
$brand->gallery = $gallery;
$brand->save();
$this->command?->info(sprintf(
'Marke „%s“: Titelbild %s, %d Galeriebilder ergänzt, %d vorhanden.',
$slug,
$titleSet ? 'gesetzt' : 'behalten',
$added,
count($gallery) - $added,
));
}
}
}

View file

@ -0,0 +1,72 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Traegt die Markenlogos nach.
*
* Grundlage: dev/2026-08-28/markenlogos/ (Zulieferung vom 03.09.2026). Die Dateien liegen unter
* public/img/assets/marken/ und sind dort **optisch aufeinander abgestimmt** – das Frontend
* rendert sie in einer gemeinsamen Box, in der jedes Logo seine Zeichenflaeche ausfuellt:
*
* Rotpunkt Zeichenflaeche = Inhalt → fuellt die Box, gilt als Bezugsgroesse
* CABINET Zeichenflaeche rechts +18 % → rendert 15 % kleiner als Rotpunkt
* Februe Zeichenflaeche oben/unten +20 % → rendert wie vor der Abstimmung
* Otterpohl knapp beschnitten, weiss freigestellt (Zulieferung 17.09.2026, Nachtrag 15)
*
* Alle drei waren in der Zulieferung sehr unterschiedlich stark umrandet (CABINET nutzte nur
* 29 Prozent der Hoehe) und wirkten dadurch ungleich gross. Die Abstimmung steckt bewusst in den
* Dateien und nicht im Blade: so gilt sie an allen vier Stellen gleichzeitig – Seitenkopf,
* Startseiten-Kachel, /marken und „Weitere Marken“.
*
* Wird ein Logo im Admin ausgetauscht, sollte die neue Datei genauso knapp beschnitten sein.
* Ohne Rand rendert sie in Rotpunkt-Groesse, mit viel Rand entsprechend kleiner.
*
* {@see CmsBrandSeeder} bringt dieselben Werte fuer frische Installationen mit, ueberspringt
* aber bereits angelegte Marken. Dieser Seeder fasst nur das Feld `logo` an und auch nur dann,
* wenn es leer ist – ein selbst hochgeladenes Logo bleibt also stehen. Mehrfaches Ausfuehren
* ist unbedenklich.
*
* php artisan db:seed --class=BrandLogoSeeder
*/
class BrandLogoSeeder extends Seeder
{
/** @var array<string, string> */
private const LOGOS = [
'cabinet' => 'marken/cabinet.svg',
'rotpunkt' => 'marken/rotpunkt.svg',
'februe' => 'marken/februe.png',
'tischlerei' => 'marken/otterpohl.png',
];
public function run(): void
{
$set = 0;
$kept = 0;
foreach (self::LOGOS as $slug => $logo) {
$brand = CmsBrand::query()->where('slug', $slug)->first();
if (! $brand) {
$this->command?->warn("Marke „{$slug}“ nicht gefunden – uebersprungen.");
continue;
}
if (trim((string) $brand->logo) !== '') {
$kept++;
continue;
}
$brand->logo = $logo;
$brand->save();
$set++;
}
$this->command?->info("Markenlogos gesetzt: {$set}, vorhandene behalten: {$kept}.");
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Trägt den Fertigungsort der Kernmarken nach (Nachtrag 11 zu Arbeitsplan 14.09.2026, Punkt 1.3).
*
* Die Herkunfts-Sektion „Aus der Region gefertigt“ liest Ort, Entfernung und Rolle aus der
* Marke. {@see CmsBrandSeeder} bringt dieselben Werte für frische Installationen mit,
* überspringt aber bereits angelegte Marken. Dieser Seeder fasst nur die drei Felder an und
* auch nur bei Marken, deren Fertigungsort noch leer ist – ein im Admin eingetragener Ort
* bleibt stehen. Mehrfaches Ausführen ist unbedenklich.
*
* Entfernungen gerundet, Straße ab Bielefeld Mitte. ⚠️ Rolle CABINET nach Kevins Verständnis
* (Fertigung Köln, Beratung im Store Bielefeld) – von Marcel bestätigen lassen.
*
* php artisan db:seed --class=BrandOriginSeeder
*/
class BrandOriginSeeder extends Seeder
{
/** @var array<string, array{origin_city: array<string, string>, origin_distance: array<string, string>, origin_role: array<string, string>}> */
private const ORIGINS = [
'cabinet' => [
'origin_city' => ['de' => 'Köln', 'en' => 'Cologne'],
'origin_distance' => ['de' => 'rund 200 km', 'en' => 'about 200 km'],
'origin_role' => [
'de' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
'en' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
],
],
'rotpunkt' => [
'origin_city' => ['de' => 'Bünde', 'en' => 'Bünde'],
'origin_distance' => ['de' => 'rund 30 km', 'en' => 'about 30 km'],
'origin_role' => [
'de' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
'en' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
],
],
'februe' => [
'origin_city' => ['de' => 'Herford', 'en' => 'Herford'],
'origin_distance' => ['de' => 'rund 20 km', 'en' => 'about 20 km'],
'origin_role' => [
'de' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
'en' => 'Office and contract furniture from their own production in Herford.',
],
],
'tischlerei' => [
'origin_city' => ['de' => 'Rheda-Wiedenbrück', 'en' => 'Rheda-Wiedenbrück'],
'origin_distance' => ['de' => 'rund 35 km', 'en' => 'about 35 km'],
'origin_role' => [
'de' => 'Tischlerei und Innenausbau aus Rheda-Wiedenbrück – vom einzelnen Möbel bis zum ganzen Raum, inklusive Montage.',
'en' => 'Joinery and interior fit-out from Rheda-Wiedenbrück – from a single piece to the whole room, installation included.',
],
],
];
public function run(): void
{
$set = 0;
$kept = 0;
foreach (self::ORIGINS as $slug => $origin) {
$brand = CmsBrand::query()->where('slug', $slug)->first();
if (! $brand) {
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
continue;
}
if (trim((string) $brand->getTranslation('origin_city', 'de', false)) !== '') {
$kept++;
continue;
}
$brand->fill($origin);
$brand->save();
$set++;
}
$this->command?->info("Fertigungsorte gesetzt: {$set}, vorhandene behalten: {$kept}.");
}
}

View file

@ -0,0 +1,438 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Ausführliche Markenseiten für CABINET, Rotpunkt Küchen und Febrü.
*
* Grundlage für Rotpunkt und Febrü sind die abgestimmten Fassungen:
* dev/2026-08-28/Rotpunkt_Webseitentext_FINAL.md
* dev/2026-08-28/Februe_Webseitentext_FINAL.md
*
* Die dort mit „AUFKLAPPBARER DETAILBEREICH“ gekennzeichneten Vertiefungen stehen hier als
* `detail` am jeweiligen Abschnitt und werden im Frontend aufklappbar dargestellt. Die internen
* Quellen- und Umsetzungshinweise sind bewusst nicht übernommen – sie sind laut Zulieferung
* nicht zur Veröffentlichung bestimmt.
*
* ⚠️ Für CABINET lag keine Zulieferung vor. Der Text ist nach dem Vorbild der beiden
* abgestimmten Seiten von uns verfasst und beschreibt bewusst nur die eigene Arbeitsweise:
* keine Zahlen, Zertifizierungen oder Herstelleraussagen, die niemand belegen kann. Er braucht
* die Freigabe durch Marcel Scheibe, bevor er live geht.
*
* Feedback 17.09.2026 (dev/2026-08-28/feedback-17-09-2026.md) eingearbeitet: CABINET Einstieg
* „Bild und grobes Maß“ und die Fragen vorab; Febrü „Kunden – keine Schubladen“, „Aus Herford“
* und „Wertschätzung“, doppelter Schlusssatz entfernt; Rotpunkt „Mehr als Küchen“. Die
* markeneigenen Handwerk-Abschnitte sind entfallen: das Thema steht als gemeinsamer Abschluss
* auf jeder Markenseite (Lang-Sektion `brand_craft`, siehe BrandDetail).
*
* ⚠️ Überschreibt bei bestehenden Marken **nur** die Felder aus {@see FIELDS}. Bilder, Galerie,
* Leistungen und Reihenfolge bleiben unangetastet, damit gepflegte Inhalte erhalten bleiben.
*
* php artisan db:seed --class=BrandPageTextSeeder
*/
class BrandPageTextSeeder extends Seeder
{
/** Felder, die dieser Seeder anfasst. Alles andere bleibt, wie Marcel es gepflegt hat. */
private const FIELDS = ['tagline', 'description', 'sections', 'widget_label', 'cta_title', 'cta_text'];
public function run(): void
{
$updated = 0;
foreach ($this->pages() as $slug => $data) {
$brand = CmsBrand::query()->where('slug', $slug)->first();
if (! $brand) {
$this->command?->warn("Marke „{$slug}“ nicht gefunden – übersprungen.");
continue;
}
foreach (self::FIELDS as $field) {
if (array_key_exists($field, $data)) {
$brand->{$field} = $data[$field];
}
}
$brand->save();
$updated++;
$this->command?->info("Markenseite „{$slug}“: ".count($data['sections'] ?? []).' Abschnitte übernommen.');
}
$this->command?->info("Markenseiten aktualisiert: {$updated}.");
}
/**
* @return array<string, array<string, mixed>>
*/
private function pages(): array
{
return [
'cabinet' => [
'widget_label' => ['de' => 'Persönliche Schrankplanung vereinbaren', 'en' => 'Arrange a personal wardrobe consultation'],
'description' => [
'de' => '<p>Ein Schrank ist selten der Grund, warum jemand ein Zuhause liebt. Er ist aber sehr oft der Grund, warum ein Raum funktioniert – oder eben nicht. Und Hand aufs Herz: Die schönste Dachschräge nützt wenig, wenn darunter seit dem Einzug drei Kartons stehen, für die nie ein Platz gefunden wurde.</p>'
.'<p>CABINET plant Einbauschränke, die sich dem Raum anpassen statt umgekehrt: von Wand zu Wand, vom Boden bis zur Decke, um die Schräge herum und am Heizkörper vorbei.</p>'
.'<p>Unsere Aufgabe ist es, herauszufinden, was genau dieser eine Raum leisten muss. Bei uns in der Bielefelder Rathausstraße entsteht daraus nicht einfach ein Schrank, sondern eine Lösung, die den vorhandenen Platz vollständig nutzt.</p>',
'en' => '<p>A wardrobe is rarely the reason someone loves a home. It is very often the reason a room works – or does not.</p>',
],
'cta_title' => ['de' => 'Ihr Raum. Persönlich geplant.', 'en' => 'Your room. Personally planned.'],
'cta_text' => [
'de' => 'Jeder Raum hat seine Ecken, Schrägen und Eigenheiten. Gemeinsam planen wir eine CABINET-Lösung, die genau dort hineinpasst, im Alltag funktioniert und den vorhandenen Platz vollständig nutzt. Persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
'en' => 'Every room has its corners, slopes and quirks. Together we plan a CABINET solution that fits exactly, works day to day and uses the available space completely.',
],
'sections' => [
[
'title' => 'Millimeter statt Standardmaß',
'paragraphs' => [
'Schränke von der Stange sind für den durchschnittlichen Raum gemacht. Nur wohnt niemand im durchschnittlichen Raum. Es gibt die Schräge, den Mauervorsprung, das Fenster an der ungünstigen Stelle und den Heizkörper, den niemand versetzen möchte.',
'CABINET plant jedes Element für seinen Platz. Der Schrank endet damit nicht dort, wo das Raster aufhört, sondern dort, wo der Raum aufhört. Was sonst als schwer nutzbare Restfläche übrig bleibt, wird zu Stauraum.',
],
'detail' => [
'title' => 'Wo Standardlösungen an ihre Grenzen stoßen',
'paragraphs' => [
'Planbar sind beispielsweise Schränke unter Dachschrägen, raumhohe Fronten bis unter die Decke, passgenaue Nischenlösungen zwischen zwei Wänden, Übergänge zu vorhandenen Möbeln sowie Aussparungen für Fensterbänke, Vorsprünge und Installationen.',
'Auch der Sockel, die Fußleiste und der Abschluss zur Decke gehören zur Planung. Genau an diesen Übergängen entscheidet sich, ob ein Schrank wie ein Möbelstück vor der Wand wirkt oder wie ein selbstverständlicher Teil des Raumes.',
],
],
],
[
'title' => 'Ein Bild und ein grobes Maß reichen für den Anfang',
'paragraphs' => [
'Am Anfang reicht das grobe Maß. Am Ende entscheidet der Millimeter.',
'Sie müssen also nicht erst alles millimetergenau ausmessen, bevor Sie zu uns kommen. Ein paar Fotos, ein Grundriss und die ungefähren Maße reichen, damit wir Sie beraten, planen und Ihnen ein konkretes Angebot erstellen können.',
'Danach gehen wir gemeinsam ins Detail: Aufteilung, Ausstattung, Materialien, Farben und natürlich der Preis.',
'Wenn Sie das Angebot annehmen und die Anzahlung erfolgt ist, übernehmen wir das professionelle Aufmaß bei Ihnen vor Ort. Ab jetzt zählt jeder Millimeter: Auf Grundlage dieser Maße machen wir die Feinplanung fertig, geben die Fertigung frei und bestellen Ihren Schrank.',
],
'claim' => 'So läuft’s: Foto / Grundriss + grobe Maße → Beratung → Planung & Angebot → Angebotsannahme → Anzahlung → professionelles Aufmaß → Feinplanung / Fertigungsfreigabe → Bestellung / Fertigung',
],
[
'title' => 'Innen entscheidet sich, ob ein Schrank funktioniert',
'paragraphs' => [
'Eine schöne Front ist schnell ausgesucht. Ob ein Schrank im Alltag trägt, entscheidet sich dahinter: bei Einteilung, Auszügen, Fachhöhen, Beleuchtung und der Frage, was in welcher Höhe erreichbar sein muss.',
'Wir planen die Innenausstattung deshalb nicht nach Katalogseite, sondern nach dem, was tatsächlich untergebracht werden soll.',
],
'detail' => [
'title' => 'Die Fragen, die wir vorher stellen',
'paragraphs' => [
'Wie viel Hängefläche wird wirklich gebraucht und wie viel Fachboden? Wo sollen Schuhe, Koffer, Bettwäsche, Sportsachen oder Weihnachtsdeko hin? Wer nutzt den Schrank – und wie groß ist diese Person?',
'Auch die Dinge, die man nicht jeden Tag braucht, gehören in die Planung: Wäschekörbe, Bügelbrett, Staubsauger, Karnevalskostüme oder besondere Sportsachen. Wenn wir vorher wissen, was untergebracht werden soll, können wir den Platz direkt sinnvoll dafür einplanen.',
],
],
],
[
'title' => 'Fronten, die zum Raum gehören',
'paragraphs' => [
'Ein Einbauschrank kann bewusst auffallen oder nahezu verschwinden. Fronten in Farbe, Holzoptik, Glas oder Spiegel, mit Griff oder grifflos, verändern die Wirkung eines Raumes erheblich – gerade weil ein raumhoher Schrank eine große Fläche ist.',
'Wir stimmen Fronten, Griffe und Oberflächen auf die vorhandene Architektur ab: auf Bodenbelag, Türen, Licht und angrenzende Möbel. So entsteht kein Fremdkörper, sondern eine ruhige Fläche, die den Raum größer wirken lässt.',
],
'detail' => [
'title' => 'Sichtbar oder unsichtbar – beides ist eine Entscheidung',
'paragraphs' => [
'Eine wandfarbene, grifflose Front tritt zurück und lässt den Raum offener wirken. Eine Spiegelfront bringt Licht in schmale Flure und Ankleiden. Eine dunkle Holzfront setzt dagegen bewusst einen gestalterischen Schwerpunkt.',
'Beides ist richtig – nur eben nicht gleichzeitig. Wir klären deshalb früh, welche Rolle der Schrank im Raum übernehmen soll, bevor wir über Dekore sprechen.',
],
],
],
[
'title' => 'Mehr als Schlafzimmer',
'paragraphs' => [
'Maßgefertigter Stauraum ist längst nicht auf die Ankleide beschränkt. Genauso gefragt sind Garderoben im Flur, Hauswirtschaftsräume, Dachgeschosse, Arbeitszimmer, Wohnwände mit Medientechnik und Aktenschränke in Praxen und Kanzleien.',
'Materialien, Farben und Gestaltungselemente lassen sich über mehrere Räume hinweg aufgreifen. So entsteht nicht in jedem Zimmer eine neue Idee, sondern ein zusammenhängendes Interior-Konzept mit einer gemeinsamen gestalterischen Sprache.',
'Zusammen mit Rotpunkt Küchen, Febrü und weiteren ausgewählten Partnern betrachten wir bei Bedarf den gesamten Innenraum: CABINET übernimmt die millimetergenaue Sonderlösung, Rotpunkt die Küche und Febrü die Arbeitswelt.',
],
'claim' => 'CABINET für den Millimeter. B2in für das Ganze.',
],
[
'title' => 'Warum CABINET bei uns?',
'paragraphs' => [
'Bei uns beginnt eine Planung nicht mit einem Prospekt, sondern mit Ihrem Raum und der Frage, wie Sie darin leben möchten. Erst danach sprechen wir über Fronten, Auszüge und Beschläge.',
'Wir planen Stauraum, Wege, Licht, Materialien und angrenzende Wohnbereiche als zusammenhängendes Konzept – und begleiten den gesamten Weg von der ersten Idee über das Aufmaß bis zur fertigen Montage.',
'Dabei planen wir nicht für den nächsten schnellen Verkauf, sondern für eine möglichst lange und zufriedene Nutzung. Ein Schrank, der zu viel kann, aber nicht gebraucht wird, ist keine gute Lösung – nur eine teure.',
],
],
[
'title' => 'Erleben statt nur beschreiben',
'paragraphs' => [
'Oberflächen, Beschläge und Auszüge lassen sich schlecht beschreiben und gut ausprobieren. In unserem Showroom in Bielefeld können Sie Fronten anfassen, Auszüge öffnen, Innenausstattungen vergleichen und die Unterschiede selbst beurteilen.',
'Sie müssen sich also nicht allein auf unser Verkaufsgespräch verlassen, sondern entscheiden auf Grundlage dessen, was Sie tatsächlich gesehen und angefasst haben.',
],
],
],
],
'rotpunkt' => [
'tagline' => ['de' => 'Küchen, die Haltung zeigen.', 'en' => 'Kitchens with character.'],
'widget_label' => ['de' => 'Persönliche Küchenberatung vereinbaren', 'en' => 'Arrange a personal kitchen consultation'],
'description' => [
'de' => '<p>Eine Küche ist weit mehr als ein funktionaler Raum. Sie ist Treffpunkt, Arbeitsplatz und Mittelpunkt des täglichen Lebens. Und Hand aufs Herz: Wo finden die besten Gespräche statt – und wo enden die meisten Partys? Meistens in der Küche.</p>'
.'<p>Rotpunkt verbindet außergewöhnliche Gestaltungsfreiheit, intelligente Stauraumlösungen und durchdachte Materiallösungen zu Küchen, die nicht nur gut aussehen, sondern dauerhaft zum Leben ihrer Besitzer passen.</p>'
.'<p>Unsere Aufgabe ist es, genau herauszuarbeiten, welche Lösung zu Ihnen, Ihrem Alltag und Ihren persönlichen Vorstellungen passt. Bei uns in der Bielefelder Rathausstraße entsteht daraus nicht einfach nur eine Küche. Auf Wunsch denken wir das ganze Zuhause mit und ergänzen es bei Bedarf bis ins Detail handwerklich.</p>',
'en' => '<p>A kitchen is far more than a functional room. It is a meeting place, a workspace and the centre of daily life.</p>',
],
'cta_title' => ['de' => 'Ihre Küche. Persönlich geplant.', 'en' => 'Your kitchen. Personally planned.'],
'cta_text' => [
'de' => 'Jeder Raum ist anders. Jeder Alltag stellt andere Anforderungen. Gemeinsam entwickeln wir eine Rotpunkt-Küche, die gestalterisch überzeugt, im Alltag funktioniert und dauerhaft zu Ihnen passt. Persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
'en' => 'Every room is different. Together we develop a Rotpunkt kitchen that convinces in design, works day to day and suits you for the long run.',
],
'sections' => [
[
'title' => 'Aus Ostwestfalen. Für Ihr Zuhause.',
'paragraphs' => [
'Rotpunkt entwickelt und fertigt seit 1930 hochwertige Küchenmöbel an den ostwestfälischen Standorten Bünde und Preußisch Oldendorf-Getmold.',
'Regionale Fertigung, jahrzehntelange Erfahrung und die unmittelbare Nähe zum Hersteller sind für uns keine romantischen Randnotizen, sondern die Grundlage für verlässliche Qualität und persönliche Küchenplanung.',
],
],
[
'title' => 'Gestaltungsfreiheit bis ins Detail',
'paragraphs' => [
'Rotpunkt bietet eine außergewöhnliche Vielfalt an Fronten, Farben, Materialien, Korpusdekoren, Arbeitsplatten, Griffvarianten und grifflosen Lösungen. Mehr als 20 beidseitig ausgeführte Korpusfarben sorgen dafür, dass eine Küche nicht nur außen, sondern auch innen konsequent gestaltet werden kann.',
'So planen wir keinen vorgegebenen Küchenblock, sondern eine Lösung, die sich an Raum, Architektur und den tatsächlichen Gewohnheiten ihrer Besitzer orientiert.',
],
'detail' => [
'title' => 'FENIX und authentische Holzoptiken',
'paragraphs' => [
'FENIX ist eine besonders matte Oberfläche mit geringer Lichtreflexion, angenehm weicher Haptik und ausgeprägten Anti-Fingerprint-Eigenschaften. Oberflächliche Mikrokratzer können thermisch behandelt werden. 27 aufeinander abgestimmte Farben eröffnen eine breite Gestaltungsvielfalt.',
'Auch bei ausgewählten Fronten in Holzoptik zeigt sich die Liebe zum Detail. Synchronporen-Dekore stimmen sichtbare Maserung und fühlbare Struktur präzise aufeinander ab. Dadurch entsteht bereits bei pflegeleichten Melamin-Holzreproduktionen eine natürliche Wirkung.',
'Durch eine sorgfältige Planung kann die Holzoptik über mehrere Fronten hinweggeführt werden. Das Ergebnis ist ein ruhiges, zusammenhängendes Frontbild – ohne dass dafür zwingend Echtholz eingesetzt werden muss.',
],
],
],
[
'title' => 'Mehr Raum. Mehr Ergonomie. Mehr Küche.',
'paragraphs' => [
'Besonders hohe Unterschränke, niedrige Sockelhöhen und zusätzliche Schrankbreiten schaffen mehr nutzbaren Stauraum und eröffnen neue planerische Möglichkeiten. Klingt nach Kleinkram? Vielleicht. Im täglichen Gebrauch macht genau dieser Kleinkram einen erstaunlich großen Unterschied.',
],
'detail' => [
'title' => 'NEW DIMENSIONS',
'paragraphs' => [
'Mit dem Maßkonzept NEW DIMENSIONS sind beispielsweise Unterschrankhöhen von bis zu 910 Millimetern in Verbindung mit einem nur 50 Millimeter hohen Sockel möglich.',
'Zusätzliche Schrankbreiten und vielfältige Innenausstattungen helfen uns, den vorhandenen Raum konsequent und ergonomisch zu nutzen. Die Küche wirkt dadurch nicht nur großzügiger – sie ist es tatsächlich.',
],
],
],
[
'title' => 'Nachhaltigkeit mit konkreter Substanz',
'paragraphs' => [
'Nachhaltigkeit braucht keine großen Versprechen, sondern nachvollziehbare Fakten. Greenline steht bei Rotpunkt für konkrete Materialien, Zertifizierungen und messbare Unterschiede. Deshalb legen wir offen, welche Materialien Rotpunkt verwendet, welche Zertifizierungen dahinterstehen und welchen konkreten Unterschied Greenline für Umwelt und Raumklima macht.',
'Die Greenline BioBoard Gen2 besteht aus bis zu 90 Prozent recyceltem Holz. 95 Prozent des Rotpunkt-Küchensortiments sind FSC-zertifiziert. Außerdem sind die Produkte nach JIS F4Star hinsichtlich ihrer Formaldehydemissionen zertifiziert.',
'Sie haben bereits eine vergleichbare Küche im Blick, die beim Thema Nachhaltigkeit mehr verspricht? Bringen Sie die Unterlagen gerne mit. Wir schauen gemeinsam genau hin – ehrlich, nachvollziehbar und ohne Verkaufsnebel.',
],
'detail' => [
'title' => 'Zahlen, Zertifizierungen und Raumklima',
'paragraphs' => [
// ⚠️ Waldflächen-Aussage: laut Zulieferung nur mit ausdrücklicher Herstellerbestätigung
// veröffentlichen. Steht als Punkt in der Deployment-Checkliste.
'Nach Angaben von Rotpunkt bleibt durch den Einsatz der Greenline-Spanplatten jährlich eine Waldfläche von ungefähr 50 Fußballfeldern erhalten.',
'Die Greenline BioBoard Gen2 gibt laut Rotpunkt rund 50 Prozent weniger Formaldehyd an die Raumluft ab als herkömmliche Produkte. Das ist insbesondere für ein möglichst gesundes Raumklima relevant.',
'Die FSC-Zertifizierung wird unter der Kennung FSC C131864 geführt. Entscheidend ist für uns nicht das grüne Etikett, sondern die konkrete und überprüfbare Aussage dahinter.',
],
],
],
[
'title' => 'Mehr als Küchen',
'paragraphs' => [
'Die Möglichkeiten von Rotpunkt enden nicht bei der klassischen Küche. Das Möbel- und Planungssystem lässt sich auch für Hauswirtschaftsräume, Badezimmer, Sideboards, Wohnmöbel und weitere Bereiche rund um das Wohnen einsetzen.',
'Materialien, Farben und Gestaltungselemente können dabei über mehrere Räume hinweg aufgegriffen werden. So entsteht ein stimmiges Gesamtbild, statt in jedem Raum wieder ganz von vorne anzufangen.',
'Bei Bedarf denken wir den Innenraum deshalb auch über die Küche hinaus – von Möbeln und Beleuchtung bis zu Tapeten, Fußböden und, wenn es sein soll, der letzten Blume.',
],
],
[
'title' => 'Warum eine Rotpunkt-Küche bei uns?',
'paragraphs' => [
'Bei uns beginnt eine neue Küche nicht mit einem Prospekt, sondern mit Ihrem Raum und der Frage, wie Sie darin leben möchten.',
'Arbeitsabläufe, Ergonomie, Stauraum, Materialien, Beleuchtung, Geräte und angrenzende Wohnbereiche planen wir als zusammenhängendes Konzept. Auch die Partystellplätze wollen wohlüberlegt sein – also jene Orte, an denen Gäste später stehen, sitzen, reden und vielleicht etwas länger bleiben.',
'Wir stellen nicht einfach möglichst viele Produkte zusammen. Wir entwickeln eine Küche, die gestalterisch, funktional und wirtschaftlich überzeugt – und begleiten den gesamten Weg von der ersten Idee bis zur professionellen Montage.',
'Dabei planen wir nicht für den nächsten schnellen Verkauf, sondern für eine möglichst lange und zufriedene Nutzung. Das ist kurzfristig vielleicht nicht immer die wirtschaftlichste Entscheidung für uns. Für eine ehrliche und dauerhafte Kundenbeziehung ist es die einzig richtige.',
],
],
[
'title' => 'Erleben statt nur erzählen',
'paragraphs' => [
'Eine gute Entscheidung entsteht nicht allein am Planungstisch. Auf Wunsch – und manchmal auch mit etwas freundlicher Überzeugungsarbeit – begleiten wir Sie nach Abstimmung in das nahe Rotpunkt-Werk oder in ausgewählte Ausstellungen der Gerätehersteller.',
'Dort können Sie Materialien, Fertigung und Technik selbst erleben, Geräte ausprobieren und Fragen direkt vor Ort stellen. Sie müssen sich also nicht allein auf unser Verkaufsgespräch verlassen, sondern erhalten Informationen aus erster Hand, können vergleichen und anschließend mit einem sicheren Gefühl entscheiden.',
'Wir begleiten Sie dabei, ordnen die unterschiedlichen Möglichkeiten ein und führen Ihre Entscheidungen zu einem stimmigen Gesamtkonzept zusammen.',
],
],
],
],
'februe' => [
'tagline' => ['de' => 'Arbeiten, wo man gerne bleibt.', 'en' => 'Work where people like to stay.'],
'widget_label' => ['de' => 'Persönliche Arbeitsplatzberatung vereinbaren', 'en' => 'Arrange a personal workspace consultation'],
'description' => [
'de' => '<p>Ein Büro ist kein Parkplatz für Schreibtische. Es ist ein Raum für Konzentration, Austausch, Bewegung und neue Ideen. Und manchmal entsteht die beste Idee eben nicht am Schreibtisch, sondern zwischen Kaffeemaschine, Lounge und einem guten Gespräch.</p>'
.'<p>Febrü verbindet ergonomische Arbeitsplätze mit wohnlichem Design, intelligentem Stauraum, durchdachter Akustik und flexiblen Raumstrukturen.</p>'
.'<p>Wir entwickeln daraus Arbeitswelten, die zu den Menschen, ihren Aufgaben und ihrer Umgebung passen – vom einzelnen Homeoffice bis zur vollständigen Unternehmensfläche.</p>',
'en' => '<p>An office is not a car park for desks. It is a space for concentration, exchange, movement and new ideas.</p>',
],
'cta_title' => ['de' => 'Ihr Arbeitsplatz. Persönlich geplant.', 'en' => 'Your workspace. Personally planned.'],
'cta_text' => [
'de' => 'Ob Homeoffice, Praxis, Kanzlei, wachsendes Unternehmen oder vollständige Arbeitswelt: Wir planen jeden Arbeitsplatz mit demselben Anspruch. Nur der Umfang verändert sich. Gemeinsam entwickeln wir eine Arbeitsumgebung, die gesundes Arbeiten unterstützt, gestalterisch überzeugt und den Menschen zeigt, dass ihre tägliche Leistung wahrgenommen und wertgeschätzt wird – persönlich geplant bei B2in in der Rathausstraße 11 in Bielefeld.',
'en' => 'Whether a home office, a practice, a law firm or a complete working environment: we plan every workspace to the same standard. Only the scope changes.',
],
'sections' => [
[
'title' => 'Zuhause oder Projekt. Zwei Wege. Derselbe Anspruch.',
'paragraphs' => [
'Unsere Startseite führt bewusst über zwei Wege: Zuhause und Projekt. Denn ein einzelnes Homeoffice stellt andere Anforderungen als eine Büroetage mit fünfzig Arbeitsplätzen.',
'Unterschiedliche Aufgaben brauchen unterschiedliche Planung und natürlich auch unterschiedliche Kalkulationen. Bei uns gibt es trotzdem nur Kunden – mit unterschiedlichen Räumen, Anforderungen und Stückzahlen. Der Anspruch an Produkt, Beratung und Service bleibt derselbe.',
],
'detail' => [
'title' => 'Bei uns gibt es Kunden – keine Schubladen',
'paragraphs' => [
'Ob Privatkunde oder Unternehmen: Für uns gibt es zunächst einmal Kunden mit unterschiedlichen Räumen, Anforderungen und Stückzahlen.',
'Natürlich beeinflusst die Menge den Preis. Fünfzig Arbeitsplätze werden anders kalkuliert als ein einzelner Schreibtisch fürs Homeoffice. Der Anspruch an Produkt, Beratung und Service bleibt trotzdem derselbe.',
'Febrü bleibt Febrü. B2in bleibt B2in. Und jeder Kunde erhält eine Lösung, die zu seinem tatsächlichen Bedarf passt.',
],
],
'claim' => 'Ob im Kleinen oder im Großen – die Detailtreue entscheidet.',
],
[
'title' => 'Aus Herford. Für moderne Arbeitswelten.',
'paragraphs' => [
'Febrü entwickelt und produziert hochwertige Büromöbel direkt im ostwestfälischen Herford – nur wenige Kilometer von Bielefeld entfernt. Eine sehr hohe Fertigungstiefe ermöglicht kurze Wege, verlässliche Qualität und hohe Flexibilität bei Materialien, Farben und individuellen Anforderungen.',
],
'detail' => [
'title' => 'Von einem Schreibtisch bis zum vollständigen Bürotrakt',
'paragraphs' => [
'Das Sortiment reicht von ergonomischen und höhenverstellbaren Schreibtischen über Konferenz- und Besprechungstische, Bürostühle und Loungeelemente bis zu Empfangslösungen, Stauraum, Akustikelementen, mobilen Medienmöbeln, Schließfachsystemen und flexiblen Raumstrukturen.',
'Damit lässt sich ein einzelner Arbeitsplatz genauso überzeugend planen wie eine Praxis, eine Kanzlei, ein Konferenzbereich oder ein vollständiger Bürotrakt.',
],
],
'claim' => 'Made in Herford. Geplant in Bielefeld.',
],
[
'title' => 'Nicht drei Marken. Ein durchgängiger Raum.',
'paragraphs' => [
'Ein Bürotrakt endet nicht am Schreibtisch. Menschen brauchen Orte zum Arbeiten, für Besprechungen und Rückzug – aber ebenso für Pausen, den gemeinsamen Kaffee, das Mittagessen und den informellen Austausch.',
'Genau hier greifen Febrü, Rotpunkt und CABINET bei uns ineinander: Febrü gestaltet die Arbeitswelt, Rotpunkt die Küche im Bürotrakt und CABINET die millimetergenaue Sonderlösung.',
'Entscheidend ist nicht, welcher Hersteller welches Möbel liefert. Entscheidend ist, dass am Ende alles zusammengehört. Wir stimmen Arbeitsplätze, Küche, Stauraum, Farben, Materialien, Beleuchtung und Akustik zu einem durchgängigen Konzept aufeinander ab.',
],
'detail' => [
'title' => 'Wie die drei Marken zusammenspielen',
'paragraphs' => [
'Febrü gestaltet ergonomische Arbeitsplätze, Konferenzbereiche, Loungezonen, Akustik, Stauraum und flexible Raumstrukturen.',
'Rotpunkt gestaltet Mitarbeiterküchen, Coffee-Points und gemeinsam genutzte Aufenthaltsküchen als selbstverständlichen Bestandteil der Arbeitswelt – und nicht als übrig gebliebene Funktionszeile. Gerade hier entstehen oft Gespräche, Verbindungen und Ideen, für die im offiziellen Meeting kein Platz war.',
'CABINET übernimmt dort, wo Nischen vollständig genutzt, Schränke von Wand zu Wand und vom Boden bis zur Decke geplant oder besondere Übergänge zwischen Arbeitsplatz, Stauraum und Architektur geschaffen werden sollen. So entstehen beispielsweise maßgefertigte Garderoben, Aktenschränke, Medienlösungen und Stauraumelemente.',
],
],
'claim' => 'Febrü für die Arbeitswelt. Rotpunkt für die Küche. CABINET für die millimetergenaue Sonderlösung. B2in für das Ganze.',
],
[
'title' => 'Ein Arbeitsplatz. Fünfzig Arbeitsplätze. Ein Anspruch.',
'paragraphs' => [
'Ob wir über einen einzelnen Arbeitsplatz oder einen vollständigen Bürotrakt sprechen: Das grundsätzliche Problem bleibt dasselbe. Fühlt sich der Mensch, der dort täglich arbeitet, gesehen und wertgeschätzt – oder wurde ihm lediglich irgendwo ein Tisch hingestellt?',
'Die Stückzahl verändert den Umfang der Aufgabe, aber nicht ihre Bedeutung. Wir bewerten einen Arbeitsplatz deshalb nicht nach Quadratmetern oder Auftragswert, sondern danach, was er für den Menschen leisten muss.',
],
'detail' => [
'title' => 'Wertschätzung ist keine Frage der Stückzahl',
'paragraphs' => [
'Gerade in Zeiten des Fachkräftemangels können Unternehmen es sich nicht leisten, Arbeitsbedingungen als Nebensache zu behandeln. Wer Top-Leistung erwartet, muss die passenden Voraussetzungen schaffen. Ein Obstkorb und kostenloses Wasser sind freundlich – ersetzen aber keinen guten Arbeitsplatz.',
'Besonders deutlich wird das bei Menschen, die Beruf und Familie miteinander organisieren. Teilzeit bedeutet nicht halbe Leistung. Oft wird in einem kürzeren Zeitfenster hoch konzentriert gearbeitet, während gleichzeitig Schule, Termine, Betreuung und der ganz normale Familienalltag organisiert werden müssen.',
'Gerade hier darf der Arbeitsplatz kein Provisorium aus Küchentisch, Beistelltisch oder Schlafzimmerecke sein. Er muss sofort funktionieren, Bewegung ermöglichen, technisch durchdacht sein und sich trotzdem vernünftig in das Zuhause einfügen. Für den Menschen, der ihn täglich nutzt, ist ein einzelner Arbeitsplatz keine kleine Lösung. Er ist seine vollständige Arbeitswelt.',
],
],
],
[
'title' => 'Ergonomie, die im Alltag funktioniert',
'paragraphs' => [
'Ein Arbeitsplatz muss sich dem Menschen anpassen – nicht umgekehrt. Höhenverstellbare Schreibtische und ergonomische Bürostühle schaffen Bewegung und unterstützen eine gesunde Haltung.',
'Ergonomie endet aber nicht bei Tisch und Stuhl. Auch Laufwege, Greifräume, Bildschirmposition, Beleuchtung, Stauraum und der Wechsel zwischen Tätigkeiten bestimmen, wie angenehm und produktiv ein Arbeitstag verläuft.',
],
'detail' => [
'title' => 'Und nein: Wir meinen nicht nur den Weg zur Kaffeemaschine',
'paragraphs' => [
'Sinnvolle Laufwege sind Teil einer guten Planung – auch wenn Kaffeemaschine und Kühlschrank zweifellos eine gewisse Bedeutung für den Arbeitsalltag haben.',
'Das gilt für vier Stunden ebenso wie für zehn Stunden am Schreibtisch, im Unternehmen genauso wie im privaten Zuhause. Und natürlich müssen die Möbel gestalterisch zum Raum passen. Wer denkt: „Das sieht doch keiner – außer den Mitarbeitern“, hat den ersten entscheidenden Fehler bereits gemacht. Die Mitarbeiter sehen es. Jeden Tag. Und sie arbeiten darin.',
],
],
'quote' => 'Ich bin, sagen wir, alt genug, um beim Aufstehen gelegentlich zu stöhnen. Und ehrlich gesagt fühle ich mich an manchen Tagen noch etwas älter. Mein Arzt sagt, ich solle weniger sitzen. Mein Beruf findet nun einmal zu einem großen Teil am Computer statt. Ergo brauche ich eine Lösung, die mir mehr Bewegung ermöglicht, ohne dass ich dafür meinen Beruf wechseln muss.',
'quote_author' => 'Marcel Scheibe, Geschäftsführer von B2in',
],
[
'title' => 'Office meets Interior',
'paragraphs' => [
'Identische Schreibtischreihen werden einem modernen Arbeitstag nicht mehr gerecht. Menschen benötigen Bereiche für Konzentration, spontane Gespräche, Teamarbeit, Videokonferenzen, vertrauliche Besprechungen und erholsame Pausen.',
'Febrü verbindet dafür klassische Arbeitsplatzmöbel mit wohnlichen Loungeelementen, mobilen Multifunktionslösungen, Stauraum, Akustik und flexibler Raumzonierung. So entsteht nicht für jede Person derselbe Platz, sondern für jede Tätigkeit der richtige.',
],
'detail' => [
'title' => 'Das Wir und das Ich',
'paragraphs' => [
'Ja, ja – das könnte man unter dem üblichen Verkaufs-Blabla abheften. Dahinter steht aber eine einfache Wahrheit: Verantwortung lässt sich nicht dauerhaft nach außen delegieren. Was bleibt, sind das Wir und das Ich.',
'Ein Unternehmen muss Bedingungen schaffen, unter denen Menschen gemeinsam gut arbeiten, sich austauschen und konzentrieren können. Der Einzelne erhält die Möglichkeit – und trägt auch die Verantwortung –, daraus das Beste zu machen.',
'Räume und Möbel schaffen allein noch keine gute Unternehmenskultur. Schlechte Arbeitsbedingungen können eine gute Kultur aber jeden Tag unnötig erschweren.',
],
],
'claim' => 'Das Unternehmen stärkt das Wir. Der Einzelne macht daraus das Beste.',
],
[
'title' => 'Ruhe und Flexibilität sind planbar',
'paragraphs' => [
'Akustik, mobile Elemente und flexible Raumlösungen helfen dabei, Bereiche für Kommunikation, Konzentration und Rückzug zu schaffen – und sie später an veränderte Teams und Arbeitsweisen anzupassen.',
],
'detail' => [
'title' => 'Akustik und flexible Raumstrukturen',
'paragraphs' => [
'Hallende Räume, Telefongespräche und Videokonferenzen können Konzentration und Sprachverständlichkeit erheblich beeinträchtigen. Akustikpaneele, Trennwände, Screens und entsprechend geplante Möbel helfen, Schall zu reduzieren.',
'Mit modularen Möbeln und Raum-in-Raum-Lösungen lassen sich Arbeitsbereiche neu ordnen, ohne bei jeder Veränderung gleich feste Wände bauen zu müssen. Das ist im großen Büro wichtig – und im privaten Zuhause häufig noch wichtiger.',
],
],
],
[
'title' => 'Gestaltung, die zur Identität passt',
'paragraphs' => [
'Ein Büro ist ein sichtbarer Teil der Unternehmenskultur. Farben, Materialien und Raumwirkung beeinflussen, wie Mitarbeitende, Kunden und Bewerber ein Unternehmen wahrnehmen. Im Homeoffice muss der Arbeitsplatz professionell funktionieren und sich zugleich in die vorhandene Einrichtung einfügen.',
'Dank der hohen Fertigungstiefe bietet Febrü eine große Auswahl an Materialien, Dekoren, Stoffen und Gestellfarben. Corporate Design muss nicht am Briefpapier enden. Und ein Homeoffice muss nach Feierabend nicht wie ein kleines Finanzamt aussehen.',
],
'detail' => [
'title' => 'Und ja: Pflanzen gehören dazu',
'paragraphs' => [
'Eine Pflanze macht aus einem Unternehmen noch kein nachhaltiges Unternehmen. Sie macht aus einem technisch eingerichteten Raum aber häufig erst einen Ort, an dem Menschen gerne arbeiten. Das hat nichts mit Greenwashing zu tun, sondern schlicht mit Atmosphäre und Stil.',
],
],
],
[
'title' => 'Nachhaltigkeit, die länger als bis zum nächsten Umzug hält',
'paragraphs' => [
'Nachhaltigkeit beginnt bei der Lebensdauer eines Möbelstücks. Viele Febrü-Möbel sind modular aufgebaut, sodass Komponenten ausgetauscht oder ergänzt werden können, statt bei veränderten Anforderungen das gesamte Möbelstück zu ersetzen.',
],
'detail' => [
'title' => 'Konkrete Standards statt allgemeiner Versprechen',
'paragraphs' => [
'Febrü produziert mit hoher Fertigungstiefe direkt in Herford. Das sorgt für kurze Wege und kontrollierte Prozesse. Mit laserTEC entstehen hochwertige, fugenlose Kanten ohne zusätzlichen Klebstoff.',
// ⚠️ Blauer Engel gilt laut Zulieferung nur für ausgewählte Tischsysteme und
// Stauraummöbel. Produktbezug beim Redigieren zwingend beibehalten.
'Febrü arbeitet nach ISO 9001 und ISO 14001 und verfügt über einen PEFC-Produktkettennachweis. Ausgewählte Febrü-Tischsysteme und Stauraummöbel tragen den Blauen Engel.',
'Nachhaltig ist nicht das Möbelstück mit dem längsten Werbetext. Nachhaltig ist das Möbelstück, das viele Jahre funktioniert und veränderte Anforderungen mitmacht.',
],
],
],
[
'title' => 'Warum Febrü bei uns?',
'paragraphs' => [
'Bei uns beginnt eine Planung nicht mit einem Möbelkatalog, sondern mit den Menschen und der Frage, wie tatsächlich gearbeitet wird. Wo ist Konzentration gefragt? Wo entstehen Gespräche? Welche Bereiche benötigen Diskretion? Und wie viel Stauraum wird wirklich gebraucht?',
'Wir verbinden Ergonomie, Laufwege, Akustik, Stauraum, Beleuchtung, Kommunikation und Rückzug zu einem Gesamtkonzept. Es geht nicht darum, möglichst viele Möbel in einen Raum zu stellen. Es geht darum, eine bessere Arbeitswelt zu schaffen.',
],
'detail' => [
'title' => 'Im Homeoffice stellen sich andere Fragen',
'paragraphs' => [
'Wie sichtbar darf der Arbeitsplatz sein? Wo verschwinden Technik und Unterlagen nach Feierabend? Und wie lässt sich professionelles Arbeiten so in die vorhandene Einrichtung integrieren, dass das Zuhause ein Zuhause bleibt?',
'Auch hier gilt: Ein einzelner Arbeitsplatz ist keine kleinere Aufgabe. Er braucht nur eine andere Antwort als ein vollständiger Bürotrakt.',
],
],
],
[
'title' => 'Erleben statt nur aus dem Katalog auswählen',
'paragraphs' => [
'Durch die Nähe zwischen Bielefeld und Herford können Febrü-Produkte nach Abstimmung persönlich erlebt werden. Wir organisieren und begleiten den Besuch der Ausstellung – für einen Schreibtisch genauso wie für einen vollständigen Unternehmensstandort.',
'Dort lassen sich Materialien anfassen, Stühle und Tische ausprobieren, Akustiklösungen erleben und unterschiedliche Arbeitswelten vergleichen. Sie müssen sich nicht allein auf ein Verkaufsgespräch verlassen.',
],
],
],
],
];
}
}

View file

@ -0,0 +1,61 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Englische Fassung der Markenfelder, die bisher nur deutsch existierten (09.10.2026): Merkmale,
* Leistungen, Abschnitte, Showroom und Bildunterschriften der vier Kernmarken. Die Texte liegen in
* `database/seeders/data/brand-translations-en.json`, nach Slug.
*
* Gesetzt wird nur, was in `field_translations.en` noch fehlt – eine im Admin gepflegte Übersetzung
* bleibt. Bildunterschriften je Bild, nur für Bilder, die in der Galerie stehen. Mehrfaches
* Ausführen ist unbedenklich.
*
* php artisan db:seed --class=BrandTranslationsSeeder
*/
class BrandTranslationsSeeder extends Seeder
{
public const LOCALE = 'en';
public function run(): void
{
/** @var array<string, array<string, mixed>> $data */
$data = json_decode((string) file_get_contents(__DIR__.'/data/brand-translations-en.json'), true, flags: JSON_THROW_ON_ERROR);
$updated = [];
foreach ($data as $slug => $fields) {
$brand = CmsBrand::query()->where('slug', $slug)->first();
if (! $brand) {
continue;
}
$existing = $brand->field_translations[self::LOCALE] ?? [];
foreach ($fields as $field => $value) {
if ($field === 'gallery_captions') {
$value = array_merge(
array_intersect_key((array) $value, $brand->localized('gallery_captions', CmsBrand::BASE_LOCALE)),
array_filter((array) ($existing['gallery_captions'] ?? [])),
);
} elseif (filled($existing[$field] ?? null)) {
continue;
}
$brand->setLocalized($field, self::LOCALE, $value);
}
if ($brand->isDirty('field_translations')) {
$brand->save();
$updated[] = $slug;
}
}
$this->command?->info($updated === []
? 'Marken-Übersetzungen: nichts zu tun.'
: 'Marken-Übersetzungen (en) ergänzt: '.implode(', ', $updated).'.');
}
}

View file

@ -8,19 +8,6 @@ use Illuminate\Support\Facades\Lang;
class CmsArticleSeeder extends Seeder
{
/**
* Numerische IDs aus resources/lang/{locale}/b2in.php → Slugs für cms_articles.slug
*
* @var array<int, string>
*/
private const ARTICLE_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',
];
public function run(): void
{
foreach ($this->getArticles() as $i => $data) {
@ -49,19 +36,28 @@ class CmsArticleSeeder extends Seeder
$articlesEn = [];
}
ksort($articlesDe);
// Kein ksort: die Reihenfolge in der Lang-Datei bestimmt die Reihenfolge im Magazin –
// im CMS ueber `order`, im Lang-Fallback ueber die Array-Reihenfolge. Beide Wege stimmen
// damit ueberein.
$out = [];
foreach ($articlesDe as $id => $rowDe) {
$id = (int) $id;
$slug = self::ARTICLE_ID_TO_SLUG[$id] ?? null;
if (! is_array($rowDe)) {
continue;
}
$slug = CmsArticle::slugForLangArticle($id, $rowDe);
if ($slug === null) {
continue;
}
$rowEn = $articlesEn[$id] ?? $rowDe;
if (! is_array($rowEn)) {
$rowEn = $rowDe;
}
$out[] = [
'slug' => $slug,
'title' => [
@ -74,6 +70,7 @@ class CmsArticleSeeder extends Seeder
],
'image' => (string) ($rowDe['image'] ?? ''),
'category' => (string) ($rowDe['category'] ?? ''),
'world' => (string) ($rowDe['world'] ?? CmsArticle::WORLD_INTERIOR),
'date_label' => (string) ($rowDe['date'] ?? ''),
'read_time' => (string) ($rowDe['readTime'] ?? ''),
'author' => is_array($rowDe['author'] ?? null) ? $rowDe['author'] : [],
@ -81,7 +78,7 @@ class CmsArticleSeeder extends Seeder
'de' => is_array($rowDe['content'] ?? null) ? $rowDe['content'] : [],
'en' => is_array($rowEn['content'] ?? null) ? $rowEn['content'] : [],
],
'is_published' => true,
'is_published' => ($rowDe['published'] ?? true) !== false,
];
}

View file

@ -0,0 +1,172 @@
<?php
namespace Database\Seeders;
use App\Models\CmsBrand;
use Illuminate\Database\Seeder;
/**
* Legt die Kernmarken an. Texte aus dev/2026-08-28/umsetzung-inhalte.md §5.
*
* Die vierte Marke ist Otterpohl aus Rheda-Wiedenbrück (Arbeitsplan 14.09.2026, Punkt 1.2;
* Zulieferung 17.09.2026). Ihre Texte liegen in {@see OtterpohlBrandSeeder}, der sie bei
* bestehenden Installationen auch nachträgt. Der Slug „tischlerei“ bleibt stabil.
*
* Bestehende Marken werden nicht überschrieben – Marcel pflegt sie im Admin. Für bereits
* angelegte Marken setzt {@see BrandLogoSeeder} die Logos nach und {@see BrandOriginSeeder}
* den Fertigungsort für die Herkunfts-Sektion (Nachtrag 11).
*
* Stimmungsbilder und Galerien fehlen als Zulieferung und bleiben leer; die Kacheln und
* Detailseiten zeigen so lange einen Platzhalter.
*/
class CmsBrandSeeder extends Seeder
{
public function run(): void
{
$created = 0;
foreach ($this->brands() as $index => $data) {
if (CmsBrand::query()->where('slug', $data['slug'])->exists()) {
continue;
}
CmsBrand::query()->create(array_merge($data, ['order' => $index + 1]));
$created++;
}
$this->command?->info("Kernmarken: {$created} angelegt.");
}
/**
* @return array<int, array<string, mixed>>
*/
private function brands(): array
{
return [
[
'slug' => 'cabinet',
'logo' => 'marken/cabinet.svg',
'name' => ['de' => 'CABINET', 'en' => 'CABINET'],
'tagline' => ['de' => 'Einbauschränke nach Maß.', 'en' => 'Fitted wardrobes made to measure.'],
'teaser' => [
'de' => 'Maßgefertigte Schranklösungen, geplant bis auf den Millimeter – gefertigt für Räume, in denen Standard nicht genügt.',
'en' => 'Bespoke wardrobe solutions, planned to the millimetre – built for rooms where standard will not do.',
],
'summary' => [
'de' => 'Wo Standardmaße an ihre Grenzen stoßen, fängt CABINET an. Schranklösungen, die exakt in Ihren Raum geplant werden – von der Dachschräge über die Nische bis zur raumhohen Front. Gefertigt in der Qualität, die man erst beim zweiten Hinschauen ganz erfasst.
Erleben Sie die Möglichkeiten in unserem Showroom in Bielefeld.',
'en' => 'Where standard dimensions reach their limits, CABINET begins. Wardrobe solutions planned precisely for your room – from sloped ceilings and alcoves to full-height fronts. Built to a quality you only fully grasp on second look.
Experience the possibilities in our Bielefeld showroom.',
],
'highlights' => [
'Millimetergenaue Planung',
'Individuelle Fronten und Innenausstattung',
'Persönliche Beratung vor Ort',
],
'description' => [
'de' => '<p>CABINET steht für Einbaulösungen, die sich dem Raum anpassen statt umgekehrt. Jedes Element wird für seinen Platz geplant – vom Dachschrägenschrank bis zur raumhohen Garderobe. Gefertigt in Deutschland, montiert von Fachleuten, die den Unterschied zwischen „passt" und „sitzt" kennen.</p>',
'en' => '<p>CABINET stands for built-in solutions that adapt to the room rather than the other way round. Every element is planned for its place – from sloped-ceiling wardrobes to full-height hallway storage. Made in Germany, installed by specialists who know the difference between fitting and belonging.</p>',
],
'services' => [
['title' => 'Aufmaß vor Ort', 'description' => 'Nach Angebotsannahme und Anzahlung erfolgt das professionelle Aufmaß bei Ihnen vor Ort. Es bildet die Grundlage für die finale Feinplanung und Fertigungsfreigabe.'],
['title' => 'Individuelle Planung', 'description' => 'Grundriss, Nutzung und Stil bestimmen die Lösung, nicht der Katalog.'],
['title' => 'Fertigung in Deutschland', 'description' => 'Materialien und Verarbeitung, die auf Dauer angelegt sind.'],
['title' => 'Montage durch Fachleute', 'description' => 'Wir liefern und montieren – bis zum letzten Detail.'],
],
'showroom' => 'Showroom Bielefeld',
'origin_city' => ['de' => 'Köln', 'en' => 'Cologne'],
'origin_distance' => ['de' => 'rund 200 km', 'en' => 'about 200 km'],
'origin_role' => [
'de' => 'Gefertigt in der Kölner Manufaktur, beraten und geplant im CABINET Store Bielefeld.',
'en' => 'Made in the Cologne manufactory, advised and planned at the CABINET Store Bielefeld.',
],
'is_published' => true,
],
[
'slug' => 'rotpunkt',
'logo' => 'marken/rotpunkt.svg',
'name' => ['de' => 'Rotpunkt Küchen', 'en' => 'Rotpunkt Küchen'],
'tagline' => ['de' => 'Küchen, die Haltung zeigen.', 'en' => 'Kitchens with character.'],
'teaser' => [
'de' => 'Premium-Küchen aus deutscher Fertigung – individuell geplant, nachhaltig gedacht und gebaut für den Mittelpunkt Ihres Zuhauses.',
'en' => 'Premium kitchens made in Germany – individually planned, sustainably conceived and built for the heart of your home.',
],
'summary' => [
'de' => 'Premium-Küchen aus deutscher Fertigung – individuell geplant und gebaut für den Raum, in dem am meisten passiert. Rotpunkt steht für durchdachte Funktion, langlebige Materialien und einen Anspruch an Nachhaltigkeit, der über das Marketing hinausgeht.
Beratung und Planung in unserem Küchenstudio.',
'en' => 'Premium kitchens made in Germany – individually planned and built for the room where most of life happens. Rotpunkt stands for considered function, durable materials and a commitment to sustainability that goes beyond marketing.
Consultation and planning in our kitchen studio.',
],
'highlights' => [
'Fertigung in Deutschland',
'Individuelle Planung nach Ihrem Grundriss',
'Nachhaltige Materialien',
],
'description' => [
'de' => '<p>Die Küche ist der Raum, in dem sich das Leben sammelt. Rotpunkt fertigt sie in Deutschland, mit Blick auf Materialien, die halten, und auf eine Planung, die zum Alltag passt – nicht zum Prospekt. Wir begleiten von der ersten Skizze bis zur fertigen Küche.</p>',
'en' => '<p>The kitchen is where life gathers. Rotpunkt builds them in Germany, with an eye on materials that last and on planning that fits everyday life – not a brochure. We accompany you from the first sketch to the finished kitchen.</p>',
],
'services' => [
['title' => 'Küchenplanung', 'description' => 'Gemeinsam entwickeln wir Grundriss, Stauraum und Arbeitswege.'],
['title' => 'Nachhaltige Materialien', 'description' => 'Deutsche Fertigung mit Blick auf Herkunft und Langlebigkeit.'],
['title' => 'Geräte und Technik', 'description' => 'Passende Ausstattung, abgestimmt auf Ihre Art zu kochen.'],
['title' => 'Lieferung und Aufbau', 'description' => 'Koordiniert aus einer Hand, inklusive Anschluss.'],
],
'showroom' => 'Küchenstudio',
'origin_city' => ['de' => 'Bünde', 'en' => 'Bünde'],
'origin_distance' => ['de' => 'rund 30 km', 'en' => 'about 30 km'],
'origin_role' => [
'de' => 'Küchen aus eigener Fertigung in Bünde – geplant bei uns im Küchenstudio.',
'en' => 'Kitchens from their own production in Bünde – planned with us in the kitchen studio.',
],
'is_published' => true,
],
[
'slug' => 'februe',
'logo' => 'marken/februe.png',
'name' => ['de' => 'Febrü', 'en' => 'Febrü'],
'tagline' => ['de' => 'Arbeiten, wo man gerne bleibt.', 'en' => 'Work where you like to stay.'],
'teaser' => [
'de' => 'Möbel für konzentriertes Arbeiten – vom durchdachten Homeoffice bis zur kompletten Büroeinrichtung.',
'en' => 'Furniture for focused work – from a well-considered home office to a complete office fit-out.',
],
'summary' => [
'de' => 'Ob Homeoffice oder ganze Büroetage: Febrü macht Möbel für konzentriertes Arbeiten. Höhenverstellbare Tische, durchdachte Stauraumlösungen und Sitzmöbel, die auch nach acht Stunden noch überzeugen – mit einem Design, das ins Zuhause genauso passt wie ins Unternehmen.',
'en' => 'Whether a home office or a whole office floor: Febrü makes furniture for focused work. Height-adjustable desks, well-considered storage and seating that still convinces after eight hours – with a design that suits the home as much as the company.',
],
'highlights' => [
'Ergonomie, die man merkt',
'Vom einzelnen Arbeitsplatz bis zur Bürofläche',
'Für Zuhause und fürs Unternehmen',
],
'description' => [
'de' => '<p>Gutes Arbeiten beginnt beim Möbel. Febrü baut Tische, Stühle und Stauraum, die den Rücken entlasten und den Raum ordnen – zuerst für das Zuhause, in dem immer öfter gearbeitet wird, und ebenso für Büros und Unternehmen.</p>',
'en' => '<p>Good work begins with the furniture. Febrü builds desks, chairs and storage that relieve the back and bring order to a room – first for the home, where more and more work happens, and equally for offices and companies.</p>',
],
'services' => [
['title' => 'Homeoffice', 'description' => 'Ein Arbeitsplatz, der sich ins Zuhause fügt, statt es zu besetzen.'],
['title' => 'Ergonomie', 'description' => 'Höhenverstellbare Tische und Sitzlösungen für langes Arbeiten.'],
['title' => 'Büroeinrichtung', 'description' => 'Vom einzelnen Arbeitsplatz bis zur kompletten Fläche.'],
['title' => 'Akustik und Ordnung', 'description' => 'Stauraum und Raumgliederung, die Konzentration ermöglichen.'],
],
'showroom' => '',
'origin_city' => ['de' => 'Herford', 'en' => 'Herford'],
'origin_distance' => ['de' => 'rund 20 km', 'en' => 'about 20 km'],
'origin_role' => [
'de' => 'Büro- und Objektmöbel aus eigener Fertigung in Herford.',
'en' => 'Office and contract furniture from their own production in Herford.',
],
'is_published' => true,
],
// Vierte Kernmarke Otterpohl (Nachtrag 15). Slug bleibt „tischlerei“, Texte aus OtterpohlBrandSeeder.
array_merge(OtterpohlBrandSeeder::data(), [
'slug' => OtterpohlBrandSeeder::SLUG,
'logo' => OtterpohlBrandSeeder::LOGO,
]),
];
}
}

View file

@ -3,33 +3,39 @@
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Lang;
class CmsContentSeeder extends Seeder
{
/**
* Sections to skip (not migrated, handled elsewhere or irrelevant).
* Überschreibt bestehende CMS-Einträge mit den Werten aus den Lang-Dateien.
*
* netzwerk_cabinet_partner / netzwerk_immobilien_hint: siehe NetzwerkPageCmsSeeder
* (nur einfügen wenn leer, damit manuelle CMS-Anpassungen nicht überschrieben werden).
* Standardmäßig aus, damit ein erneuter Seeder-Lauf die im Admin gepflegten Inhalte
* nicht zurücksetzt. Nur einschalten, wenn die Lang-Dateien bewusst als Quelle der
* Wahrheit gelten sollen – vorher ein DB-Backup ziehen:
*
* @var array<string>
* $seeder = new CmsContentSeeder;
* $seeder->overwriteExisting = true;
* $seeder->run();
*/
protected array $skipSections = [
'netzwerk_cabinet_partner',
'netzwerk_immobilien_hint',
];
public bool $overwriteExisting = false;
/**
* Nur diese Section-Keys verarbeiten (leer = alle).
*
* Erlaubt einen gezielten Lauf für einzelne Sektionen, ohne den Rest anzufassen.
*
* @var array<int, string>
*/
public array $onlySections = [];
public function run(): void
{
/** @var array<string, array{0: string, 1: string}> $sectionMap */
$sectionMap = config('cms_section_map.sections', []);
/** @var array<string, string> $langKeys */
$langKeys = config('cms_section_map.lang_keys', []);
/** @var array<string, mixed> $configTheme */
$configTheme = config('content.themes.b2in', []);
/** @var array<string, mixed> $langDe */
$langDe = Lang::get('b2in.themes.b2in', [], 'de');
/** @var array<string, mixed> $langEn */
@ -45,29 +51,23 @@ class CmsContentSeeder extends Seeder
$langEn = [];
}
/** @var array<string, mixed> $themeDe */
$themeDe = array_merge($configTheme, $langDe);
/** @var array<string, mixed> $themeEn */
$themeEn = array_merge($configTheme, $langEn);
$created = 0;
$updated = 0;
$skipped = 0;
foreach ($sectionMap as $sectionKey => $groupKeyPair) {
if (in_array($sectionKey, $this->skipSections, true)) {
if ($this->onlySections !== [] && ! in_array($sectionKey, $this->onlySections, true)) {
continue;
}
$langSectionKey = $langKeys[$sectionKey] ?? $sectionKey;
if (! array_key_exists($langSectionKey, $themeDe)) {
$this->command?->warn("Unmapped or missing theme data for section: {$sectionKey} (lang key: {$langSectionKey}) – skipping.");
if (! array_key_exists($sectionKey, $langDe)) {
$this->command?->warn("Unmapped or missing theme data for section: {$sectionKey} – skipping.");
continue;
}
$sectionDataDe = $themeDe[$langSectionKey];
$sectionDataEn = $themeEn[$langSectionKey] ?? $sectionDataDe;
$sectionDataDe = $langDe[$sectionKey];
$sectionDataEn = $langEn[$sectionKey] ?? $sectionDataDe;
[$group, $key] = $groupKeyPair;
$type = $this->detectType($sectionDataDe);
@ -83,6 +83,12 @@ class CmsContentSeeder extends Seeder
];
if ($content) {
if (! $this->overwriteExisting) {
$skipped++;
continue;
}
$content->update([
'type' => $type,
'value' => $value,
@ -100,7 +106,15 @@ class CmsContentSeeder extends Seeder
}
}
$this->command?->info("CmsContent: {$created} created, {$updated} updated.");
$this->command?->info("CmsContent: {$created} created, {$updated} updated, {$skipped} kept (existing CMS content).");
if ($skipped > 0 && ! $this->overwriteExisting) {
$this->command?->comment('Bestehende CMS-Inhalte wurden nicht angetastet. Zum Überschreiben $overwriteExisting = true setzen.');
}
if ($created > 0 || $updated > 0) {
app(CmsContentService::class)->clearCache();
}
}
private function detectType(mixed $data): string

View file

@ -5,15 +5,168 @@ namespace Database\Seeders;
use App\Models\CmsProject;
use Illuminate\Database\Seeder;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Lang;
class CmsProjectSeeder extends Seeder
{
private const PROJECT_SLUG = 'azizi-creek-views-4';
/**
* Seed the CMS projects table from resources/lang/{locale}/b2in.php
* (themes.b2in.immobilien_projects.projects.{slug}).
* Projektdaten Azizi Creek Views 4 – bis 06.10.2026 als Sektion themes.b2in.immobilien_projects
* in den Lang-Dateien, die keine Seite mehr anzeigt (Archiv: dev/2026-08-28/archiv-2026-10-06/).
*
* @var array<string, array<string, mixed>>
*/
private const SOURCE = [
'de' => [
'slug' => 'azizi-creek-views-4',
'title' => 'Azizi Developments: Creek Views 4',
'location' => 'Al Jaddaf, Dubai',
'status' => 'NEW LAUNCH',
'launch_date' => '03.03.2026',
'price_from' => '<span class="whitespace-nowrap">ab 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
'image' => 'expose/a1/image-4.jpeg',
'highlights' => [
'Prime Waterfront Views',
'1BR: <span class="whitespace-nowrap">ab 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
'Exklusives 3BR Penthouse (Single Inventory)',
'High Rental Demand & Capital Appreciation',
],
'quick_facts' => [
[
'icon' => 'home-modern',
'label' => 'Typen',
'value' => '1BR & 3BR Penthouse',
],
[
'icon' => 'squares-2x2',
'label' => 'Größe',
'value' => '543 – 2.346 sqft',
],
[
'icon' => 'building-office-2',
'label' => 'Einheiten',
'value' => 'Nur 132 (Limited)',
],
[
'icon' => 'user',
'label' => 'Entwickler',
'value' => 'Azizi Developments',
],
],
'investment_case' => [
'title' => 'Starkes Investment, hohe Nachfrage.',
'text' => 'Creek Views 4 bietet eine strategische Top-Lage in Al Jaddaf. Die Kombination aus limitiertem Angebot (nur 132 Einheiten) und Premium-Ausstattung macht dieses Projekt zur idealen Wahl für Investoren, die auf Wertsteigerung (Capital Appreciation) und hohe Mietnachfrage abzielen.',
'views' => [
'Road View',
'Sitting & Play Area View',
'Neighbour View',
],
],
'gallery' => [
'expose/a1/image-4.jpeg',
'expose/a1/image-3.jpeg',
'expose/a1/image-2.jpeg',
'expose/a1/image-5.jpeg',
'expose/a1/image-6.jpeg',
'expose/a1/image-7.jpeg',
],
'location_info' => [
'title' => 'Strategische Location: Al Jaddaf',
'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai',
'points' => [
'Direkte Anbindung an Dubai Creek und Waterfront',
'Wenige Minuten bis Downtown Dubai & Burj Khalifa',
'Exzellente Infrastruktur und wachsender Stadtteil',
],
],
'contact' => [
'title' => 'Sichern Sie sich eine der 132 Einheiten.',
'subtitle' => 'Ihr Ansprechpartner: Marcel Scheibe',
'options' => [
'' => 'Ich interessiere mich für...',
'1br' => '1 Bedroom Apartment',
'3br_penthouse' => '3BR Penthouse (Single Unit)',
'general' => 'Allgemeine Beratung',
],
],
],
'en' => [
'slug' => 'azizi-creek-views-4',
'title' => 'Azizi Developments: Creek Views 4',
'location' => 'Al Jaddaf, Dubai',
'status' => 'NEW LAUNCH',
'launch_date' => '03.03.2026',
'price_from' => '<span class="whitespace-nowrap">from 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
'image' => 'expose/a1/image-4.jpeg',
'highlights' => [
'Prime Waterfront Views',
'1BR: <span class="whitespace-nowrap">from 1.125.000 AED</span> <span class="whitespace-nowrap">(ca. 283.000 EUR</span> / <span class="whitespace-nowrap">306.000 USD)</span>',
'Exclusive 3BR Penthouse (Single Inventory)',
'High Rental Demand & Capital Appreciation',
],
'quick_facts' => [
[
'icon' => 'home-modern',
'label' => 'Types',
'value' => '1BR & 3BR Penthouse',
],
[
'icon' => 'squares-2x2',
'label' => 'Size',
'value' => '543 – 2,346 sqft',
],
[
'icon' => 'building-office-2',
'label' => 'Units',
'value' => 'Only 132 (Limited)',
],
[
'icon' => 'user',
'label' => 'Developer',
'value' => 'Azizi Developments',
],
],
'investment_case' => [
'title' => 'Strong Investment, High Demand.',
'text' => 'Creek Views 4 offers a strategic top location in Al Jaddaf. The combination of limited supply (only 132 units) and premium finishes makes this project the ideal choice for investors targeting capital appreciation and high rental demand.',
'views' => [
'Road View',
'Sitting & Play Area View',
'Neighbour View',
],
],
'gallery' => [
'expose/a1/image-4.jpeg',
'expose/a1/image-3.jpeg',
'expose/a1/image-2.jpeg',
'expose/a1/image-5.jpeg',
'expose/a1/image-6.jpeg',
'expose/a1/image-7.jpeg',
],
'location_info' => [
'title' => 'Strategic Location: Al Jaddaf',
'map_url' => 'https://maps.google.com/?q=Al+Jaddaf+Dubai',
'points' => [
'Direct connection to Dubai Creek and Waterfront',
'Just minutes to Downtown Dubai & Burj Khalifa',
'Excellent infrastructure and growing district',
],
],
'contact' => [
'title' => 'Secure one of the 132 units.',
'subtitle' => 'Your contact: Marcel Scheibe',
'options' => [
'' => 'I am interested in...',
'1br' => '1 Bedroom Apartment',
'3br_penthouse' => '3BR Penthouse (Single Unit)',
'general' => 'General Consultation',
],
],
],
];
/**
* Seed the CMS projects table from {@see self::SOURCE}.
*
* Zusätzliche Blöcke investor_trust / furniture_benefit sind in den Lang-Dateien
* nicht enthalten und werden hier mit DE/EN ergänzt.
@ -33,20 +186,8 @@ class CmsProjectSeeder extends Seeder
*/
private function getProjects(): array
{
$key = 'b2in.themes.b2in.immobilien_projects.projects.'.self::PROJECT_SLUG;
/** @var array<string, mixed> $de */
$de = Lang::get($key, [], 'de');
/** @var array<string, mixed> $en */
$en = Lang::get($key, [], 'en');
if ($de === [] || ! is_array($de)) {
return [];
}
if (! is_array($en)) {
$en = [];
}
$de = self::SOURCE['de'];
$en = self::SOURCE['en'];
$launchDate = isset($de['launch_date']) && is_string($de['launch_date'])
? Carbon::createFromFormat('d.m.Y', $de['launch_date'])

View file

@ -0,0 +1,72 @@
<?php
namespace Database\Seeders;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Seeder;
/**
* Macht jedes Feld, das die Website kennt, im CMS bearbeitbar (07.10.2026).
*
* Ein CMS-Eintrag zeigt im Editor nur die Felder, die in ihm stehen. Kam ein Feld später in die
* Lang-Datei (etwa „Zur Marke“ in „Aus der Region gefertigt“), fehlt es in älteren Einträgen und
* die Website nimmt still den Standardtext. Der Seeder ergänzt solche Felder der obersten Ebene
* aus der Lang-Datei – vorhandene Werte bleiben unangetastet, Felder aus
* {@see UnusedCmsContentCleanupSeeder::UNUSED_FIELDS} kommen nicht zurück.
* Mehrfaches Ausführen ist unbedenklich.
*
* php artisan db:seed --class=CmsSectionFieldsSeeder
*/
class CmsSectionFieldsSeeder extends Seeder
{
public function run(): void
{
/** @var array<string, array{0: string, 1: string}> $sections */
$sections = config('cms_section_map.sections', []);
$theme = config('app.theme', 'b2in');
$added = [];
foreach ($sections as $sectionKey => [$group, $key]) {
$row = CmsContent::query()->where('group', $group)->where('key', $key)->first();
if (! $row) {
continue;
}
$skip = UnusedCmsContentCleanupSeeder::UNUSED_FIELDS["{$group}.{$key}"] ?? [];
$value = $row->getTranslations('value');
$fields = [];
foreach ($value as $locale => $content) {
$defaults = trans("b2in.themes.{$theme}.{$sectionKey}", [], $locale);
if (! is_array($content) || ! is_array($defaults)) {
continue;
}
foreach ($defaults as $field => $default) {
if (! array_key_exists($field, $content) && ! in_array($field, $skip, true)) {
$content[$field] = $default;
$fields[$field] = true;
}
}
$value[$locale] = $content;
}
if ($fields === []) {
continue;
}
$row->value = $value;
$row->save();
app(CmsContentService::class)->clearCache($group);
$added[] = "{$group}.{$key} (".implode(', ', array_keys($fields)).')';
}
$this->command?->info($added === []
? 'CMS-Felder: alle Einträge vollständig.'
: 'CMS-Felder ergänzt: '.implode('; ', $added).'.');
}
}

View file

@ -0,0 +1,125 @@
<?php
namespace Database\Seeders\Concerns;
use App\Models\CmsArticle;
use App\Models\CmsBrand;
use FluxCms\Core\Models\CmsContent;
use FluxCms\Core\Services\CmsContentService;
use Illuminate\Database\Eloquent\Model;
/**
* Textersetzungen über alle gepflegten Inhalte: CMS-Sektionen (alle Sprachen), Marken und
* Magazinbeiträge. Ersetzt wird per strtr() – idempotent, solange der neue Text den alten nicht
* enthält. `$exact` ersetzt nur Werte, die vollständig übereinstimmen.
*/
trait ReplacesCmsTexts
{
/** @var list<string> */
private array $brandFields = [
'name', 'tagline', 'teaser', 'summary', 'description', 'widget_label', 'cta_title', 'cta_text',
'origin_city', 'origin_distance', 'origin_role', 'highlights', 'services', 'sections', 'gallery',
];
/** @var list<string> */
private array $articleFields = ['title', 'subtitle', 'content'];
/**
* @param array<string, string> $replacements
* @param array<string, string> $exact
* @return int Anzahl geänderter Textstellen
*/
protected function replaceCmsTexts(array $replacements, array $exact = []): int
{
$count = 0;
$groups = [];
foreach (CmsContent::query()->get() as $row) {
$changes = 0;
$row->value = $this->replaceDeep($row->getTranslations('value'), $replacements, $exact, $changes);
if ($changes > 0) {
$row->save();
$groups[$row->group] = true;
$count += $changes;
}
}
foreach (array_keys($groups) as $group) {
app(CmsContentService::class)->clearCache($group);
}
foreach (CmsBrand::query()->get() as $brand) {
$count += $this->replaceInModel($brand, $this->brandFields, $replacements, $exact);
}
foreach (CmsArticle::query()->get() as $article) {
$count += $this->replaceInModel($article, $this->articleFields, $replacements, $exact);
}
return $count;
}
/**
* @param list<string> $fields
* @param array<string, string> $replacements
* @param array<string, string> $exact
*/
private function replaceInModel(Model $model, array $fields, array $replacements, array $exact): int
{
$changes = 0;
$translatable = property_exists($model, 'translatable') ? $model->translatable : [];
foreach ($fields as $field) {
$isTranslatable = in_array($field, $translatable, true);
$value = $isTranslatable ? $model->getTranslations($field) : $model->getAttribute($field);
if (! is_array($value)) {
continue;
}
$before = $changes;
$value = $this->replaceDeep($value, $replacements, $exact, $changes);
if ($changes > $before) {
$isTranslatable ? $model->setTranslations($field, $value) : $model->setAttribute($field, $value);
}
}
if ($changes > 0) {
$model->save();
}
return $changes;
}
/**
* @param array<mixed> $data
* @param array<string, string> $replacements
* @param array<string, string> $exact
* @return array<mixed>
*/
private function replaceDeep(array $data, array $replacements, array $exact, int &$count): array
{
foreach ($data as $key => $item) {
if (is_array($item)) {
$data[$key] = $this->replaceDeep($item, $replacements, $exact, $count);
continue;
}
if (! is_string($item)) {
continue;
}
$replaced = $exact[$item] ?? strtr($item, $replacements);
if ($replaced !== $item) {
$data[$key] = $replaced;
$count++;
}
}
return $data;
}
}

Some files were not shown because too many files have changed in this diff Show more