diff --git a/.claude/launch.json b/.claude/launch.json index c27f906..a97d3cf 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -79,6 +79,26 @@ ], "port": 8094 }, + { + "name": "ebsolar", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8096'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8096 -t public bin/dev-router.php" + ], + "port": 8096 + }, + { + "name": "ebsolar-prod", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8097'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8097 -t public bin/dev-router.php" + ], + "port": 8097 + }, { "name": "immobilien-deeg-prod", "runtimeExecutable": "powershell", diff --git a/README.md b/README.md index 15289d3..61e8ecf 100644 --- a/README.md +++ b/README.md @@ -377,6 +377,10 @@ Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `v Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Hausverwaltung und Immobilien GmbH) liegt in `var/immobilien-deeg.db` mit Medien in `var/immobilien-deeg-media`; die Startkonfiguration `immobilien-deeg` in `.claude/launch.json` nutzt Port 8094, `immobilien-deeg-prod` denselben Bestand im Produktionsmodus auf Port 8095 zum Messen. `scripts/seed-immobilien-deeg.php ` baut Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `bewertung` (Bewertungsanfrage mit Auswahl Wohnung, Haus, Mehrfamilienhaus, Grundstück); `sh scripts/reset-immobilien-deeg.sh [Bilderordner]` (Standard `var/immobilien-deeg-assets`: Logo und zwei Architekturfotos 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. +### Sechste lokale Referenzwebsite: EB Solar + +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. + 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/STATUS.md b/docs/STATUS.md index 5443396..9caf612 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,37 @@ # Projektstatus +## Sechste Installation: der Redesign-Entwurf „EB Solar“ (16.09.2026) + +**Anlass:** Umut: „bitte redesign aufsetzen ebsolar.de achte auf die richtlinien wie immer machs wie die anderen tagespflege, elektro-baernhard co“ – dazu das Paket `ebsolar-redesign.zip` (statische Website: Startseite, Projektübersicht, acht Projektseiten, eigene SVG-Symbole, Kontaktformular per `mailto`). + +**Neu.** Neben Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Maler Drews und DEEG steht jetzt **EB Solar** (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden): Datenbank `var/ebsolar.db`, Medien `var/ebsolar-media`, Bildquellen `var/ebsolar-assets` (Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos, `projects.json`), Startkonfigurationen `ebsolar` (Port 8096, dev) und `ebsolar-prod` (Port 8097, `APP_ENV=prod` 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, Menü und zwei Formulare; `sh scripts/reset-ebsolar.sh` wirft weg, migriert und baut neu. Schrift ist Arial (System) wie in der Vorlage, es gibt nichts zu holen. + +**Aus der Vorlage übernommen:** Farben (`#b52c2c` Rot, `#252b30` Tinte, `#f5f6f6` helle Fläche, `#e6e8e8` Linie), Schriftgrößen, Abstände, Wortlaut und die Reihenfolge der Abschnitte – Hero mit Bildunterschrift über dem Foto, die vier Zusicherungen zwischen zwei Linien, drei Schritte, „Gutes Handwerk. Neue Energie.“ auf heller Fläche, Projektraster, Herstellerleiste, „Was kann Ihr Dach?“. + +**Bewusste Abweichungen.** +- Das Kontaktformular der Vorlage öffnet nur das E-Mail-Programm des Besuchers. Hier ist es das serverseitige Formular `kontakt`; die Anfrage liegt in der Verwaltung und geht an pv@elektro-bernhard.de. +- Der **Solarcheck** ist im Paket nur ein Verweis auf ebsolar.de. Hier ist er eine eigene Seite mit dem Formular `solarcheck`: dieselben acht Schritte und dieselben Fragen wie im Original (abgerufen 16.09.2026), nur der Schieberegler für den Jahresverbrauch ist ein Zahlenfeld – ein Schieberegler ist im Formularbaustein nicht vorgesehen. +- Die acht Projektseiten sind **Beiträge** der Kategorie „Projekte“ mit Galerie und Bildbetrachter; `/projekte` ist eine Beitragsliste. Die Projektart („Photovoltaik“, „Terrassenmontage · SolarEdge“) steht als **Schlagwort** am Beitrag und nicht als Kategorie: „vorheriges/nächstes Projekt“ folgt der ersten Kategorie, und die muss bei allen acht dieselbe sein, sonst führt die Navigation an Rheinstetten vorbei. Die Beitragsliste zeigt nur Kategorien, deshalb tragen die Karten anders als in der Vorlage keine Artzeile. +- Impressum und Datenschutz sind eigene Seiten (Angaben vom Impressum der laufenden Website); was nur Elektro Bernhard beantworten kann, steht als „NOCH ZU PRÜFEN“ im Text. +- Kein Cookie-Banner: es wird nichts von Dritten geladen. + +**Bildkarten im Solarcheck (17.09.2026).** Umut: „du kannst so schöne svg's erstellen kannst du bitte noch schöne icons für den solarcheck machen statt checkboxe schöne bilder?“ Die fünf Auswahlfragen (Personen, Dachform, Dachfläche, Eindeckung, Speicher) zeigen jetzt Bildkarten statt Optionsfelder – 20 Symbole in Tinte und Rot auf heller Fläche, im Stil der übrigen Symbole der Website. `scripts/build-ebsolar-options.mjs` zeichnet sie als SVG und rastert sie mit Chromium auf 800×600 (4:3 wie die Karte), weil die Mediathek kein SVG nimmt; SVG und PNG liegen in `var/ebsolar-assets/options/`, das Aufbauskript hängt die PNGs per `images` an die Felder. Lighthouse `/solarcheck` danach: Leistung 98/98, Barrierefreiheit, Best Practices, SEO 100. + +**Im Kern behoben: Pfeile waren Schriftzeichen.** Der Button haengte ein `↗` an, die Beitragsnavigation ein `←`/`→` – Buchstaben also, die je nach Schrift anders aussehen und mit der Schriftstaerke nicht mitwachsen. Der Symbolsatz (`templates/blocks/icon.html.twig`, `DocumentValidator::ICONS`) kennt jetzt drei Pfeile: `arrow-right`, `arrow-left`, `arrow-up-right`, gezeichnet wie die uebrigen 17 Symbole. Button und Beitragsnavigation setzen sie als eingebettetes SVG in denselben `` wie bisher – `& span{display:none}` nimmt den Pfeil also weiterhin weg, wo eine Vorlage keinen will (DEEG). Die Groesse haengt an der Schrift des Knopfs (`.button-arrow>svg{width:1.15em}`), nicht an einer festen Zahl. Auf EB Solar tragen auch die Textlinks ihren Pfeil als Symbolbaustein neben dem Text; er zeigt auf dieselbe Adresse und ist als Schmuck von Vorlesesoftware und Tastatur ausgenommen. + +**Im Kern behoben: zweimal derselbe Selektor in eigenem CSS loeschte die erste Regel.** `CustomCode::parse()` legte die Deklarationen je Selektor in einem Array ab – `$rules[$suffix] = …`. Standen `& .post-card{border:0}` und `& .post-card{transform:none}` untereinander, gewann die zweite, und `border:0` verschwand ohne Meldung; im Projektraster von EB Solar kam dadurch der 1-px-Rahmen des Kerns zurueck. In CSS ist zweimal derselbe Selektor eine Kaskade, kein Widerruf: die Deklarationen werden jetzt in der Reihenfolge zusammengefuegt, in der sie standen. Dasselbe galt fuer `&{…}` neben den Eigenschaften des Elements selbst – auch das ging vorher verloren. + +**Im Kern behoben: das vorgezogene Bild wurde nicht vorab geholt.** Für ein Hintergrundbild des ersten Abschnitts schreibt `page.html.twig` längst ein `` (`heroBackground`). Für ein gewöhnliches `` über der Falz fehlte das Gegenstück – der Browser fand es erst, wenn das Stylesheet geparst war, und diese Rundreise ging auf die Startseite von EB Solar (deren Startbild ein Bild und kein Hintergrund ist) direkt in die LCP-Zeit. Neu liefert `PageController::heroImage()` Quelle, Variantensatz und Größenangabe genau so, wie `blocks/block.html.twig` sie an das `img` schreibt (sonst lädt der Browser zweimal), und die Vorlage schreibt daraus die Vorabladung – aber nur, wenn nicht ohnehin schon ein Hintergrundbild vorgeholt wird. + +**Und eine Regel fürs Aufbauen:** eine Einblendung auf dem größten Element über der Falz kostet fast eine Sekunde LCP (gemessen: 916 ms „element render delay“, mobil 92 statt 99). Das Startbild blendet deshalb nicht ein; Kleinzeile, Überschrift, Text und Knöpfe daneben schon. + +**Geprüft:** `npm run test:links` gegen Port 8096: 17 Adressen in der Sitemap, 48 Seiten, 127 Adressen, kein toter Verweis. `npm run test:browser` (`PHP_BINARY=C:/php84/php.exe`): sechs Seiten-/Viewport-Prüfungen mit axe, Tastatur und ohne JavaScript bestanden, **keine redaktionellen Befunde**; Ladebudget der Startseite 14 Anfragen, 17 Abfragen. Lighthouse 13.4.1 (`scripts/audit.mjs`, je drei Läufe mobil und Desktop, Mediane) gegen den Produktionsmodus auf Port 8097 für `/`, `/projekte`, `/solarcheck`, `/michelbach`, `/rheinstetten`, `/impressum`, `/datenschutz`: **Barrierefreiheit, Best Practices und SEO überall 100**, Leistung 99–100, agentisches Browsing überall 100 außer `/solarcheck` mobil (98). Startseite LCP 2,1 s mobil / 0,8 s Desktop, CLS 0, TBT 0 ms; Berichte in `reports/lighthouse-ebsolar/`. Bildbetrachter der Galerie geöffnet und geblättert („1 von 4“), Beitragsnavigation stimmt mit der Reihenfolge der Vorlage überein (Michelbach ← Rheinstetten, → Au am Rhein), 404 und Solarcheck im Browser angesehen, Desktop und 375 px. PHP-Tests **581 grün** (PHP 8.4), php-cs-fixer und Twig-Lint ohne Befund. + +**Stolperstein beim Messen:** Im Produktionsmodus liegen die uebersetzten Twig-Vorlagen in `var/cache/prod`. Nach einer Aenderung an `blocks/icon.html.twig` warf jede Seite mit Pfeil dort **500** („Key ‚arrow-right‘ … does not exist“), waehrend der Entwicklungsserver auf 8096 laengst richtig lieferte. `APP_ENV=prod … bin/console cache:clear` raeumt das auf; wer im Produktionsmodus misst, muss das nach jeder Vorlagenaenderung tun. + +**Bekannt:** `/solarcheck` mobil hat CLS 0,08 (agentisches Browsing 98). Das Formularskript blendet nach dem Laden alle Schritte bis auf den ersten aus – ohne Skript stehen sie untereinander, mit Skript springt die Seite einmal. Der Wert liegt unter dem Ziel von 0,1, aber die Bewegung ist sichtbar; sie gehört in den Kern (Schritte serverseitig zusammenfalten), nicht ins Aufbauskript. + + ## Startseite nach der Installation: eine Baustellenseite statt einer Anleitung (16.09.2026) **Anlass:** Umut: „Kannst du bitte für die Erstseite bzw. nach der Installation eine schöne Erstseite erstellen – Under Construction oder so, ohne Details mit ‚melde dich an‘ usw. Am besten auch noch Logo von KantSites: ‚Hier entsteht eine neue Webpräsenz mit KantSites‘.“ diff --git a/public/assets/site.css b/public/assets/site.css index 649c3a2..148936c 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -44,6 +44,11 @@ p{hyphens:auto} /* Blocks introduced with schema v6. No JavaScript: the accordion is details/summary. */ .block-icon{flex-shrink:0;display:block} +/* Der Pfeil am Button und in der Beitragsnavigation ist dasselbe Symbol wie ueberall sonst - + frueher stand dort ein Schriftzeichen, das je nach Schrift anders aussah. Er waechst mit der + Schrift des Knopfs, statt eine eigene Groesse zu haben. */ +.button-arrow,.nav-arrow{display:inline-flex;align-items:center} +.button-arrow>svg,.nav-arrow>svg{width:1.15em;height:1.15em} .block-iconcard{display:grid;gap:12px;padding:26px;border:1px solid currentColor;border-radius:12px} .block-iconcard + .block-iconcard,.block-quote + .block-quote,.block-cta + .block-cta,.block-iconcard + .block-quote,.block-quote + .block-iconcard{margin-top:18px} /* Gestapelt brauchen Karten den Abstand; nebeneinander in einer Zeile macht er die zweite bis diff --git a/scripts/build-ebsolar-options.mjs b/scripts/build-ebsolar-options.mjs new file mode 100644 index 0000000..b991f92 --- /dev/null +++ b/scripts/build-ebsolar-options.mjs @@ -0,0 +1,114 @@ +/* + * Zeichnet die Bildkarten des Solarchecks von EB Solar 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 JPEG, PNG und WebP, kein SVG. Deshalb entstehen die + * Symbole hier als SVG (leicht zu ändern, alles in einer Datei) und werden mit Chromium auf + * 800 × 600 gerastert, dem Seitenverhältnis 4:3 der Karte. Stil wie die übrigen Symbole der + * Website: dünne Linie in Tinte, runde Enden, ein roter Akzent, helle Fläche dahinter. + * + * node scripts/build-ebsolar-options.mjs → var/ebsolar-assets/options/*.png und *.svg + */ +import { chromium } from 'playwright'; +import { mkdir, writeFile } from 'node:fs/promises'; + +const ziel = process.argv[2] || 'var/ebsolar-assets/options'; +const TINTE = '#252b30', ROT = '#b52c2c', HELL = '#f5f6f6', PAPIER = '#ffffff', GRAU = '#c9ced0'; + +/** Ein Bild: 800 × 600, helle Fläche, Inhalt in der Mitte. */ +const bild = (inhalt) => ` + +${inhalt} +`; + +/** Eine Person: Kopf und Schultern. */ +const person = (x, y, s = 1, farbe = TINTE) => ` + +`; + +/** Ein Haus: Wände plus Dach nach Angabe; das Dach ist der Akzent. */ +const haus = (dach) => ` + + +${dach}`; + +/** Ein Dach als schräge Fläche mit Modulen im Raster. */ +const module = (spalten, zeilen) => { + const x0 = 160, y0 = 150, b = 480, h = 300, scher = 90; + const zellen = []; + for (let z = 0; z < zeilen; z++) { + for (let s = 0; s < spalten; s++) { + const bx = b / spalten, bh = h / zeilen; + const x = x0 + s * bx + (z * bh) * (scher / h), y = y0 + z * bh; + zellen.push(``); + } + } + return `${zellen.join('')}`; +}; + +const fragezeichen = (x, y, s = 1) => ``; + +const batterie = (inhalt, farbe = TINTE) => ` + + +${inhalt}`; + +const bilder = { + // Wie viele Personen leben im Haushalt? + 'personen-1': person(400, 320, 1.3), + 'personen-2': person(300, 320, 1.15) + person(500, 320, 1.15), + 'personen-3-4': person(210, 330) + person(400, 330) + person(590, 330), + 'personen-4-plus': person(160, 330, .85) + person(320, 330, .85) + person(480, 330, .85) + person(640, 330, .85) + + ``, + + // Welche Form hat Ihr Dach? + 'dach-flach': haus(``), + 'dach-sattel': haus(``), + 'dach-pult': haus(``), + 'dach-versetzt': ` + + +`, + 'dach-sonstiges': haus(``) + fragezeichen(400, 250, 1.1), + + // Wie groß ist Ihre Dachfläche? + 'flaeche-50': module(2, 1), + 'flaeche-100': module(3, 2), + 'flaeche-200': module(5, 3), + 'flaeche-200-plus': module(7, 4), + 'flaeche-unsicher': `` + fragezeichen(400, 300, 1.4), + + // Aus welchem Material besteht die Dacheindeckung? + 'eindeckung-ziegel': [0, 1, 2, 3].map(z => [0, 1, 2, 3, 4].map(s => { + if (z % 2 === 1 && s === 4) return ''; + const x = 150 + s * 100 + (z % 2) * 50, y = 170 + z * 80; + return ``; + }).join('')).join(''), + 'eindeckung-bitumen': ` + + + +`, + 'eindeckung-blech': ` + + + +`, + 'eindeckung-anderes': `` + fragezeichen(400, 300, 1.4), + + // Wünschen Sie einen Stromspeicher? + 'speicher-ja': batterie(``), + 'speicher-nein': batterie('', GRAU) + ``, +}; + +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-ebsolar.sh b/scripts/reset-ebsolar.sh new file mode 100644 index 0000000..092ee70 --- /dev/null +++ b/scripts/reset-ebsolar.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Wirft die EB-Solar-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/ebsolar.db" +export MEDIA_DIRECTORY="$root/var/ebsolar-media" +rm -f "$root/var/ebsolar.db" +rm -rf "$root/var/ebsolar-media" +"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q +assets="${1:-$root/var/ebsolar-assets}" +# Die Vorlage nutzt Arial; es gibt keine Schrift zu holen. +"$php" -d memory_limit=512M scripts/seed-ebsolar.php "$assets" diff --git a/scripts/seed-ebsolar.php b/scripts/seed-ebsolar.php new file mode 100644 index 0000000..32a647c --- /dev/null +++ b/scripts/seed-ebsolar.php @@ -0,0 +1,1182 @@ +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); +$posts = new PostRepository($db, $media, $settings); +$theme = new Theme($db); + +$firma = 'Elektro Bernhard Regenerative Energie GmbH'; +$mail = 'pv@elektro-bernhard.de'; +$mailInfo = 'info@elektro-bernhard.de'; +$telefon = '+49 7221 3944909'; +$telHref = 'tel:+4972213944909'; +$instagram = 'https://www.instagram.com/elektrobernhardenergie/'; +$SCHEMA = 24; + +// --------------------------------------------------------------------------------------------- +// Bausteine +// --------------------------------------------------------------------------------------------- + +$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w]; +$ecken = static fn (string $r): array => ['topLeft' => $r, 'topRight' => $r, 'bottomRight' => $r, 'bottomLeft' => $r]; +$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']; +$obenUnten = static fn (string $w): array => ['top' => $w, '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; +}; + +/** Der Rhythmus der Vorlage: 81/78 px am Schreibtisch, 58 auf dem Tablet, 47 auf dem Telefon. */ +$abschnitt = static function (string $id, array $children, array $style = [], ?string $anchor = null) use ($section): array { + $basis = [ + 'desktop' => ['padding' => ['top' => '81px', 'bottom' => '78px']], + 'tablet' => ['padding' => ['top' => '58px', 'bottom' => '58px']], + 'mobile' => ['padding' => ['top' => '47px', 'bottom' => '47px']], + ]; + foreach ($style as $device => $values) { + $basis[$device] = [...($basis[$device] ?? []), ...$values]; + } + + return $section($id, $children, $basis, $anchor); +}; + +$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 – Kopfleiste, Aktionen, 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 $anchor = null, string $css = ''): array { + $block = ['id' => $id, 'type' => 'column', 'children' => $children]; + if ([] !== $style) { + $block['style'] = $style; + } + if ('all' !== $sichtbarkeit) { + $block['layout'] = ['visibility' => $sichtbarkeit]; + } + if (null !== $anchor) { + $block['anchor'] = $anchor; + } + 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 rote Strich vor der Kleinzeile. */ +$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. Der Baustein hängt das Symbol „arrow-up-right“ an – das passt hier, die Vorlage + * setzt genau diesen Pfeil hinter jeden Knopf. Wo sie keinen will, nimmt der Span ihn weg. + */ +$button = static function (string $id, string $text, string $href, array $style = [], bool $pfeil = true): array { + $block = ['id' => $id, 'type' => 'button', 'props' => ['text' => $text, 'href' => $href], 'style' => $style]; + if (!$pfeil) { + $block['css'] = '& span{display:none}'; + } + + return $block; +}; + +$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = '', bool $vergroessern = false): 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'; + } elseif ($vergroessern) { + // Der Bildbetrachter der Galerie: ohne Skript bleibt der Verweis auf das große Bild. + $props['click'] = 'vergroessern'; + } + + $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. +$h1Stil = ['desktop' => ['fontWeight' => '600', 'fontSize' => '64px', 'lineHeight' => 1.075, 'letterSpacing' => '-0.045em', 'margin' => ['bottom' => '0px']], 'tablet' => ['fontSize' => '45px'], 'mobile' => ['fontSize' => '40px']]; +$h2Stil = ['desktop' => ['fontWeight' => '600', 'fontSize' => '40px', 'lineHeight' => 1.16, 'letterSpacing' => '-0.035em', 'margin' => ['bottom' => '0px']], 'tablet' => ['fontSize' => '32px'], 'mobile' => ['fontSize' => '30px']]; +$h3Stil = ['desktop' => ['fontWeight' => '600', 'fontSize' => '20px', 'lineHeight' => 1.16, 'letterSpacing' => '-0.02em', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '19px']]; +$kleinStil = ['desktop' => ['fontSize' => '12px', 'fontWeight' => '600', 'letterSpacing' => '0.1em', 'textTransform' => 'uppercase', 'lineHeight' => 1.5, 'color' => '@grau', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '11px']]; +$fliess = ['desktop' => ['color' => '@grau', 'fontSize' => '16px', 'lineHeight' => 1.8, 'margin' => ['bottom' => '0px']]]; +$linkCss = "& a{color:#b52c2c;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#952222}"; +/** Der Textlink der Vorlage: 14 px, halbfett, Pfeil daneben, rot beim Zeigen. */ +$textLinkCss = "& a{font-weight:500;font-size:14px;text-decoration:none}\n& a:hover{color:#b52c2c}"; + +/** + * Ein Textlink mit Pfeil. Der Pfeil ist ein Symbolbaustein neben dem Text, kein Schriftzeichen + * im Text: ein "→" ist ein Buchstabe und sieht in jeder Schrift anders aus. Text und Pfeil + * zeigen auf dieselbe Adresse; der Pfeil ist Schmuck und deshalb von Vorlesesoftware und + * Tastatur ausgenommen - das erledigt der Symbolbaustein fuer ein dekoratives Symbol selbst. + * + * @param string $pfeil "arrow-right", "arrow-up-right" oder "arrow-left" + * @param bool $vorn Pfeil vor dem Text - fuer den Rueckweg + */ +$textlink = static function (string $id, string $label, string $href, string $pfeil = 'arrow-right', array $style = [], bool $vorn = false, string $farbe = '@tinte') use ($row, $text): array { + $wort = $text($id.'-text', ''.$label.'', [ + 'desktop' => ['fontSize' => '14px', 'fontWeight' => '500', 'color' => $farbe, 'lineHeight' => 1.5, 'margin' => ['bottom' => '0px']], + ], '& a{text-decoration:none}'); + $symbol = ['id' => $id.'-pfeil', 'type' => 'icon', 'props' => ['icon' => $pfeil, 'size' => 20, 'decorative' => true, 'label' => '', 'href' => $href], + 'style' => ['desktop' => ['flexShrink' => 0, 'color' => $farbe]]]; + + return [...$row($id, $vorn ? [$symbol, $wort] : [$wort, $symbol], [], [ + 'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => '10px', 'margin' => ['top' => '0px'], ...($style['desktop'] ?? [])], + ...array_diff_key($style, ['desktop' => null]), + ]), 'css' => '&:hover a{color:#b52c2c} &:hover .block-icon{color:#b52c2c}']; +}; + +$kleinzeile = static fn (string $id, string $inhalt, string $farbe = '@grau', string $abstand = '21px'): array => $text($id, $inhalt, [ + 'desktop' => [...$kleinStil['desktop'], 'color' => $farbe, 'margin' => ['bottom' => $abstand]], + 'mobile' => $kleinStil['mobile'], +]); + +// --------------------------------------------------------------------------------------------- +// Website, Gestaltung, Medien, Formulare +// --------------------------------------------------------------------------------------------- + +$db->insert('website', ['id' => 'default', 'name' => 'EB Solar', 'default_locale' => 'de', 'theme' => '{}']); + +$theme->save( + [ + ['key' => 'rot', 'name' => 'Signalrot', 'value' => '#b52c2c'], + ['key' => 'rot-dunkel', 'name' => 'Rot beim Zeigen', 'value' => '#952222'], + ['key' => 'tinte', 'name' => 'Tinte', 'value' => '#252b30'], + ['key' => 'grau', 'name' => 'Fließtext', 'value' => '#666d72'], + ['key' => 'grau-tief', 'name' => 'Nebentext dunkel', 'value' => '#51585c'], + ['key' => 'linie', 'name' => 'Trennlinie', 'value' => '#e6e8e8'], + ['key' => 'hell', 'name' => 'Helle Fläche', 'value' => '#f5f6f6'], + ['key' => 'papier', 'name' => 'Papier', 'value' => '#ffffff'], + ], + [ + // Die Vorlage lädt bewusst keine Webschrift; Arial steht auf jedem Gerät bereit. + ['key' => 'body', 'name' => 'Fließtext', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 500, 600], 'fallback' => 'sans-serif'], + ['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Arial', 'source' => 'system', 'weights' => [400, 600], 'fallback' => 'sans-serif'], + ], + ['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.65, 'color' => '@tinte', 'backgroundColor' => '@papier', 'linkColor' => '@tinte', 'containerWidth' => '1280px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'], + ['shape' => 'rounded', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'inherit', 'fontSize' => 15, 'fontWeight' => 600, 'letterSpacing' => 0, 'textTransform' => 'none', 'backgroundColor' => '@rot', 'color' => '@papier', 'hoverBackgroundColor' => '@rot-dunkel', 'hoverColor' => '@papier'], +); +echo "Farben, Schriften und Buttons gesetzt\n"; + +$medien = []; +$hochladen = static function (string $datei, string $alt, string $titel = '') use ($assets, $media, &$medien): array { + if (isset($medien[$datei])) { + return $medien[$datei]; + } + $pfad = $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' => '' !== $titel ? $titel : pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]); + $medien[$datei] = $media->find($eintrag['id']); + echo "Bild übernommen: $datei\n"; + + return $medien[$datei]; +}; + +$logo = $hochladen('logo.png', $firma, 'Elektro Bernhard – Markenzeichen'); +$favicon = $hochladen('favicon.jpg', $firma, 'Elektro Bernhard – Favicon'); +$hero = $hochladen('hero.jpg', 'Photovoltaikanlage von Elektro Bernhard auf dem Dach eines Wohnhauses', 'Photovoltaik auf dem Wohnhausdach'); + +$hersteller = [ + ['sma.png', 'SMA'], + ['solaredge.png', 'SolarEdge'], + ['byd.png', 'BYD'], + ['solarfabrik.png', 'Solar Fabrik'], + ['axitec.png', 'AXITEC'], + ['meyerburger.png', 'Meyer Burger'], +]; +foreach ($hersteller as [$datei, $name]) { + $hochladen($datei, $name, $name); +} + +// Die Bildkarten des Solarchecks: Symbole in Tinte und Rot auf heller Fläche, gezeichnet von +// scripts/build-ebsolar-options.mjs (SVG-Quellen liegen daneben). Sobald eine Option ein Bild hat, +// zeigt der Formularbaustein die Auswahl als Karten statt als Optionsfelder. +$karte = static fn (string $name, string $alt): string => $hochladen('options/'.$name.'.png', $alt, 'Solarcheck: '.$name)['id']; +$karten = [ + 'personen' => [ + '1 Person' => $karte('personen-1', 'Eine Person'), + '2 Personen' => $karte('personen-2', 'Zwei Personen'), + '3 – 4 Personen' => $karte('personen-3-4', 'Drei Personen'), + '4+ Personen' => $karte('personen-4-plus', 'Vier Personen und ein Plus'), + ], + 'dachform' => [ + 'Flachdach' => $karte('dach-flach', 'Haus mit flachem Dach'), + 'Satteldach' => $karte('dach-sattel', 'Haus mit Giebeldach'), + 'Pultdach' => $karte('dach-pult', 'Haus mit einseitig geneigtem Dach'), + 'erweitert/versetzt' => $karte('dach-versetzt', 'Zwei Gebäudeteile mit versetzten Dächern'), + 'sonstiges' => $karte('dach-sonstiges', 'Haus mit Fragezeichen'), + ], + 'dachflaeche' => [ + 'bis 50 m²' => $karte('flaeche-50', 'Dachfläche mit zwei Modulen'), + '50 m² – 100 m²' => $karte('flaeche-100', 'Dachfläche mit sechs Modulen'), + '100 m² – 200 m²' => $karte('flaeche-200', 'Dachfläche mit fünfzehn Modulen'), + 'über 200 m²' => $karte('flaeche-200-plus', 'Dachfläche mit achtundzwanzig Modulen'), + 'nicht sicher' => $karte('flaeche-unsicher', 'Dachfläche mit Fragezeichen'), + ], + 'eindeckung' => [ + 'Ziegel' => $karte('eindeckung-ziegel', 'Dachziegel in Reihen'), + 'Bitumen' => $karte('eindeckung-bitumen', 'Bitumenbahn wird auf ein Flachdach gerollt'), + 'Blech' => $karte('eindeckung-blech', 'Trapezblech im Schnitt'), + 'Anderes' => $karte('eindeckung-anderes', 'Fläche mit Fragezeichen'), + ], + 'speicher' => [ + 'Ja' => $karte('speicher-ja', 'Akku mit Blitz'), + 'Nein' => $karte('speicher-nein', 'Durchgestrichener Akku'), + ], +]; + +$einwilligung = 'Ich habe die Datenschutzhinweise gelesen. Meine Angaben werden verwendet, um meine Anfrage zu beantworten.'; + +$forms->save('kontakt', 'Kontaktanfrage', [ + ['name' => 'name', 'type' => 'text', 'label' => 'Ihr Name', 'required' => true, 'help' => 'Vor- und Nachname'], + ['name' => 'email', 'type' => 'email', 'label' => 'E-Mail-Adresse', 'required' => true], + ['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Für einen persönlichen Rückruf'], + ['name' => 'nachricht', 'type' => 'textarea', 'label' => 'Ihre Nachricht', 'required' => true, 'help' => 'Was haben Sie vor? Erzählen Sie uns kurz von Ihrem Projekt.'], + ['name' => 'datenschutz', 'type' => 'consent', 'label' => $einwilligung], +], ['recipient' => $mail, 'subject' => 'Neue Nachricht über ebsolar.de', 'success' => 'Vielen Dank. Ihre Nachricht ist angekommen – wir melden uns bei Ihnen.', 'replyToField' => 'email', 'buttonLabel' => 'Nachricht senden'], 0); +echo "Formular angelegt: kontakt\n"; + +// Der Solarcheck: dieselben acht Schritte und Fragen wie das Original auf ebsolar.de +// (abgerufen 16.09.2026). Der Jahresverbrauch ist dort ein Schieberegler; hier ein Zahlenfeld. +$forms->save('solarcheck', 'Solarcheck', [ + ['name' => 'schritt_haushalt', 'type' => 'step', 'label' => 'Ihr Haushalt', 'help' => 'Damit wir einschätzen, wie viel Strom Sie brauchen.'], + ['name' => 'personen', 'type' => 'radio', 'label' => 'Wie viele Personen leben im Haushalt?', 'required' => true, 'options' => ['1 Person', '2 Personen', '3 – 4 Personen', '4+ Personen'], 'images' => $karten['personen']], + + ['name' => 'schritt_verbrauch', 'type' => 'step', 'label' => 'Verbrauch und Baujahr', 'help' => 'Schätzwerte genügen – die letzte Stromrechnung hilft.'], + ['name' => 'jahresverbrauch', 'type' => 'number', 'label' => 'Jahresverbrauch in kWh ca.', 'required' => true, 'min' => 500, 'max' => 50000, 'unit' => 'kWh'], + ['name' => 'baujahr_haus', 'type' => 'number', 'label' => 'Baujahr Haus', 'required' => true, 'min' => 1800, 'max' => 2026], + ['name' => 'baujahr_elektro', 'type' => 'number', 'label' => 'Baujahr Elektroinstallation', 'required' => true, 'min' => 1800, 'max' => 2026], + + ['name' => 'schritt_dachform', 'type' => 'step', 'label' => 'Ihr Dach', 'help' => 'Die Form entscheidet über Montage und Ertrag.'], + ['name' => 'dachform', 'type' => 'radio', 'label' => 'Welche Form hat Ihr Dach?', 'required' => true, 'options' => ['Flachdach', 'Satteldach', 'Pultdach', 'erweitert/versetzt', 'sonstiges'], 'images' => $karten['dachform']], + + ['name' => 'schritt_flaeche', 'type' => 'step', 'label' => 'Dachfläche'], + ['name' => 'dachflaeche', 'type' => 'radio', 'label' => 'Wie groß ist Ihre Dachfläche ca.?', 'required' => true, 'options' => ['bis 50 m²', '50 m² – 100 m²', '100 m² – 200 m²', 'über 200 m²', 'nicht sicher'], 'images' => $karten['dachflaeche']], + + ['name' => 'schritt_eindeckung', 'type' => 'step', 'label' => 'Dacheindeckung'], + ['name' => 'eindeckung', 'type' => 'radio', 'label' => 'Aus welchem Material besteht die Dacheindeckung?', 'required' => true, 'options' => ['Ziegel', 'Bitumen', 'Blech', 'Anderes'], 'images' => $karten['eindeckung']], + + ['name' => 'schritt_speicher', 'type' => 'step', 'label' => 'Stromspeicher'], + ['name' => 'speicher', 'type' => 'radio', 'label' => 'Wünschen Sie einen Stromspeicher?', 'required' => true, 'options' => ['Ja', 'Nein'], 'images' => $karten['speicher']], + + ['name' => 'schritt_ort', 'type' => 'step', 'label' => 'Wo soll die Anlage verbaut werden?'], + ['name' => 'strasse', 'type' => 'text', 'label' => 'Straße & Hausnummer', 'required' => true], + ['name' => 'plz', 'type' => 'text', 'label' => 'PLZ', 'required' => true], + ['name' => 'stadt', 'type' => 'text', 'label' => 'Stadt', 'required' => true], + + ['name' => 'schritt_kontakt', 'type' => 'step', 'label' => 'Wie können wir Sie kontaktieren?'], + ['name' => 'name', 'type' => 'text', 'label' => 'Name', 'required' => true, 'help' => 'Vor- und Nachname'], + ['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true], + ['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => true], + ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich willige ein, dass meine Angaben gespeichert werden, damit meine Anfrage beantwortet werden kann.'], +], ['recipient' => $mail, 'subject' => 'Neuer Solarcheck über ebsolar.de', 'success' => 'Vielen Dank. Ihr Solarcheck ist angekommen – wir melden uns mit Ihrer persönlichen Kalkulation.', 'replyToField' => 'email', 'buttonLabel' => 'Solarcheck absenden', 'summary' => true], 0); +echo "Formular angelegt: solarcheck\n"; + +// --------------------------------------------------------------------------------------------- +// Kopfbereich: helle Leiste, darunter der weiße Kopf, der beim Scrollen oben bleibt +// --------------------------------------------------------------------------------------------- + +$marke = static fn (string $id, string $breite, string $mobil) => $bild($id, $logo, $firma, [ + 'desktop' => ['maxWidth' => $breite, 'margin' => $seiten('0px')], + 'tablet' => ['maxWidth' => '225px'], + 'mobile' => ['maxWidth' => $mobil], +], '/'); + +$kopf = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.', + 'blocks' => [ + $section('kopf-leiste', [ + $zeile('kopf-leiste-zeile', [ + $column('kopf-leiste-firma', [ + $text('kopf-leiste-firma-text', 'Elektro Bernhard · Regenerative Energie GmbH', ['desktop' => ['fontSize' => '12px', 'letterSpacing' => '0.025em', 'lineHeight' => 1.6, 'color' => '#5f6568', 'margin' => ['bottom' => '0px']]]), + ], [], 'hide-mobile'), + $column('kopf-leiste-ort', [ + $text('kopf-leiste-ort-text', 'Baden-Baden & Umgebung', [ + 'desktop' => ['fontSize' => '12px', 'letterSpacing' => '0.025em', 'lineHeight' => 1.6, 'color' => '#5f6568', 'margin' => ['bottom' => '0px']], + 'mobile' => ['fontSize' => '11px', 'margin' => ['left' => 'auto']], + ]), + ]), + ], '20px', ['justifyContent' => 'space-between'], ['justifyContent' => 'flex-end']), + ], [ + 'desktop' => ['backgroundColor' => '@hell', 'color' => '#646a6d', 'padding' => ['top' => '7px', 'bottom' => '7px']], + ]), + $section('kopf-haupt', [ + $row('kopf-haupt-zeile', [ + $column('kopf-marke', [$marke('kopf-logo', '270px', '210px')], ['desktop' => ['flexShrink' => 0]]), + $column('kopf-navigation', [ + ['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'mobile', 'burgerLabel' => 'both'], + 'style' => ['desktop' => [ + 'fontSize' => '14px', 'fontWeight' => '500', 'justifyContent' => 'flex-end', 'display' => 'flex', + // Der Menü-Knopf im Zuschnitt der Vorlage: feiner Rahmen, kleine Rundung. + 'burgerColor' => '@tinte', 'burgerBorderColor' => '@linie', 'burgerBorderRadius' => '5px', 'burgerFontSize' => '14px', + ]], + // Der letzte Eintrag ist der rote Knopf „Zum Solarcheck“. + 'css' => "& .menu-list{gap:4px 29px;justify-content:flex-end;align-items:center}\n& .menu-list a{transition:color .18s}\n& .menu-list a:hover{color:#b52c2c;text-decoration:none}\n& .menu-list>li:last-child>a{background:#b52c2c;color:#ffffff;border-radius:6px;padding:12px 19px;font-weight:600}\n& .menu-list>li:last-child>a:hover{background:#952222;color:#ffffff}", + ], + ], ['desktop' => ['display' => 'flex', 'justifyContent' => 'flex-end', 'alignItems' => 'center']]), + ], [], [ + 'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'space-between', 'gap' => '28px', 'minHeight' => '99px', 'margin' => ['top' => '0px']], + 'tablet' => ['minHeight' => '84px'], + 'mobile' => ['minHeight' => '76px', 'gap' => '16px'], + ]), + ], [ + 'desktop' => ['backgroundColor' => '#fffffffa', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '0px', 'bottom' => '0px']], + ]), + ], +]; + +// --------------------------------------------------------------------------------------------- +// Fußbereich +// --------------------------------------------------------------------------------------------- + +$fuss = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.', + 'blocks' => [ + $section('fuss', [ + $zeile('fuss-oben', [ + $column('fuss-marke', [$marke('fuss-logo', '235px', '235px')], ['desktop' => ['flexShrink' => 0]]), + $column('fuss-spruch', [ + $text('fuss-spruch-text', 'Solarenergie aus Baden-Baden.
Für die Region. Für morgen.', [ + 'desktop' => ['fontSize' => '13px', 'color' => '@grau', 'lineHeight' => 1.7, 'margin' => ['bottom' => '0px', 'left' => '15px']], + 'mobile' => ['margin' => ['left' => '0px']], + ]), + ], ['desktop' => ['margin' => ['right' => 'auto'], 'flexGrow' => 1], 'mobile' => ['order' => 2, 'flexBasis' => '100%', 'flexGrow' => 0]]), + $column('fuss-sozial', [ + ['id' => 'fuss-instagram', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => true, 'networks' => ['instagram']], + 'style' => ['desktop' => ['fontSize' => '14px', 'fontWeight' => '500']], + 'css' => '& a:hover{color:#b52c2c}'], + ], ['mobile' => ['order' => 3]]), + ], '30px', ['justifyContent' => 'space-between', 'padding' => ['top' => '54px', 'bottom' => '39px']], ['flexWrap' => 'wrap', 'alignItems' => 'flex-start', 'gap' => '22px', 'padding' => ['top' => '38px', 'bottom' => '29px']]), + $zeile('fuss-unten', [ + $column('fuss-recht', [ + $text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '12px', 'color' => '@grau', 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '11px']]), + ]), + $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' => '24px']], + 'css' => '& a:hover{color:#b52c2c}'], + $text('fuss-agentur', 'Website by Kant-IT Solutions', ['desktop' => ['fontSize' => '12px', 'color' => '@grau', 'margin' => ['bottom' => '0px']]], '& a:hover{color:#b52c2c}'), + ], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px', 'justifyContent' => 'flex-start']), + ], '30px', ['justifyContent' => 'space-between', 'alignItems' => 'center', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '21px', 'bottom' => '21px'], 'color' => '@grau'], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '12px', 'padding' => ['top' => '21px', 'bottom' => '21px']]), + ], ['desktop' => ['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"; + +// --------------------------------------------------------------------------------------------- +// Der Kontaktbereich als wiederverwendbare Sektion: Startseite, Projektübersicht und jeder +// Projektbeitrag verweisen darauf. Eine Änderung erreicht so alle Seiten auf einmal. +// --------------------------------------------------------------------------------------------- + +// Formulare im Zuschnitt der Vorlage: volle Breite statt der 640 px des Kerns, und zwei +// Spalten dort, wo zwei kurze Felder nebeneinander passen (Name/E-Mail, PLZ/Stadt). Angesprochen +// werden die Felder über ihr `data-label`, weil eigenes CSS kein :nth-child kennt. +$formularCss = '& .block-form{max-width:none;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:20px;row-gap:18px} +& .block-form>.form-field{grid-column:1/-1} +& .block-form>.form-step{grid-column:1/-1} +& .block-form>.cms-button{grid-column:1/-1;justify-self:start} +& .block-form>.form-error-summary{grid-column:1/-1} +& .block-form>.form-success{grid-column:1/-1} +& .block-form>.form-missing{grid-column:1/-1} +& .block-form>.form-progress{grid-column:1/-1} +& .block-form>.form-step-nav{grid-column:1/-1} +& .block-form>.form-summary{grid-column:1/-1} +& .form-step{grid-template-columns:repeat(2,minmax(0,1fr));column-gap:20px;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} +& .block-form>[data-label="Ihr Name"]{grid-column:span 1} +& .block-form>[data-label="E-Mail-Adresse"]{grid-column:span 1} +& .block-form>[data-label="Telefon"]{grid-column:span 1} +& .form-step>[data-label="PLZ"]{grid-column:span 1} +& .form-step>[data-label="Stadt"]{grid-column:span 1} +& .form-step>[data-label="E-Mail"]{grid-column:span 1} +& .form-step>[data-label="Telefon"]{grid-column:span 1} +& .form-step>[data-label="Baujahr Haus"]{grid-column:span 1} +& .form-step>[data-label="Baujahr Elektroinstallation"]{grid-column:span 1} +& .form-field{align-content:start} +& .form-field label{font-size:14px;font-weight:600;color:#252b30} +& .form-field input{background:#ffffff;border:1px solid #dfe2e2;border-radius:6px;padding:13px 14px;font-size:16px;color:#252b30} +& .form-field select{background:#ffffff;border:1px solid #dfe2e2;border-radius:6px;padding:13px 14px;font-size:16px;color:#252b30} +& .form-field textarea{background:#ffffff;border:1px solid #dfe2e2;border-radius:6px;padding:13px 14px;font-size:16px;color:#252b30;min-height:130px} +& .form-choice legend{font-size:14px;font-weight:600;color:#252b30} +& .form-choice-grid{grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:14px} +& .form-choice-card label{border-color:#dfe2e2;border-radius:10px} +& .field-note{font-size:13px;color:#5b6266;opacity:1} +& .form-step-intro{color:#5b6266;opacity:1} +& .form-progress-step{opacity:1;color:#5b6266} +& .form-progress-step[aria-current]{color:#252b30} +& .check-row label{font-weight:400;font-size:14px;line-height:1.6}'; + +$kontaktbereich = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Kontaktbereich', 'description' => 'Der Abschnitt „Was kann Ihr Dach?“ mit Solarcheck und Kontaktformular.', + 'blocks' => [ + $section('kontakt-abschnitt', [ + $column('kontakt-karte', [ + $zeile('kontakt-kopf', [ + $column('kontakt-text', [ + $kleinzeile('kontakt-label', 'Der nächste Schritt', '@rot', '13px'), + $heading('kontakt-titel', 'Was kann Ihr Dach?', 2, $h2Stil), + $text('kontakt-intro', 'Erzählen Sie uns von Ihrem Vorhaben. Über unseren Solarcheck erhalten wir die Grundlage für Ihre persönliche Kalkulation.', [ + 'desktop' => ['color' => '@grau', 'fontSize' => '15px', 'lineHeight' => 1.7, 'maxWidth' => '520px', 'margin' => ['top' => '18px', 'bottom' => '0px']], + 'mobile' => ['fontSize' => '16px'], + ]), + ], ['desktop' => ['flexGrow' => 1]]), + $column('kontakt-aktionen', [ + $button('kontakt-knopf', 'Zum Solarcheck', '/solarcheck', [ + 'desktop' => ['minWidth' => '212px', 'justifyContent' => 'space-between', 'margin' => ['top' => '0px']], + 'mobile' => ['width' => '100%', 'minWidth' => '0px'], + ]), + $textlink('kontakt-sprung', 'Direkt Kontakt aufnehmen', '#kontaktformular', 'arrow-right', ['desktop' => ['margin' => ['top' => '18px']]]), + ], ['desktop' => ['flexShrink' => 0, 'display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start'], 'mobile' => ['margin' => ['top' => '25px']]]), + ], '50px', ['alignItems' => 'center'], ['flexDirection' => 'column', 'alignItems' => 'stretch', 'gap' => '0px'], ['gap' => '30px']), + $column('kontakt-formular', [ + $zeile('kontakt-formular-kopf', [ + $heading('kontakt-formular-titel', 'Kontakt aufnehmen', 3, $h3Stil), + $text('kontakt-formular-pflicht', 'Felder mit * sind Pflichtfelder', ['desktop' => ['fontSize' => '13px', 'color' => '@grau', 'margin' => ['bottom' => '0px', 'left' => 'auto']]]), + ], '20px', ['justifyContent' => 'space-between', 'margin' => ['bottom' => '24px']], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '6px']), + ['id' => 'kontakt-formular-block', 'type' => 'form', 'props' => ['form' => 'kontakt'], 'css' => $formularCss], + ], [ + 'desktop' => ['backgroundColor' => '@papier', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'borderRadius' => $ecken('10px'), 'padding' => $seiten('34px'), 'margin' => ['top' => '38px']], + 'mobile' => ['padding' => $seiten('20px'), 'margin' => ['top' => '28px']], + ], 'all', 'kontaktformular'), + ], [ + 'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'borderRadius' => $ecken('12px'), 'padding' => ['top' => '47px', 'right' => '50px', 'bottom' => '47px', 'left' => '50px']], + 'tablet' => ['padding' => $seiten('40px')], + 'mobile' => ['padding' => ['top' => '29px', 'right' => '25px', 'bottom' => '29px', 'left' => '25px'], 'borderRadius' => $ecken('9px')], + ]), + ], [ + 'desktop' => ['padding' => ['top' => '0px', 'bottom' => '78px']], + 'mobile' => ['padding' => ['top' => '0px', 'bottom' => '47px']], + ], 'kontakt'), + ], +]; +$partials->create('section', 'kontakt', 'Kontaktbereich', $kontaktbereich); +echo "Wiederverwendbare Sektion angelegt: kontakt\n"; + +$kontaktref = ['id' => 'kontakt-sektion', 'type' => 'partialref', 'props' => ['partial' => 'kontakt']]; + +// --------------------------------------------------------------------------------------------- +// Startseite +// --------------------------------------------------------------------------------------------- + +$startseite = [ + 'schemaVersion' => $SCHEMA, 'title' => 'EB Solar · Sonnenenergie aus Ihrer Region', + 'description' => 'Photovoltaik von Elektro Bernhard in Baden-Baden. Individuelle Planung und professionelle Umsetzung Ihrer Solaranlage.', + 'blocks' => [ + // Hero: links der Text, rechts das Bild mit der Bildunterschrift darüber. + $section('hero', [ + $row('hero-zeile', [ + $column('hero-text', [ + $zeile('hero-label', [ + $flaeche('hero-strich', ['desktop' => ['width' => '24px', 'height' => '2px', 'minHeight' => '2px', 'backgroundColor' => '@rot', 'margin' => $seiten('0px'), 'flexShrink' => 0]]), + $text('hero-label-text', 'Solarenergie. Persönlich. Regional.', [ + 'desktop' => ['fontSize' => '11px', 'fontWeight' => '600', 'letterSpacing' => '0.11em', 'textTransform' => 'uppercase', 'color' => '#5f656a', 'lineHeight' => 1.5, 'margin' => ['bottom' => '0px']], + 'mobile' => ['fontSize' => '10px'], + ]), + ], '10px', ['margin' => ['bottom' => '21px']], ['margin' => ['bottom' => '18px']]), + $heading('hero-titel', 'Ihr Dach.
Ihr Strom.
Unsere Expertise.', 1, $h1Stil, '& em{color:#b52c2c;font-style:normal}'), + $text('hero-intro', 'Machen Sie mehr aus der Sonne. Wir planen und installieren Ihre Photovoltaikanlage – passend zu Ihrem Zuhause und Ihrem Alltag.', [ + 'desktop' => ['color' => '@grau', 'fontSize' => '16px', 'lineHeight' => 1.8, 'maxWidth' => '440px', 'margin' => ['top' => '25px', 'bottom' => '0px']], + 'mobile' => ['margin' => ['top' => '21px', 'bottom' => '0px'], 'lineHeight' => 1.75], + ]), + $zeile('hero-aktionen', [ + $button('hero-knopf', 'Solarprojekt starten', '/solarcheck', ['desktop' => ['margin' => ['top' => '0px']]]), + $textlink('hero-sprung', 'Unsere Projekte ansehen', '#projekte'), + ], '25px', ['flexWrap' => 'wrap', 'margin' => ['top' => '31px']], ['gap' => '16px', 'margin' => ['top' => '24px']]), + $text('hero-notiz', 'In wenigen Schritten zu Ihrer individuellen Anfrage.', [ + 'desktop' => ['fontSize' => '12px', 'color' => '@grau', 'margin' => ['top' => '14px', 'bottom' => '0px']], + 'mobile' => ['fontSize' => '11px', 'margin' => ['top' => '12px', 'bottom' => '0px']], + ]), + ], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexGrow' => 0, 'flexBasis' => 'auto']]), + $column('hero-bild', [ + $bild('hero-foto', $hero, 'Photovoltaikanlage von Elektro Bernhard auf dem Dach eines Wohnhauses', [ + 'desktop' => ['imageHeight' => '492px', 'objectFit' => 'cover', 'width' => '100%', 'margin' => $seiten('0px')], + 'tablet' => ['imageHeight' => '465px'], + 'mobile' => ['imageHeight' => '340px'], + ]), + // Die Bildunterschrift liegt als Spalte über dem Foto: eine Zeile darf im + // Kern keine Fläche tragen (.block-row setzt background:transparent). + $column('hero-unterschrift', [ + $zeile('hero-unterschrift-zeile', [ + $column('hero-marke-kreis', [ + $symbol('hero-marke-pfeil', 'arrow-up-right', 20, ['desktop' => ['color' => '@rot']]), + ], [ + 'desktop' => ['width' => '36px', 'height' => '36px', 'flexShrink' => 0, 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'borderRadius' => $ecken('50%'), 'display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center'], + ], 'hide-tablet'), + $column('hero-unterschrift-text', [ + $text('hero-unterschrift-titel', 'Aus Sonnenlicht wird Unabhängigkeit.', ['desktop' => ['fontSize' => '14px', 'fontWeight' => '600', 'lineHeight' => 1.4, 'margin' => ['bottom' => '0px']], 'tablet' => ['fontSize' => '12px'], 'mobile' => ['fontSize' => '13px']]), + $text('hero-unterschrift-klein', 'Photovoltaik von Elektro Bernhard', ['desktop' => ['fontSize' => '12px', 'color' => '@grau', 'lineHeight' => 1.4, 'margin' => ['top' => '3px', 'bottom' => '0px']]]), + ]), + ], '16px', [], ['gap' => '12px'], ['gap' => '10px']), + ], [ + 'desktop' => ['position' => 'absolute', 'bottom' => '20px', 'left' => '20px', 'right' => '20px', + 'backgroundColor' => '#fffffff5', 'borderRadius' => $ecken('8px'), 'padding' => ['top' => '16px', 'right' => '19px', 'bottom' => '16px', 'left' => '19px']], + 'tablet' => ['bottom' => '12px', 'left' => '12px', 'right' => '12px', 'padding' => $seiten('13px')], + 'mobile' => ['bottom' => '15px', 'left' => '15px', 'right' => '15px', 'padding' => $seiten('14px')], + ]), + ], [ + 'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'borderRadius' => $ecken('12px'), 'backgroundColor' => '#e4e8e9', 'flexGrow' => 1.06, 'flexBasis' => '0', 'minWidth' => '0'], + 'mobile' => ['borderRadius' => $ecken('9px'), 'flexGrow' => 0, 'flexBasis' => 'auto'], + ]), + ], [], [ + 'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'gap' => '65px', 'margin' => ['top' => '0px']], + 'tablet' => ['gap' => '35px'], + 'mobile' => ['flexDirection' => 'column', 'gap' => '30px'], + ]), + ], [ + 'desktop' => ['padding' => ['top' => '54px', 'bottom' => '47px']], + 'tablet' => ['padding' => ['top' => '40px', 'bottom' => '40px']], + 'mobile' => ['padding' => ['top' => '36px', 'bottom' => '28px']], + ]), + + // Die vier Zusicherungen zwischen zwei Linien. + $section('vorteile', [ + $zeile('vorteile-zeile', array_map( + static fn (array $paar): array => $zeile('vorteil-'.$paar[0], [ + $symbol('vorteil-'.$paar[0].'-haken', 'check', 16, ['desktop' => ['color' => '@rot', 'flexShrink' => 0]]), + $text('vorteil-'.$paar[0].'-text', $paar[1], ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'margin' => ['bottom' => '0px']], 'mobile' => ['fontSize' => '12px']]), + ], '11px', [], ['gap' => '7px']), + [['1', 'Elektro-Meisterbetrieb'], ['2', 'Individuell geplant'], ['3', 'Professionell installiert'], ['4', 'Persönlich vor Ort']], + ), '25px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'color' => '@grau-tief'], ['display' => 'grid', 'gridColumns' => 2, 'gap' => '14px', 'alignItems' => 'center']), + ], [ + 'desktop' => ['borderWidth' => $obenUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '23px', 'bottom' => '23px']], + 'mobile' => ['padding' => ['top' => '19px', 'bottom' => '19px']], + ]), + + // Drei Schritte von der Idee bis aufs Dach. + $abschnitt('leistungen-abschnitt', [ + $zeile('leistungen-kopf', [ + $column('leistungen-kopf-text', [ + $kleinzeile('leistungen-label', 'Von der Idee bis aufs Dach', '@grau', '13px'), + $heading('leistungen-titel', 'Eine Solarlösung, die zu Ihnen passt.', 2, $h2Stil), + ], ['desktop' => ['flexGrow' => 1]]), + $column('leistungen-kopf-notiz', [ + $text('leistungen-notiz', 'Durchdacht geplant. Sorgfältig umgesetzt.
Mit einem Ansprechpartner an Ihrer Seite.', [ + 'desktop' => ['fontSize' => '15px', 'color' => '@grau', 'lineHeight' => 1.7, 'margin' => ['bottom' => '0px']], + 'tablet' => ['maxWidth' => '260px', 'margin' => ['top' => '16px', 'bottom' => '0px']], + ]), + ], ['desktop' => ['flexShrink' => 0]]), + ], '32px', ['alignItems' => 'flex-end', 'justifyContent' => 'space-between', 'margin' => ['bottom' => '39px']], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '0px', 'margin' => ['bottom' => '28px']], ['alignItems' => 'flex-start', 'flexDirection' => 'column', 'gap' => '0px']), + $row('leistungen-raster', array_map( + static fn (array $l): array => $column('leistung-'.$l[0], [ + $text('leistung-'.$l[0].'-nummer', $l[0], ['desktop' => ['fontSize' => '12px', 'fontWeight' => '600', 'color' => '@rot', 'lineHeight' => 1.5, 'margin' => ['bottom' => '23px']], 'mobile' => ['margin' => ['bottom' => '6px']]]), + $heading('leistung-'.$l[0].'-titel', $l[1], 3, $h3Stil), + $text('leistung-'.$l[0].'-text', $l[2], [ + 'desktop' => ['fontSize' => '15px', 'color' => '@grau', 'lineHeight' => 1.8, 'maxWidth' => '345px', 'margin' => ['top' => '14px', 'bottom' => '0px']], + 'mobile' => ['fontSize' => '16px', 'lineHeight' => 1.7, 'maxWidth' => 'none', 'margin' => ['top' => '12px', 'bottom' => '0px']], + ]), + ], [ + 'desktop' => ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '25px']], + 'mobile' => ['padding' => ['top' => '22px']], + ]), + [ + ['01', 'Gut beraten & geplant', 'Wir betrachten Ihr Dach, Ihren Strombedarf und Ihre Wünsche. Daraus entsteht die Grundlage für Ihre Solaranlage.'], + ['02', 'Individuell abgestimmt', 'Die passende Technik für Ihr Zuhause: Wir stimmen die Komponenten aufeinander und auf Ihre Anforderungen ab.'], + ['03', 'Fachgerecht umgesetzt', 'Unser erfahrenes Team übernimmt die Installation. Sorgfältig, zuverlässig und mit Blick auf jedes Detail.'], + ], + ), [], [ + 'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '40px', 'margin' => ['top' => '0px']], + 'tablet' => ['gap' => '23px'], + 'mobile' => ['gridColumns' => 1, 'gap' => '25px'], + ]), + ], [], 'leistungen'), + + // Über uns auf heller Fläche. + $section('ueber-uns-abschnitt', [ + $row('ueber-uns-zeile', [ + $column('ueber-uns-kopf', [ + $kleinzeile('ueber-uns-label', 'Elektro Bernhard', '@grau', '17px'), + $heading('ueber-uns-titel', 'Gutes Handwerk.
Neue Energie.', 2, [ + 'desktop' => [...$h2Stil['desktop'], 'fontSize' => '43px', 'lineHeight' => 1.16], + 'tablet' => ['fontSize' => '35px'], + 'mobile' => ['fontSize' => '35px'], + ]), + ], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexGrow' => 0, 'flexBasis' => 'auto']]), + $column('ueber-uns-text', [ + $text('ueber-uns-absatz-1', 'Wir kommen aus dem Elektrohandwerk. Mit der Elektro Bernhard Regenerative Energie GmbH haben wir unsere Leidenschaft für nachhaltige Energie zu unserem Schwerpunkt gemacht.', [ + 'desktop' => ['fontSize' => '17px', 'lineHeight' => 1.8, 'maxWidth' => '600px', 'margin' => ['bottom' => '0px']], + 'mobile' => ['fontSize' => '16px'], + ]), + $text('ueber-uns-absatz-2', 'Als Elektro-Meisterbetrieb in Baden-Baden verbinden wir fachliches Know-how mit persönlicher Beratung – für Solarprojekte hier in der Region.', [ + 'desktop' => ['fontSize' => '15px', 'color' => '@grau', 'lineHeight' => 1.8, 'maxWidth' => '600px', 'margin' => ['top' => '14px', 'bottom' => '0px']], + 'mobile' => ['fontSize' => '16px'], + ]), + $textlink('ueber-uns-sprung', 'Lernen wir uns kennen', '#kontaktformular', 'arrow-right', ['desktop' => ['margin' => ['top' => '23px']]]), + ], ['desktop' => ['flexGrow' => 1.2, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexGrow' => 0, 'flexBasis' => 'auto']]), + ], [], [ + 'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '90px', 'margin' => ['top' => '0px']], + 'tablet' => ['gap' => '40px'], + 'mobile' => ['flexDirection' => 'column', 'gap' => '23px'], + ]), + ], [ + 'desktop' => ['backgroundColor' => '@hell', 'padding' => ['top' => '68px', 'bottom' => '68px']], + 'tablet' => ['padding' => ['top' => '50px', 'bottom' => '50px']], + 'mobile' => ['padding' => ['top' => '42px', 'bottom' => '42px']], + ], 'ueber-uns'), + + // Projekte: drei Beiträge aus der Kategorie „Projekte“ und die Herstellerleiste. + $abschnitt('projekte-abschnitt', [ + $zeile('projekte-kopf', [ + $column('projekte-kopf-text', [ + $kleinzeile('projekte-label', 'Einblicke in unsere Arbeit', '@grau', '13px'), + $heading('projekte-titel', 'Sonnenenergie in der Praxis.', 2, $h2Stil), + ], ['desktop' => ['flexGrow' => 1]]), + $column('projekte-alle', [ + $textlink('projekte-alle-link', 'Alle Projekte', '/projekte', 'arrow-up-right'), + ], ['desktop' => ['flexShrink' => 0], 'mobile' => ['margin' => ['top' => '18px']]]), + ], '32px', ['alignItems' => 'flex-end', 'justifyContent' => 'space-between', 'margin' => ['bottom' => '31px']], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '0px', 'margin' => ['bottom' => '28px']], ['alignItems' => 'flex-start', 'flexDirection' => 'column', 'gap' => '0px']), + ['id' => 'projekte-liste', 'type' => 'postlist', 'props' => ['count' => 3, 'category' => 'projekte', 'layout' => 'karten', 'paginate' => false, 'showMeta' => false, 'showExcerpt' => false, 'showTerms' => false], + 'style' => ['desktop' => ['margin' => ['top' => '0px']]], + // Die Karte der Vorlage: randloses Bild mit Rundung, darunter der Titel. + // Die Vorlage setzt über den Titel noch die Projektart. Die steht hier als + // Schlagwort am Beitrag und nicht als Kategorie: „vorheriges/nächstes + // Projekt“ folgt der ersten Kategorie, und die muss bei allen acht dieselbe + // sein. Die Beitragsliste zeigt nur Kategorien, deshalb bleibt die Zeile weg. + 'css' => "& .post-list{grid-template-columns:repeat(3,minmax(0,1fr));gap:25px}\n& .post-card{background:none;border:0;border-radius:0;overflow:visible;transform:none;transition:none;box-shadow:none} +& .post-card:hover{transform:none;box-shadow:none}\n& .post-image{border-radius:8px;background:#f5f6f6}\n& .post-image img{aspect-ratio:1.5;transition:transform .35s}\n& .post-card:hover .post-image img{transform:scale(1.035)}\n& .post-view{display:none}\n& .post-card>.post-body{display:flex;flex-direction:column;gap:6px;padding:17px 0 0}\n& .post-terms{order:-1;margin:0;font-size:12px}\n& .post-terms a{color:#666d72;text-decoration:none}\n& .post-card h3{font-size:18px;font-weight:600;line-height:1.4;letter-spacing:-0.02em}\n& .post-card h3 a:hover{color:#b52c2c;text-decoration:none}", + ], + $column('hersteller', [ + $text('hersteller-text', 'Wir setzen auf bewährte Technik.', ['desktop' => ['fontSize' => '13px', 'color' => '@grau', 'textAlign' => 'center', 'margin' => ['bottom' => '15px']]]), + ['id' => 'hersteller-logos', 'type' => 'logos', 'children' => array_map( + static fn (array $h): array => $bild('hersteller-'.pathinfo($h[0], PATHINFO_FILENAME), $medien[$h[0]], $h[1], ['desktop' => ['margin' => $seiten('0px')]]), + $hersteller, + ), 'style' => ['desktop' => ['gap' => '24px', 'margin' => ['top' => '0px']], 'mobile' => ['gap' => '16px']], + 'css' => "& img{max-height:76px;width:auto;filter:grayscale(1);opacity:.66}\n& li{display:flex;justify-content:center}"], + ], [ + 'desktop' => ['borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '29px'], 'margin' => ['top' => '66px']], + 'mobile' => ['padding' => ['top' => '24px'], 'margin' => ['top' => '40px']], + ]), + ], [], 'projekte'), + + $kontaktref, + ], +]; + +// --------------------------------------------------------------------------------------------- +// Seiten anlegen +// --------------------------------------------------------------------------------------------- + +$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; +}; + +$startseite = $einblenden($startseite, [ + 'hero-label' => ['fade'], + 'hero-titel' => ['rise', 'short'], + 'hero-intro' => ['rise', 'medium'], + 'hero-aktionen' => ['rise', 'long'], + 'hero-notiz' => ['fade', 'long'], + // Das Startbild blendet bewusst nicht ein: es ist das groesste Element ueber der Falz und + // damit der LCP. Eine Einblendung schiebt seinen ersten Anstrich um fast eine Sekunde. + 'vorteile-zeile' => ['fade'], + 'leistungen-kopf' => ['rise'], + 'leistung-01' => ['rise'], + 'leistung-02' => ['rise', 'short'], + 'leistung-03' => ['rise', 'medium'], + 'ueber-uns-kopf' => ['rise'], + 'ueber-uns-text' => ['rise', 'short'], + 'projekte-kopf' => ['rise'], + 'projekte-liste' => ['rise', 'short'], + 'hersteller' => ['fade'], +]); + +$anlegen('home', $startseite, 'EB Solar · Sonnenenergie aus Ihrer Region', $hero['id']); + +// Die Projektübersicht. +$uebersicht = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Unsere Projekte', + 'description' => 'Photovoltaikprojekte von Elektro Bernhard: Bilder und Einblicke in unsere Installationen in der Region.', + 'blocks' => [ + $section('projektuebersicht', [ + $column('projektuebersicht-kopf', [ + $kleinzeile('projektuebersicht-label', 'Elektro Bernhard · Referenzen', '@grau', '15px'), + $heading('projektuebersicht-titel', 'Unsere Projekte.', 1, $h1Stil), + $text('projektuebersicht-intro', 'Von der Dachanlage bis zur Terrassenmontage: Einblicke in unsere Arbeit und die Solarlösungen unserer Kunden.', [ + 'desktop' => ['fontSize' => '16px', 'color' => '@grau', 'lineHeight' => 1.8, 'maxWidth' => '580px', 'margin' => ['top' => '23px', 'bottom' => '0px']], + 'mobile' => ['margin' => ['top' => '18px', 'bottom' => '0px']], + ]), + ], [ + 'desktop' => ['padding' => ['top' => '20px', 'bottom' => '40px']], + 'mobile' => ['padding' => ['top' => '12px', 'bottom' => '28px']], + ]), + $heading('projektuebersicht-raster-titel', 'Alle Projekte', 2, [], 'position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap'), + ['id' => 'projektuebersicht-liste', 'type' => 'postlist', 'props' => ['count' => 12, 'category' => 'projekte', 'layout' => 'karten', 'paginate' => true, 'showMeta' => false, 'showExcerpt' => false, 'showTerms' => false], + 'css' => "& .post-list{grid-template-columns:repeat(2,minmax(0,1fr));gap:44px 32px}\n& .post-card{background:none;border:0;border-radius:0;overflow:visible;transform:none;transition:none;box-shadow:none} +& .post-card:hover{transform:none;box-shadow:none}\n& .post-image{border-radius:8px;background:#f5f6f6}\n& .post-image img{aspect-ratio:1.65;transition:transform .35s}\n& .post-card:hover .post-image img{transform:scale(1.035)}\n& .post-view{display:none}\n& .post-card>.post-body{display:flex;flex-direction:column;gap:7px;padding:19px 0 0}\n& .post-terms{order:-1;margin:0;font-size:12px}\n& .post-terms a{color:#666d72;text-decoration:none}\n& .post-card h3{font-size:22px;font-weight:600;line-height:1.35;letter-spacing:-0.02em}\n& .post-card h3 a:hover{color:#b52c2c;text-decoration:none}", + ], + ], [ + 'desktop' => ['padding' => ['top' => '45px', 'bottom' => '70px']], + 'mobile' => ['padding' => ['top' => '28px', 'bottom' => '42px']], + ]), + $kontaktref, + ], +]; +$anlegen('projekte', $uebersicht, 'Unsere Projekte · EB Solar'); + +// Der Solarcheck als eigene Seite mit dem achtschrittigen Formular. +$solarcheck = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Solarcheck', + 'description' => 'In wenigen Minuten zur persönlichen Kalkulation: Beantworten Sie acht kurze Fragen zu Haushalt, Dach und Speicher – wir melden uns bei Ihnen.', + 'blocks' => [ + $section('solarcheck-kopf', [ + $column('solarcheck-kopf-inhalt', [ + $kleinzeile('solarcheck-label', 'In etwa drei Minuten', '@rot', '15px'), + $heading('solarcheck-titel', 'Solarcheck', 1, $h1Stil), + $text('solarcheck-intro', 'Ihr eigenes Kraftwerk auf dem Dach: Sie produzieren Ihren eigenen Strom und sparen Kosten. Beantworten Sie ein paar Fragen zu Ihrem Haushalt und Ihrem Dach – anschließend melden wir uns bei Ihnen.', [ + 'desktop' => ['fontSize' => '17px', 'color' => '@grau', 'lineHeight' => 1.8, 'maxWidth' => '620px', 'margin' => ['top' => '23px', 'bottom' => '0px']], + 'mobile' => ['fontSize' => '16px', 'margin' => ['top' => '18px', 'bottom' => '0px']], + ]), + ]), + ], [ + 'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '64px', 'bottom' => '60px']], + 'mobile' => ['padding' => ['top' => '40px', 'bottom' => '36px']], + ]), + $abschnitt('solarcheck-formular', [ + $column('solarcheck-karte', [ + ['id' => 'solarcheck-block', 'type' => 'form', 'props' => ['form' => 'solarcheck'], 'css' => $formularCss], + ], [ + // Volle Breite: die Karte nimmt den ganzen Inhaltsbereich ein. + 'desktop' => ['backgroundColor' => '@papier', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'borderRadius' => $ecken('12px'), 'padding' => ['top' => '42px', 'right' => '50px', 'bottom' => '42px', 'left' => '50px']], + 'tablet' => ['padding' => $seiten('34px')], + 'mobile' => ['padding' => $seiten('20px'), 'borderRadius' => $ecken('9px')], + ]), + ]), + ], +]; +$anlegen('solarcheck', $solarcheck, 'Solarcheck · EB Solar'); + +/** + * Eine Rechtsseite: Kleinzeile, Titel, Vorspann auf heller Fläche, dann Absätze mit + * Zwischenüberschriften in zwei Spalten. + */ +$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $abschnitt, $row, $column, $kleinzeile, $heading, $text, $nurOben, $nurUnten, $h1Stil, $linkCss): array { + $kopf = $section($praefix.'-kopf', [ + $kleinzeile($praefix.'-label', $label, '@grau', '15px'), + $heading($praefix.'-h1', $titel, 1, [ + 'desktop' => [...$h1Stil['desktop'], 'fontSize' => '52px'], + 'tablet' => ['fontSize' => '42px'], + 'mobile' => ['fontSize' => '36px'], + ]), + $text($praefix.'-vorspann', $vorspann, [ + 'desktop' => ['maxWidth' => '640px', 'fontSize' => '17px', 'lineHeight' => 1.7, 'color' => '@grau', 'margin' => ['top' => '22px', 'bottom' => '0px']], + 'mobile' => ['fontSize' => '16px', 'margin' => ['top' => '16px', 'bottom' => '0px']], + ]), + ], [ + 'desktop' => ['backgroundColor' => '@hell', 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '64px', 'bottom' => '60px']], + 'tablet' => ['padding' => ['top' => '52px', 'bottom' => '48px']], + 'mobile' => ['padding' => ['top' => '40px', 'bottom' => '36px']], + ]); + + $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' => ['fontSize' => '16px', 'fontWeight' => '600', 'letterSpacing' => '-0.01em', 'lineHeight' => 1.5, 'color' => '@tinte', 'margin' => ['bottom' => '0px']], + ]), + ], [ + 'desktop' => ['flexBasis' => '280px', 'flexShrink' => 0], + 'tablet' => ['flexBasis' => '220px'], + 'mobile' => ['flexBasis' => 'auto'], + ]), + $column($praefix.'-punkt-'.$i.'-text', [ + $text($praefix.'-p-'.$i, $inhalt, [ + 'desktop' => ['fontSize' => '15px', 'lineHeight' => 1.8, 'color' => '@grau', 'margin' => ['bottom' => '0px']], + ], $linkCss), + ], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0']]), + ], [], [ + 'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '48px', 'margin' => ['top' => '0px'], + 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px', 'bottom' => '26px']], + 'tablet' => ['gap' => '32px'], + 'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '22px', 'bottom' => '22px']], + ]); + } + + $inhalt = $abschnitt($praefix, [ + $column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '960px']]), + ], ['desktop' => ['padding' => ['top' => '50px', 'bottom' => '90px']], 'mobile' => ['padding' => ['top' => '28px', 'bottom' => '56px']]]); + + return [$kopf, $inhalt]; +}; + +$anlegen('impressum', [ + 'schemaVersion' => $SCHEMA, 'title' => 'Impressum', + 'description' => 'Anbieterangaben der Elektro Bernhard Regenerative Energie GmbH in Baden-Baden.', + 'blocks' => $rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [ + ['Anbieterin', $firma.'
(unabhängig von der Elektro Bernhard GmbH)
Ooser Hauptstraße 51
76532 Baden-Baden'], + ['Vertreten durch', 'Ridvan Kantarci, Geschäftsführer'], + ['Registereintrag', 'Handelsregister: HRB 741275
NOCH ZU PRÜFEN: Registergericht eintragen (im Impressum der laufenden Website nicht genannt).'], + ['Kontakt', "Telefon: $telefon
E-Mail: $mailInfo
Photovoltaik: $mail"], + ['Umsatzsteuer', 'Umsatzsteuer-Identifikationsnummer nach § 27a UStG: DE 815 618 082'], + ['Verantwortlich für den Inhalt', 'Ridvan Kantarci, 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.'], + ['Haftung für Links', 'Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen Einfluss haben. Für diese Inhalte ist stets die jeweilige Anbieterin oder der jeweilige Anbieter verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche Rechtsverstöße überprüft. Bei Bekanntwerden von Rechtsverletzungen entfernen wir solche Links 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. Downloads und Kopien dieser Seite sind nur für den privaten, nicht kommerziellen Gebrauch gestattet.'], + ['Bildnachweis', 'Logo, Herstellerlogos, Titelbild und Projektfotos stammen von der bisherigen Website www.ebsolar.de und werden hier für den Redesign-Entwurf verwendet. NOCH ZU PRÜFEN: Urheber der Fotos und die jeweiligen Nutzungsrechte eintragen.'], + ]), +], 'Impressum | '.$firma); + +$anlegen('datenschutz', [ + 'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz', + 'description' => 'Wie die Elektro Bernhard Regenerative Energie GmbH Daten beim Besuch dieser Website und bei einer Anfrage verarbeitet.', + 'blocks' => $rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutzhinweise', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei Ihrer Anfrage verarbeitet werden – und welche Rechte Sie haben.', [ + ['1. Verantwortliche', "$firma, Ooser Hauptstraße 51, 76532 Baden-Baden, vertreten durch Ridvan Kantarci. Telefon $telefon, E-Mail $mailInfo.

