Compare commits

...
6 Commits
Author SHA1 Message Date
kantarciandClaude Opus 5.5 32cb77b72e Serve Lora and Manrope for the EBI Immobilien draft
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:01:39 +02:00
kantarciandClaude Opus 5.5 233bab1e43 Add the EBI Immobilien redesign draft
Rebuild of ebi-immobilien.de (Karlsruhe) in its own installation on
ports 8106/8107: home page, search request and valuation as step forms,
contact, legal pages and redirects for the old WordPress addresses.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 10:01:18 +02:00
kantarciandClaude Opus 5.5 7fb4d2bfbd Note the 1.35.1 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:50:01 +02:00
kantarciandClaude Opus 5.5 ef7cb02cbd Release 1.35.1
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:48:43 +02:00
kantarciandClaude Opus 5.5 7eaa3f6efa Tidy the mail and system screens and let the mail log be cleared
Text in admin cards had no size of its own, so 16px browser default sat
next to 13px notes; cards now use 13px throughout while fields stay at
16px for iOS. The mail page gets option cards, a 2x2 SMTP grid, a styled
save button in the card foot and a log with status pills and collapsed
server messages, plus "Protokoll leeren". The package card uses the same
sections and a styled file picker. The E-Mail-Versand subitem only shows
inside System and sits beside it in the horizontal strip.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 07:48:43 +02:00
kantarciandClaude Opus 5.5 daaeb52588 Note the 1.35.0 release in the status
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 21:08:27 +02:00
31 changed files with 1597 additions and 117 deletions

No files matched your search

