From 45d1141951cd4b84e8294ccb8033a7f75a6366fa Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 21 Sep 2026 07:37:04 +0200 Subject: [PATCH] Show the own site icon to Google under every address, and the KANTARCI landing page - Google kept showing the KantSites K for Elektro Bernhard after the site icon was changed. The own icon only existed at 32 and 180 px, and Google takes multiples of 48 only; /favicon.svg was a file in public/, so the web server always delivered the bundled K. - The icon is now also cut at 48 and 192 px. The head links /favicon-192.png, /favicon.ico serves the 48 px PNG, and /favicon.svg is answered by the application: the bundled mark without an own icon (now in public/assets/favicon.svg), otherwise a 302 to the 192 px PNG. Sites that set their icon earlier get the new sizes on first request. A removed icon no longer counts even though its files remain. - The archive update only adds files to public/, so it now deletes the old public/favicon.svg explicitly. - Seventh local reference site: the KANTARCI Immobilien landing page with a three-step rental request, own database, seed and reset scripts. Tests: 582 PHPUnit on PHP 8.2 and 8.4, 35 node, test:editor and test:browser green (test:browser reports only the known editor colour contrast on /grundlagen). Co-Authored-By: Claude Opus 5 --- .claude/launch.json | 20 + README.md | 4 + docs/ARCHITECTURE.md | 2 +- docs/STATUS.md | 24 + public/{ => assets}/favicon.svg | 0 scripts/build-kantarci-assets.php | 40 ++ scripts/build-kantarci-options.mjs | 83 +++ scripts/reset-kantarci-immobilien.sh | 17 + scripts/seed-kantarci-immobilien.php | 733 +++++++++++++++++++++++++++ src/Content/MediaLibrary.php | 8 +- src/Controller/MediaController.php | 75 ++- src/Ops/ArchiveUpdate.php | 13 + templates/favicon.html.twig | 4 +- tests/CmsTest.php | 46 ++ 14 files changed, 1057 insertions(+), 12 deletions(-) rename public/{ => assets}/favicon.svg (100%) create mode 100644 scripts/build-kantarci-assets.php create mode 100644 scripts/build-kantarci-options.mjs create mode 100644 scripts/reset-kantarci-immobilien.sh create mode 100644 scripts/seed-kantarci-immobilien.php diff --git a/.claude/launch.json b/.claude/launch.json index f7ab68c..2363248 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -108,6 +108,26 @@ "$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8095'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8095 -t public bin/dev-router.php" ], "port": 8095 + }, + { + "name": "kantarci-immobilien", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8098'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8098 -t public bin/dev-router.php" + ], + "port": 8098 + }, + { + "name": "kantarci-immobilien-prod", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8099'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8099 -t public bin/dev-router.php" + ], + "port": 8099 } ] } diff --git a/README.md b/README.md index 61e8ecf..468b539 100644 --- a/README.md +++ b/README.md @@ -381,6 +381,10 @@ Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Ha Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden) liegt in `var/ebsolar.db` mit Medien in `var/ebsolar-media`; die Startkonfiguration `ebsolar` in `.claude/launch.json` nutzt Port 8096, `ebsolar-prod` denselben Bestand im Produktionsmodus auf Port 8097 zum Messen. `scripts/seed-ebsolar.php ` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als wiederverwendbare Sektion, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge mit Galerie und Bildbetrachter, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `solarcheck` (acht Schritte wie das Original auf ebsolar.de, die fünf Auswahlfragen als Bildkarten – gezeichnet und gerastert von `node scripts/build-ebsolar-options.mjs`); `sh scripts/reset-ebsolar.sh [Bilderordner]` (Standard `var/ebsolar-assets`: Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos und `projects.json` aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. +### Siebte lokale Referenzwebsite: KANTARCI Immobilien (Landingpage) + +Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien GmbH, Baden-Baden) liegt in `var/kantarci-immobilien.db` mit Medien in `var/kantarci-immobilien-media`; die Startkonfiguration `kantarci-immobilien` in `.claude/launch.json` nutzt Port 8098, `kantarci-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8099 zum Messen. `sh scripts/reset-kantarci-immobilien.sh [Bilderordner]` (Standard `var/kantarci-immobilien-assets` mit `kantarci-banner.jpeg` aus dem gelieferten Paket) schneidet Logo, Hausmotiv und Seitensymbol aus dem Banner (`scripts/build-kantarci-assets.php`), zeichnet die Bildkarten der Mietanfrage (`node scripts/build-kantarci-options.mjs`), wirft die Installation weg und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` (drei Schritte mit Bildkarten und Zusammenfassung) auf. Schriften sind Arial und Georgia, es gibt nichts zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des 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`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 863c7f0..dca944b 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1054,7 +1054,7 @@ Umut: „dann brauchen wir Favicon-Möglichkeit zum Festlegen“. Ein Favicon is Naheliegend wäre gewesen, die WebP-Varianten der Mediathek zu verwenden. Dagegen sprechen zwei Dinge: die kleinste Variante ist 480 px breit (für ein 32-px-Symbol zwei Größenordnungen zu viel), und ein Favicon ist **quadratisch** – ein breites Logo würde gestaucht oder zur Briefmarke. -**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein mittiger quadratischer Ausschnitt in 32 px (Reiter) und 180 px (Startbildschirm), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png` und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt. +**Deshalb entstehen die Symboldateien beim Speichern der Einstellung, nicht beim Aufruf**: ein quadratischer Ausschnitt in 32 px (Reiter), 180 px (Startbildschirm) sowie 48 und 192 px (Google nimmt nur Vielfache von 48), als PNG, weil das jeder Browser liest. Schlägt das fehl, wird die Einstellung gar nicht erst gespeichert – ein Verweis auf ein Symbol, das es nicht gibt, wäre schlechter als das alte Symbol. Ausgeliefert werden sie unter `/favicon.png`, `/favicon-192.png`, `/favicon.ico` (48 px) und `/apple-touch-icon.png`, mit der Bildkennung als Version am Verweis, damit ein Wechsel im Browser ankommt. Auch `/favicon.svg` beantwortet die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen, mit eigenem ein 302 auf das 192er – als Datei in `public/` hätte der Webserver immer das mitgelieferte Zeichen ausgeliefert, und Suchmaschinen hätten es behalten. Nebenwirkung, die die Tests fanden: `SiteSettings` fragt die Datenbank jetzt auch aus der Nicht-gefunden-Seite heraus – und **vor** der Installation gibt es die Tabelle noch nicht. Eine gezielt gefangene `TableNotFoundException` liefert dort die Standardwerte; jeder andere Datenbankfehler bleibt ein Fehler. diff --git a/docs/STATUS.md b/docs/STATUS.md index 12c02da..336a955 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,29 @@ # Projektstatus +## Siebte Installation: Landingpage „KANTARCI Immobilien – Mietanfrage“ (18.09.2026) + +**Anlass:** Umut: „neues Projekt wir brauchen eine einfache landingpage nach gleichem prinzip. einfache landingpage impressum und datenschutz notwendig? baus ein“ – dazu das Paket `KANTARCI-Immobilien-Landingpage.zip` (Next.js/Vinext für Cloudflare mit D1-Datenbank: Kopf, dunkler Hero, Werteleiste, Mietanfrage, Fuß, ein Banner als einziges Bild). Später: „Formular gefällt mir noch nicht kannst du ein multipage formular machen ggfls mit icon checkbox“ und die Anbieterdaten (Geschäftsführer Ridvan Kantarci, Ooser Hauptstr. 51, 76532 Baden-Baden, info@kantarci-immo.de). + +**Neu.** Datenbank `var/kantarci-immobilien.db`, Medien `var/kantarci-immobilien-media`, Bildquellen `var/kantarci-immobilien-assets` (das Banner, daraus zugeschnitten Logo, Hausmotiv und Seitensymbol, dazu `options/` mit 14 Bildkarten), Startkonfigurationen `kantarci-immobilien` (Port 8098, dev) und `kantarci-immobilien-prod` (Port 8099). `sh scripts/reset-kantarci-immobilien.sh` schneidet die Bilder zu (`scripts/build-kantarci-assets.php`, GD), zeichnet die Bildkarten (`node scripts/build-kantarci-options.mjs`, Chromium), migriert und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf, Fuß, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` auf. Schriften Arial und Georgia (System). + +- **Impressum und Datenschutz sind Pflicht:** geschäftlicher Auftritt (§ 5 DDG) und ein Formular mit personenbezogenen Daten (Art. 13 DSGVO). Offen und im Text als „NOCH ZU PRÜFEN“ markiert: Handelsregister, Telefonnummer, USt-IdNr., Erlaubnis nach § 34c GewO (nur bei Vermittlung/Verwaltung für Dritte), Urheber des Hausfotos, Hosting- und Mail-Auftragsverarbeiter, Löschfrist. +- **Mietanfrage in drei Schritten** mit Zusammenfassung: Haushalt (Bildkarten Allein, Zu zweit, Familie mit Kindern, Wohngemeinschaft; Personenzahl nur bei den beiden letzten), Wohnwünsche (Zimmer als Bildkarten, Ausstattung als Mehrfachauswahl mit sechs Bildkarten, Einzug, Warmmiete, Objekt), Kontaktdaten. Der Einzug ist eine Auswahl statt eines Datums – der Formularbaukasten kennt kein Datumsfeld. +- **E-Mail:** Die Vorlage nennt `immo@kantarci-immobilien.de`, die Domain existiert aber nicht (kein DNS, 18.09.2026). Überall steht jetzt `info@kantarci-immo.de` (MX vorhanden), auch als Empfänger der Anfrage. +- **CMS-Lücken beim Aufbau:** eigenes CSS kennt kein `@media` – Raster, die am Telefon einspaltig werden sollen, laufen über `repeat(auto-fill,minmax(…))`. Verschachtelte Selektoren erlauben keine Umlaute, also auch kein `[data-label="Gewünschter Einzug"]` (Feld heißt deshalb „Einzugstermin“). Die Zusammenfassung ist selbst ein `.form-step`; wer die Schritte zweispaltig setzt, muss `.form-summary-body` über beide Spalten legen. `` in einem Textbaustein wird entfernt. Die Fehlermeldung des Formulars duzt („Bitte prüfe …“), die Seite siezt. + +**Geprüft:** Aufbau per Reset-Skript fehlerfrei; Startseite, Impressum, Datenschutz 200, unbekannte Adresse 404; im Browser (1280 px und 375 px, kein seitliches Scrollen) alle drei Schritte, Bedingung Personenzahl, Zusammenfassung und Absenden – die Anfrage liegt vollständig in `form_submission`. Mailversand ist lokal nicht eingerichtet. Lighthouse und axe noch nicht gelaufen. + +## Favicon auch für die Google-Suche (21.09.2026) + +**Anlass:** Umut: Google zeigt bei Elektro Bernhard weiter das KantSites-K, obwohl ein eigenes Symbol gesetzt ist – „mach das im Kern sobald man Favicon setzt alles ersetzen“. + +- **Ursache:** Das eigene Symbol gab es nur in 32 und 180 px, Google nimmt nur Vielfache von 48 px. Und `/favicon.svg` lag als Datei in `public/`, der Webserver lieferte also immer das K aus – dort hatte Google es beim ersten Besuch gefunden. +- **Neu:** `FAVICON_SIZES` sind 32, 48, 180 und 192. Der Kopf verweist zusätzlich auf `/favicon-192.png` (`sizes="192x192"`). `/favicon.ico` liefert das 48er-PNG. `/favicon.svg` beantwortet jetzt die Anwendung: ohne eigenes Symbol das mitgelieferte Zeichen (liegt jetzt unter `public/assets/favicon.svg`), mit eigenem ein 302 auf `/favicon-192.png`. Fehlen bei älteren Installationen die neuen Größen, entstehen sie beim ersten Abruf aus der gespeicherten Einstellung. Wird das Symbol entfernt, gelten die liegengebliebenen PNGs nicht mehr. +- **Update:** Das Archiv-Update kopiert `public/` nur hinzu; `ENTFALLEN_IN_PUBLIC` löscht deshalb die alte `public/favicon.svg`, sonst bliebe sie auf dem Server und überdeckte die Route. +- **Danach bei Google:** In der Search Console die Startseite unter „URL prüfen“ neu zur Indexierung einreichen. Google holt Favicons selten neu ab, das kann trotzdem Tage bis Wochen dauern. + +**Geprüft:** neuer Test `testGoogleGetsTheOwnSiteIconUnderEveryAddress` und der bisherige Favicon-Test grün, Update-Tests unverändert (eine vorbestehende Warnung), php-cs-fixer sauber; lokal (ohne eigenes Symbol) `/favicon.svg` 200, `/favicon.ico` → `/favicon.svg`. + ## Favicon mit Zuschnitt, Pflichtseiten per Suche, neueste zuerst (18.09.2026) **Anlass:** Umut: „Was ein Dropdown? nicht dein ernst? Sofort fixen über Mediathek auswählbar machen zurecht schneidbar machen und auch uploadbar dort machen!“ – dazu „Genauso Adresse Datenschutz und Impressum über search felder auswählbar machen“ und „Beiträge und Seiten sortieren absteigend also neueste zuerst“. diff --git a/public/favicon.svg b/public/assets/favicon.svg similarity index 100% rename from public/favicon.svg rename to public/assets/favicon.svg diff --git a/scripts/build-kantarci-assets.php b/scripts/build-kantarci-assets.php new file mode 100644 index 0000000..9af2cff --- /dev/null +++ b/scripts/build-kantarci-assets.php @@ -0,0 +1,40 @@ + + */ + +[$_, $banner, $ziel] = $argv + [null, null, null]; +if (!is_file((string) $banner) || null === $ziel) { + fwrite(STDERR, "Aufruf: php scripts/build-kantarci-assets.php \n"); + exit(1); +} +if (!is_dir($ziel)) { + mkdir($ziel, 0777, true); +} +$quelle = imagecreatefromjpeg($banner); + +$zuschnitt = static function (int $x, int $y, int $b, int $h, int $faktor = 1) use ($quelle) { + $bild = imagecreatetruecolor($b * $faktor, $h * $faktor); + imagecopyresampled($bild, $quelle, 0, 0, $x, $y, $b * $faktor, $h * $faktor, $b, $h); + + return $bild; +}; + +// Logo: Bildmarke und Schriftzug oben links, auf Weiß. +imagepng($zuschnitt(22, 8, 396, 104), $ziel.'/logo-kantarci.png', 9); + +// Hausmotiv: der Fotoanteil rechts, oberhalb des goldenen Bands. +imagejpeg($zuschnitt(500, 0, 401, 334), $ziel.'/haus.jpg', 90); + +// Seitensymbol: die Bildmarke mittig auf Weiß, 256 × 256. +$symbol = imagecreatetruecolor(256, 256); +imagefill($symbol, 0, 0, imagecolorallocate($symbol, 255, 255, 255)); +imagecopyresampled($symbol, $quelle, 28, 28, 26, 12, 200, 200, 80, 80); +imagepng($symbol, $ziel.'/symbol-kantarci.png', 9); + +echo "Bilder geschrieben nach $ziel\n"; diff --git a/scripts/build-kantarci-options.mjs b/scripts/build-kantarci-options.mjs new file mode 100644 index 0000000..606a19b --- /dev/null +++ b/scripts/build-kantarci-options.mjs @@ -0,0 +1,83 @@ +/* + * Zeichnet die Bildkarten der KANTARCI-Mietanfrage und rastert sie zu PNG. + * + * Die Formularbausteine zeigen eine Auswahl als Bildkarten, sobald eine Option ein Bild aus der + * Mediathek hat - und die Mediathek nimmt kein SVG. Deshalb entstehen die Symbole hier als SVG + * und werden mit Chromium auf 800 × 600 (4:3 wie die Karte) gerastert. Stil der Landingpage: + * dünne Linie in Navy, runde Enden, ein goldener Akzent, warme helle Fläche dahinter. + * + * node scripts/build-kantarci-options.mjs → var/kantarci-immobilien-assets/options/*.png und *.svg + */ +import { chromium } from 'playwright'; +import { mkdir, writeFile } from 'node:fs/promises'; + +const ziel = process.argv[2] || 'var/kantarci-immobilien-assets/options'; +const NAVY = '#142535', GOLD = '#b49a66', HELL = '#f5f3ee', PAPIER = '#ffffff'; + +const bild = (inhalt) => ` + +${inhalt} +`; + +/** Eine Person: Kopf und Schultern. */ +const person = (x, y, s = 1, farbe = NAVY) => ` + +`; + +/** Ein Grundriss: Außenwand, dazu Innenwände; der jeweils letzte Raum ist golden hinterlegt. */ +const grundriss = (waende, akzent) => ` +${akzent ? `` : ''} + +${waende}`; + +const bilder = { + // Wer zieht ein? + 'haushalt-allein': person(400, 330, 1.3), + 'haushalt-zu-zweit': person(275, 330, 1.1) + person(525, 330, 1.1, GOLD), + 'haushalt-familie': person(250, 320, 1.1) + person(550, 320, 1.1) + person(400, 400, .7, GOLD), + 'haushalt-wg': person(200, 340, .9) + person(400, 340, .9, GOLD) + person(600, 340, .9), + + // Wie viele Zimmer? + 'zimmer-1': grundriss('', 'M177 137h446v326H177z'), + 'zimmer-2': grundriss('', 'M407 137h216v326H407z'), + 'zimmer-3': grundriss('', 'M367 307h256v156H367z'), + 'zimmer-4': grundriss('', 'M407 307h216v156H407z') + + ``, + + // Was ist Ihnen wichtig? + 'ausstattung-balkon': ` + + +`, + 'ausstattung-aufzug': ` + +`, + 'ausstattung-stellplatz': ` + +`, + 'ausstattung-kueche': ` + + + +`, + 'ausstattung-barrierefrei': ` + + +`, + 'ausstattung-haustier': ` + + +`, +}; + +await mkdir(ziel, { recursive: true }); +const browser = await chromium.launch(); +const page = await browser.newPage({ viewport: { width: 800, height: 600 }, deviceScaleFactor: 1 }); +for (const [name, inhalt] of Object.entries(bilder)) { + const svg = bild(inhalt); + await writeFile(`${ziel}/${name}.svg`, svg); + await page.setContent(`${svg}`); + await page.locator('svg').screenshot({ path: `${ziel}/${name}.png`, omitBackground: false }); + console.log('gezeichnet:', name); +} +await browser.close(); diff --git a/scripts/reset-kantarci-immobilien.sh b/scripts/reset-kantarci-immobilien.sh new file mode 100644 index 0000000..36c5be2 --- /dev/null +++ b/scripts/reset-kantarci-immobilien.sh @@ -0,0 +1,17 @@ +#!/bin/sh +# Wirft die KANTARCI-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht. +set -e +root="C:/Django Project/KantSites" +php="${PHP_BINARY:-C:/php84/php.exe}" +export DATABASE_URL="sqlite:///$root/var/kantarci-immobilien.db" +export MEDIA_DIRECTORY="$root/var/kantarci-immobilien-media" +rm -f "$root/var/kantarci-immobilien.db" +rm -rf "$root/var/kantarci-immobilien-media" +"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q +assets="${1:-$root/var/kantarci-immobilien-assets}" +# Logo, Hausmotiv und Symbol sind Zuschnitte aus dem gelieferten Banner, die Bildkarten +# der Mietanfrage zeichnet Chromium (Playwright). +"$php" scripts/build-kantarci-assets.php "$assets/kantarci-banner.jpeg" "$assets" +node scripts/build-kantarci-options.mjs "$assets/options" +# Die Vorlage nutzt Arial und Georgia; es gibt keine Schrift zu holen. +"$php" -d memory_limit=512M scripts/seed-kantarci-immobilien.php "$assets" diff --git a/scripts/seed-kantarci-immobilien.php b/scripts/seed-kantarci-immobilien.php new file mode 100644 index 0000000..2bb896c --- /dev/null +++ b/scripts/seed-kantarci-immobilien.php @@ -0,0 +1,733 @@ +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); +$theme = new Theme($db); + +$firma = 'KANTARCI Immobilien GmbH'; +// Die Vorlage nennt immo@kantarci-immobilien.de; die Domain gibt es nicht (18.09.2026, kein +// DNS-Eintrag). Umut hat info@kantarci-immo.de für Impressum und Datenschutz genannt, die Domain +// hat Mailserver - deshalb steht sie überall, auch als Empfänger der Mietanfrage. +$mail = 'info@kantarci-immo.de'; +$gf = 'Ridvan Kantarci'; +$strasse = 'Ooser Hauptstraße 51'; +$ort = '76532 Baden-Baden'; +$SCHEMA = 24; + +// --------------------------------------------------------------------------------------------- +// 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']; + +$section = static function (string $id, array $children, array $style = [], ?string $anchor = null): array { + $block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style]; + if (null !== $anchor) { + $block['anchor'] = $anchor; + } + + return $block; +}; + +$row = static function (string $id, array $children, array $design = [], array $style = []): array { + $block = ['id' => $id, 'type' => 'row', 'children' => $children]; + if ([] !== $design) { + $block['layout'] = ['design' => $design]; + } + if ([] !== $style) { + $block['style'] = $style; + } + + return $block; +}; + +/** Eine Zeile, die nebeneinander stellt statt zu rastern – Kleinzeile, Werteleiste, Kopf, Fuß. */ +$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = []) use ($row): array { + $style = ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => $gap, 'margin' => ['top' => '0px'], ...$extra]]; + if ([] !== $tablet) { + $style['tablet'] = $tablet; + } + if ([] !== $mobil) { + $style['mobile'] = $mobil; + } + + return $row($id, $children, [], $style); +}; + +$column = static function (string $id, array $children, array $style = [], string $sichtbarkeit = 'all', string $css = ''): array { + $block = ['id' => $id, 'type' => 'column', 'children' => $children]; + if ([] !== $style) { + $block['style'] = $style; + } + if ('all' !== $sichtbarkeit) { + $block['layout'] = ['visibility' => $sichtbarkeit]; + } + 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; +}; + +/** Eine reine Fläche: der kurze Goldstrich vor der Kleinzeile, der Verlauf über dem Hausbild. */ +$flaeche = static function (string $id, array $style, string $css = ''): array { + $block = ['id' => $id, 'type' => 'spacer', 'props' => ['size' => 'small'], 'style' => $style]; + if ('' !== $css) { + $block['css'] = $css; + } + + return $block; +}; + +/** Ein Button ohne den Pfeil des Bausteins: die Vorlage kommt ohne Pfeile aus. */ +$button = static function (string $id, string $text, string $href, array $style = []): array { + return ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style, 'css' => '& span{display:none}']; +}; + +$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 function (string $id, string $name, int $groesse, array $style = []): array { + return ['id' => $id, 'type' => 'icon', 'props' => ['icon' => $name, 'size' => $groesse, 'decorative' => true, 'label' => ''], 'style' => $style]; +}; + +/** + * Setzt die Einblendungen nach einer Liste, statt sie über das ganze Dokument zu streuen. + * + * @param array $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; +}; + +// Wiederkehrende Gestaltungswerte der Vorlage. +$kleinStil = ['desktop' => ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '0.17em', 'lineHeight' => 1.6, 'margin' => ['bottom' => '20px']], 'mobile' => ['fontSize' => '11px']]; +$linkCss = "& a{color:#102333;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#88703f}"; +// Die Mietanfrage in drei Schritten: volle Breite statt der 640 px des Kerns, zwei Spalten dort, +// wo zwei kurze Felder nebeneinander passen. Eigenes CSS kennt kein @media: die Raster füllen +// deshalb mit auto-fill, am Telefon bleibt so eine Spalte. Felder über `data-label`, weil es kein +// :nth-child kennt; Deckkraft der Nebentexte auf 1, sonst fallen sie unter 4,5:1. +$formularCss = '& .block-form{max-width:none} +& .form-step{grid-template-columns:repeat(auto-fill,minmax(190px,1fr));column-gap:16px;row-gap:18px} +& .form-step>.form-field{grid-column:1/-1} +& .form-step>.form-step-title{grid-column:1/-1} +& .form-step>.form-step-intro{grid-column:1/-1} +& .form-step>[data-label="Vorname"]{grid-column:span 1} +& .form-step>[data-label="Nachname"]{grid-column:span 1} +& .form-step>[data-label="Einzugstermin"]{grid-column:span 1} +& .form-step>[data-label="Max. Warmmiete pro Monat"]{grid-column:span 1} +& .form-step-title{font-family:Georgia,serif;font-size:22px;font-weight:400;color:#142535;margin-bottom:4px} +& .form-step-intro{font-size:14px;color:#5f6b74;opacity:1} +& .form-progress-step{opacity:1;color:#5f6b74} +& .form-progress-step[aria-current]{color:#142535} +& .form-field{align-content:start} +& .form-field label{font-size:14px;font-weight:600;color:#142535} +& .form-field input{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535} +& .form-field select{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535} +& .form-field textarea{background:#fcfcfc;border:1px solid #dce0e2;border-radius:3px;padding:12px 13px;font-size:16px;color:#142535;min-height:110px} +& .form-choice legend{font-size:14px;font-weight:600;color:#142535} +& .form-choice-grid{grid-template-columns:repeat(2,minmax(0,1fr));gap:12px} +& [data-label="Ausstattung"] .form-choice-grid{grid-template-columns:repeat(auto-fill,minmax(125px,1fr));gap:10px} +& .form-step>.form-summary-body{grid-column:1/-1} +& .form-summary-title{font-family:Arial,sans-serif;font-size:15px;font-weight:700;letter-spacing:0} +& .form-summary-step{border-color:#dce0e2;border-radius:6px} +& .form-choice-card label{border-color:#dce0e2;border-radius:6px;font-size:14px} +& .field-note{font-size:13px;color:#5f6b74;opacity:1} +& .required-mark{color:#88703f} +& .check-row label{font-weight:400;font-size:14px;line-height:1.65;color:#576570} +& .cms-button{justify-content:center;background:#142535;color:#fff;border-radius:3px} +& .cms-button:hover{background:#234256;color:#fff}'; + +/** Die Kleinzeile in Versalien, wahlweise mit dem kurzen Goldstrich davor. */ +$kleinzeile = static function (string $id, string $inhalt, string $farbe = '@gold-text', bool $strich = false) use ($text, $zeile, $flaeche, $kleinStil): array { + $zeilenText = $text($id.'-text', $inhalt, ['desktop' => [...$kleinStil['desktop'], 'color' => $farbe, 'margin' => ['bottom' => '0px']], 'mobile' => $kleinStil['mobile']]); + if (!$strich) { + return [...$zeilenText, 'id' => $id, 'style' => ['desktop' => [...$kleinStil['desktop'], 'color' => $farbe], 'mobile' => $kleinStil['mobile']]]; + } + + return $zeile($id, [ + $flaeche($id.'-strich', ['desktop' => ['width' => '27px', 'height' => '1px', 'backgroundColor' => '@gold', 'flexShrink' => 0]], 'min-height:0'), + $zeilenText, + ], '12px', ['margin' => ['top' => '0px', 'bottom' => '20px']]); +}; + +// --------------------------------------------------------------------------------------------- +// Website, Gestaltung, Medien, Formular +// --------------------------------------------------------------------------------------------- + +$db->insert('website', ['id' => 'default', 'name' => 'KANTARCI Immobilien', 'default_locale' => 'de', 'theme' => '{}']); + +$theme->save( + [ + ['key' => 'navy', 'name' => 'Navy', 'value' => '#102333'], + ['key' => 'tinte', 'name' => 'Schrift', 'value' => '#142535'], + ['key' => 'gold', 'name' => 'Gold (Linien, Flächen)', 'value' => '#b49a66'], + ['key' => 'gold-knopf', 'name' => 'Gold als Knopf', 'value' => '#bda16a'], + ['key' => 'gold-hell', 'name' => 'Gold auf Navy', 'value' => '#cbb17b'], + ['key' => 'gold-text', 'name' => 'Gold als Schrift auf Weiß', 'value' => '#88703f'], + ['key' => 'text', 'name' => 'Fließtext', 'value' => '#5f6b74'], + ['key' => 'text-navy', 'name' => 'Fließtext auf Navy', 'value' => '#d1d8de'], + ['key' => 'hell', 'name' => 'Helle Fläche', 'value' => '#f1f3f3'], + ['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#e1e4e5'], + ['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'], + ], + [ + // Die Vorlage lädt keine Webschrift: Arial für Text, Georgia für Überschriften. + ['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'], + ['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Georgia', 'source' => 'system', 'weights' => [400], 'fallback' => 'serif'], + ], + ['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@tinte', 'containerWidth' => '1140px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'], + ['shape' => 'square', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 700, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@gold-knopf', 'color' => '@navy', 'hoverBackgroundColor' => '#d0b582', 'hoverColor' => '@navy'], +); +echo "Farben, Schriften und Buttons gesetzt\n"; + +$medien = []; +foreach ([ + 'logo' => ['logo-kantarci.png', 'KANTARCI Immobilien GmbH'], + 'haus' => ['haus.jpg', 'Modernes Wohnhaus mit begrünten Balkonen'], + 'symbol' => ['symbol-kantarci.png', 'Bildmarke von KANTARCI Immobilien'], + // Die Bildkarten der Mietanfrage (scripts/build-kantarci-options.mjs). + 'haushalt-allein' => ['options/haushalt-allein.png', 'Symbol: eine Person'], + 'haushalt-zu-zweit' => ['options/haushalt-zu-zweit.png', 'Symbol: zwei Personen'], + 'haushalt-familie' => ['options/haushalt-familie.png', 'Symbol: zwei Erwachsene und ein Kind'], + 'haushalt-wg' => ['options/haushalt-wg.png', 'Symbol: drei Personen'], + 'zimmer-1' => ['options/zimmer-1.png', 'Grundriss mit einem Zimmer'], + 'zimmer-2' => ['options/zimmer-2.png', 'Grundriss mit zwei Zimmern'], + 'zimmer-3' => ['options/zimmer-3.png', 'Grundriss mit drei Zimmern'], + 'zimmer-4' => ['options/zimmer-4.png', 'Grundriss mit vier Zimmern'], + 'ausstattung-balkon' => ['options/ausstattung-balkon.png', 'Symbol: Balkon'], + 'ausstattung-aufzug' => ['options/ausstattung-aufzug.png', 'Symbol: Aufzug'], + 'ausstattung-stellplatz' => ['options/ausstattung-stellplatz.png', 'Symbol: Parkplatz'], + 'ausstattung-kueche' => ['options/ausstattung-kueche.png', 'Symbol: Küche'], + 'ausstattung-barrierefrei' => ['options/ausstattung-barrierefrei.png', 'Symbol: Rollstuhl'], + 'ausstattung-haustier' => ['options/ausstattung-haustier.png', 'Symbol: Pfote'], +] 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"; +} + +// Drei Schritte: wer einzieht, wie gewohnt werden soll, wie wir Sie erreichen. Haushalt, Zimmer +// und Ausstattung sind Bildkarten; die Personenzahl fragt das Formular nur, wo sie offen ist. +$karten = static fn (array $zuordnung): array => array_map(static fn (string $schluessel): string => $medien[$schluessel]['id'], $zuordnung); +$forms->save('mietanfrage', 'Mietanfrage', [ + ['name' => 'schritt_haushalt', 'type' => 'step', 'label' => 'Ihr Haushalt', 'help' => 'Wer zieht in die neue Wohnung ein?'], + ['name' => 'haushalt', 'type' => 'radio', 'label' => 'Wer zieht ein?', 'required' => true, 'options' => ['Allein', 'Zu zweit', 'Familie mit Kindern', 'Wohngemeinschaft'], + 'images' => $karten(['Allein' => 'haushalt-allein', 'Zu zweit' => 'haushalt-zu-zweit', 'Familie mit Kindern' => 'haushalt-familie', 'Wohngemeinschaft' => 'haushalt-wg'])], + ['name' => 'personen', 'type' => 'number', 'label' => 'Personen im Haushalt', 'required' => true, 'min' => 2, 'max' => 30, + 'showIf' => ['field' => 'haushalt', 'values' => ['Familie mit Kindern', 'Wohngemeinschaft']]], + + ['name' => 'schritt_wohnen', 'type' => 'step', 'label' => 'Ihre Wohnwünsche', 'help' => 'Alles hier ist freiwillig – je mehr wir wissen, desto besser passt unser Vorschlag.'], + ['name' => 'zimmer', 'type' => 'radio', 'label' => 'Wie viele Zimmer?', 'required' => false, 'options' => ['1 Zimmer', '2 Zimmer', '3 Zimmer', '4 und mehr'], + 'images' => $karten(['1 Zimmer' => 'zimmer-1', '2 Zimmer' => 'zimmer-2', '3 Zimmer' => 'zimmer-3', '4 und mehr' => 'zimmer-4'])], + ['name' => 'ausstattung', 'type' => 'checkboxes', 'label' => 'Ausstattung', 'required' => false, 'help' => 'Was ist Ihnen wichtig? Mehrfachauswahl möglich.', + 'options' => ['Balkon oder Terrasse', 'Aufzug', 'Stellplatz', 'Einbauküche', 'Barrierefrei', 'Haustier erlaubt'], + 'images' => $karten(['Balkon oder Terrasse' => 'ausstattung-balkon', 'Aufzug' => 'ausstattung-aufzug', 'Stellplatz' => 'ausstattung-stellplatz', 'Einbauküche' => 'ausstattung-kueche', 'Barrierefrei' => 'ausstattung-barrierefrei', 'Haustier erlaubt' => 'ausstattung-haustier'])], + ['name' => 'einzug', 'type' => 'select', 'label' => 'Einzugstermin', 'required' => false, 'options' => ['Ab sofort', 'In 1 bis 3 Monaten', 'In 3 bis 6 Monaten', 'Später oder flexibel']], + ['name' => 'warmmiete', 'type' => 'number', 'label' => 'Max. Warmmiete pro Monat', 'required' => false, 'min' => 1, 'max' => 100000, 'unit' => '€'], + ['name' => 'objekt', 'type' => 'text', 'label' => 'Gewünschte Wohnung / Objektadresse', 'required' => false, 'help' => 'Noch kein bestimmtes Objekt? Lassen Sie das Feld einfach frei.'], + + ['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Ihre Kontaktdaten', 'help' => 'Damit wir uns persönlich bei Ihnen melden können.'], + ['name' => 'vorname', 'type' => 'text', 'label' => 'Vorname', 'required' => true], + ['name' => 'nachname', 'type' => 'text', 'label' => 'Nachname', 'required' => true], + ['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true], + ['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefonnummer', 'required' => false, 'help' => 'Für eine persönliche Rückmeldung'], + ['name' => 'anschrift', 'type' => 'text', 'label' => 'Aktuelle Anschrift', 'required' => false, 'help' => 'Straße, Hausnummer, PLZ und Ort'], + ['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => false, 'help' => 'Was ist Ihnen beim Wohnen sonst noch wichtig?'], + ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich bin einverstanden, dass KANTARCI meine Angaben zur Bearbeitung meiner Mietanfrage speichert und mich kontaktiert.'], +], ['recipient' => $mail, 'subject' => 'Neue Mietanfrage über die Landingpage', 'success' => 'Vielen Dank für Ihr Interesse. Ihre Mietanfrage ist angekommen – wir melden uns persönlich bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Mietanfrage absenden', 'summary' => true], 0); +echo "Formular angelegt: mietanfrage\n"; + +// --------------------------------------------------------------------------------------------- +// Kopf- und Fußbereich +// --------------------------------------------------------------------------------------------- + +$kopf = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.', + 'blocks' => [ + $section('kopf', [ + $zeile('kopf-zeile', [ + $column('kopf-marke', [ + $bild('kopf-logo', $medien['logo'], 'KANTARCI Immobilien GmbH – zur Startseite', [ + 'desktop' => ['maxWidth' => '300px', 'margin' => $seiten('0px')], + 'mobile' => ['maxWidth' => '220px'], + ], '/'), + ], ['desktop' => ['flexShrink' => 1, 'minWidth' => '0']]), + $column('kopf-kontakt', [ + $zeile('kopf-kontakt-zeile', [ + $symbol('kopf-kontakt-symbol', 'mail', 20, ['desktop' => ['color' => '@navy', 'flexShrink' => 0]]), + $text('kopf-kontakt-link', 'Kontakt aufnehmen', ['desktop' => ['fontSize' => '14px', 'fontWeight' => '600', 'margin' => ['bottom' => '0px']]], "& a{color:#102333}\n& a:hover{color:#88703f}"), + ], '10px'), + ], ['desktop' => ['flexShrink' => 0]], 'hide-mobile'), + ], '24px', ['justifyContent' => 'space-between']), + ], [ + 'desktop' => ['backgroundColor' => '@papier', 'padding' => ['top' => '22px', 'bottom' => '22px']], + 'mobile' => ['padding' => ['top' => '16px', 'bottom' => '16px']], + ]), + ], +]; + +$fuss = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.', + 'blocks' => [ + $section('fuss', [ + $zeile('fuss-oben', [ + $column('fuss-marke', [ + $text('fuss-name', 'KANTARCI', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'letterSpacing' => '0.14em', 'lineHeight' => 1.3, 'color' => '@papier', 'margin' => ['bottom' => '0px']]]), + $text('fuss-zusatz', 'IMMOBILIEN GMBH', ['desktop' => ['fontSize' => '10px', 'letterSpacing' => '0.22em', 'color' => '@gold-hell', 'margin' => ['bottom' => '0px']]]), + ]), + $column('fuss-spruch', [ + $text('fuss-spruch-text', 'Lebensraum schaffen. Zukunft bauen.', ['desktop' => ['fontSize' => '13px', 'color' => '#bac4cd', 'margin' => ['bottom' => '0px']]]), + ]), + $column('fuss-mail', [ + $text('fuss-mail-text', ''.$mail.'', ['desktop' => ['fontSize' => '13px', 'margin' => ['bottom' => '0px']]], "& a{color:#bac4cd;overflow-wrap:anywhere}\n& a:hover{color:#fff;text-decoration:underline}"), + ]), + ], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'padding' => ['top' => '34px', 'bottom' => '26px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#243a4c'], + ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']), + $zeile('fuss-unten', [ + $text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '12px', 'color' => '#bac4cd', 'margin' => ['bottom' => '0px']]]), + $zeile('fuss-links', [ + // Impressum und Datenschutz kommen aus den Einstellungen, nicht aus einem Menü. + ['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'], + 'style' => ['desktop' => ['fontSize' => '12px', 'gap' => '4px', 'color' => '#bac4cd']], + 'css' => "& a{color:#bac4cd}\n& a:hover{color:#fff}"], + $text('fuss-agentur', 'Website by Kant-IT Solutions', ['desktop' => ['fontSize' => '12px', 'margin' => ['bottom' => '0px']]], "& a{color:#bac4cd}\n& a:hover{color:#fff}"), + ], '22px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']), + ], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'padding' => ['top' => '20px', 'bottom' => '26px']], + ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px']), + ], [ + 'desktop' => ['backgroundColor' => '@navy', 'color' => '#bac4cd', '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"; + +/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Navy, dann Absätze mit Zwischenüberschriften. */ +$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array { + $kopf = $section($praefix.'-kopf', [ + $kleinzeile($praefix.'-label', $label, '@gold-hell', true), + $heading($praefix.'-h1', $titel, 1, [ + 'desktop' => ['fontSize' => '52px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '20px']], + 'mobile' => ['fontSize' => '38px'], + ]), + $text($praefix.'-vorspann', $vorspann, [ + 'desktop' => ['maxWidth' => '640px', 'fontSize' => '17px', 'lineHeight' => 1.75, 'color' => '@text-navy', 'margin' => ['bottom' => '0px']], + 'mobile' => ['fontSize' => '16px'], + ]), + ], [ + 'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '72px', 'bottom' => '64px']], + 'mobile' => ['padding' => ['top' => '44px', 'bottom' => '40px']], + ]); + + $punkte = []; + foreach ($absaetze as $i => [$zwischentitel, $inhalt]) { + $punkte[] = $row($praefix.'-punkt-'.$i, [ + $column($praefix.'-punkt-'.$i.'-titel', '' === $zwischentitel ? [] : [ + $heading($praefix.'-h2-'.$i, $zwischentitel, 2, [ + 'desktop' => ['fontFamily' => 'body', 'fontSize' => '16px', 'fontWeight' => '700', 'lineHeight' => 1.5, 'letterSpacing' => '0', 'color' => '@tinte', 'margin' => ['bottom' => '0px']], + ]), + ], [ + 'desktop' => ['flexBasis' => '260px', 'flexShrink' => 0], + 'tablet' => ['flexBasis' => '200px'], + 'mobile' => ['flexBasis' => 'auto'], + ]), + $column($praefix.'-punkt-'.$i.'-text', [ + $text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss), + ], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexBasis' => 'auto']]), + ], [], [ + 'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '44px', 'margin' => ['top' => '0px'], + 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px', 'bottom' => '26px']], + 'tablet' => ['gap' => '28px'], + 'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '20px', 'bottom' => '20px']], + ]); + } + + $inhalt = $section($praefix, [ + $column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '920px']]), + ], ['desktop' => ['padding' => ['top' => '48px', 'bottom' => '90px']], 'mobile' => ['padding' => ['top' => '24px', 'bottom' => '56px']]]); + + return [$kopf, $inhalt]; +}; + +// --------------------------------------------------------------------------------------------- +// Startseite +// --------------------------------------------------------------------------------------------- + +$wertStil = ['desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '12px', 'textAlign' => 'center', 'lineHeight' => 1.5]]; +$wert = static fn (string $id, string $icon, string $label): array => $zeile('wert-'.$id, [ + $symbol('wert-'.$id.'-symbol', $icon, 24, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]), + $text('wert-'.$id.'-text', $label, $wertStil), +], '13px', [], ['flexDirection' => 'column', 'gap' => '9px', 'flexGrow' => 1, 'flexBasis' => '0']); + +$schritt = static fn (string $n, string $titel, string $inhalt): array => $zeile('schritt-'.$n, [ + $text('schritt-'.$n.'-nummer', $n, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'lineHeight' => 1.2, 'color' => '@gold-text', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#c9b58e', 'padding' => ['top' => '8px'], 'margin' => ['bottom' => '0px'], 'flexShrink' => 0]]), + $text('schritt-'.$n.'-text', ''.$titel.''.$inhalt, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.65, 'color' => '@text', 'maxWidth' => '290px', 'margin' => ['top' => '5px', 'bottom' => '0px']]], + '& strong{display:block;color:#102333;font-size:15px;margin-bottom:4px}'), +], '17px', ['alignItems' => 'flex-start', 'margin' => ['top' => '22px']]); + +$startseite = [ + 'schemaVersion' => $SCHEMA, + 'title' => 'Mietanfrage', + 'description' => 'Ihr neues Zuhause beginnt hier. Melden Sie Ihr Interesse an einer Mietwohnung bei KANTARCI Immobilien an – persönlich und unverbindlich.', + 'blocks' => [ + // 1 – Hero: Navy, Text links, das Haus rechts mit Verlauf und Bildzeile. + $section('hero', [ + $row('hero-zeile', [ + $column('hero-text', [ + $kleinzeile('hero-label', 'KANTARCI IMMOBILIEN', '#d1bb90', true), + $heading('hero-titel', 'Hier beginnt Ihr
neues Zuhause.', 1, [ + 'desktop' => ['fontSize' => '62px', 'lineHeight' => 1.09, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '25px']], + 'tablet' => ['fontSize' => '45px'], + 'mobile' => ['fontSize' => '42px'], + ], '& em{font-style:normal;color:#cbb17b}'), + $text('hero-intro', 'Ein Ort zum Ankommen. Raum für Ihr Leben. Entdecken Sie neue Mietwohnungen und teilen Sie uns mit, wie Sie wohnen möchten.', [ + 'desktop' => ['maxWidth' => '450px', 'fontSize' => '16px', 'lineHeight' => 1.85, 'color' => '@text-navy', 'margin' => ['bottom' => '30px']], + 'mobile' => ['fontSize' => '15px'], + ]), + $button('hero-knopf', 'Jetzt Mietinteresse anmelden', '#mietanfrage', ['desktop' => ['margin' => ['top' => '0px']]]), + $text('hero-notiz', 'Unverbindlich. Persönlich. In wenigen Minuten.', ['desktop' => ['fontSize' => '12px', 'color' => '#b1bec7', 'margin' => ['top' => '14px', 'bottom' => '0px']]]), + ], [ + 'desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start', 'justifyContent' => 'center', 'padding' => ['top' => '64px', 'bottom' => '60px']], + 'tablet' => ['padding' => ['top' => '44px', 'bottom' => '44px']], + 'mobile' => ['padding' => ['top' => '40px', 'bottom' => '8px']], + ]), + $column('hero-visual', [ + $bild('hero-bild', $medien['haus'], 'Modernes Wohnhaus mit begrünten Balkonen', [ + 'desktop' => ['imageHeight' => '480px', 'objectFit' => 'cover', 'objectPosition' => 'top left', 'margin' => $seiten('0px')], + 'tablet' => ['imageHeight' => '460px'], + 'mobile' => ['imageHeight' => '280px'], + ], null, '& img{display:block;width:100%}'), + $flaeche('hero-verlauf', ['desktop' => ['position' => 'absolute', 'left' => '0px', 'right' => '0px', 'bottom' => '0px', 'width' => 'auto', 'height' => 'auto', 'zIndex' => 1]], 'top:45%;min-height:0;background:linear-gradient(transparent,rgba(16,35,51,.88));pointer-events:none'), + $column('hero-bildzeile', [ + $text('hero-bildzeile-klein', 'RAUM FÜR MORGEN', ['desktop' => ['fontSize' => '11px', 'letterSpacing' => '0.18em', 'color' => '#f2e4c6', 'margin' => ['bottom' => '4px']]]), + $text('hero-bildzeile-satz', 'Wohnen. Wohlfühlen. Ankommen.', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '25px', 'lineHeight' => 1.3, 'color' => '@papier', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '21px']]), + ], ['desktop' => ['position' => 'absolute', 'left' => '32px', 'right' => '32px', 'bottom' => '28px', 'zIndex' => 2], 'mobile' => ['left' => '20px', 'bottom' => '18px']]), + ], [ + 'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'backgroundColor' => '#d9dde0', 'minHeight' => '480px'], + 'tablet' => ['minHeight' => '460px'], + 'mobile' => ['minHeight' => '280px'], + ]), + ], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [ + 'desktop' => ['gap' => '56px', 'alignItems' => 'stretch', 'margin' => ['top' => '0px']], + 'tablet' => ['gap' => '28px'], + 'mobile' => ['gap' => '24px'], + ]), + ], [ + 'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '0px', 'bottom' => '0px']], + 'mobile' => ['padding' => ['top' => '0px', 'bottom' => '24px']], + ], 'start'), + + // 2 – Die Werteleiste aus dem Banner. + $section('werte', [ + $zeile('werte-zeile', [ + $wert('modern', 'star', 'Modern & nachhaltig'), + $wert('komfort', 'home', 'Zeitgemäßer Wohnkomfort'), + $wert('leben', 'people', 'Zuhause für jedes Leben'), + ], '20px', ['justifyContent' => 'space-between', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e4e6e7', 'padding' => ['top' => '26px', 'bottom' => '26px']], + ['alignItems' => 'flex-start', 'gap' => '14px', 'padding' => ['top' => '22px', 'bottom' => '22px']]), + ], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '0px']]]), + + // 3 – Mietanfrage: Einleitung links, Formularkarte rechts. + $section('anfrage', [ + $row('anfrage-zeile', [ + $column('anfrage-text', [ + $kleinzeile('anfrage-label', 'IHRE MIETANFRAGE'), + $heading('anfrage-h2', 'Ein paar Angaben.
Viele Möglichkeiten.', 2, [ + 'desktop' => ['fontSize' => '38px', 'lineHeight' => 1.2, 'letterSpacing' => '-0.02em', 'margin' => ['bottom' => '22px']], + 'tablet' => ['fontSize' => '33px'], + 'mobile' => ['fontSize' => '34px'], + ]), + $text('anfrage-intro', 'Sie interessieren sich für eine bestimmte Wohnung oder suchen noch das passende Zuhause? Erzählen Sie uns kurz von Ihren Wünschen.', [ + 'desktop' => ['maxWidth' => '350px', 'fontSize' => '16px', 'lineHeight' => 1.8, 'color' => '@text', 'margin' => ['bottom' => '12px']], + 'mobile' => ['maxWidth' => '100%'], + ]), + $schritt('01', 'Anfrage ausfüllen', 'Haushalt, Wohnwünsche und Kontaktdaten – in drei kurzen Schritten.'), + $schritt('02', 'Persönlich ins Gespräch kommen', 'Wir melden uns bei Ihnen, um die Möglichkeiten zu besprechen.'), + $zeile('anfrage-direkt', [ + $symbol('anfrage-direkt-symbol', 'mail', 24, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]), + $text('anfrage-direkt-text', 'Lieber direkt schreiben?'.$mail.'', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], + "& strong{display:block;color:#102333}\n& a{color:#5f6b74;overflow-wrap:anywhere}\n& a:hover{color:#88703f;text-decoration:underline}"), + ], '15px', ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px'], 'margin' => ['top' => '38px']], ['margin' => ['top' => '26px'], 'padding' => ['top' => '20px']]), + ], ['desktop' => ['padding' => ['top' => '14px']], 'mobile' => ['padding' => ['top' => '0px']]]), + $column('anfrage-karte', [ + $zeile('anfrage-karte-kopf', [ + $column('anfrage-karte-titel', [ + $heading('anfrage-karte-h3', 'Ihr Weg zum neuen Zuhause', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '20px', 'fontWeight' => '600', 'letterSpacing' => '-0.015em', 'lineHeight' => 1.35, 'margin' => ['bottom' => '5px']]]), + $text('anfrage-karte-pflicht', 'In drei kurzen Schritten. Pflichtfelder sind mit * markiert.', ['desktop' => ['fontSize' => '13px', 'color' => '@text', 'margin' => ['bottom' => '0px']]]), + ]), + $symbol('anfrage-karte-symbol', 'home', 32, ['desktop' => ['color' => '@gold-text', 'flexShrink' => 0]]), + ], '16px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px', 'bottom' => '28px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#edf0f0', 'padding' => ['bottom' => '22px']]), + ['id' => 'anfrage-formular', 'type' => 'form', 'props' => ['form' => 'mietanfrage'], 'css' => $formularCss], + $text('anfrage-hinweis', 'Ihre Angaben werden nur für Ihre Mietanfrage verwendet – mehr dazu in den Datenschutzhinweisen. Bitte senden Sie hier keine Ausweiskopien oder anderen vertraulichen Unterlagen.', [ + 'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['top' => '18px', 'bottom' => '0px']], + ], $linkCss), + $text('anfrage-unverbindlich', '✓ Unverbindliche Anfrage – kein Mietvertragsabschluss.', ['desktop' => ['fontSize' => '12px', 'color' => '@text', 'textAlign' => 'center', 'margin' => ['top' => '12px', 'bottom' => '0px']]]), + ], [ + 'desktop' => ['backgroundColor' => '@papier', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e2e5e6', 'borderRadius' => ['topLeft' => '7px', 'topRight' => '7px', 'bottomRight' => '7px', 'bottomLeft' => '7px'], 'padding' => ['top' => '32px', 'right' => '34px', 'bottom' => '32px', 'left' => '34px'], 'shadowY' => '12px', 'shadowBlur' => '40px', 'shadowColor' => '#14253510'], + 'tablet' => ['padding' => ['top' => '26px', 'right' => '24px', 'bottom' => '26px', 'left' => '24px']], + 'mobile' => ['padding' => ['top' => '25px', 'right' => '20px', 'bottom' => '25px', 'left' => '20px']], + ]), + ], ['desktop' => ['columns' => 'two'], 'mobile' => ['columns' => 'one']], [ + 'desktop' => ['gap' => '65px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px']], + 'tablet' => ['gap' => '32px'], + 'mobile' => ['gap' => '26px'], + ]), + ], [ + 'desktop' => ['padding' => ['top' => '74px', 'bottom' => '84px']], + 'tablet' => ['padding' => ['top' => '55px', 'bottom' => '60px']], + 'mobile' => ['padding' => ['top' => '44px', 'bottom' => '48px']], + ], 'mietanfrage'), + ], +]; + +// --------------------------------------------------------------------------------------------- +// Seiten anlegen und veröffentlichen +// --------------------------------------------------------------------------------------------- + +$erstellt = []; +$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db, &$erstellt): 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']); + $erstellt[$slug] = $id; + echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n"; + + return $id; +}; + +// Der Text des Hero spielt beim Laden, das Hausbild bleibt ohne Einblendung (LCP). +$startseite = $einblenden($startseite, [ + 'hero-label' => ['fade'], + 'hero-titel' => ['rise', 'short'], + 'hero-intro' => ['rise', 'medium'], + 'hero-knopf' => ['rise', 'long'], + 'hero-notiz' => ['fade', 'long'], + 'anfrage-text' => ['rise'], + 'anfrage-karte' => ['rise', 'short'], +]); + +$anlegen('home', $startseite, 'Mietanfrage | KANTARCI Immobilien GmbH', $medien['haus']['id']); + +$pruefen = static fn (string $was): string => 'NOCH ZU PRÜFEN: '.$was; + +$anlegen('impressum', [ + 'schemaVersion' => $SCHEMA, 'title' => 'Impressum', + 'description' => 'Anbieterangaben der KANTARCI Immobilien GmbH.', + 'blocks' => [ + ...$rechtsseite('impressum', 'RECHTLICHE ANGABEN', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [ + ['Anbieterin', $firma.'
'.$strasse.'
'.$ort], + ['Vertreten durch', $gf.', Geschäftsführer'], + ['Registereintrag', $pruefen('Handelsregisternummer (HRB …) und Registergericht (Amtsgericht …).')], + ['Kontakt', "E-Mail: $mail
".$pruefen('Telefonnummer ergänzen – das Gesetz verlangt neben der E-Mail einen zweiten schnellen Kontaktweg.')], + ['Umsatzsteuer', $pruefen('Umsatzsteuer-Identifikationsnummer nach § 27a UStG, falls vorhanden.')], + ['Aufsichtsbehörde', $pruefen('Wer Wohnungen für Dritte vermittelt oder verwaltet, braucht eine Erlaubnis nach § 34c GewO; dann die zuständige Erlaubnisbehörde hier eintragen. Vermietet die GmbH nur eigene Wohnungen, entfällt die Angabe.')], + ['Verantwortlich für den Inhalt', $gf.', Anschrift wie oben.'], + ['Verbraucherstreitbeilegung', 'Wir sind nicht bereit und nicht verpflichtet, an einem Streitbeilegungsverfahren vor einer Verbraucherschlichtungsstelle teilzunehmen.'], + ['Haftung für Inhalte', 'Als Diensteanbieter sind wir für eigene Inhalte auf diesen Seiten nach den allgemeinen Gesetzen verantwortlich. Wir sind jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen. Bei Bekanntwerden von Rechtsverletzungen entfernen wir diese Inhalte umgehend.'], + ['Urheberrecht', 'Die von uns erstellten Inhalte und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung.'], + ['Bildnachweis', 'Logo und Hausmotiv: '.$firma.'. '.$pruefen('Urheber und Lizenz des Hausfotos eintragen, falls es nicht von KANTARCI selbst stammt.')], + ]), + ], +], 'Impressum | '.$firma); + +$anlegen('datenschutz', [ + 'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz', + 'description' => 'Wie die KANTARCI Immobilien GmbH Daten beim Besuch der Website und bei einer Mietanfrage verarbeitet.', + 'blocks' => [ + ...$rechtsseite('datenschutz', 'IHRE DATEN', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Mietanfrage verarbeitet werden – und welche Rechte Sie haben.', [ + ['1. Verantwortliche', "$firma, $strasse, $ort, vertreten durch den Geschäftsführer $gf. E-Mail $mail."], + ['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst 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.