NOCH ZU PRÜFEN: Ob eine Datenschutzbeauftragte oder ein Datenschutzbeauftragter zu benennen ist, hängt von der Zahl der ständig mit der Verarbeitung beschäftigten Personen ab; gegebenenfalls hier die Kontaktdaten ergänzen."], + ['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.

NOCH ZU PRÜFEN: Hostinganbieter benennen, Auftragsverarbeitungsvertrag abschließen, Verarbeitungsländer und die Speicherdauer der Serverprotokolle eintragen.'], + ['3. Cookies, Schriften, 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. Der Link zu unserem Instagram-Profil führt zu Meta Platforms Ireland Ltd.; erst mit dem Klick verlassen Sie unsere Website.'], + ['4. Solarcheck und Kontaktanfrage', 'Wenn Sie den Solarcheck ausfüllen oder uns über das Kontaktformular schreiben, verarbeiten wir Ihre Angaben zu Haushalt, Gebäude, Dach und Kontakt, um Ihre Anfrage zu beantworten und Ihnen ein Angebot zu unterbreiten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Anbahnung eines Vertrags), sonst Art. 6 Abs. 1 lit. f DSGVO. Die Formulare speichern den Zeitpunkt Ihrer Einwilligung und schützen 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 Beantwortung nicht mehr nötig sind und keine gesetzliche Aufbewahrungspflicht entgegensteht. Entsteht aus Ihrer Anfrage ein Auftrag, gelten für die dann anfallenden Unterlagen die handels- und steuerrechtlichen Fristen.'], + ['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 oder es ist für die Ausführung Ihres Auftrags nötig, etwa gegenüber dem Netzbetreiber bei der Anmeldung Ihrer Anlage.