+21 -1
View File
@@ -208,6 +208,26 @@
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8112'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8112 -t public bin/dev-router.php"
],
"port": 8112
},
{
"name": "ebi-immobilien",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8106'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8106 -t public bin/dev-router.php"
],
"port": 8106
},
{
"name": "ebi-immobilien-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebi-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebi-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8107'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8107 -t public bin/dev-router.php"
],
"port": 8107
}
]
}
}
+4
View File
@@ -401,6 +401,10 @@ Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-B
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server und schreibt zuletzt das Inhaltspaket `var/huefner-paket.zip` für die Live-Seite (dort unter System importieren, Konten bleiben). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
### Redesign-Entwurf: EBI Immobilien (Karlsruhe)
Der Nachbau von www.ebi-immobilien.de liegt in `var/ebi-immobilien.db` mit Medien in `var/ebi-immobilien-media`; die Startkonfiguration `ebi-immobilien` nutzt Port 8106, `ebi-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8107 zum Messen. Die Originalbilder der alten Website liegen in `var/ebi-immobilien-quelle`; eine Datei `startbild.jpg/.png/.webp` dort ersetzt das Startbild. `sh scripts/reset-ebi-immobilien.sh [Quellordner]` bereitet die Bilder vor (`scripts/build-ebi-immobilien-assets.php`, ohne Argument nimmt es die schon vorbereiteten aus `var/ebi-immobilien-assets`), wirft die Installation weg, baut mit `scripts/seed-ebi-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Suchauftrag, Immobilienbewertung, Kontakt, Impressum, Datenschutz, 404, Weiterleitungen der alten Adressen, Menü und die Formulare `suchauftrag`, `bewertung`, `kontakt` auf, holt Manrope und Lora auf den eigenen Server und schreibt das Inhaltspaket `var/ebi-immobilien-paket.zip`. Das Passwort für „umut“ steht nach jedem Reset in `var/ebi-immobilien-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8107 CMS_PATHS=/,/suchauftrag,/immobilienbewertung,/kontakt CMS_AUDIT_DIR=reports/lighthouse-ebi-immobilien node scripts/audit.mjs`. Die bewussten Abweichungen vom Original stehen im Kopf des Seed-Skripts.
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
+1 -1
View File
@@ -1 +1 @@
1.35.0
1.35.1
+28 -2
View File
@@ -1,6 +1,30 @@
# Projektstatus
## E-Mail-Versand als eigene Seite, HTTPS-Probe bei IONOS (12.10.2026)
## Redesign-Entwurf EBI Immobilien, Karlsruhe (13.10.2026)
**Anlass:** Umut: „einfacher Rebuild und Redesign von ebi-immobilien.de, soll vollendet aussehen, aber nicht übertrieben clean“.
**Erledigt:** Neue Installation `var/ebi-immobilien.db` (Port 8106, Messserver 8107), Aufbau nur per `sh scripts/reset-ebi-immobilien.sh` (Bilder: `build-ebi-immobilien-assets.php`, Inhalte: `seed-ebi-immobilien.php`). Logo, Fotos, Kontaktdaten und Formularfelder von der alten WordPress-Seite; Gestaltung neu: Navy und Logo-Blau, Sand als warme Fläche, Lora/Manrope vom eigenen Server. Startseite (Hero mit Umuts KI-Startbild, Leistungskarten, Suchauftrag, Bewertung, Kontaktband), Formularseiten Suchauftrag (2 Schritte), Immobilienbewertung (3 Schritte, bei Grundstück ohne Zimmer/Baujahr/Küche) und Kontakt, Impressum, Datenschutz, 404, 301 von `/contact-us`, `/immobilien`, `/immobilienbewertungen`, `/property-search`. „Jetzt anrufen“ als Menü-Button, auf dem Telefon im Klappmenü. Kein Kerncode geändert.
**Geprüft:** Im Browser bei 1440 und 375 px (Startseite, Formularseiten, Klappmenü, bedingte Felder bei „Grundstück“, kein waagrechtes Scrollen). `test:browser` gegen 8106 grün (Barrierefreiheit, Tastatur, ohne JS, Budget: HTML 59 KB). Lighthouse (`audit.mjs`, Median aus 3 Läufen) auf 8107 für `/`, `/suchauftrag`, `/immobilienbewertung`, `/kontakt`: überall 100 in Barrierefreiheit, Best Practices, SEO und Agentic Browsing; Leistung 100, nur `/immobilienbewertung` mobil 99. Ein Formular wurde nicht abgeschickt, der Mailversand ist nicht geprüft. PHPUnit lief nicht (kein Kerncode geändert).
**Bekannte Lücken:** Eine Objektliste gibt es nicht (das Original zeigte „0 Results“). Im Impressum fehlen Inhaber, Rechtsform und Angaben nach § 34c GewO – die alte Seite nennt sie nicht. Das Startbild hat nur 1672 px Breite.
**Nächster Schritt:** Impressumsangaben von Umut, dann Hoster/Livegang klären (Inhaltspaket `var/ebi-immobilien-paket.zip` liegt bereit).
## Fassung 1.35.1: E-Mail-Versand und Systemseite aufgeräumt, Protokoll leeren (13.10.2026)
**Anlass:** Umut: E-Mail-Seite und „Inhalte übertragen“ sehen uneinheitlich aus, Unterpunkt „E-Mail-Versand“ steht immer offen, „Logs leeren“ fehlt.
**Erledigt:** Fließtext in Karten hat jetzt durchgehend 13 px (vorher Browser-16 px neben 13 px), Felder bleiben 16 px. Neue gemeinsame Bausteine in `admin.css`: `.card-section` (Abschnitt mit Trennlinie), `.button-row`, `.form-foot`, `.option`/`.option-list` (Auswahl als Karten), `.pill-error`, Dateiauswahl im Knopfstil. E-Mail-Seite: Wege als Karten mit Erklärung, SMTP 2×2, Testmail-Abschnitt, „Speichern“ als Hauptknopf im Kartenfuß (war ungestylt). Protokoll mit Zustandsplakette, Hinweis vorn, Servermeldung zugeklappt (`admin/_mail_entry.html.twig`). „Protokoll leeren“ (`POST /admin/system/e-mail/protokoll`, löscht `mail.log` und `mail.log.1`). Unterpunkt nur im Bereich System sichtbar; in der waagrechten Leiste (761–1000 px) neben „System“.
**Geprüft:** `SystemScreenTest` erweitert (Leeren nur mit Token, danach leer, Editor 403, Unterpunkt auf Einstellungen nicht sichtbar). PHPUnit vollständig 682 grün, php-cs-fixer sauber, `lint:twig`, `test:admin`, `test:editor` grün. Im Browser (Hüfner lokal) bei 1240, 900 und 375 px: E-Mail-Seite, Systemseite, Einstellungen; kein waagrechtes Scrollen am Handy.
**Veröffentlicht:** Tag `v1.35.1`, Release-Eintrag mit Archiv (3635 Dateien, 5,3 MB, gebaut aus `var/release-source-1.35.1`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Hüfner auf 1.35.1 aktualisieren (die offenen Punkte von 1.35.0 gelten weiter).
## Fassung 1.35.0: E-Mail-Versand als eigene Seite, HTTPS-Probe bei IONOS (12.10.2026)
**Anlass:** huefner-gartenbau.de: mit `native://default` geht der Versand, Umut will die Einstellung samt SMTP in der Verwaltung, als Unterpunkt statt auf „System“. „HTTPS erzwingen“ meldete 403, obwohl HTTPS lief.
@@ -8,7 +32,9 @@
**Geprüft:** `MailCheckTest` (DSN aus Feldern samt Sonderzeichen, Vorbelegung ohne Passwort, Wege ausprobieren über die Fabrik, Hinweis bei fremdem Absender), `SystemScreenTest` (Menüpunkt, Seite, Testen, fehlendes Passwort, Speichern nur aus `.env`, nur Administratoren), `HttpsRedirectTest`. PHPUnit vollständig 682 grün (die bekannte Warnung aus `UpdaterTest`), php-cs-fixer sauber; `var/log/mail.log` entsteht durch Tests nicht mehr. Im Browser auf der Wegwerfkopie: „Alle Wege testen“ zeigt je Weg Ergebnis und Hinweis, SMTP-Felder verschwinden bei anderen Wegen, Handybreite ohne waagrechtes Scrollen, Unterpunkt im Menü markiert. Live auf IONOS ist die neue Probe noch nicht geprüft; dass `/.well-known/` dort 403 liefert und `/index.php/...` durchkommt, ist nachgemessen.
**Offen:** Nicht committet, kein Release.
**Veröffentlicht:** Tag `v1.35.0`, Release-Eintrag mit Archiv (3634 Dateien, 5,3 MB, gebaut aus `var/release-source-1.35.0`), `.sha256` und `install.php`; Prüfsumme nach dem Herunterladen bestätigt. Keine Migration.
**Offen:** Hüfner auf 1.35.0 aktualisieren, „HTTPS erzwingen“ erneut drücken, SMTP über das zentrale kant-it.de-Postfach einrichten und testen.
## Fassung 1.34.0: Testmail und Versandprotokoll in der Verwaltung (12.10.2026)
+69 -14
View File
@@ -293,7 +293,24 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
}
.card-head h2, .card-head h3 { margin: 0 }
.card-head .muted { font-size: 12px }
.card-body { padding: 8px 22px 24px; min-width: 0 }
.card-body { padding: 8px 22px 24px; min-width: 0; font-size: 13px; line-height: 1.6 }
/* Fließtext in Karten ist 13 px wie Hinweise und Fakten. Felder bleiben bei 16 px - darunter
vergrößert iOS beim Antippen die ganze Seite. :where() hält die Regel schwächer als jede Klasse. */
:where(.card-body) :where(input:not([type=checkbox], [type=radio]), select, textarea) { font-size: 16px }
.card-body h3 { font-size: 14px; margin: 26px 0 4px }
.card-body h3:first-child { margin-top: 14px }
/* Abschnitte einer Karte: Trennlinie, Überschrift, gleicher Abstand - als fieldset oder form. */
.card-section { border: 0; border-top: 1px solid var(--line); margin: 22px 0 0; padding: 18px 0 0; min-width: 0 }
.card-section > legend { float: left; width: 100%; font-weight: 650; font-size: 14px; margin: 0 0 12px; padding: 0 }
.card-section > legend + * { clear: both }
.card-section > h3, .card-body .card-section > h3:first-child { font-size: 14px; font-weight: 650; margin: 0 0 12px }
.card-section .check-label { margin: 14px 0 0 }
.card-section > h3 + .check-label, .card-section > h3 + input + .check-label { margin-top: 0 }
.card-section label.field-first { margin-top: 0 }
/* Dateiauswahl im Stil der übrigen Knöpfe statt des grauen Browserknopfs. */
input[type=file] { padding: 6px; cursor: pointer; max-width: 720px }
input[type=file]::file-selector-button { margin-right: 12px; padding: 7px 14px; border: 1px solid var(--line); border-radius: 6px; background: #f3f5f1; color: var(--ink); font: inherit; font-size: 13px; font-weight: 600; cursor: pointer }
input[type=file]:hover::file-selector-button { background: #e8ede6 }
.card-body code { overflow-wrap: anywhere }
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
.hint { margin: 12px 0 0 }
@@ -306,6 +323,22 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
.card-body > p:first-child { margin-top: 14px }
.card-body .primary, .card-body .secondary { margin-top: 22px }
.card-foot { padding: 16px 22px; border-top: 1px solid var(--line); background: #fafbf8; border-radius: 0 0 12px 12px }
/* Der Fuss eines Formulars in einer Karte: Hauptknopf links, Erklärung daneben. */
.form-foot { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; font-size: 12px }
.form-foot .primary { margin: 0 }
/* Knöpfe nebeneinander, gleicher Abstand, ohne den Abstand nach oben der Einzelknöpfe. */
.button-row { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px }
.card-body .button-row .primary, .card-body .button-row .secondary { margin: 0 }
/* Auswahl als Karten: ganze Fläche klickbar, Titel und eine Zeile Erklärung. */
.option-list { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 8px; max-width: 720px }
.option { display: flex; align-items: flex-start; gap: 10px; margin: 0; padding: 12px 14px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); font-weight: 400; cursor: pointer }
.option:hover { border-color: #a9b8af }
.option:has(input:checked) { border-color: var(--accent); background: #f4f8f1; box-shadow: inset 0 0 0 1px var(--accent) }
.option input { width: 16px; min-height: 16px; margin: 2px 0 0; flex: none; accent-color: var(--accent) }
.option > span { display: grid; gap: 2px; min-width: 0 }
.option strong { font-size: 13px; font-weight: 600 }
.option > span > span { font-size: 12px; color: var(--muted); line-height: 1.45 }
.check-label input, .option input { accent-color: var(--accent) }
/* A card that opens: used for everything secondary, so a screen starts calm. */
details.card > summary {
@@ -376,6 +409,7 @@ details.card > summary:hover { background: #fafbf8 }
.pill-live { background: #e7f0d6; color: #2c5340 }
.pill-draft { background: #fff2d9; color: #6f4e18 }
.pill-off { background: #f1f2f0; color: #5a635e }
.pill-error { background: #fff0eb; color: var(--danger) }
/* The per-row settings. One disclosure that opens in place, instead of six competing popovers
that clip each other at the edge of the card. */
@@ -593,6 +627,10 @@ details.card > summary:hover { background: #fafbf8 }
.nav-label { display: none }
.nav-list { display: flex; gap: 4px }
.nav-item { white-space: nowrap; padding: 8px 12px }
/* In der Leiste stehen Unterpunkte direkt neben ihrem Hauptpunkt, nicht in einer eigenen Zeile. */
.nav-list > li:has(.nav-sub) { display: flex; gap: 4px }
.nav-sub { display: flex; margin: 0 }
.nav-sub .nav-item { padding: 8px 12px }
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
.shell-account { padding: 3px; gap: 2px }
.shell-user { padding: 3px }
@@ -718,23 +756,37 @@ details.card > summary:hover { background: #fafbf8 }
.check-bad { color: var(--danger) }
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
.log-list code { font-size: 11px; overflow-wrap: anywhere }
.mail-result { margin-top: 14px }
#mail .facts { margin-top: 0 }
.mail-result { margin-top: 16px }
.mail-result p { margin: 0 0 6px }
.mail-result p:last-child { margin-bottom: 0 }
.mail-result pre { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 11px; margin: 6px 0 0 }
.mail-result-head { margin: 0 0 10px }
/* Unterpunkte im Menü: eingerückt auf die Höhe des Textes, ohne Symbol. */
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0 }
.nav-sub .nav-item { padding-left: 42px; font-size: 13px }
.mail-form { margin-top: 18px; max-width: 640px }
.mail-form fieldset { border: 0; margin: 0 0 16px; padding: 0; min-width: 0 }
.mail-form legend { font-weight: 600; font-size: 13px; margin-bottom: 6px; padding: 0 }
.mail-form .check-label { display: flex; gap: 8px; align-items: baseline; margin: 4px 0 }
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 8px 12px }
.mail-grid label { margin-top: 0 }
.mail-grid input { width: 100%; box-sizing: border-box }
.mail-actions { display: flex; flex-wrap: wrap; gap: 8px }
.mail-actions button { margin: 0 }
.nav-sub { list-style: none; margin: 2px 0 4px; padding: 0; display: grid; gap: 2px }
.nav-sub .nav-item { padding: 7px 10px 7px 39px; font-size: 12.5px; color: var(--shell-muted) }
.nav-sub .nav-item:hover { color: var(--shell-ink) }
.nav-sub .nav-item[aria-current=page] { color: #fff; background: #21362d }
/* E-Mail-Versand: der eingestellte Weg als Zeile, darunter Abschnitte mit Trennlinie. */
.mail-current { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 8px; margin-top: 14px; padding: 12px 14px; border-radius: 10px; background: #f6f8f4 }
.mail-current-source { flex-basis: 100%; font-size: 12px }
.mail-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 0 14px; max-width: 720px }
.mail-grid label { margin: 0 0 7px }
.mail-grid > div { margin-bottom: 4px }
/* Protokoll: Zustand links, daneben was, wann und - bei Fehlern - warum. */
.mail-log { list-style: none; margin: 14px 0 0; padding: 0 }
.mail-entry { display: grid; grid-template-columns: 112px minmax(0, 1fr); gap: 4px 14px; align-items: start; padding: 12px 0; border-top: 1px solid var(--line) }
.mail-entry:first-child { border-top: 0; padding-top: 4px }
.mail-entry .pill { justify-self: start; margin-top: 1px }
.mail-entry-text p { margin: 0 }
.mail-entry-line { overflow-wrap: anywhere }
.mail-entry-time { font-size: 12px; color: var(--muted) }
.mail-entry-hint { margin-top: 6px !important; color: var(--ink) }
.mail-entry-detail { margin-top: 6px }
.mail-entry-detail summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; width: fit-content }
.mail-entry-detail pre { margin: 6px 0 0; padding: 10px 12px; border-radius: 8px; background: #f6f8f4; font-size: 11px; line-height: 1.55; white-space: pre-wrap; overflow-wrap: anywhere }
@media (max-width: 560px) {
.mail-entry { grid-template-columns: minmax(0, 1fr) }
}
/* Ohne SMTP brauchen die Felder niemand; wo :has() fehlt, bleiben sie einfach sichtbar. */
.mail-form:has(input[name=weg]:checked:not([value=smtp])) .smtp-fields { display: none }
@@ -848,6 +900,9 @@ details.card > summary:hover { background: #fafbf8 }
.nav-list { display: grid; gap: 2px }
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
.nav-list > li:has(.nav-sub) { display: block }
.nav-sub { display: grid; margin: 2px 0 4px }
.nav-sub .nav-item { padding: 10px 14px 10px 44px }
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
.shell-version { display: none }
}
+5
View File
@@ -32,6 +32,11 @@ DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…`
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
**Lora** und **Manrope** (Überschriften und Fließtext des Entwurfs „EBI Immobilien“) stehen unter
der SIL Open Font License 1.1, Text in `lora-OFL.txt` und `manrope-OFL.txt`. Copyright 2011 The
Lora Project Authors (github.com/cyrealtype/Lora-Cyrillic), Reserved Font Name „Lora“; Copyright
2018 The Manrope Project Authors (github.com/googlefonts/manrope).
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
+8
View File
@@ -38,3 +38,11 @@
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
/* latin-ext */
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
/* latin */
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2018 The Manrope Project Authors (https://github.com/googlefonts/manrope)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+94
View File
@@ -0,0 +1,94 @@
<?php
/**
* Bereitet die Bilder von ebi-immobilien.de für die Mediathek des Nachbaus vor.
*
* Quelle sind die Originaldateien der laufenden Website (wp-content/uploads, am 13.10.2026
* abgerufen). Daraus entstehen:
* - logo.png: das Logo „EBI Immobilien“ ohne Rand, 680 px breit (Original 1361 × 407),
* - favicon.png: das Seitensymbol der Website (192 × 192),
* - startbild.webp: „startbild.*“ aus dem Quellordner, sonst das Wohnquartier aus dem Original-Hero,
* - bewertung.webp: Ordner, Rechner und Stift (Original „workbook“), auf 1600 px verkleinert,
* - schreibtisch.webp: Schreibtisch mit Brille und Notizblock (Original „services-h-fig“).
*
* Aufruf: php scripts/build-ebi-immobilien-assets.php <Ordner mit den Originalen> <Zielordner>
*/
$quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\');
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/EBI.png')) {
fwrite(STDERR, "Aufruf: php scripts/build-ebi-immobilien-assets.php <Ordner mit EBI.png usw.> <Zielordner>\n");
exit(1);
}
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
exit(1);
}
/** Ein Ausschnitt, auf eine Zielbreite verkleinert, als WebP. */
$webp = static function (string $datei, string $name, int $x, int $y, int $b, int $h, int $breite) use ($quelle, $ziel): void {
$pfad = $quelle.'/'.$datei;
$bild = str_ends_with($datei, '.png') ? imagecreatefrompng($pfad) : imagecreatefromjpeg($pfad);
if (false === $bild) {
fwrite(STDERR, "Bild lässt sich nicht lesen: $pfad\n");
exit(1);
}
$hoehe = (int) round($h * $breite / $b);
$neu = imagecreatetruecolor($breite, $hoehe);
imagecopyresampled($neu, $bild, 0, 0, $x, $y, $breite, $hoehe, $b, $h);
imagewebp($neu, $ziel.'/'.$name, 78);
echo "$name: {$breite} × {$hoehe}\n";
};
// Startbild: liegt im Quellordner eine Datei „startbild.jpg/.png/.webp“ (z. B. ein neu erzeugtes
// Bild), wird sie auf 1920 px Breite und höchstens 16:9 gebracht. Sonst das Original-Hero, das oben
// einen 116 px hohen grauen Balken trägt (Zuschnitt des Themes).
$eigenes = glob($quelle.'/startbild.{jpg,jpeg,png,webp}', GLOB_BRACE) ?: [];
if ([] !== $eigenes) {
$pfad = $eigenes[0];
$bild = match (strtolower(pathinfo($pfad, PATHINFO_EXTENSION))) {
'png' => imagecreatefrompng($pfad),
'webp' => imagecreatefromwebp($pfad),
default => imagecreatefromjpeg($pfad),
};
$b = imagesx($bild);
$h = min(imagesy($bild), (int) round($b * 9 / 16));
$y = (int) round((imagesy($bild) - $h) / 2);
$breite = min(1920, $b);
$hoehe = (int) round($h * $breite / $b);
$neu = imagecreatetruecolor($breite, $hoehe);
imagecopyresampled($neu, $bild, 0, 0, 0, $y, $breite, $hoehe, $b, $h);
imagewebp($neu, $ziel.'/startbild.webp', 78);
echo "startbild.webp aus ".basename($pfad).": {$breite} × {$hoehe}\n";
} else {
$webp('karlsruhe-3518473_1920-e1600241476184.png', 'startbild.webp', 0, 118, 1920, 722, 1920);
}
$webp('workbook-1205044_1920-1920x1280.jpg', 'bewertung.webp', 0, 0, 1920, 1280, 1600);
$webp('services-h-fig-1920x1280.jpg', 'schreibtisch.webp', 0, 0, 1920, 1280, 1600);
// Logo: durchsichtigen Rand abschneiden und verkleinern.
$logo = imagecreatefrompng($quelle.'/EBI.png');
[$links, $oben, $rechts, $unten] = [imagesx($logo), imagesy($logo), 0, 0];
for ($y = 0; $y < imagesy($logo); ++$y) {
for ($x = 0; $x < imagesx($logo); ++$x) {
if ((imagecolorat($logo, $x, $y) >> 24 & 0x7F) < 120) {
$links = min($links, $x);
$rechts = max($rechts, $x);
$oben = min($oben, $y);
$unten = max($unten, $y);
}
}
}
$b = $rechts - $links + 1;
$h = $unten - $oben + 1;
$breite = 680;
$hoehe = (int) round($h * $breite / $b);
$neu = imagecreatetruecolor($breite, $hoehe);
imagealphablending($neu, false);
imagesavealpha($neu, true);
imagefill($neu, 0, 0, imagecolorallocatealpha($neu, 0, 0, 0, 127));
imagecopyresampled($neu, $logo, 0, 0, $links, $oben, $breite, $hoehe, $b, $h);
imagepng($neu, $ziel.'/logo.png', 9);
echo "logo.png: {$breite} × {$hoehe}\n";
copy($quelle.'/cropped-favicon-192x192.png', $ziel.'/favicon.png');
echo "favicon.png übernommen\n";
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Wirft die EBI-Immobilien-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
# Erstes Argument (optional): Ordner mit den Originalbildern von ebi-immobilien.de
# (EBI.png, cropped-favicon-192x192.png, Hero- und Fotodateien aus wp-content/uploads).
# Liegt dort eine startbild.jpg/.png/.webp, ersetzt sie das Startbild; Alternativtext per EBI_STARTBILD_ALT.
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/ebi-immobilien.db"
export MEDIA_DIRECTORY="$root/var/ebi-immobilien-media"
assets="$root/var/ebi-immobilien-assets"
if [ -n "$1" ]; then
"$php" scripts/build-ebi-immobilien-assets.php "$1" "$assets"
fi
if [ ! -f "$assets/startbild.webp" ] || [ ! -f "$assets/logo.png" ]; then
echo "Bilder fehlen: erst mit dem Ordner der Originalbilder als Argument aufrufen." >&2
exit 1
fi
rm -f "$root/var/ebi-immobilien.db"
rm -rf "$root/var/ebi-immobilien-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
"$php" -d memory_limit=512M scripts/seed-ebi-immobilien.php "$assets"
# Manrope und Lora vom eigenen Server; der Abruf ergaenzt fonts.css nur.
if [ -f "$root/var/cacert.pem" ]; then
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
else
"$php" bin/console cms:fonts:fetch
fi
# Das Inhaltspaket fuer eine Live-Seite (ADR 118): dort unter System -> Inhalte uebertragen importieren.
rm -f "$root/var/ebi-immobilien-paket.zip"
"$php" bin/console cms:paket:export --ziel="$root/var/ebi-immobilien-paket.zip"
+978
View File
@@ -0,0 +1,978 @@
<?php
/**
* Baut den Redesign-Entwurf „EBI Immobilien“ (Karlsruhe) als Inhalt dieses CMS auf.
*
* Vorlage ist die laufende WordPress-Seite www.ebi-immobilien.de (am 13.10.2026 abgelesen): Start
* mit Karlsruher Wohnquartier im Hero, „Was bieten wir Ihnen“ in drei Punkten, Suchauftrag,
* Immobilienbewertung, Kontakt, Impressum und Datenschutz. Logo, Bilder, Formularfelder und
* Kontaktdaten sind daraus übernommen; Gestaltung und Texte sind neu: Navy und das Logo-Blau,
* Sandton als warme Fläche, Lora für Überschriften, Manrope für Text.
*
* Bewusste Abweichungen:
* - Die Immobilienliste des Originals ist leer („0 Results“) und hängt an einem Theme mit
* Kundenkonten. Der Nachbau hat keine Objektliste; die Startseite führt stattdessen zu
* Suchauftrag und Bewertung. Alte Adressen leiten weiter.
* - Suchauftrag und Bewertung sind CMS-Formulare in Schritten mit Zusammenfassung statt
* Contact Form 7. „Kaufen oder mieten“ ist beim Suchauftrag neu; die Bewertungsfragen zu
* Zimmern, Baujahr und Küche entfallen bei einem Grundstück.
* - Kein Cookie-Banner: die Seite lädt nichts von Dritten.
*
* Das Skript schreibt ausschließlich in die Datenbank aus DATABASE_URL und in MEDIA_DIRECTORY,
* beide müssen auf eine frische, migrierte Installation zeigen (siehe reset-ebi-immobilien.sh).
* Erstes Argument: der Ordner aus build-ebi-immobilien-assets.php.
*/
require dirname(__DIR__).'/config/bootstrap.php';
use App\Content\DocumentValidator;
use App\Content\FormRepository;
use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
use App\Content\RedirectRepository;
use App\Content\SiteSettings;
use App\Content\Theme;
use App\Infrastructure\ConnectionFactory;
use Symfony\Component\HttpFoundation\File\UploadedFile;
$root = dirname(__DIR__);
$assets = $argv[1] ?? null;
if (null === $assets || !is_dir($assets)) {
fwrite(STDERR, "Bitte den Ordner mit den Bildern als Argument angeben.\n");
exit(1);
}
$url = str_replace('%kernel.project_dir%', str_replace('\\', '/', $root), (string) $_SERVER['DATABASE_URL']);
$mediaDirectory = str_replace('%kernel.project_dir%', $root, (string) $_SERVER['MEDIA_DIRECTORY']);
if (!str_contains($url, 'ebi-immobilien')) {
fwrite(STDERR, "Sicherung: DATABASE_URL muss auf die EBI-Immobilien-Datenbank zeigen.\n");
exit(1);
}
$db = ConnectionFactory::create($url);
if (false !== $db->fetchOne('SELECT id FROM page LIMIT 1')) {
fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n");
exit(1);
}
$media = new MediaLibrary($db, $mediaDirectory);
$settings = new SiteSettings($db, $media);
$pages = new PageRepository($db, new DocumentValidator(), $settings);
$partials = new PartialRepository($db, new DocumentValidator());
$menus = new MenuRepository($db, $settings);
$forms = new FormRepository($db);
$redirects = new RedirectRepository($db);
$theme = new Theme($db);
// Angaben aus dem Impressum von ebi-immobilien.de (13.10.2026).
$firma = 'EBI Immobilien';
$strasse = 'Hopfenstraße 10b';
$plz = '76185';
$ort = 'Karlsruhe';
$telefon = '0172 6110975';
$tel = 'tel:+491726110975';
$mail = 'info@ebi-immobilien.de';
$SCHEMA = DocumentValidator::SCHEMA_VERSION;
// ---------------------------------------------------------------------------------------------
// Bausteine
// ---------------------------------------------------------------------------------------------
$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w];
$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px'];
$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px'];
$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w];
$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = '', string $breite = 'normal', string $sichtbar = 'all'): array {
$block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style];
if (null !== $anchor) {
$block['anchor'] = $anchor;
}
if ('' !== $css) {
$block['css'] = $css;
}
$layout = [];
if ('normal' !== $breite) {
$layout['width'] = $breite;
}
if ('all' !== $sichtbar) {
$layout['visibility'] = $sichtbar;
}
if ([] !== $layout) {
$block['layout'] = $layout;
}
return $block;
};
$row = static function (string $id, array $children, array $design = [], array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'row', 'children' => $children];
if ([] !== $design) {
$block['layout'] = ['design' => $design];
}
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$zweiSpalten = ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']];
/** Eine Zeile, die nebeneinander stellt statt zu rastern. */
$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], string $css = '') use ($row): array {
// Ein eigener Rand ergänzt den oberen Nullrand, statt ihn zu ersetzen (sonst greift der
// Standardabstand einer Zeile).
$style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, ...$extra, 'margin' => ['top' => '0px', ...($extra['margin'] ?? [])]]];
if ([] !== $mobil) {
$style['mobile'] = $mobil;
}
return $row($id, $children, [], $style, $css);
};
$column = static function (string $id, array $children, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'column', 'children' => $children];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$heading = static function (string $id, string $text, int $level, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level]];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$text = static function (string $id, string $inhalt, array $style = [], string $css = ''): array {
$block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]];
if ([] !== $style) {
$block['style'] = $style;
}
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array {
$gross = end($eintrag['variants']);
$props = [
'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'],
'mediaId' => $eintrag['id'],
'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']),
];
if (null !== $href) {
$props['href'] = $href;
$props['click'] = 'link';
}
$block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style];
if ('' !== $css) {
$block['css'] = $css;
}
return $block;
};
$symbol = static fn (string $id, string $name, int $groesse, array $style = []): array => ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style];
/**
* Ein Button: Pfeil, der beim Überfahren nach rechts gleitet, und ein leichtes Anheben.
*
* @param string $art blau (Theme), weiss (Weiß auf dunkel), rahmen (Navy-Rahmen auf hell), hell (Rahmen auf dunkel)
*/
$knopf = static function (string $id, string $inhalt, string $href, string $art = 'blau', array $style = [], string $icon = 'arrow-right'): array {
$farbe = [
'blau' => '',
'weiss' => "background:#fff;color:#14273a;\n&:hover{background:#eaf5fb;color:#14273a}\n",
'rahmen' => "background:transparent;color:#14273a;box-shadow:inset 0 0 0 1.5px #14273a;\n&:hover{background:#14273a;color:#fff}\n",
'hell' => "background:transparent;color:#fff;box-shadow:inset 0 0 0 1.5px rgba(255,255,255,.6);\n&:hover{background:rgba(255,255,255,.12);color:#fff}\n",
][$art];
$props = ['text' => $inhalt, 'href' => $href, 'icon' => $icon];
if ('phone' === $icon) {
$props['iconPosition'] = 'start';
} elseif ('none' !== $icon) {
$props['iconPosition'] = 'end';
$props['iconMotion'] = 'slide';
}
return ['id' => $id, 'type' => 'button', 'props' => $props, 'css' => "min-height:52px;padding:0 26px;margin-top:0;transition:transform .2s ease,background-color .2s ease,color .2s ease;\n".$farbe.'&:hover{transform:translateY(-2px)}']
+ ([] === $style ? [] : ['style' => $style]);
};
/**
* Setzt die Einblendungen nach einer Liste.
*
* @param array<string, array{0: string, 1?: string}> $plan Block-ID => [Art, Verzögerung]
*/
$einblenden = static function (array $dokument, array $plan): array {
$gehen = function (array $bloecke) use (&$gehen, $plan): array {
foreach ($bloecke as $i => $block) {
if (isset($plan[$block['id'] ?? ''])) {
[$art, $verzug] = [...$plan[$block['id']], 'none'];
$bloecke[$i]['layout'] = [...($block['layout'] ?? []), 'effects' => ['entrance' => $art, 'delay' => $verzug]];
}
if (isset($block['children'])) {
$bloecke[$i]['children'] = $gehen($block['children']);
}
}
return $bloecke;
};
$dokument['blocks'] = $gehen($dokument['blocks']);
return $dokument;
};
$linkCss = "& a{color:#0b6aa2;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#14273a}";
/** Die Kleinzeile: kurze blaue Linie davor, Versalien. */
$kleinzeile = static fn (string $id, string $inhalt, string $farbe = '@blau', string $unten = '18px') => $text($id, $inhalt, ['desktop' => [
'fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.16em', 'lineHeight' => 1.6, 'textTransform' => 'uppercase', 'color' => $farbe, 'margin' => ['top' => '0px', 'bottom' => $unten],
]], "display:flex;align-items:center;gap:12px\n&::before{content:'';width:28px;height:2px;background:#2ea8e0;flex-shrink:0}");
$h2Stil = [
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '44px', 'fontWeight' => '500', 'lineHeight' => 1.15, 'letterSpacing' => '-0.01em', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '20px']],
'tablet' => ['fontSize' => '38px'],
'mobile' => ['fontSize' => '31px'],
];
$h2Hell = [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'color' => '@papier']];
$fliess = static fn (string $farbe = '@text', string $unten = '0px', string $breite = 'none', string $groesse = '17px') => [
'desktop' => ['fontSize' => $groesse, 'lineHeight' => 1.75, 'color' => $farbe, 'maxWidth' => $breite, 'margin' => ['top' => '0px', 'bottom' => $unten]],
'mobile' => ['maxWidth' => 'none', 'fontSize' => '16px'],
];
// Ein Link über die ganze Karte: Die Karte ist positioniert, der Link spannt sein ::after auf.
$ganzeKarte = "position:relative;\n& a::after{content:'';position:absolute;top:0;right:0;bottom:0;left:0}\n& a:focus-visible{outline:none}\n&:focus-within{outline:3px solid #2ea8e0;outline-offset:4px}";
/** Ein Haken vor einer Zeile – für Aufzählungen in Abschnitten. */
$haken = static fn (string $id, string $inhalt, string $farbe = '@text', string $hakenfarbe = '@blau') => $zeile($id, [
$symbol($id.'-icon', 'check-circle', 24, ['desktop' => ['color' => $hakenfarbe, 'flexShrink' => 0]]),
$text($id.'-text', $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => $farbe, 'margin' => ['bottom' => '0px']]], "& strong{font-weight:700}
".$linkCss),
], '12px', ['alignItems' => 'flex-start', 'margin' => ['bottom' => '14px']]);
// ---------------------------------------------------------------------------------------------
// Website, Gestaltung, Medien
// ---------------------------------------------------------------------------------------------
$db->insert('website', ['id' => 'default', 'name' => $firma, 'default_locale' => 'de', 'theme' => '{}']);
$theme->save(
[
['key' => 'tinte', 'name' => 'Navy (Überschriften, dunkle Flächen)', 'value' => '#14273a'],
['key' => 'navy-tief', 'name' => 'Navy tief (Fußleiste)', 'value' => '#0e1c2a'],
['key' => 'blau', 'name' => 'Blau (Knöpfe, kleine Schrift)', 'value' => '#0b6aa2'],
['key' => 'blau-dunkel', 'name' => 'Blau dunkel (Knöpfe beim Überfahren)', 'value' => '#08527d'],
['key' => 'logo-blau', 'name' => 'Logo-Blau (Linien, Akzente auf Navy)', 'value' => '#2ea8e0'],
['key' => 'himmel', 'name' => 'Hellblau (Schrift auf Navy)', 'value' => '#7fd0f2'],
['key' => 'text', 'name' => 'Fließtext', 'value' => '#4d5966'],
['key' => 'sand', 'name' => 'Sand (warme Flächen)', 'value' => '#f5f1ea'],
['key' => 'linie', 'name' => 'Linien', 'value' => '#e3ddd2'],
['key' => 'auf-dunkel', 'name' => 'Schrift auf Navy', 'value' => '#c3cfdb'],
['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'],
],
[
['key' => 'body', 'name' => 'Text', 'family' => 'Manrope', 'source' => 'google', 'weights' => [400, 500, 600, 700], 'fallback' => 'sans-serif'],
['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Lora', 'source' => 'google', 'weights' => [500, 600], 'fallback' => 'serif'],
],
['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '17px', 'lineHeight' => 1.65, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@blau', 'containerWidth' => '1200px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '6px'],
['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'body', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@blau', 'color' => '@papier', 'hoverBackgroundColor' => '@blau-dunkel', 'hoverColor' => '@papier'],
);
echo "Farben, Schriften und Buttons gesetzt\n";
$medien = [];
foreach ([
'logo' => ['logo.png', 'EBI Immobilien'],
'favicon' => ['favicon.png', 'Seitensymbol von EBI Immobilien'],
'karlsruhe' => ['startbild.webp', getenv('EBI_STARTBILD_ALT') ?: 'Modernes Wohnquartier in der Abenddämmerung mit beleuchteten Balkonen und Rasenfläche'],
'bewertung' => ['bewertung.webp', 'Aktenordner, Taschenrechner und roter Stift auf einem Schreibtisch'],
'schreibtisch' => ['schreibtisch.webp', 'Schreibtisch mit Tastatur, Brille, Stiften und Notizblock'],
] as $schluessel => [$datei, $alt]) {
$pfad = rtrim($assets, '/\\').'/'.$datei;
if (!is_file($pfad)) {
fwrite(STDERR, "Bild fehlt: $pfad\n");
exit(1);
}
$eintrag = $media->upload(new UploadedFile($pfad, basename($datei), null, null, true));
$media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]);
$medien[$schluessel] = $media->find($eintrag['id']);
echo "Bild übernommen: $datei\n";
}
// ---------------------------------------------------------------------------------------------
// Formulare – Felder wie auf ebi-immobilien.de, in Schritte geteilt
// ---------------------------------------------------------------------------------------------
$einwilligung = ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich bin einverstanden, dass EBI Immobilien meine Angaben zur Bearbeitung meiner Anfrage speichert und mich kontaktiert.'];
$forms->save('suchauftrag', 'Suchauftrag', [
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Wunschimmobilie', 'help' => 'Was suchen Sie? Bis auf die Art der Immobilie ist alles freiwillig.'],
['name' => 'vorhaben', 'type' => 'radio', 'label' => 'Kaufen oder mieten?', 'required' => true, 'options' => ['Kaufen', 'Mieten']],
['name' => 'art', 'type' => 'radio', 'label' => 'Art der Immobilie', 'required' => true, 'options' => ['Wohnung', 'Einfamilienhaus', 'Mehrfamilienhaus']],
['name' => 'zimmer', 'type' => 'number', 'label' => 'Anzahl Zimmer', 'required' => false, 'min' => 1, 'max' => 50],
['name' => 'wohnflaeche', 'type' => 'number', 'label' => 'Wohnfläche ab', 'required' => false, 'min' => 1, 'max' => 10000, 'unit' => 'm²'],
['name' => 'baujahr', 'type' => 'number', 'label' => 'Baujahr ab', 'required' => false, 'min' => 1800, 'max' => 2100],
['name' => 'preisspanne', 'type' => 'text', 'label' => 'Preisspanne', 'required' => false, 'help' => 'z. B. bis 450.000 € oder 900 bis 1.200 € warm'],
['name' => 'sonstiges', 'type' => 'textarea', 'label' => 'Sonstiges', 'required' => false, 'help' => 'Wunschlage, Balkon, Garten, Stellplatz …'],
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Damit wir Ihnen passende Angebote schicken können.'],
['name' => 'anrede', 'type' => 'radio', 'label' => 'Anrede', 'required' => true, 'options' => ['Herr', 'Frau']],
['name' => 'name', 'type' => 'text', 'label' => 'Vor- und Nachname', 'required' => true],
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => true],
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
['name' => 'strasse', 'type' => 'text', 'label' => 'Straße und Hausnummer', 'required' => false],
['name' => 'ort', 'type' => 'text', 'label' => 'PLZ und Ort', 'required' => false],
$einwilligung,
], ['recipient' => $mail, 'subject' => 'Neuer Suchauftrag über die Website', 'success' => 'Vielen Dank! Ihr Suchauftrag ist bei uns angekommen. Wir melden uns persönlich bei Ihnen, sobald wir ein passendes Objekt haben.', 'replyToField' => 'email', 'buttonLabel' => 'Suchauftrag senden', 'summary' => true], 0);
echo "Formular angelegt: suchauftrag\n";
$gebaeude = ['Wohnung', 'Haus', 'Mehrfamilienhaus'];
$forms->save('bewertung', 'Immobilienbewertung', [
['name' => 'schritt_objekt', 'type' => 'step', 'label' => 'Ihre Immobilie', 'help' => 'Worum geht es?'],
['name' => 'objekt', 'type' => 'radio', 'label' => 'Was möchten Sie verkaufen oder vermieten?', 'required' => true, 'options' => ['Wohnung', 'Haus', 'Mehrfamilienhaus', 'Grundstück']],
['name' => 'vorhaben', 'type' => 'radio', 'label' => 'Möchten Sie verkaufen oder vermieten?', 'required' => true, 'options' => ['Verkaufen', 'Vermieten']],
['name' => 'schritt_details', 'type' => 'step', 'label' => 'Eckdaten', 'help' => 'Ungefähre Angaben genügen für eine erste Einschätzung.'],
['name' => 'flaeche', 'type' => 'number', 'label' => 'Fläche', 'required' => true, 'min' => 1, 'max' => 100000, 'unit' => 'm²',
'help' => 'Wohnfläche – bei einem Grundstück die Grundstücksfläche'],
['name' => 'baujahr', 'type' => 'number', 'label' => 'Baujahr', 'required' => true, 'min' => 1700, 'max' => 2100, 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
['name' => 'zimmer', 'type' => 'number', 'label' => 'Anzahl Zimmer', 'required' => true, 'min' => 1, 'max' => 200, 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
['name' => 'kueche', 'type' => 'radio', 'label' => 'Ist eine Einbauküche vorhanden?', 'required' => true, 'options' => ['Ja – gebraucht', 'Ja – neuwertig', 'Keine vorhanden'], 'showIf' => ['field' => 'objekt', 'values' => $gebaeude]],
['name' => 'parken', 'type' => 'select', 'label' => 'Welche Parkmöglichkeiten gibt es?', 'required' => true, 'options' => ['Tiefgarage', 'Garage', 'Doppelgarage', 'Carport', 'Stellplatz', 'Mehrere', 'Keine']],
['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Wir melden uns mit einer Einschätzung persönlich bei Ihnen.'],
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
['name' => 'email', 'type' => 'email', 'label' => 'Ihre E-Mail-Adresse', 'required' => true],
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Freiwillig – wenn wir Sie lieber anrufen sollen'],
['name' => 'lage', 'type' => 'text', 'label' => 'Lage der Immobilie', 'required' => false, 'help' => 'Ort oder Stadtteil, gern mit Straße'],
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => false],
$einwilligung,
], ['recipient' => $mail, 'subject' => 'Neue Bewertungsanfrage über die Website', 'success' => 'Vielen Dank! Ihre Bewertungsanfrage ist bei uns angekommen. Wir melden uns persönlich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Bewertung anfragen', 'summary' => true], 0);
echo "Formular angelegt: bewertung\n";
$forms->save('kontakt', 'Kontakt', [
['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true],
['name' => 'email', 'type' => 'email', 'label' => 'Ihre E-Mail-Adresse', 'required' => true],
['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false],
['name' => 'betreff', 'type' => 'text', 'label' => 'Betreff', 'required' => false],
['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true],
$einwilligung,
], ['recipient' => $mail, 'subject' => 'Neue Nachricht über die Website', 'success' => 'Vielen Dank für Ihre Nachricht. Wir melden uns so bald wie möglich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Nachricht senden'], 0);
echo "Formular angelegt: kontakt\n";
// ---------------------------------------------------------------------------------------------
// Kopfbereich: Navy-Leiste mit Kontakt (nur Desktop), darunter weiß mit Logo, Menü, Menü-Button
// ---------------------------------------------------------------------------------------------
$leistenLink = "& a{color:#dbe5ee;text-decoration:none;transition:color .2s}\n& a:hover{color:#fff}";
$leistenPunkt = static fn (string $id, string $icon, string $inhalt) => $zeile($id, [
$symbol($id.'-icon', $icon, 16, ['desktop' => ['color' => '@himmel']]),
$text($id.'-text', $inhalt, ['desktop' => ['fontSize' => '13px', 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]], $leistenLink),
], '8px');
$kopf = [
'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.',
'blocks' => [
$section('kopf-leiste', [
$zeile('kopf-leiste-zeile', [
$text('kopf-leiste-spruch', 'Ihr Immobilienmakler in Karlsruhe und Umgebung', ['desktop' => ['fontSize' => '13px', 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]]),
$zeile('kopf-leiste-kontakt', [
$leistenPunkt('kopf-leiste-telefon', 'phone', '<a href="'.$tel.'">'.$telefon.'</a>'),
$leistenPunkt('kopf-leiste-mail', 'mail', '<a href="mailto:'.$mail.'">'.$mail.'</a>'),
], '28px'),
], '20px', ['justifyContent' => 'space-between', 'minHeight' => '40px']),
], ['desktop' => ['backgroundColor' => '@tinte', 'padding' => ['top' => '0px', 'bottom' => '0px']]], null, '', 'wide', 'hide-mobile'),
$section('kopf', [
$zeile('kopf-zeile', [
$bild('kopf-logo', $medien['logo'], $firma.' – zur Startseite', [
// Feste Breite: sonst bekommt das Logo seine Breite erst nach dem Laden (CLS).
'desktop' => ['width' => '200px', 'maxWidth' => '200px', 'flexShrink' => 0, 'margin' => $seiten('0px')],
'mobile' => ['width' => '150px', 'maxWidth' => '150px'],
], '/', '& img{display:block;width:100%;height:auto;border-radius:0}'),
['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet', 'burgerLabel' => 'icon'],
'style' => ['desktop' => [
'fontSize' => '15px', 'fontWeight' => '600', 'color' => '@tinte',
'burgerColor' => '@tinte', 'burgerBackgroundColor' => '@papier', 'burgerBorderColor' => '@linie', 'burgerBorderRadius' => '6px',
]],
// Blaue Linie, die beim Überfahren von der Mitte aus wächst; das Klappmenü liegt
// als Fläche unter dem ganzen Kopf.
'css' => "& .menu-list{gap:4px 34px;align-items:center}\n"
."& .menu-list a{position:relative;padding:8px 0;text-decoration:none}\n"
."& .menu-list a::after{content:'';position:absolute;left:50%;right:50%;bottom:0;height:2px;background:#2ea8e0;transition:left .25s ease,right .25s ease}\n"
."& .menu-list a:hover::after{left:0;right:0}\n"
."& .menu-list .menu-button{padding:12px 22px}\n"
."& .menu-list .menu-button::after{display:none}\n"
."& .menu-burger{margin-left:auto}\n"
."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n"
."& .menu-burger-body{position:absolute;left:0;right:0;top:100%;background:#fff;padding:4px 20px 20px;border-top:1px solid #e3ddd2;box-shadow:0 18px 30px rgba(20,39,58,.12);font-size:18px}\n"
."& .menu-burger-body .menu-list{flex-direction:column;align-items:stretch;gap:0}\n"
."& .menu-burger-body .menu-list a{display:block;padding:14px 0;border-bottom:1px solid #eee8de}\n"
.'& .menu-burger-body .menu-list a::after{display:none}'],
], '28px', ['justifyContent' => 'space-between', 'minHeight' => '88px'], ['minHeight' => '70px', 'gap' => '6px']),
], [
'desktop' => ['backgroundColor' => '#fffffff7', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'shadowY' => '1px', 'shadowBlur' => '0px', 'shadowColor' => '#14273a1a'],
], null, '', 'wide'),
],
];
// ---------------------------------------------------------------------------------------------
// Fußbereich: Sand mit blauer Oberkante, Navy-Leiste unten
// ---------------------------------------------------------------------------------------------
$fussLink = "& a{color:#4d5966;text-decoration:none;transition:color .2s}\n& a:hover{color:#0b6aa2}";
$fussTitel = static fn (string $id, string $inhalt) => $heading($id, $inhalt, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '19px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '14px']]]);
$fussText = static fn (string $id, string $inhalt) => $text($id, $inhalt, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.95, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $fussLink."\n& a{overflow-wrap:anywhere}");
$fuss = [
'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.',
'blocks' => [
$section('fuss', [
$column('fuss-raster', [
$column('fuss-marke', [
$bild('fuss-logo', $medien['logo'], $firma, ['desktop' => ['width' => '190px', 'maxWidth' => '190px', 'margin' => $seiten('0px')]], null, '& img{display:block;width:100%;height:auto;border-radius:0}'),
$text('fuss-spruch', 'Wir helfen Ihnen, Ihr neues Zuhause zu finden – und bewerten Ihre Immobilie, wenn Sie verkaufen oder vermieten möchten.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@text', 'maxWidth' => '300px', 'margin' => ['top' => '20px', 'bottom' => '0px']], 'mobile' => ['maxWidth' => 'none']]),
]),
$column('fuss-service', [
$fussTitel('fuss-service-titel', 'Service'),
$fussText('fuss-service-liste', '<a href="/suchauftrag">Suchauftrag erstellen</a><br><a href="/immobilienbewertung">Immobilienbewertung</a><br><a href="/#leistungen">Unsere Leistungen</a>'),
]),
$column('fuss-kontakt', [
$fussTitel('fuss-kontakt-titel', 'Kontakt'),
$fussText('fuss-kontakt-text', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort.'<br><a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a>'),
]),
$column('fuss-anfrage', [
$fussTitel('fuss-anfrage-titel', 'Fragen?'),
$text('fuss-anfrage-text', 'Schreiben Sie uns – wir melden uns persönlich.', ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '18px']]]),
$knopf('fuss-anfrage-knopf', 'Nachricht schreiben', '/kontakt', 'rahmen'),
]),
], [
'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '40px', 'padding' => ['top' => '64px', 'bottom' => '52px']],
'tablet' => ['gridColumns' => 2],
'mobile' => ['gridColumns' => 1, 'gap' => '32px', 'padding' => ['top' => '48px', 'bottom' => '40px']],
]),
], [
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'borderWidth' => $nurOben('4px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau'],
]),
$section('fuss-leiste', [
$zeile('fuss-unten', [
$text('fuss-copyright', '© 2026 '.$firma.', Karlsruhe', ['desktop' => ['fontSize' => '13px', 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]]),
$zeile('fuss-links', [
['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'],
'style' => ['desktop' => ['fontSize' => '13px', 'gap' => '24px', 'color' => '@auf-dunkel']],
'css' => "& a{color:#c3cfdb;text-decoration:none}\n& a:hover{color:#fff}"],
$text('fuss-agentur', '<a href="https://kant-it.de">Webdesign by Kant-IT Solutions</a>', ['desktop' => ['fontSize' => '13px', 'margin' => ['bottom' => '0px']]], "& a{color:#c3cfdb;text-decoration:none}\n& a:hover{color:#fff}"),
], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']),
], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'rowGap' => '12px', 'padding' => ['top' => '20px', 'bottom' => '22px']],
['flexDirection' => 'column', 'alignItems' => 'flex-start']),
], ['desktop' => ['backgroundColor' => '@navy-tief', 'padding' => ['top' => '0px', 'bottom' => '0px']]]),
],
];
$partials->create('header', 'default', 'Kopfbereich', $kopf);
$partials->create('footer', 'default', 'Fußbereich', $fuss);
echo "Kopf- und Fußbereich angelegt\n";
// ---------------------------------------------------------------------------------------------
// Startseite
// ---------------------------------------------------------------------------------------------
/** Eine Leistung als Karte: Symbol im hellblauen Kreis, Titel, Satz, Textlink. */
$leistung = static function (int $n, string $icon, string $titel, string $satz, string $link, string $href) use ($column, $zeile, $text, $heading, $symbol, $rund, $nurUnten, $ganzeKarte): array {
$id = 'leistung-'.$n;
return $column($id, [
$zeile($id.'-kopf', [
$column($id.'-symbol', [$symbol($id.'-icon', $icon, 24, ['desktop' => ['color' => '@blau']])], ['desktop' => [
'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '60px', 'height' => '60px', 'flexShrink' => 0,
'backgroundColor' => '#e8f4fb', 'borderRadius' => $rund('50%'),
]], 'transition:background-color .25s ease,color .25s ease'),
], '12px', ['justifyContent' => 'space-between', 'margin' => ['bottom' => '26px']]),
$heading($id.'-titel', $titel, 3, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '24px', 'fontWeight' => '600', 'lineHeight' => 1.25, 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '12px']]]),
$text($id.'-satz', $satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '24px']]]),
$text($id.'-link', '<a href="'.$href.'">'.$link.'</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'margin' => ['top' => 'auto', 'bottom' => '0px']]],
"& a{color:#0b6aa2;text-decoration:none;display:inline-flex;align-items:center;gap:8px}\n& a::before{content:'';order:2;width:18px;height:2px;background:currentColor;transition:width .25s ease}\n& a:hover{color:#14273a}"),
], [
'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'backgroundColor' => '@papier', 'borderRadius' => $rund('8px'),
'padding' => ['top' => '34px', 'right' => '32px', 'bottom' => '34px', 'left' => '32px'],
'borderWidth' => $nurUnten('3px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff',
'shadowY' => '10px', 'shadowBlur' => '30px', 'shadowColor' => '#14273a14'],
'mobile' => ['padding' => ['top' => '28px', 'right' => '24px', 'bottom' => '28px', 'left' => '24px']],
], $ganzeKarte."\ntransition:transform .3s ease,box-shadow .3s ease,border-color .3s ease;\n&:hover{transform:translateY(-6px);border-bottom-color:#2ea8e0;box-shadow:0 22px 44px rgba(20,39,58,.14)}\n&:hover [id=\"{$id}-symbol\"]{background-color:#0b6aa2;color:#fff}\n&:hover [id=\"{$id}-icon\"]{color:#fff}\n&:hover a::before{width:30px}");
};
/** Ein Schritt mit runder Ziffer. */
$schritt = static fn (int $n, string $titel, string $satz) => $zeile('schritt-'.$n, [
$text('schritt-'.$n.'-nummer', (string) $n, ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '40px', 'height' => '40px', 'flexShrink' => 0,
'borderRadius' => $rund('50%'), 'borderWidth' => $seiten('1.5px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau',
'fontFamily' => 'heading', 'fontSize' => '18px', 'fontWeight' => '600', 'color' => '@blau', 'margin' => ['bottom' => '0px']]]),
$text('schritt-'.$n.'-text', '<strong>'.$titel.'</strong>'.$satz, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '0px']]],
'& strong{display:block;font-size:17px;font-weight:700;color:#14273a;margin-bottom:2px}'),
], '18px', ['alignItems' => 'flex-start', 'margin' => ['bottom' => '22px']]);
/** Eine Kennzahl im Hero: großer Wert, kleine Beschriftung, Linie davor. */
$fakt = static fn (string $id, string $wert, string $beschriftung) => $column($id, [
$text($id.'-wert', $wert, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '30px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@papier', 'margin' => ['bottom' => '6px']], 'mobile' => ['fontSize' => '24px']]),
$text($id.'-text', $beschriftung, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']]]),
], ['desktop' => ['borderWidth' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '2px'], 'borderStyle' => 'solid', 'borderColor' => '@logo-blau', 'padding' => ['left' => '18px']]]);
$startseite = [
'schemaVersion' => $SCHEMA,
'title' => 'Finden Sie Ihr zukünftiges Zuhause',
'description' => 'EBI Immobilien in Karlsruhe: Wir helfen Ihnen beim Kauf oder der Miete Ihrer Wunschimmobilie und bewerten Ihre Immobilie für Verkauf oder Vermietung.',
'blocks' => [
// 1 – Hero: Karlsruher Wohnquartier, abgedunkelt, Text links, Kennzahlen unten.
$section('hero', [
$column('hero-text', [
$kleinzeile('hero-klein', 'Immobilien in Karlsruhe und Umgebung', '@himmel', '22px'),
$heading('hero-titel', 'Finden Sie Ihr<br>zukünftiges Zuhause.', 1, [
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '66px', 'fontWeight' => '500', 'lineHeight' => 1.08, 'letterSpacing' => '-0.015em', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '24px']],
'tablet' => ['fontSize' => '52px'],
'mobile' => ['fontSize' => '38px', 'margin' => ['bottom' => '18px']],
], 'text-shadow:0 2px 24px rgba(10,20,32,.35)'),
$text('hero-satz', 'Ob Kauf oder Miete, Wohnung oder Haus: Wir begleiten Sie persönlich auf dem Weg zu Ihrer Wunschimmobilie – und bewerten Ihre Immobilie, wenn Sie verkaufen oder vermieten möchten.', [
'desktop' => ['fontSize' => '19px', 'lineHeight' => 1.7, 'color' => '#eef3f7', 'maxWidth' => '600px', 'margin' => ['bottom' => '36px']],
'mobile' => ['fontSize' => '17px', 'margin' => ['bottom' => '28px']],
]),
$zeile('hero-knoepfe', [
$knopf('hero-knopf-suche', 'Suchauftrag erstellen', '/suchauftrag'),
$knopf('hero-knopf-bewertung', 'Immobilie bewerten lassen', '/immobilienbewertung', 'hell', [], 'none'),
], '14px', ['flexWrap' => 'wrap']),
$row('hero-fakten', [
$fakt('hero-fakt-erfahrung', '15+ Jahre', 'Erfahrung bei Kauf und Miete'),
$fakt('hero-fakt-region', 'Karlsruhe', 'und Umgebung'),
$fakt('hero-fakt-persoenlich', 'Persönlich', 'ein fester Ansprechpartner'),
], ['desktop' => ['columns' => 'three', 'gap' => 'medium'], 'mobile' => ['columns' => 'one', 'gap' => 'small']], [
'desktop' => ['margin' => ['top' => '64px'], 'maxWidth' => '760px'],
'mobile' => ['margin' => ['top' => '44px']],
]),
], ['desktop' => ['maxWidth' => '820px']]),
], [
'desktop' => ['backgroundImage' => $medien['karlsruhe']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0f2133', 'overlayOpacity' => 0.15,
'color' => '@papier', 'minHeight' => '82vh', 'display' => 'flex', 'alignItems' => 'center', 'padding' => ['top' => '110px', 'bottom' => '96px']],
'mobile' => ['minHeight' => 'auto', 'padding' => ['top' => '64px', 'bottom' => '56px']],
], null, "position:relative\n&::before{content:'';position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(90deg,rgba(13,28,44,.78) 0%,rgba(13,28,44,.5) 50%,rgba(13,28,44,.1) 85%);pointer-events:none}\n& .section-inner{position:relative;z-index:1}"),
// 2 – Leistungen auf Sand: Kopf, drei Karten aus „Was bieten wir Ihnen“.
$section('leistungen-bereich', [
$row('leistungen-kopf', [
$column('leistungen-kopf-titel', [
$kleinzeile('leistungen-klein', 'Was wir Ihnen bieten'),
$heading('leistungen-titel', 'Kaufen, mieten, verkaufen –<br>mit einem Ansprechpartner.', 2, [...$h2Stil, 'desktop' => [...$h2Stil['desktop'], 'margin' => ['top' => '0px', 'bottom' => '0px']]]),
]),
$text('leistungen-satz', 'Seit über 15 Jahren unterstützen wir Käufer, Mieter und Eigentümer in Karlsruhe. Unkompliziert, ehrlich und mit Blick fürs Detail.', [
'desktop' => ['maxWidth' => '380px', 'fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']],
'mobile' => ['maxWidth' => 'none', 'fontSize' => '16px'],
]),
], [], [
'desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'rowGap' => '20px', 'columnGap' => '48px', 'margin' => ['top' => '0px', 'bottom' => '48px']],
'mobile' => ['margin' => ['bottom' => '32px']],
]),
$column('leistungen-raster', [
$leistung(1, 'home', 'Ihr Traumhaus finden', 'Wir helfen Ihnen, das passende Zuhause zu finden – ob zum Kauf oder zur Miete. Sagen Sie uns, was Sie suchen.', 'Suchauftrag erstellen', '/suchauftrag'),
$leistung(2, 'award', 'Langjährige Erfahrung', 'Seit über 15 Jahren begleiten wir Immobilienkäufe und Vermietungen. Von der Besichtigung bis zur Schlüsselübergabe.', 'Kontakt aufnehmen', '/kontakt'),
$leistung(3, 'document', 'Immobilienbewertung', 'Sie möchten verkaufen oder vermieten? Wir schätzen den Wert Ihrer Immobilie realistisch ein.', 'Zur Bewertung', '/immobilienbewertung'),
], [
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '28px'],
'tablet' => ['gridColumns' => 1, 'gap' => '20px'],
]),
], [
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '100px', 'bottom' => '104px']],
'mobile' => ['padding' => ['top' => '60px', 'bottom' => '60px']],
], 'leistungen'),
// 3 – Suchauftrag: Foto mit versetztem Rahmen links, Text und drei Schritte rechts.
$section('suche-bereich', [
$row('suche-zeile', [
$column('suche-bild-spalte', [
$bild('suche-bild', $medien['schreibtisch'], 'Schreibtisch mit Tastatur, Brille, Stiften und Notizblock', [
'desktop' => ['margin' => $seiten('0px'), 'imageHeight' => '520px', 'objectFit' => 'cover', 'imageRadius' => $rund('8px')],
'mobile' => ['imageHeight' => '300px'],
], null, '& img{display:block;width:100%}'),
$column('suche-etikett', [
$symbol('suche-etikett-icon', 'search', 24, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
$text('suche-etikett-text', '<strong>Nicht fündig geworden?</strong>Wir suchen für Sie weiter.', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]],
'& strong{display:block;font-family:Lora,serif;font-size:18px;font-weight:600;color:#fff;margin-bottom:2px}'),
], [
'desktop' => ['position' => 'absolute', 'right' => '-28px', 'bottom' => '36px', 'display' => 'flex', 'gap' => '14px', 'alignItems' => 'flex-start', 'maxWidth' => '290px',
'backgroundColor' => '@tinte', 'borderRadius' => $rund('8px'), 'padding' => ['top' => '20px', 'right' => '22px', 'bottom' => '20px', 'left' => '22px'],
'shadowY' => '18px', 'shadowBlur' => '40px', 'shadowColor' => '#14273a40'],
'tablet' => ['right' => '20px'],
'mobile' => ['right' => '12px', 'left' => '12px', 'bottom' => '12px', 'maxWidth' => 'none'],
]),
], ['desktop' => ['position' => 'relative']], "&::before{content:'';position:absolute;top:-18px;left:-18px;width:55%;height:60%;border-top:3px solid #2ea8e0;border-left:3px solid #2ea8e0;border-top-left-radius:12px;pointer-events:none}"),
$column('suche-text', [
$kleinzeile('suche-klein', 'Immobilien-Suchauftrag'),
$heading('suche-titel', 'Ihre Traumimmobilie war noch nicht dabei?', 2, $h2Stil),
$text('suche-satz', 'Wir unterstützen Sie gern bei Ihrer Suche. Erzählen Sie uns, was Sie sich wünschen – wir halten die Augen offen und melden uns, sobald etwas Passendes dabei ist.', $fliess('@text', '32px', '520px')),
$schritt(1, 'Wünsche angeben', 'Art, Größe, Lage und Budget – in zwei kurzen Schritten.'),
$schritt(2, 'Wir suchen für Sie', 'Wir gleichen Ihre Wünsche mit unseren Objekten und dem Markt ab.'),
$schritt(3, 'Persönliche Rückmeldung', 'Passt etwas, melden wir uns direkt bei Ihnen.'),
$knopf('suche-knopf', 'Suchauftrag erstellen', '/suchauftrag', 'blau', ['desktop' => ['margin' => ['top' => '14px']]]),
], ['desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start']]),
], $zweiSpalten, [
'desktop' => ['gap' => '96px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '1 1'],
'tablet' => ['gap' => '56px'],
]),
], [
'desktop' => ['padding' => ['top' => '112px', 'bottom' => '112px']],
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']],
], 'suchauftrag'),
// 4 – Bewertung auf Navy: Text und Haken links, Foto rechts mit blauem Versatz.
$section('bewertung-bereich', [
$row('bewertung-zeile', [
$column('bewertung-text', [
$kleinzeile('bewertung-klein', 'Immobilienbewertung', '@himmel'),
$heading('bewertung-titel', 'Was ist Ihre Immobilie wert?', 2, $h2Hell),
$text('bewertung-satz', 'Gerne bewerten wir Ihre Immobilie und unterstützen Sie beim Verkauf oder bei der Vermietung. Für eine erste Einschätzung genügen uns wenige Angaben:', $fliess('@auf-dunkel', '28px', '520px')),
$haken('bewertung-punkt-1', '<strong>Art und Vorhaben</strong> – Wohnung, Haus oder Grundstück, Verkauf oder Vermietung', '#dbe5ee', '@logo-blau'),
$haken('bewertung-punkt-2', '<strong>Eckdaten</strong> – Wohnfläche, Baujahr und Zimmer', '#dbe5ee', '@logo-blau'),
$haken('bewertung-punkt-3', '<strong>Ausstattung</strong> – Einbauküche und Parkmöglichkeiten', '#dbe5ee', '@logo-blau'),
$zeile('bewertung-knoepfe', [
$knopf('bewertung-knopf', 'Zum Bewertungsformular', '/immobilienbewertung', 'weiss'),
$knopf('bewertung-anrufen', $telefon, $tel, 'hell', [], 'phone'),
], '14px', ['flexWrap' => 'wrap', 'margin' => ['top' => '22px']]),
]),
$column('bewertung-bild-spalte', [
$bild('bewertung-bild', $medien['bewertung'], 'Aktenordner, Taschenrechner und roter Stift auf einem Schreibtisch', [
'desktop' => ['margin' => $seiten('0px'), 'imageHeight' => '480px', 'objectFit' => 'cover', 'imageRadius' => $rund('8px')],
'mobile' => ['imageHeight' => '280px'],
], null, '& img{display:block;width:100%;box-shadow:18px 18px 0 #0b6aa2}'),
], ['desktop' => ['padding' => ['right' => '22px', 'bottom' => '22px']]]),
], $zweiSpalten, [
'desktop' => ['gap' => '88px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '1 1'],
'tablet' => ['gap' => '56px'],
]),
], [
'desktop' => ['backgroundColor' => '@tinte', 'color' => '@papier', 'padding' => ['top' => '112px', 'bottom' => '112px']],
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']],
], 'bewertung', "position:relative;overflow:hidden\n&::after{content:'';position:absolute;right:-120px;top:-120px;width:360px;height:360px;border:2px solid rgba(46,168,224,.18);border-radius:50%;pointer-events:none}"),
// 5 – Kontakt: Blau, große Telefonnummer, Knopf zur Kontaktseite.
$section('kontakt-bereich', [
$row('kontakt-zeile', [
$column('kontakt-text', [
$heading('kontakt-titel', 'Sprechen Sie mit uns.', 2, [...$h2Hell, 'desktop' => [...$h2Hell['desktop'], 'fontSize' => '40px', 'margin' => ['top' => '0px', 'bottom' => '10px']]]),
$text('kontakt-satz', 'Sie haben Fragen zu einer Immobilie, zum Suchauftrag oder zur Bewertung? Rufen Sie an oder schreiben Sie uns.', $fliess('#e3f1f9', '0px', '560px')),
]),
$zeile('kontakt-wege', [
$knopf('kontakt-anrufen', $telefon, $tel, 'weiss', [], 'phone'),
$knopf('kontakt-schreiben', 'Nachricht schreiben', '/kontakt', 'hell'),
], '14px', ['flexWrap' => 'wrap', 'justifyContent' => 'flex-end'], ['justifyContent' => 'flex-start']),
], $zweiSpalten, [
'desktop' => ['gap' => '40px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '3 2'],
'tablet' => ['gap' => '28px'],
]),
], [
'desktop' => ['backgroundColor' => '@blau', 'color' => '@papier', 'padding' => ['top' => '64px', 'bottom' => '64px']],
'mobile' => ['padding' => ['top' => '48px', 'bottom' => '48px']],
], 'kontakt'),
],
];
// ---------------------------------------------------------------------------------------------
// Seiten anlegen und veröffentlichen
// ---------------------------------------------------------------------------------------------
$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): string {
$id = $pages->createPage($slug, null, 'page', $dokument);
if ('' !== $seoTitel) {
$db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]);
}
if ('' !== $teilbild) {
$db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]);
}
$seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]);
$pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']);
echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n";
return $id;
};
// Hero-Text steigt beim Laden auf; der Abschnittshintergrund ist das LCP-Element und bleibt ruhig.
$startseite = $einblenden($startseite, [
'hero-titel' => ['rise', 'short'],
'hero-satz' => ['rise', 'medium'],
'hero-knoepfe' => ['rise', 'medium'],
'hero-fakten' => ['fade', 'long'],
'leistungen-kopf' => ['rise'],
'leistung-1' => ['rise'],
'leistung-2' => ['rise', 'short'],
'leistung-3' => ['rise', 'medium'],
'suche-bild-spalte' => ['left'],
'suche-text' => ['rise', 'short'],
'bewertung-text' => ['rise'],
'bewertung-bild-spalte' => ['right', 'short'],
'kontakt-zeile' => ['rise'],
]);
$homeId = $anlegen('home', $startseite, 'EBI Immobilien Karlsruhe – Immobilien kaufen, mieten und bewerten lassen', $medien['karlsruhe']['id']);
/** Der Seitenkopf der Unterseiten: Quartiersfoto, stark abgedunkelt, Kleinzeile, Titel, Vorspann. */
$seitenkopf = static fn (string $praefix, string $klein, string $titel, string $vorspann) => $section($praefix.'-kopf', [
$kleinzeile($praefix.'-klein', $klein, '@himmel'),
$heading($praefix.'-h1', $titel, 1, [
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '54px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '18px']],
'mobile' => ['fontSize' => '36px'],
]),
$text($praefix.'-vorspann', $vorspann, [
'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '#dbe5ee', 'margin' => ['bottom' => '0px']],
'mobile' => ['fontSize' => '16px'],
]),
], [
'desktop' => ['backgroundImage' => $medien['karlsruhe']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0f2133', 'overlayOpacity' => 0.78,
'color' => '@papier', 'padding' => ['top' => '88px', 'bottom' => '80px'], 'borderWidth' => $nurUnten('4px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau'],
'mobile' => ['padding' => ['top' => '52px', 'bottom' => '48px']],
]);
// Das Formular in einer weißen Karte: Felder auf Sand, Fokus in Logo-Blau, Schrittanzeige lesbar.
$formularCss = '& .block-form{max-width:none}
& .form-field label{font-size:15px;font-weight:700;color:#14273a}
& .form-field input{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:50px}
& .form-field select{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:50px}
& .form-field textarea{background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:13px 14px;font-size:16px;color:#14273a;min-height:130px}
& .form-field input:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
& .form-field select:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
& .form-field textarea:focus{border-color:#0b6aa2;outline:3px solid #2ea8e04d;outline-offset:0}
& .field-note{font-size:13px;color:#4d5966;opacity:1}
& .form-step-intro{color:#4d5966;opacity:1}
& .form-progress-step{color:#4d5966;opacity:1}
& .form-choice{display:flex;flex-wrap:wrap;gap:10px}
& .form-choice .check-row{gap:10px;align-items:center;background:#faf8f4;border:1px solid #cfc6b6;border-radius:6px;padding:11px 16px;transition:border-color .2s,background-color .2s}
& .form-choice .check-row:hover{border-color:#0b6aa2;background:#fff}
& .check-row label{font-weight:500;font-size:15px;color:#14273a}
& .check-row input{accent-color:#0b6aa2;min-height:0;width:18px;height:18px;padding:0;margin:0}
& .cms-button{min-height:54px;font-size:16px;transition:transform .2s ease,background-color .2s ease}
& .cms-button:hover{transform:translateY(-2px)}';
$karte = [
'desktop' => ['backgroundColor' => '@papier', 'borderRadius' => $rund('10px'), 'padding' => $seiten('44px'), 'shadowY' => '18px', 'shadowBlur' => '50px', 'shadowColor' => '#14273a1f'],
'mobile' => ['padding' => ['top' => '26px', 'right' => '20px', 'bottom' => '28px', 'left' => '20px']],
];
/** Die Kontaktkarte für die Seitenspalte: Navy, Telefon und E-Mail. */
$kontaktKarte = static fn (string $praefix) => $column($praefix.'-kontakt', [
$heading($praefix.'-kontakt-titel', 'Lieber direkt sprechen?', 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'fontWeight' => '600', 'color' => '@papier', 'margin' => ['top' => '0px', 'bottom' => '16px']]]),
$zeile($praefix.'-kontakt-telefon', [
$symbol($praefix.'-kontakt-telefon-icon', 'phone', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
$text($praefix.'-kontakt-telefon-text', '<a href="'.$tel.'">'.$telefon.'</a>', ['desktop' => ['fontSize' => '16px', 'margin' => ['bottom' => '0px']]], "& a{color:#fff;text-decoration:none;font-weight:700}\n& a:hover{color:#7fd0f2}"),
], '12px', ['margin' => ['bottom' => '10px']]),
$zeile($praefix.'-kontakt-mail', [
$symbol($praefix.'-kontakt-mail-icon', 'mail', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
$text($praefix.'-kontakt-mail-text', '<a href="mailto:'.$mail.'">'.$mail.'</a>', ['desktop' => ['fontSize' => '16px', 'margin' => ['bottom' => '0px']]], "& a{color:#fff;text-decoration:none;overflow-wrap:anywhere}\n& a:hover{color:#7fd0f2}"),
], '12px', ['margin' => ['bottom' => '10px']]),
$zeile($praefix.'-kontakt-ort', [
$symbol($praefix.'-kontakt-ort-icon', 'pin', 20, ['desktop' => ['color' => '@logo-blau', 'flexShrink' => 0]]),
$text($praefix.'-kontakt-ort-text', $strasse.', '.$plz.' '.$ort, ['desktop' => ['fontSize' => '15px', 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]]),
], '12px', ['alignItems' => 'flex-start']),
], ['desktop' => ['backgroundColor' => '@tinte', 'borderRadius' => $rund('10px'), 'padding' => $seiten('30px')]]);
/** Eine Formularseite: Kopf, Formularkarte links, Seitenspalte mit Hinweisen und Kontakt rechts. */
$formularseite = static function (string $praefix, string $klein, string $titel, string $vorspann, string $kartentitel, string $formular, array $seitenspalte) use ($seitenkopf, $section, $row, $column, $heading, $text, $karte, $formularCss, $kontaktKarte, $linkCss, $zweiSpalten): array {
return [
$seitenkopf($praefix, $klein, $titel, $vorspann),
$section($praefix, [
$row($praefix.'-zeile', [
$column($praefix.'-karte', [
$heading($praefix.'-karte-titel', $kartentitel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '28px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '24px']], 'mobile' => ['fontSize' => '24px']]),
['id' => $praefix.'-formular', 'type' => 'form', 'props' => ['form' => $formular], 'css' => $formularCss],
$text($praefix.'-hinweis', 'Ihre Angaben verwenden wir nur für Ihre Anfrage. Mehr dazu in der <a href="/datenschutz">Datenschutzerklärung</a>.', ['desktop' => ['fontSize' => '13px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '18px', 'bottom' => '0px']]], $linkCss),
], $karte),
$column($praefix.'-seite', [...$seitenspalte, $kontaktKarte($praefix)], ['desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'gap' => '24px']]),
], $zweiSpalten, [
'desktop' => ['gap' => '40px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'columnWidths' => '2 1'],
'tablet' => ['gap' => '28px'],
]),
], [
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '64px', 'bottom' => '100px']],
'mobile' => ['padding' => ['top' => '28px', 'bottom' => '60px']],
]),
];
};
/** Hinweiskarte der Seitenspalte: Titel und Haken. */
$hinweis = static fn (string $id, string $titel, array $punkte) => $column($id, [
$heading($id.'-titel', $titel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'fontWeight' => '600', 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '18px']]]),
...array_map(static fn (int $i, string $p) => $haken($id.'-punkt-'.$i, $p, '@text', '@blau'), array_keys($punkte), $punkte),
], ['desktop' => ['backgroundColor' => '@papier', 'borderRadius' => $rund('10px'), 'padding' => $seiten('30px'), 'borderWidth' => $nurOben('3px'), 'borderStyle' => 'solid', 'borderColor' => '@logo-blau']]);
$anlegen('suchauftrag', $einblenden([
'schemaVersion' => $SCHEMA, 'title' => 'Suchauftrag',
'description' => 'Ihre Traumimmobilie war noch nicht dabei? Erstellen Sie einen Suchauftrag bei EBI Immobilien in Karlsruhe – wir melden uns, sobald etwas Passendes dabei ist.',
'blocks' => $formularseite('suchauftrag', 'Immobilien-Suchauftrag', 'Suchauftrag erstellen', 'Ihre Traumimmobilie haben Sie noch nicht gefunden? Wir unterstützen Sie gern bei Ihrer Suche – sagen Sie uns, was Sie sich wünschen.', 'Was suchen Sie?', 'suchauftrag', [
$hinweis('suchauftrag-ablauf', 'So geht es weiter', [
'Wir sehen uns Ihre Wünsche persönlich an.',
'Passt ein Objekt, melden wir uns bei Ihnen.',
'Eine Besichtigung vereinbaren wir direkt mit Ihnen.',
]),
]),
], ['suchauftrag-karte' => ['rise'], 'suchauftrag-seite' => ['rise', 'short']]), 'Suchauftrag erstellen | '.$firma.' Karlsruhe');
$anlegen('immobilienbewertung', $einblenden([
'schemaVersion' => $SCHEMA, 'title' => 'Immobilienbewertung',
'description' => 'Was ist Ihre Immobilie wert? EBI Immobilien bewertet Wohnungen, Häuser, Mehrfamilienhäuser und Grundstücke in Karlsruhe und Umgebung.',
'blocks' => $formularseite('bewertung', 'Immobilienbewertung', 'Was ist Ihre Immobilie wert?', 'Gerne bewerten wir Ihre Immobilie und unterstützen Sie beim Verkauf oder bei der Vermietung. Beantworten Sie dazu ein paar kurze Fragen.', 'Bewertung anfragen', 'bewertung', [
$hinweis('bewertung-ablauf', 'Gut zu wissen', [
'Drei kurze Schritte, ungefähre Angaben genügen.',
'Für Verkauf und Vermietung.',
'Wir melden uns persönlich mit einer Einschätzung.',
]),
]),
], ['bewertung-karte' => ['rise'], 'bewertung-seite' => ['rise', 'short']]), 'Immobilienbewertung Karlsruhe | '.$firma);
$anlegen('kontakt', $einblenden([
'schemaVersion' => $SCHEMA, 'title' => 'Kontakt',
'description' => 'So erreichen Sie EBI Immobilien in Karlsruhe: Telefon, E-Mail und Kontaktformular.',
'blocks' => $formularseite('kontakt', 'Kontakt', 'Kontaktieren Sie uns', 'Sie haben Fragen zu einer Immobilie, zum Suchauftrag oder zur Bewertung? Wir freuen uns auf Ihre Nachricht.', 'Ihre Nachricht', 'kontakt', [
$hinweis('kontakt-themen', 'Wobei wir helfen', [
'Kauf und Miete von Wohnungen und Häusern',
'<a href="/suchauftrag">Suchauftrag</a> für Ihre Wunschimmobilie',
'<a href="/immobilienbewertung">Bewertung</a> für Verkauf oder Vermietung',
]),
]),
], ['kontakt-karte' => ['rise'], 'kontakt-seite' => ['rise', 'short']]), 'Kontakt | '.$firma.' Karlsruhe');
/** Eine Rechtsseite: Seitenkopf, dann Absätze mit Zwischenüberschriften. */
$rechtsseite = static function (string $praefix, string $titel, string $vorspann, array $absaetze) use ($seitenkopf, $section, $column, $heading, $text, $linkCss, $nurOben): array {
$punkte = [];
foreach ($absaetze as $i => [$zwischentitel, $inhalt]) {
if ('' !== $zwischentitel) {
$punkte[] = $heading($praefix.'-h2-'.$i, $zwischentitel, 2, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '23px', 'fontWeight' => '600', 'lineHeight' => 1.3, 'color' => '@tinte', 'margin' => ['top' => '36px', 'bottom' => '10px']]]);
}
$punkte[] = $text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss);
}
return [
$seitenkopf($praefix, 'Rechtliches', $titel, $vorspann),
$section($praefix, [
$column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '820px']]),
], ['desktop' => ['padding' => ['top' => '32px', 'bottom' => '100px']], 'mobile' => ['padding' => ['top' => '12px', 'bottom' => '64px']]]),
];
};
$anlegen('impressum', [
'schemaVersion' => $SCHEMA, 'title' => 'Impressum',
'description' => 'Anbieterangaben von EBI Immobilien, Hopfenstraße 10b, 76185 Karlsruhe.',
'blocks' => $rechtsseite('impressum', 'Impressum', 'Angaben gemäß § 5 DDG.', [
['Anbieter', $firma.'<br>'.$strasse.'<br>'.$plz.' '.$ort],
['Kontakt', 'Telefon: <a href="'.$tel.'">'.$telefon.'</a><br>E-Mail: <a href="mailto:'.$mail.'">'.$mail.'</a>'],
['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'],
['Haftung für Inhalte', 'Die Inhalte dieser Website wurden mit größtmöglicher Sorgfalt erstellt. Für die Richtigkeit, Vollständigkeit und Aktualität der Inhalte übernehmen wir jedoch keine Gewähr. Angaben zu Immobilien sind unverbindlich; maßgeblich ist das jeweilige Exposé.'],
['Haftung für Links', 'Diese Website enthält Verknüpfungen zu Websites Dritter. Für deren Inhalte ist der jeweilige Betreiber verantwortlich. Bei Bekanntwerden von Rechtsverstößen entfernen wir solche Links umgehend.'],
['Urheberrecht', 'Die Inhalte dieser Website unterliegen dem deutschen Urheber- und Leistungsschutzrecht. Jede Verwertung außerhalb der Grenzen des Urheberrechts bedarf der vorherigen schriftlichen Zustimmung. Die Darstellung dieser Website in fremden Frames ist nur mit schriftlicher Erlaubnis zulässig.'],
['Bildnachweis', 'Fotos: Pixabay (Pixabay-Lizenz).'],
]),
], 'Impressum | '.$firma);
$anlegen('datenschutz', [
'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz',
'description' => 'Welche Daten beim Besuch der Website von EBI Immobilien und bei einer Anfrage verarbeitet werden.',
'blocks' => $rechtsseite('datenschutz', 'Datenschutzerklärung', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei einer Anfrage verarbeitet werden – und welche Rechte Sie haben.', [
['1. Verantwortlicher', $firma.', '.$strasse.', '.$plz.' '.$ort.'. Telefon <a href="'.$tel.'">'.$telefon.'</a>, E-Mail <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
['2. Besuch dieser Website', 'Beim Aufruf verarbeitet unser Hostingdienstleister technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO. Mit dem Hostingdienstleister besteht ein Vertrag über die Auftragsverarbeitung nach Art. 28 DSGVO. Die Serverprotokolle werden nach spätestens sieben Tagen gelöscht oder anonymisiert, sofern sie nicht zur Aufklärung eines Sicherheitsvorfalls länger gebraucht werden.'],
['3. Cookies und externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'],
['4. Suchauftrag, Bewertung und Kontaktformular', 'Wenn Sie uns über ein Formular schreiben, verarbeiten wir Ihre Angaben – etwa Name, E-Mail-Adresse, Telefonnummer, Anschrift und die Angaben zu Ihrer Wunschimmobilie oder Ihrer Immobilie –, um Ihre Anfrage zu bearbeiten und Ihnen passende Angebote zu machen (Art. 6 Abs. 1 lit. b DSGVO). Die Anfrage wird in unserem System gespeichert und per E-Mail an uns weitergeleitet. Wir löschen sie, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'],
['5. Kontakt per E-Mail oder Telefon', 'Wenn Sie uns schreiben oder anrufen, verarbeiten wir Ihre Angaben, um Ihr Anliegen zu bearbeiten (Art. 6 Abs. 1 lit. b oder f DSGVO).'],
['6. Ihre Rechte', 'Sie haben unter den gesetzlichen Voraussetzungen das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit. Einer Verarbeitung auf Grundlage von Art. 6 Abs. 1 lit. f DSGVO können Sie widersprechen.'],
['7. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'],
['', 'Fragen zum Datenschutz beantworten wir gern unter <a href="mailto:'.$mail.'">'.$mail.'</a>.'],
]),
], 'Datenschutz | '.$firma);
$fehlerId = $anlegen('404', [
'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden',
'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.',
'blocks' => [
$section('fehler', [
$kleinzeile('fehler-label', 'Fehler 404'),
$heading('fehler-titel', 'Diese Seite gibt es leider nicht.', 1, [
'desktop' => ['fontFamily' => 'heading', 'fontSize' => '56px', 'fontWeight' => '500', 'lineHeight' => 1.1, 'color' => '@tinte', 'margin' => ['top' => '0px', 'bottom' => '20px']],
'mobile' => ['fontSize' => '36px'],
]),
$text('fehler-text', 'Vielleicht wurde sie verschoben. Von der Startseite aus finden Sie alles Wichtige – oder Sie erstellen direkt einen Suchauftrag.', $fliess('@text', '32px', '520px')),
$zeile('fehler-knoepfe', [
$knopf('fehler-knopf', 'Zur Startseite', '/'),
$knopf('fehler-suche', 'Suchauftrag erstellen', '/suchauftrag', 'rahmen', [], 'none'),
], '14px', ['flexWrap' => 'wrap']),
], [
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '112px', 'bottom' => '132px']],
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']],
]),
],
]);
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]);
// Alte WordPress-Adressen von ebi-immobilien.de.
foreach ([
'/contact-us' => '/kontakt',
'/immobilienbewertungen' => '/immobilienbewertung',
'/immobilien' => '/suchauftrag',
'/property-search' => '/suchauftrag',
] as $von => $nach) {
$redirects->save($von, $nach, 301);
}
echo "Weiterleitungen angelegt\n";
// ---------------------------------------------------------------------------------------------
// Menü, Einstellungen, Konto
// ---------------------------------------------------------------------------------------------
$seiteId = static fn (string $pfad): string => (string) $db->fetchOne('SELECT id FROM page WHERE path = ?', [$pfad]);
$eintrag = static fn (string $label, string $pfad, array $extra = []): array => ['label' => $label, 'type' => 'page', 'pageId' => $seiteId($pfad), 'children' => [], ...$extra];
$menus->save('header', 'Hauptnavigation', [
['label' => 'Leistungen', 'type' => 'page', 'pageId' => $homeId, 'anchor' => 'leistungen', 'children' => []],
$eintrag('Suchauftrag', 'suchauftrag'),
$eintrag('Immobilienbewertung', 'immobilienbewertung'),
$eintrag('Kontakt', 'kontakt'),
// „Jetzt anrufen“ als Menü-Button (ADR 110); auf dem Telefon im aufgeklappten Menü (Umut, 13.10.2026).
$eintrag('Jetzt anrufen', 'kontakt', [
'type' => 'url', 'url' => $tel, 'pageId' => null,
'icon' => 'phone', 'iconPosition' => 'before',
'button' => [
'id' => 'm'.substr(hash('sha256', 'ebi-immobilien-anrufen'), 0, 10), 'shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal',
'fill' => '@blau', 'ink' => '@papier', 'hoverFill' => '@tinte', 'hoverInk' => '@papier',
'mobile' => 'menu', 'iconMotion' => 'none',
],
]),
], 0);
echo "Menü angelegt\n";
$settings->assignNotFoundPage($fehlerId, '');
$settings->save([
'legalName' => $firma,
'description' => 'EBI Immobilien in Karlsruhe: Immobilien kaufen und mieten, Suchauftrag und Immobilienbewertung.',
'email' => $mail, 'phone' => '+49 172 6110975',
'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE',
'areaServed' => 'Karlsruhe',
'localBusiness' => true, 'llmsTxt' => true,
'faviconMediaId' => $medien['favicon']['id'],
'stickyHeader' => true, 'headerOverlay' => false,
'motion' => 'always',
'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz',
// Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste.
'consentBanner' => false, 'scrollTop' => true, 'postMetaHeader' => false,
]);
echo "Einstellungen gespeichert\n";
$passwort = bin2hex(random_bytes(12));
$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']);
file_put_contents($root.'/var/ebi-immobilien-zugang.txt', "Verwaltung: Benutzer „umut“, Passwort: $passwort\n");
echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n";
+13
View File
@@ -113,6 +113,19 @@ final class MailController extends AbstractController
return $this->backToMail($request);
}
/** „Letzte Versuche“ leeren: var/log/mail.log und mail.log.1. */
#[Route('/%kant.admin_path%/system/e-mail/protokoll', name: 'admin_mail_clear', methods: ['POST'])]
public function clear(Request $request): Response
{
$this->denyAccessUnlessGranted('ROLE_ADMIN');
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
return $this->backToMail($request, 'Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
$this->mail->clearLog();
return $this->backToMail($request, 'Das Protokoll ist leer.', 'ok');
}
/** @param array{an: string, von: string} $eingabe */
private function mailAdressFehler(array $eingabe): ?string
{
+10
View File
@@ -212,6 +212,16 @@ final class MailCheck
return $eintraege;
}
/** Protokoll leeren: die Datei und ihre ältere Hälfte. */
public function clearLog(): void
{
foreach ([$this->logFile, $this->logFile.'.1'] as $datei) {
if (is_file($datei)) {
@unlink($datei);
}
}
}
public function logFile(): string
{
return $this->logFile;
+15
View File
@@ -0,0 +1,15 @@
{# Ein Versuch, gleich gebaut für „Letzte Versuche“ und „Alle Wege ausprobiert“: Zustand, worum es
ging, wann - bei Fehlern der Hinweis vorn und die Meldung des Servers zugeklappt dahinter. #}
<li class="mail-entry">
<span class="pill {{ eintrag.ok ? 'pill-live' : 'pill-error' }}">{{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</span>
<div class="mail-entry-text">
<p class="mail-entry-line">
{% if titel is defined %}<strong>{{ titel }}</strong>{% else %}{{ eintrag.anlass }} an <code>{{ eintrag.an }}</code>{% if eintrag.von %} von <code>{{ eintrag.von }}</code>{% endif %}{% if eintrag.weg is defined and eintrag.weg %} <span class="muted">· {{ eintrag.weg }}</span>{% endif %}{% endif %}
</p>
{% if eintrag.zeit is defined %}<p class="mail-entry-time">{{ eintrag.zeit }} UTC</p>{% endif %}
{% if not eintrag.ok %}
{% if eintrag.hinweis %}<p class="mail-entry-hint">{{ eintrag.hinweis }}</p>{% endif %}
<details class="mail-entry-detail"><summary>Meldung des Servers</summary><pre>{{ eintrag.meldung }}</pre></details>
{% endif %}
</div>
</li>
+105 -90
View File
@@ -10,120 +10,135 @@
{% set f = mailForm ?? {} %}
{% set e = mail.einstellung %}
{% set weg = f.weg ?? e.weg %}
<div class="card" id="mail">
{% set beschreibung = {
smtp: 'Empfohlen. Ein echtes Postfach, unabhängig vom Hoster.',
native: 'Über den Server. Der Absender muss zu einer Domain beim Hoster gehören.',
'sendmail-t': 'Über den Server, falls mail() fehlt.',
'sendmail-bs': 'Über den Server, bei manchen Hostern gesperrt.',
} %}
{% if mailNotice %}
<p class="notice {{ mailNotice.art == 'ok' ? 'ok' : 'error' }}" role="{{ mailNotice.art == 'ok' ? 'status' : 'alert' }}">{{ mailNotice.text }}</p>
{% endif %}
<form class="card mail-form" id="mail" action="{{ path('admin_mail_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<div class="card-head"><h2>Versandweg</h2><span class="muted">gilt für alle Formulare</span></div>
<div class="card-body">
<dl class="facts">
<dt>Eingestellt</dt>
<dd>
{% if mail.gueltig %}
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">· angemeldet als</span> <code>{{ mail.benutzer }}</code>{% endif %}
{% else %}
<span class="check-bad"><strong>Die Einstellung ist ungültig.</strong></span>
{% endif %}
<br><span class="muted">Gespeichert in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>.{% if mail.quelle != '.env' %} Sie hat dort Vorrang; speichern lässt sich hier nichts, testen schon.{% endif %}</span>
</dd>
</dl>
{% if mailNotice %}
<p class="notice {{ mailNotice.art == 'ok' ? 'ok' : 'error' }} mail-result" role="{{ mailNotice.art == 'ok' ? 'status' : 'alert' }}">{{ mailNotice.text }}</p>
{% endif %}
{% if mailResult %}
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet' }}</strong>{% if mailResult.weg is defined %} <span>({{ mailResult.weg }})</span>{% endif %}.{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
<div class="mail-current">
<span class="muted">Eingestellt</span>
{% if mail.gueltig %}
<strong>{{ mail.art }}</strong>{% if mail.ziel %} <code>{{ mail.ziel }}</code>{% endif %}{% if mail.benutzer %} <span class="muted">als</span> <code>{{ mail.benutzer }}</code>{% endif %}
{% else %}
<strong class="danger-text">Die Einstellung ist ungültig.</strong>
{% endif %}
<span class="muted mail-current-source">in <code>{{ mail.quelle }}</code> als <code>MAILER_DSN</code>{% if mail.quelle != '.env' %} – hat Vorrang, speichern lässt sich hier nichts, testen schon{% endif %}</span>
</div>
{% endif %}
{% if mailResults %}
<div class="mail-result">
<p><strong>Alle Wege ausprobiert</strong> <span class="muted">– an {{ (f.an ?? '') }}. Was angenommen wurde, kann gespeichert werden; angekommen ist es damit noch nicht, bitte im Postfach nachsehen.</span></p>
<ul class="log-list">
{% for r in mailResults %}
<li class="{{ r.ok ? 'check-ok' : 'check-bad' }}">
<strong>{{ r.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ r.weg }}
{% if not r.ok %}<br><code>{{ r.meldung }}</code>{% if r.hinweis %}<br><span class="muted">{{ r.hinweis }}</span>{% endif %}{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% endif %}
<form class="mail-form" action="{{ path('admin_mail_save') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<fieldset>
<legend>Versandweg</legend>
<label class="check-label" for="weg-smtp"><input id="weg-smtp" type="radio" name="weg" value="smtp"{% if weg == 'smtp' %} checked{% endif %}> <span>SMTP mit Postfach <span class="muted">– empfohlen, unabhängig vom Hoster</span></span></label>
<fieldset class="card-section">
<legend>Weg wählen</legend>
<div class="option-list">
<label class="option" for="weg-smtp"><input id="weg-smtp" type="radio" name="weg" value="smtp"{% if weg == 'smtp' %} checked{% endif %}><span><strong>SMTP mit Postfach</strong><span>{{ beschreibung.smtp }}</span></span></label>
{% for key, w in mail.wege %}
<label class="check-label" for="weg-{{ key }}"><input id="weg-{{ key }}" type="radio" name="weg" value="{{ key }}"{% if weg == key %} checked{% endif %}> {{ w.label }}</label>
<label class="option" for="weg-{{ key }}"><input id="weg-{{ key }}" type="radio" name="weg" value="{{ key }}"{% if weg == key %} checked{% endif %}><span><strong>{{ w.label }}</strong><span>{{ beschreibung[key] ?? '' }}</span></span></label>
{% endfor %}
</fieldset>
</div>
</fieldset>
<fieldset class="smtp-fields">
<legend>SMTP</legend>
<div class="mail-grid">
<div>
<label for="mail-server">Server</label>
<input id="mail-server" name="server" type="text" inputmode="url" autocomplete="off" spellcheck="false" maxlength="200" placeholder="smtp.ionos.de" value="{{ f.server ?? e.server }}">
</div>
<div>
<label for="mail-port">Port</label>
<input id="mail-port" name="port" type="number" min="1" max="65535" value="{{ f.port ?? e.port }}" aria-describedby="mail-port-help">
</div>
<div>
<label for="mail-user">Benutzer</label>
<input id="mail-user" name="benutzer" type="text" autocomplete="off" spellcheck="false" maxlength="200" placeholder="formular@meine-firma.de" value="{{ f.benutzer ?? e.benutzer }}">
</div>
<div>
<label for="mail-pass">Passwort</label>
<input id="mail-pass" name="passwort" type="password" autocomplete="new-password" maxlength="500" placeholder="{{ e.passwort ? 'gespeichert – leer lassen zum Behalten' : '' }}">
</div>
<fieldset class="card-section smtp-fields">
<legend>SMTP-Zugang</legend>
<div class="mail-grid">
<div>
<label for="mail-server">Server</label>
<input id="mail-server" name="server" type="text" inputmode="url" autocomplete="off" spellcheck="false" maxlength="200" placeholder="smtp.ionos.de" value="{{ f.server ?? e.server }}">
</div>
<p class="field-help" id="mail-port-help">587 mit STARTTLS ist üblich (IONOS, Microsoft 365: <code>smtp.office365.com</code>), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in <code>.env</code>.</p>
</fieldset>
<fieldset>
<legend>Testmail</legend>
<div class="mail-grid">
<div>
<label for="mail-to">An</label>
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" placeholder="name@beispiel.de" value="{{ f.an ?? (mailResult.an ?? '') }}">
</div>
<div>
<label for="mail-from">Absender</label>
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ f.von ?? mail.absender }}" aria-describedby="mail-from-help">
</div>
<div>
<label for="mail-port">Port</label>
<input id="mail-port" name="port" type="number" min="1" max="65535" value="{{ f.port ?? e.port }}" aria-describedby="mail-port-help">
</div>
<p class="field-help" id="mail-from-help">Derselbe Absender wie bei den Formularen. Über den Server (mail(), sendmail) muss er zu einer eigenen Domain beim Hoster gehören, bei SMTP ist es meist das angemeldete Postfach.</p>
</fieldset>
<div>
<label for="mail-user">Benutzer</label>
<input id="mail-user" name="benutzer" type="text" autocomplete="off" spellcheck="false" maxlength="200" placeholder="formular@meine-firma.de" value="{{ f.benutzer ?? e.benutzer }}">
</div>
<div>
<label for="mail-pass">Passwort</label>
<input id="mail-pass" name="passwort" type="password" autocomplete="new-password" maxlength="500" placeholder="{{ e.passwort ? 'gespeichert – leer lassen zum Behalten' : '' }}">
</div>
</div>
<p class="field-help" id="mail-port-help">587 mit STARTTLS ist üblich (IONOS, Microsoft 365: <code>smtp.office365.com</code>), 465 verschlüsselt von Anfang an. Das Passwort wird nie angezeigt und landet nur in <code>.env</code>.</p>
</fieldset>
<div class="mail-actions">
<fieldset class="card-section">
<legend>Testmail</legend>
<div class="mail-grid">
<div>
<label for="mail-to">An</label>
<input id="mail-to" name="to" type="email" autocomplete="email" maxlength="200" placeholder="name@beispiel.de" value="{{ f.an ?? (mailResult.an ?? '') }}">
</div>
<div>
<label for="mail-from">Absender</label>
<input id="mail-from" name="from" type="email" maxlength="200" placeholder="leer: dieselbe Adresse" value="{{ f.von ?? mail.absender }}" aria-describedby="mail-from-help">
</div>
</div>
<p class="field-help" id="mail-from-help">Derselbe Absender wie bei den Formularen. Über den Server (mail(), sendmail) muss er zu einer eigenen Domain beim Hoster gehören, bei SMTP ist es meist das angemeldete Postfach.</p>
<div class="button-row">
<button class="secondary" type="submit" name="aktion" value="test">Diesen Weg testen</button>
<button class="secondary" type="submit" name="aktion" value="alle">Alle Wege testen</button>
{% if mail.quelle == '.env' %}<button type="submit" name="aktion" value="speichern">Diesen Weg speichern</button>{% endif %}
</div>
<p class="field-help">„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier. Gespeichert wird {{ mail.quelle == '.env' ? 'nur mit „Diesen Weg speichern“' : 'hier nichts, weil MAILER_DSN in der Server-Umgebung steht' }}.</p>
</form>
<p class="field-help">„Alle Wege testen“ schickt je Weg eine Testmail – bis zu vier.</p>
{% if mailResult %}
<div class="notice {{ mailResult.ok ? 'ok' : 'error' }} mail-result" role="{{ mailResult.ok ? 'status' : 'alert' }}">
<p><strong>{{ mailResult.ok ? 'Testmail an ' ~ mailResult.an ~ ' vom Mailserver angenommen' : 'Testmail an ' ~ mailResult.an ~ ' nicht versendet' }}</strong>{% if mailResult.weg is defined %} ({{ mailResult.weg }}){% endif %}.{% if not mailResult.ok %} {{ mailResult.meldung }}{% endif %}</p>
{% if mailResult.hinweis %}<p>{{ mailResult.hinweis }}</p>{% endif %}
{% if mailResult.dialog %}<details><summary>Antworten des Mailservers</summary><pre>{{ mailResult.dialog }}</pre></details>{% endif %}
</div>
{% endif %}
{% if mailResults %}
<div class="mail-result">
<p class="mail-result-head"><strong>Alle Wege ausprobiert</strong> <span class="muted">– an {{ f.an ?? '' }}. Angenommen heißt noch nicht angekommen: bitte im Postfach nachsehen.</span></p>
<ul class="mail-log">
{% for r in mailResults %}
{% include 'admin/_mail_entry.html.twig' with {eintrag: r, titel: r.weg} only %}
{% endfor %}
</ul>
</div>
{% endif %}
</fieldset>
</div>
</div>
<div class="card-foot form-foot">
{% if mail.quelle == '.env' %}
<button class="primary" type="submit" name="aktion" value="speichern">Diesen Weg speichern</button>
<span class="muted">Erst mit „Speichern“ versenden die Formulare auf dem gewählten Weg.</span>
{% else %}
<span class="muted">MAILER_DSN steht in der Server-Umgebung – speichern lässt sich hier nichts.</span>
{% endif %}
</div>
</form>
<div class="card">
<div class="card-head"><h2>Letzte Versuche</h2><span class="muted">var/log/mail.log</span></div>
<div class="card-head">
<h2>Letzte Versuche</h2>
{% if mail.protokoll is empty %}
<span class="muted">var/log/mail.log</span>
{% else %}
<form action="{{ path('admin_mail_clear') }}" method="post">
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<button class="text-button" type="submit">Protokoll leeren</button>
</form>
{% endif %}
</div>
<div class="card-body">
{% if mail.protokoll is empty %}
<p class="muted">Noch nichts versendet – weder Testmail noch Formular.</p>
{% else %}
<ul class="log-list">
<ul class="mail-log">
{% for eintrag in mail.protokoll %}
<li class="{{ eintrag.ok ? 'check-ok' : 'check-bad' }}">
<strong>{{ eintrag.ok ? 'Angenommen' : 'Fehlgeschlagen' }}</strong> · {{ eintrag.zeit }} UTC · {{ eintrag.anlass }} an <code>{{ eintrag.an }}</code>{% if eintrag.von %} von <code>{{ eintrag.von }}</code>{% endif %}{% if eintrag.weg is defined and eintrag.weg %} · {{ eintrag.weg }}{% endif %}
{% if not eintrag.ok %}<br><code>{{ eintrag.meldung }}</code>{% if eintrag.hinweis %}<br><span class="muted">{{ eintrag.hinweis }}</span>{% endif %}{% endif %}
</li>
{% include 'admin/_mail_entry.html.twig' with {eintrag: eintrag} only %}
{% endfor %}
</ul>
<p class="field-help">Die letzten zehn Versuche aus <code>var/log/mail.log</code>, neueste zuerst. Zeiten in UTC.</p>
{% endif %}
</div>
</div>
+4 -1
View File
@@ -47,10 +47,13 @@
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_theme') }}"{{ nav|default('') == 'theme' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'theme'} only %}Design</a></li>{% endif %}
{% if is_granted('ROLE_PUBLISHER') %}<li><a class="nav-item" href="{{ path('admin_settings') }}"{{ nav|default('') == 'settings' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'settings'} only %}Einstellungen</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_system') }}"{{ nav|default('') == 'system' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'system'} only %}System</a>
{# Unterpunkte von System: eigene Seiten, damit „System“ nicht alles trägt. #}
{# Unterpunkte von System: eigene Seiten, damit „System“ nicht alles trägt. Sichtbar nur
im Bereich System, sonst stünde das Menü dauerhaft aufgeklappt. #}
{% if nav|default('') in ['system', 'mail'] %}
<ul class="nav-sub">
<li><a class="nav-item" href="{{ path('admin_mail') }}"{{ nav|default('') == 'mail' ? ' aria-current="page"' : '' }}>E-Mail-Versand</a></li>
</ul>
{% endif %}
</li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_tasks') }}"{{ nav|default('') == 'tasks' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'tasks'} only %}Aufgaben</a></li>{% endif %}
{% if is_granted('ROLE_ADMIN') %}<li><a class="nav-item" href="{{ path('admin_plugins') }}"{{ nav|default('') == 'plugins' ? ' aria-current="page"' : '' }}>{% include 'admin/icon.html.twig' with {name: 'plugins'} only %}Plugins</a></li>{% endif %}
+8 -8
View File
@@ -268,22 +268,22 @@
<div class="card-body">
<p class="muted">Ein Inhaltspaket enthält Seiten mit allen Fassungen, Bereiche, Menüs, Medien und PDFs, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Design und Einstellungen. Nicht darin: Anfragen, Statistik, Zugangsdaten zu Diensten (Turnstile) und – ohne Häkchen – die Konten.</p>
<h3>Exportieren</h3>
<form action="{{ path('admin_package_export') }}" method="post">
<form class="card-section" action="{{ path('admin_package_export') }}" method="post">
<h3>Exportieren</h3>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<label class="check-label"><input type="checkbox" name="benutzer" value="1"> Konten mit Passwörtern mitnehmen</label>
<p class="field-help">Nur nötig, wenn die andere Website noch kein Konto hat. Das Paket enthält dann die Passwort-Hashes – nicht weitergeben.</p>
<button class="secondary" type="submit">Paket herunterladen</button>
<div class="button-row"><button class="secondary" type="submit">Paket herunterladen</button></div>
</form>
<h3>Importieren</h3>
<form action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
<form class="card-section" action="{{ path('admin_package_import') }}" method="post" enctype="multipart/form-data">
<h3>Importieren</h3>
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
<label for="paket-datei">Paket hochladen (.zip)</label>
<label class="field-first" for="paket-datei">Paket hochladen (.zip)</label>
<input id="paket-datei" type="file" name="paket" accept=".zip,application/zip">
<p class="field-help">Höchstens {{ uploadLimit }} je Upload (Einstellung des Hostings). Größere Pakete per FTP nach <code>var/pakete/</code> legen, dann erscheinen sie hier zur Auswahl.</p>
{% if packages is not empty %}
<fieldset class="field-group">
<fieldset class="field-set">
<legend>Oder ein Paket aus var/pakete/</legend>
{% for package in packages %}
<label class="check-label"><input type="radio" name="wartend" value="{{ package.name }}">
@@ -296,7 +296,7 @@
<p class="field-help">Ohne Häkchen bleiben Benutzer und Passwörter dieser Website, wie sie sind.</p>
<label class="check-label"><input type="checkbox" name="bestaetigt" value="1" required> Ich weiß, dass die Inhalte dieser Website ersetzt werden.</label>
<p class="field-help">Vorher wird automatisch eine Sicherung angelegt; während des Imports antwortet die Website mit der Wartungsseite.</p>
<button class="primary" type="submit">Paket importieren</button>
<div class="button-row"><button class="primary" type="submit">Paket importieren</button></div>
</form>
</div>
</div>
+17
View File
@@ -412,6 +412,21 @@ final class SystemScreenTest extends WebTestCase
$client->followRedirect();
self::assertStringContainsString('in der Server-Umgebung gesetzt', (string) $client->getResponse()->getContent());
// Umut: „Logs leeren Funktion brauch ich“ - nur mit Token, danach ist die Liste leer.
$client->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => 'erfunden']);
self::assertFileExists($this->path.'-mail.log', 'Ohne Token bleibt das Protokoll.');
$client->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => $token]);
self::assertFileDoesNotExist($this->path.'-mail.log');
$client->followRedirect();
$html = (string) $client->getResponse()->getContent();
self::assertStringContainsString('Das Protokoll ist leer.', $html);
self::assertStringContainsString('Noch nichts versendet', $html);
self::assertStringNotContainsString('Protokoll leeren', $html, 'Ohne Einträge gibt es nichts zu leeren.');
// Der Unterpunkt steht nur im Bereich System offen, nicht dauerhaft.
$client->request('GET', '/admin/settings');
self::assertStringNotContainsString('href="/admin/system/e-mail"', (string) $client->getResponse()->getContent());
// Nur für Administratoren, und nur mit Token.
$client->request('POST', '/admin/system/e-mail', ['_csrf_token' => 'erfunden', 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'to' => 'umut@cms.test']);
self::assertEmailCount(0);
@@ -420,6 +435,8 @@ final class SystemScreenTest extends WebTestCase
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
$redaktion->request('POST', '/admin/system/e-mail', ['_csrf_token' => $token, 'aktion' => 'test', 'weg' => 'smtp', 'server' => 'cms.test', 'to' => 'umut@cms.test']);
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
$redaktion->request('POST', '/admin/system/e-mail/protokoll', ['_csrf_token' => $token]);
self::assertSame(403, $redaktion->getResponse()->getStatusCode());
}
private function login(string $username = 'admin'): KernelBrowser