'.$pruefen('Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen und die Speicherdauer der Serverprotokolle eintragen.')], + ['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. Mietanfrage', 'Wenn Sie das Formular absenden, verarbeiten wir Ihre Kontaktdaten (Name, E-Mail, auf Wunsch Telefon und Anschrift) und Ihre Wohnwünsche (etwa Haushaltsgröße, Zimmerzahl, Einzug, Mietobergrenze und Ihre Nachricht), um Ihre Anfrage zu bearbeiten und Ihnen passende Wohnungen anzubieten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Mietvertrags). Pflichtangaben sind nur Name, E-Mail und Haushaltsgröße; ohne sie können wir die Anfrage nicht bearbeiten. Das Formular schützt sich gegen automatisierte Eingaben, ohne dafür Dienste Dritter zu laden.'], + ['5. Wie lange wir speichern', 'Anfragedaten löschen wir, sobald sie für die Bearbeitung nicht mehr nötig sind – spätestens sechs Monate nach der letzten Kontaktaufnahme, wenn kein Mietverhältnis entsteht. Entsteht ein Mietverhältnis, gelten für die dann anfallenden Unterlagen die gesetzlichen Aufbewahrungsfristen.

'.$pruefen('Frist mit der Praxis abgleichen.')], + ['6. Empfänger', 'Ihre Angaben erreichen uns per E-Mail und liegen in der Verwaltung dieser Website. Darüber hinaus geben wir sie nicht weiter, außer wir sind dazu gesetzlich verpflichtet.