NOCH ZU PRÜFEN: 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 $mailInfo."], + ]), +], 'Datenschutz | '.$firma); + +$fehlerId = $anlegen('404', [ + 'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden', + 'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.', + 'blocks' => [ + $abschnitt('fehler', [ + $kleinzeile('fehler-label', 'Fehler 404', '@grau', '21px'), + $heading('fehler-titel', 'Diese Seite
haben wir nicht.', 1, $h1Stil, '& em{color:#b52c2c;font-style:normal}'), + $text('fehler-text', 'Vielleicht hat sich ein Tippfehler in die Adresse geschlichen. Über die Startseite finden Sie zurück – oder sehen Sie sich unsere Projekte an.', [ + 'desktop' => ['maxWidth' => '480px', 'color' => '@grau', 'fontSize' => '17px', 'lineHeight' => 1.8, 'margin' => ['top' => '25px', 'bottom' => '28px']], + ]), + $zeile('fehler-aktionen', [ + $button('fehler-knopf', 'Zur Startseite', '/', ['desktop' => ['margin' => ['top' => '0px']]], false), + $textlink('fehler-sprung', 'Unsere Projekte ansehen', '/projekte'), + ], '25px', ['flexWrap' => 'wrap'], ['gap' => '16px']), + ], ['desktop' => ['padding' => ['top' => '110px', 'bottom' => '130px']], 'mobile' => ['padding' => ['top' => '60px', 'bottom' => '70px']]]), + ], +]); +$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]); + +// --------------------------------------------------------------------------------------------- +// Projektbeiträge: Kopf, Galerie mit Bildbetrachter, Nachbarn, Kontaktbereich +// --------------------------------------------------------------------------------------------- + +$projektDaten = json_decode((string) file_get_contents($assets.'/projects.json'), true, 512, JSON_THROW_ON_ERROR); +// Die Reihenfolge der Übersicht der Vorlage. Der jüngste Beitrag steht oben, deshalb zählen die +// Daten rückwärts: die Beitragsliste sortiert nach Datum. +$reihenfolge = ['mayr-melnhof-karton-ag', 'rheinstetten', 'michelbach', 'au-am-rhein', 'loffenau', 'ruelzheim', 'kuppenheim', 'rheinstetten-dachanlage']; +$titel = [ + 'mayr-melnhof-karton-ag' => 'Mayr-Melnhof Karton AG', + 'rheinstetten' => 'Rheinstetten', + 'michelbach' => 'Michelbach', + 'au-am-rhein' => 'Au am Rhein', + 'loffenau' => 'Loffenau', + 'ruelzheim' => 'Rülzheim', + 'kuppenheim' => 'Kuppenheim', + 'rheinstetten-dachanlage' => 'Rheinstetten Dachanlage', +]; +$kategorieLabel = ['rheinstetten' => 'Terrassenmontage · SolarEdge']; + +$nachSlug = []; +foreach ($projektDaten as $projekt) { + $nachSlug[$projekt['slug']] = $projekt; +} + +$nummer = 0; +foreach ($reihenfolge as $slug) { + if (!isset($nachSlug[$slug])) { + fwrite(STDERR, "Projekt fehlt in projects.json: $slug\n"); + exit(1); + } + $projekt = $nachSlug[$slug]; + ++$nummer; + $p = 'projekt-'.$nummer; + $name = $titel[$slug]; + $label = $kategorieLabel[$slug] ?? 'Photovoltaik'; + $bilder = []; + foreach ($projekt['images'] as $i => $eintrag) { + $datei = 'projects/'.basename((string) $eintrag['src']); + $bilder[] = $hochladen($datei, $name.' – Projektansicht '.($i + 1), $name.' '.($i + 1)); + } + $anzahl = count($bilder); + $beschreibung = sprintf('%s: %s von Elektro Bernhard – %d %s aus der Umsetzung.', $name, $label, $anzahl, 1 === $anzahl ? 'Projektbild' : 'Projektbilder'); + + $bloecke = [ + $section($p.'-kopf', [ + $textlink($p.'-zurueck', 'Alle Projekte', '/projekte', 'arrow-left', ['desktop' => ['margin' => ['bottom' => '30px']]], true, '@grau'), + $zeile($p.'-titelzeile', [ + $column($p.'-titel-text', [ + $kleinzeile($p.'-label', $label, '@grau', '15px'), + $heading($p.'-h1', $name, 1, [ + 'desktop' => [...$h1Stil['desktop'], 'fontSize' => '56px'], + 'tablet' => ['fontSize' => '42px'], + 'mobile' => ['fontSize' => '36px'], + ]), + ], ['desktop' => ['flexGrow' => 1]]), + $column($p.'-anzahl', [ + $text($p.'-anzahl-text', $anzahl.' '.(1 === $anzahl ? 'Projektbild' : 'Projektbilder'), [ + 'desktop' => ['fontSize' => '14px', 'color' => '@grau', 'margin' => ['bottom' => '6px']], + 'mobile' => ['margin' => ['top' => '17px', 'bottom' => '0px']], + ]), + ], ['desktop' => ['flexShrink' => 0]]), + ], '24px', ['alignItems' => 'flex-end', 'justifyContent' => 'space-between', 'margin' => ['bottom' => '40px']], ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '0px', 'margin' => ['bottom' => '28px']], ['alignItems' => 'flex-start']), + ['id' => $p.'-galerie', 'type' => 'gallery', 'props' => ['lightbox' => true], 'children' => array_map( + static fn (array $foto, int $i): array => $bild($p.'-bild-'.($i + 1), $foto, $name.' – Projektansicht '.($i + 1), [ + 'desktop' => ['width' => '100%', 'imageRatio' => '16/9', 'objectFit' => 'cover', 'imageRadius' => $ecken('10px'), 'margin' => $seiten('0px')], + ], null, '', true), + $bilder, array_keys($bilder), + ), 'style' => [ + 'desktop' => ['gridColumns' => 2, 'rowGap' => '33px', 'columnGap' => '28px', 'margin' => ['top' => '0px']], + 'mobile' => ['gridColumns' => 1, 'gap' => '23px'], + ], 'css' => '& > :first-child{grid-column:1/-1}'], + ], [ + 'desktop' => ['padding' => ['top' => '45px', 'bottom' => '55px']], + 'mobile' => ['padding' => ['top' => '28px', 'bottom' => '34px']], + ]), + $section($p.'-nachbarn', [ + ['id' => $p.'-navigation', 'type' => 'postnav', 'props' => ['previousLabel' => 'Vorheriges Projekt', 'nextLabel' => 'Nächstes Projekt'], + 'css' => "& .post-nav{border-top:1px solid #e6e8e8;border-bottom:1px solid #e6e8e8;padding:27px 0;gap:35px}\n& .post-nav-link{border:0;border-radius:0;background:none;padding:0}\n& .post-nav-link:hover{color:#b52c2c}\n& .post-nav-label{font-size:12px;color:#666d72}\n& .post-nav-title{font-size:16px;font-weight:500}"], + ], [ + 'desktop' => ['padding' => ['top' => '0px', 'bottom' => '70px']], + 'mobile' => ['padding' => ['top' => '0px', 'bottom' => '42px']], + ]), + $kontaktref, + ]; + + $id = $pages->createPage($slug, null, 'post', [ + 'schemaVersion' => $SCHEMA, 'title' => $name, 'description' => $beschreibung, + 'blocks' => $bloecke, + ]); + $seite = $db->fetchAssociative('SELECT version FROM page WHERE id = ?', [$id]); + // Die Daten zählen rückwärts, damit die Beitragsliste dieselbe Reihenfolge zeigt wie die + // Übersicht der Vorlage. Echte Ausführungsdaten liegen dem Paket nicht bei. + $posts->saveMetadata($id, [ + 'excerpt' => '', 'author' => '', 'publishedAt' => sprintf('2026-%02d-01', 9 - $nummer + 1 > 0 ? 9 - $nummer + 1 : 1), + 'featuredMediaId' => $bilder[0]['id'], 'ogMediaId' => $bilder[0]['id'], + 'seoTitle' => $name.' · Projekt von EB Solar', 'robots' => 'index', + ], ['Projekte'], [$label], (int) $seite['version']); + $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 'Projekt angelegt: /'.$slug.' ('.$anzahl." Bilder)\n"; +} + +// --------------------------------------------------------------------------------------------- +// Menü, Einstellungen, Konto +// --------------------------------------------------------------------------------------------- + +$sprung = static fn (string $label, string $anchor): array => ['label' => $label, 'type' => 'page', 'pageId' => $erstellt['home'], 'anchor' => $anchor, 'children' => []]; +$seite = static fn (string $label, string $slug): array => ['label' => $label, 'type' => 'page', 'pageId' => $erstellt[$slug], 'anchor' => '', 'children' => []]; + +$menus->save('header', 'Hauptnavigation', [ + $sprung('Leistungen', 'leistungen'), + $sprung('Über uns', 'ueber-uns'), + $seite('Projekte', 'projekte'), + $sprung('Kontakt', 'kontakt'), + $seite('Zum Solarcheck', 'solarcheck'), +], 0); +echo "Menü angelegt\n"; + +$settings->assignNotFoundPage($fehlerId, ''); +$settings->save([ + 'legalName' => $firma, + 'description' => 'Photovoltaik von Elektro Bernhard in Baden-Baden: individuelle Planung und professionelle Umsetzung Ihrer Solaranlage. Elektro-Meisterbetrieb für Solarprojekte in der Region.', + 'phone' => $telefon, 'email' => $mail, + 'street' => 'Ooser Hauptstraße 51', 'postalCode' => '76532', 'city' => 'Baden-Baden', 'country' => 'DE', + 'social' => ['instagram' => $instagram], + 'areaServed' => implode("\n", ['Baden-Baden', 'Rastatt', 'Gaggenau', 'Kuppenheim', 'Rheinstetten', 'Bühl', 'Karlsruhe', 'Mittelbaden']), + 'localBusiness' => true, 'llmsTxt' => true, + 'faviconMediaId' => $favicon['id'], + 'stickyHeader' => true, 'headerOverlay' => false, + 'motion' => 'always', + 'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz', + // Die Vorlage 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/CustomCode.php b/src/Content/CustomCode.php index 00e3dbe..9d3db24 100644 --- a/src/Content/CustomCode.php +++ b/src/Content/CustomCode.php @@ -193,17 +193,34 @@ final class CustomCode if (1 !== preg_match('/^[a-zA-Z0-9 .:>+~_\[\]="\'-]*$/D', $suffix)) { throw new InvalidDocument(sprintf('„&%s“ ist als verschachtelter Selektor nicht erlaubt.', $suffix)); } - $rules[$suffix] = $this->declarations($match[2][0]); + // Zusammenfuegen statt ersetzen: zweimal derselbe Selektor ist in CSS eine Kaskade, + // nicht ein Widerruf. Vorher gewann die letzte Regel, und alles, was nur in der + // ersten stand, verschwand ohne Meldung - gefunden am Projektraster von EB Solar, + // wo `& .post-card{border:0}` und `& .post-card{transform:none}` nebeneinander + // standen und der Rahmen des Kerns wieder auftauchte. + $rules[$suffix] = $this->verbinden($rules[$suffix] ?? '', $this->declarations($match[2][0])); $rest = substr_replace($rest, '', $match[0][1], strlen($match[0][0])); } if (str_contains($rest, '{') || str_contains($rest, '}')) { throw new InvalidDocument('Eigenes CSS besteht aus Eigenschaften für dieses Element und aus verschachtelten Regeln, die mit & beginnen. Andere Selektoren sind nicht vorgesehen.'); } - $rules[''] = $this->declarations($rest); + // Die Eigenschaften des Elements selbst stehen ueblicherweise vor den verschachtelten + // Regeln; eine Regel `&{…}` schreibt sie aber in denselben Schluessel. + $rules[''] = $this->verbinden($this->declarations($rest), $rules['']); return $rules; } + /** Zwei Deklarationslisten desselben Selektors, in der Reihenfolge, in der sie standen. */ + private function verbinden(string $erste, string $zweite): string + { + if ('' === $erste || '' === $zweite) { + return '' === $erste ? $zweite : $erste; + } + + return $erste.';'.$zweite; + } + /** @throws InvalidDocument */ private function declarations(string $block): string { diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 2ac0376..99971ef 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -16,7 +16,7 @@ final class DocumentValidator /** Blocks introduced with schema v6 for the sections a marketing page actually needs. */ public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem']; /** Icons ship as inline SVG from this fixed list; no icon font and no external request. */ - public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home']; + public const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check', 'people', 'music', 'home', 'arrow-right', 'arrow-left', 'arrow-up-right']; public const SPACER_SIZES = ['small', 'medium', 'large', 'huge']; /** Schema v7: tabs, the one pattern that needs a script to become a real widget. */ public const V7_TYPES = ['tabs', 'tabitem']; diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 4ea5fb7..d00e91c 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -31,6 +31,9 @@ final class PageController extends AbstractController { } + /** Dieselbe Groessenangabe, die `blocks/block.html.twig` einem Bild ohne feste Breite gibt. */ + private const IMAGE_SIZES = '(max-width: 650px) 100vw, (max-width: 1024px) 50vw, 800px'; + // A segment is a slug; up to four of them form the public path. The leading lookahead keeps // the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them. private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,3}'; @@ -209,10 +212,14 @@ final class PageController extends AbstractController // Das grosse Bild ueber der Falz - es soll zuerst geladen werden. Gesucht wird nur // im ersten Abschnitt des Inhalts: ein Bild weiter unten waere die falsche // Eile, und das Logo im Kopfbereich ist klein und laedt ohnehin sofort. - 'eagerImage' => $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]), + 'eagerImage' => $eagerImage = $this->firstImage(['blocks' => array_slice($page['document']['blocks'] ?? [], 0, 1)]), // Und das Gegenstueck fuer den haeufigeren Fall: Das grosse Bild ueber der Falz // ist gar kein Bild, sondern der Hintergrund des ersten Abschnitts. - 'heroBackground' => $this->heroBackground($areas['header'] ?? null, $page['document']), + 'heroBackground' => $heroBackground = $this->heroBackground($areas['header'] ?? null, $page['document']), + // Ist es doch ein gewoehnliches Bild, wird auch das vorab geholt - sonst findet der + // Browser es erst, wenn das Stylesheet steht, und diese Rundreise geht direkt in die + // LCP-Zeit. Nur eines von beiden: zwei grosse Bilder vorab kosten mehr, als sie bringen. + 'heroImage' => [] === $heroBackground ? $this->heroImage($page['document'], $eagerImage) : null, 'pageStyles' => $styles, 'localFonts' => $localFonts, 'styleBundle' => $this->bundle->url([] !== $localFonts), @@ -772,6 +779,55 @@ final class PageController extends AbstractController return []; } + /** + * Das vorgezogene Bild als Vorabladung im Kopf der Seite. + * + * Die Angaben sind dieselben, die `blocks/block.html.twig` an das `img` schreibt - Quelle, + * Satz der Varianten und die Groessenangabe. Weicht eines davon ab, waehlt der Browser fuer + * die Vorabladung eine andere Variante als fuer das Bild und laedt zweimal. + * + * @return array{src: string, srcset: string, sizes: string}|null + */ + private function heroImage(array $document, ?string $eagerImage): ?array + { + if (null === $eagerImage) { + return null; + } + $block = $this->blockById($document['blocks'] ?? [], $eagerImage); + $props = $block['props'] ?? []; + if (null === $block || 'image' !== ($block['type'] ?? null) || '' === (string) ($props['src'] ?? '')) { + return null; + } + $varianten = []; + foreach (is_array($props['sources'] ?? null) ? $props['sources'] : [] as $quelle) { + if (is_array($quelle) && isset($quelle['src'], $quelle['width'])) { + $varianten[] = $quelle['src'].' '.$quelle['width'].'w'; + } + } + $feste = (string) ($block['style']['desktop']['maxWidth'] ?? ''); + + return [ + 'src' => (string) $props['src'], + 'srcset' => implode(', ', $varianten), + 'sizes' => str_ends_with($feste, 'px') ? $feste : self::IMAGE_SIZES, + ]; + } + + private function blockById(array $blocks, string $id): ?array + { + foreach ($blocks as $block) { + if ($id === ($block['id'] ?? null)) { + return $block; + } + $inside = $this->blockById($block['children'] ?? [], $id); + if (null !== $inside) { + return $inside; + } + } + + return null; + } + private function firstImage(array ...$documents): ?string { foreach ($documents as $document) { diff --git a/templates/blocks/block.html.twig b/templates/blocks/block.html.twig index 1b46dd9..eed5988 100644 --- a/templates/blocks/block.html.twig +++ b/templates/blocks/block.html.twig @@ -26,7 +26,7 @@ {% endif %} {% elseif type == 'button' %} {% for device, settings in layout.button|default({}) %}{% for key, value in settings %}{% set designClasses = designClasses ~ ' b-' ~ device ~ '-' ~ key ~ '-' ~ value %}{% endfor %}{% endfor %} - {{ props.text }} + {{ props.text }} {% elseif type == 'image' %} {# Wie breit das Bild tatsaechlich erscheint. Steht im Block eine feste Breite - ein Logo mit 190px, ein Symbol mit 72px -, dann ist das die Antwort, und der Browser laedt die kleine diff --git a/templates/blocks/icon.html.twig b/templates/blocks/icon.html.twig index a57fdad..d495745 100644 --- a/templates/blocks/icon.html.twig +++ b/templates/blocks/icon.html.twig @@ -16,6 +16,9 @@ check: 'm4 12.5 5.5 5.5L20 6', people: 'M9.5 11.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7ZM2.5 20v-1c0-3.3 3-5 7-5s7 1.7 7 5v1M16.5 5.3a3.2 3.2 0 0 1 0 6.4M18 14.4c2.1.7 3.5 2.1 3.5 4.1V20', music: 'M10 18V5.5L20 3v12.5M10 9.5 20 7M10 18a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0ZM20 15.5a3 2.5 0 1 0-6 0 3 2.5 0 0 0 6 0Z', - home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20' + home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20', + 'arrow-right': 'M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5', + 'arrow-left': 'M20 12H4.5M11 5.5 4.5 12 11 18.5', + 'arrow-up-right': 'M6.5 17.5 17.5 6.5M8.5 6.5h9v9' } %} diff --git a/templates/blocks/post-nav.html.twig b/templates/blocks/post-nav.html.twig index 504cb82..b8359dc 100644 --- a/templates/blocks/post-nav.html.twig +++ b/templates/blocks/post-nav.html.twig @@ -4,13 +4,13 @@