'.$pruefen('E-Mail-Anbieter und Hostingdienst als Auftragsverarbeiter benennen.')], + ['7. 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. Eine erteilte Einwilligung können Sie jederzeit mit Wirkung für die Zukunft widerrufen.'], + ['8. 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 $mail."], + ]), + ], +], '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', '@gold-hell', true), + $heading('fehler-titel', 'Diese Seite
gibt es nicht.', 1, [ + 'desktop' => ['fontSize' => '52px', 'lineHeight' => 1.1, 'letterSpacing' => '-0.025em', 'color' => '@papier', 'margin' => ['bottom' => '22px']], + 'mobile' => ['fontSize' => '38px'], + ], '& em{font-style:normal;color:#cbb17b}'), + $text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück.', [ + 'desktop' => ['maxWidth' => '460px', 'fontSize' => '17px', 'lineHeight' => 1.8, 'color' => '@text-navy', 'margin' => ['bottom' => '28px']], + ]), + $button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '0px']]]), + ], [ + 'desktop' => ['backgroundColor' => '@navy', 'color' => '@papier', 'padding' => ['top' => '110px', 'bottom' => '130px']], + 'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']], + ]), + ], +]); +$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]); + +// --------------------------------------------------------------------------------------------- +// Einstellungen, Konto +// --------------------------------------------------------------------------------------------- + +$settings->assignNotFoundPage($fehlerId, ''); +$settings->save([ + 'legalName' => $firma, + 'description' => 'KANTARCI Immobilien GmbH: neue Mietwohnungen – modern, nachhaltig, mit zeitgemäßem Wohnkomfort. Mietinteresse unverbindlich anmelden.', + 'email' => $mail, + 'street' => $strasse, 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'DE', + 'localBusiness' => true, 'llmsTxt' => true, + 'faviconMediaId' => $medien['symbol']['id'], + 'stickyHeader' => false, 'headerOverlay' => false, + 'motion' => 'always', + 'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz', + // Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste. + 'consentBanner' => false, 'scrollTop' => false, '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"]']); + +echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n"; diff --git a/src/Content/MediaLibrary.php b/src/Content/MediaLibrary.php index 592c9b7..9ae60aa 100644 --- a/src/Content/MediaLibrary.php +++ b/src/Content/MediaLibrary.php @@ -215,8 +215,12 @@ final class MediaLibrary return [...$data, 'id' => $id, 'version' => $data['version'] + 1]; } - /** Die Größen, die ein Favicon braucht: der Reiter im Browser und das Symbol auf dem Startbildschirm. */ - public const FAVICON_SIZES = [32, 180]; + /** + * Die Größen, die ein Favicon braucht: 32 für den Reiter im Browser, 180 für den + * Startbildschirm, 48 und 192 für die Google-Suche - Google nimmt nur Vielfache von 48 und + * behielt sonst das zuerst gefundene Symbol, bei Elektro Bernhard das mitgelieferte K. + */ + public const FAVICON_SIZES = [32, 48, 180, 192]; /** * Erzeugt aus einem Bild der Mediathek die Symboldateien der Website. diff --git a/src/Controller/MediaController.php b/src/Controller/MediaController.php index fe25dd8..75d9908 100644 --- a/src/Controller/MediaController.php +++ b/src/Controller/MediaController.php @@ -3,6 +3,7 @@ namespace App\Controller; use App\Content\MediaLibrary; +use App\Content\SiteSettings; use App\Domain\StaleWrite; use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; use Symfony\Component\HttpFoundation\BinaryFileResponse; @@ -14,7 +15,7 @@ use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; final class MediaController extends AbstractController { - public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf) + public function __construct(private MediaLibrary $media, private CsrfTokenManagerInterface $csrf, private SiteSettings $siteSettings) { } @@ -159,21 +160,79 @@ final class MediaController extends AbstractController } /** - * Das Symbol der Website, in den beiden Größen, die Browser abfragen. + * Das Symbol der Website, in den Größen, die Browser und Suchmaschinen abfragen. * * Ohne eingestelltes Bild gibt es die Dateien nicht - dann bleibt es beim mitgelieferten - * favicon.svg, auf das die Seiten weiterhin verweisen. + * Zeichen unter /favicon.svg. /favicon.ico fragen Crawler auf gut Glück ab; es bekommt das + * 48er-PNG, das liest jeder Browser auch unter diesem Namen. */ - #[Route('/favicon.png', name: 'favicon_png', methods: ['GET'])] - #[Route('/apple-touch-icon.png', name: 'favicon_touch', methods: ['GET'])] - public function favicon(Request $request): Response + #[Route('/favicon.png', name: 'favicon_png', defaults: ['size' => 32], methods: ['GET'])] + #[Route('/favicon-192.png', name: 'favicon_192', defaults: ['size' => 192], methods: ['GET'])] + #[Route('/favicon.ico', name: 'favicon_ico', defaults: ['size' => 48], methods: ['GET'])] + #[Route('/apple-touch-icon.png', name: 'favicon_touch', defaults: ['size' => 180], methods: ['GET'])] + public function favicon(int $size): Response { - $size = 'favicon_touch' === $request->attributes->get('_route') ? 180 : 32; - $path = $this->media->faviconFile($size) ?? throw $this->createNotFoundException(); + $path = $this->faviconFile($size); + if (null === $path) { + if (48 === $size) { + return $this->redirect('/favicon.svg'); + } + throw $this->createNotFoundException(); + } return new BinaryFileResponse($path, headers: ['Content-Type' => 'image/png', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=604800', 'Content-Security-Policy' => "default-src 'none'"]); } + /** + * Das mitgelieferte Zeichen - aber nur, solange kein eigenes Symbol gesetzt ist. + * + * Früher lag es als Datei in public/ und kam damit immer, auch nach dem Wechsel. Google hatte + * es beim ersten Besuch gefunden und zeigte es weiter. Jetzt führt die Adresse zum eigenen + * Symbol, vorübergehend (302), damit ein Zurückwechseln im Browser wieder ankommt. + */ + #[Route('/favicon.svg', name: 'favicon_svg', methods: ['GET'])] + public function faviconSvg(): Response + { + if (null !== $this->faviconFile(192)) { + $response = $this->redirect('/favicon-192.png?v='.$this->siteSettings->load()['faviconVersion'], 302); + $response->headers->set('Cache-Control', 'public, max-age=3600'); + + return $response; + } + + return new BinaryFileResponse($this->getParameter('kernel.project_dir').'/public/assets/favicon.svg', headers: ['Content-Type' => 'image/svg+xml', 'X-Content-Type-Options' => 'nosniff', 'Cache-Control' => 'public, max-age=3600']); + } + + /** + * Eine Symboldatei, oder null ohne eigenes Symbol. + * + * Die Größen 48 und 192 kamen später dazu. Wer sein Symbol vorher gesetzt hat, hat nur 32 + * und 180 - die fehlenden entstehen beim ersten Abruf aus derselben Einstellung, statt dass + * erst jemand die Einstellungen neu speichern muss. + */ + private function faviconFile(int $size): ?string + { + // Zuerst die Einstellung: wird das Symbol entfernt, bleiben die Dateien liegen, gelten + // aber nicht mehr. + $settings = $this->siteSettings->load(); + $favicon = (string) ($settings['faviconMediaId'] ?? ''); + if ('' === $favicon) { + return null; + } + $path = $this->media->faviconFile($size); + if (null !== $path || !in_array($size, MediaLibrary::FAVICON_SIZES, true) || null === $this->media->find($favicon)) { + return $path; + } + $crop = (string) ($settings['faviconCrop'] ?? ''); + try { + $this->media->renderFavicon($favicon, '' === $crop ? null : array_map('floatval', explode(',', $crop))); + } catch (\RuntimeException) { + return null; + } + + return $this->media->faviconFile($size); + } + #[Route('/media/{id}/{width}', requirements: ['id' => '[a-f0-9]{32}', 'width' => '\d{1,4}'], methods: ['GET'])] public function serve(string $id, int $width): Response { diff --git a/src/Ops/ArchiveUpdate.php b/src/Ops/ArchiveUpdate.php index 4ef1ad2..0af8d0f 100644 --- a/src/Ops/ArchiveUpdate.php +++ b/src/Ops/ArchiveUpdate.php @@ -29,6 +29,14 @@ final class ArchiveUpdate /** Was innerhalb von public/ dem Betreiber gehört und deshalb stehen bleibt. */ private const EIGENES_IN_PUBLIC = ['assets/fonts', 'assets/build']; + /** + * Was frühere Fassungen in public/ mitbrachten und jetzt die Anwendung beantwortet. + * + * Das Zusammenführen kopiert nur hinzu. Bliebe favicon.svg liegen, lieferte der Webserver + * sie weiter selbst aus - das mitgelieferte Zeichen, auch wenn ein eigenes Symbol gesetzt ist. + */ + private const ENTFALLEN_IN_PUBLIC = ['favicon.svg']; + /** * Die Marke zwischen den beiden Schritten. * @@ -221,6 +229,11 @@ final class ArchiveUpdate // public/ wird Datei für Datei aktualisiert: Schriften und erzeugte Bündel gehören dem // Betreiber und wären sonst weg. $this->mergePublic($quelle.'/public', $this->root.'/public'); + foreach (self::ENTFALLEN_IN_PUBLIC as $eintrag) { + if (is_file($this->root.'/public/'.$eintrag) && !is_file($quelle.'/public/'.$eintrag)) { + @unlink($this->root.'/public/'.$eintrag); + } + } // Ebenso plugins/: die mitgelieferten Plugins werden aktualisiert, ein vom Betreiber // installiertes Plugin bleibt, weil sein Ordner im Archiv nicht vorkommt. $this->mergePublic($quelle.'/plugins', $this->root.'/plugins'); diff --git a/templates/favicon.html.twig b/templates/favicon.html.twig index b54ac1e..9f39e54 100644 --- a/templates/favicon.html.twig +++ b/templates/favicon.html.twig @@ -1,10 +1,12 @@ {# Das Symbol der Website. Eigenes Bild aus den Einstellungen, sonst das mitgelieferte Zeichen. - Die Kennung hängt als Version am Verweis, damit ein Wechsel im Browser auch ankommt. #} + Die Kennung hängt als Version am Verweis, damit ein Wechsel im Browser auch ankommt. + Das 192er ist für die Google-Suche: sie nimmt nur Vielfache von 48 px. #} {% set site = siteSettings.load() %} {% set favicon = site.faviconMediaId|default('') %} {% set version = site.faviconVersion|default('') ?: favicon|slice(0, 8) %} {% if favicon %} + {% else %} diff --git a/tests/CmsTest.php b/tests/CmsTest.php index ab9cd1e..85fa571 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -1626,6 +1626,52 @@ final class CmsTest extends TestCase self::assertSame($kleines['id'], $this->site->load()['faviconMediaId']); } + public function testGoogleGetsTheOwnSiteIconUnderEveryAddress(): void + { + // Umut: Google zeigte bei Elektro Bernhard nach dem Wechsel weiter das KantSites-K. Das + // eigene Symbol gab es nur in 32 px (Google will Vielfache von 48), und /favicon.svg lag + // als Datei in public/ und lieferte immer das mitgelieferte Zeichen. + self::assertFileDoesNotExist(__DIR__.'/../public/favicon.svg'); + $ohne = $this->request('/favicon.svg'); + self::assertSame(200, $ohne->getStatusCode()); + self::assertSame('image/svg+xml', $ohne->headers->get('Content-Type')); + self::assertSame('/favicon.svg', $this->request('/favicon.ico')->headers->get('Location')); + self::assertSame(404, $this->request('/favicon-192.png')->getStatusCode()); + + $media = new MediaLibrary($this->db, $this->path.'-media'); + $file = tempnam(sys_get_temp_dir(), 'kant-symbol-'); + $image = imagecreatetruecolor(300, 300); + imagepng($image, $file); + imagedestroy($image); + $item = $media->upload(new UploadedFile($file, 'zeichen.png', test: true)); + @unlink($file); + $this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => $item['id']]); + $version = substr($item['id'], 0, 8); + + self::assertStringContainsString('', $this->request('/')->getContent()); + foreach (['/favicon-192.png' => 192, '/favicon.ico' => 48] as $adresse => $kante) { + $antwort = $this->request($adresse); + self::assertSame(200, $antwort->getStatusCode(), $adresse); + self::assertSame('image/png', $antwort->headers->get('Content-Type')); + $size = getimagesizefromstring((string) file_get_contents($this->path.'-media/favicon-'.$kante.'.png')); + self::assertSame([$kante, $kante], [$size[0], $size[1]]); + } + $svg = $this->request('/favicon.svg'); + self::assertSame(302, $svg->getStatusCode()); + self::assertSame('/favicon-192.png?v='.$version, $svg->headers->get('Location')); + + // Wer sein Symbol vor den neuen Größen gesetzt hat, bekommt sie beim ersten Abruf. + unlink($this->path.'-media/favicon-48.png'); + unlink($this->path.'-media/favicon-192.png'); + self::assertSame(200, $this->request('/favicon-192.png')->getStatusCode()); + self::assertFileExists($this->path.'-media/favicon-48.png'); + + // Symbol entfernt: die Dateien liegen noch da, gelten aber nicht mehr. + $this->site->save(['country' => 'DE', 'homePath' => 'home', 'faviconMediaId' => '']); + self::assertSame(200, $this->request('/favicon.svg')->getStatusCode()); + self::assertSame(404, $this->request('/favicon.png')->getStatusCode()); + } + public function testUnderlinedLinksAreAChoiceAndOnlyApplyToRunningText(): void { // Umut: „links haben alle underlined“. Im Menü, auf Karten und über Bildern hat ein