diff --git a/.claude/launch.json b/.claude/launch.json index c49ac5a..749b8ec 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -148,6 +148,26 @@ "$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php" ], "port": 8101 + }, + { + "name": "huefner", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8102'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8102 -t public bin/dev-router.php" + ], + "port": 8102 + }, + { + "name": "huefner-prod", + "runtimeExecutable": "powershell", + "runtimeArgs": [ + "-NoProfile", + "-Command", + "$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8103'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8103 -t public bin/dev-router.php" + ], + "port": 8103 } ] } \ No newline at end of file diff --git a/README.md b/README.md index 1798974..b03bb8c 100644 --- a/README.md +++ b/README.md @@ -389,6 +389,10 @@ Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien G Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. Die Demo der Eröffnungsseite liegt unter `/eroeffnung` (noindex) und bettet die Angebote aus `app.kantarci-markt.de` ein; die App erlaubt das nur von `localhost`, deshalb misst man sie mit `CMS_HOST_RULES="MAP localhost 127.0.0.1" CMS_URL=http://localhost:8101 CMS_PATHS=/eroeffnung`. +### Neunte lokale Referenzwebsite: Hüfner Gartenbau + +Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts. + Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`. Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index af581a4..72f2294 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,18 @@ Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026) + +Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte. + +**`tel:` und `mailto:` als Linkziel von Bausteinen.** Menüeinträge (`MenuRepository::url`), Links im formatierten Text (`RichText::safeHref`) und Plugin-Felder kannten beide längst. Nur die Linkziele der Bausteine – Button, Symbolkarte, Handlungsaufruf, Symbol, verlinktes Bild – liefen über `DocumentValidator::url()` und nahmen bloß Pfad, Sprungmarke und HTTPS. Ein Knopf „Jetzt anrufen“ ging deshalb nur als Textlink im Knopf-Aussehen. Jetzt prüft `link()` diese fünf Stellen: zuerst wie bisher, dazu `tel:` mit Ziffern, `+`, Klammern und Bindestrich (keine Leerzeichen, wie im Menü) und `mailto:` mit einer gültigen Adresse und höchstens einer prozentkodierten Abfrage (`?subject=…`). Bildquellen und eingebettete Inhalte bleiben bei `url()`: `tel:` ist dort kein sinnvolles Ziel. + +Das Dokumentschema bleibt bei v24. Die Änderung erlaubt nur zusätzliche Werte in einem bestehenden Feld und braucht keinen Migrationsweg. Ein Dokument mit `tel:` an einem Knopf lässt sich aber auf einem Kern vor dieser Fassung nicht speichern. Eine Installation, die solche Dokumente bekommt, braucht also zuerst das Update. + +**Der Pfeil im Knopf folgt der Schriftfarbe.** Das Pfeilsymbol trägt die Klasse `block-icon`, und die färbt jedes Symbol in der Akzentfarbe. Auf einem roten Knopf mit weißer Schrift stand so ein grüner Pfeil, und auch die Farbe beim Überfahren erreichte ihn nicht. `.button-arrow .block-icon{color:inherit}` in `site.css` setzt das richtig. Wo eine Website ihren Pfeil absichtlich anders färbt, tut sie das weiter über das eigene CSS des Knopfs; das geht vor. + +**Geprüft:** `CmsTest::testButtonsAndLinkedBlocksMayCallOrWrite` (speichern, ausgeben, Bildquelle mit `tel:` abgewiesen), neue Ungültig-Fälle in `invalidDocuments` (Buchstaben oder Leerzeichen in der Nummer, `mailto:` ohne Adresse oder mit Anführungszeichen bzw. `<`), `testTheButtonArrowTakesTheColourOfTheButtonText`. + ## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026) Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte. diff --git a/docs/STATUS.md b/docs/STATUS.md index 2eed8b4..fbfcb4b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,27 @@ # Projektstatus +## Fassung 1.23.1: neunte Installation „Hüfner Gartenbau“, Knöpfe mit tel: und mailto:, Pfeil in der Schriftfarbe (06.10.2026) + +**Anlass:** Umut: „Hier liegt ein Designentwurf für Hüfner Gartenbau. Kannst du mir db und media erstellen für KantSites bitte. […] Effekte rein. Nichts am Code ändern, sondern alles an KantSites-Designelementen anpassen/bauen. Falls was nicht geht, Rückmeldung.“ Vorlage: `Downloads/huefner-website/huefner-website` (statisches `index.html`, 16 Fotos, Logo). + +**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/huefner.db`, Medien `var/huefner-media`, Bildquellen `var/huefner-assets`, Startkonfigurationen `huefner` (Port 8102, dev) und `huefner-prod` (Port 8103). `sh scripts/reset-huefner.sh ` kopiert die Bilder und schneidet den Baum aus dem Logo als Seitensymbol (`build-huefner-assets.php`), baut mit `seed-huefner.php` alles neu und holt Barlow und Barlow Condensed auf den eigenen Server. Ohne Argument nimmt es die schon vorbereiteten Bilder. + +- **Startseite** mit allen elf Abschnitten des Entwurfs: Hero mit Verlauf und Vorteilsband, Collage, Leistungs- und Referenzkacheln (Foto zoomt, Kachel bekommt Schatten), Chips, Ablauf mit gestrichelter Linie, Fuhrpark, Über uns, Karriere (Stellen als Links mit E-Mail-Betreff), Instagram-Raster, Abschlussband, Kontakt mit dem CMS-Formular `anfrage` (Versand an info@huefner-gartenbau.de). Dazu Impressum, Datenschutz, 404, Menü mit Sprungmarken, klebender Kopf, Einblendungen beim Scrollen. +- **Inhaltliche Ergänzungen** statt der Platzhalter der Vorlage: Öffnungszeiten Mo–Fr 8–17, Sa 8–12 (von Umut, auch als strukturierte Daten), Firmengeschichte und Impressum aus dem Handelsregister (HRB 742159, AG Mannheim, GF Florian Hüfner; Simone Hüfner war nur bis 27.04.2026 Prokuristin), Hoster IONOS, alle Fotos eigene Aufnahmen. Kennzahlen „[XX] Jahre“/„[XXX] Projekte“ ersetzt durch Aussagen der Vorlage (100 % Detailarbeit, Bagger bis 15 t, ein Team). Keine USt-IdNr. bekannt, deshalb keine Zeile. + +**Grenzen des Kerns:** + +- Nachtrag, gleicher Tag – auf Umuts Wunsch im Kern behoben (ADR 107): Knöpfe und andere verlinkbare Bausteine nehmen `tel:` und `mailto:` an, und der Pfeil im Knopf hat die Schriftfarbe statt der Akzentfarbe. Die Anrufknöpfe bei Hüfner sind jetzt echte Knöpfe, der Pfeil braucht keine eigene Regel mehr. Vorher: Textlink im Knopf-Aussehen und `color:inherit` im CSS des Knopfs. +- Kein LKW im Symbolsatz: Das Symbol steht als „Eigenes HTML“ mit dem SVG der Vorlage. +- Leiste und Kopf kleben gemeinsam (die Einstellung gilt für den ganzen Kopfbereich). +- Zeilen sind durchsichtig, farbige Karten deshalb Spalten mit Flex. +- Eigenes CSS ohne `@media`/`*`: gerätespezifische Werte über die Gestaltungswerte (Bildhöhe, Raster-Spalten, Ränder je Gerät). +- Das HTML-Budget von 150 KB (`test:browser`) war mit dem CSS je Kachel überschritten (152 KB). Die gemeinsamen Regeln stehen jetzt einmal am Abschnitt, Ergebnis 147,5 KB. + +**Geprüft:** Lighthouse im Produktionsmodus (Port 8103, je drei Läufe, Median): Startseite mobil 97 / Desktop 98, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. `test:links` ohne toten Verweis, `test:browser` grün (axe in sechs Ansichten, Tastatur, ohne JavaScript, Budget). Ganzseitige Bildschirmfotos bei 1440, 820 und 375 px gegen den Entwurf verglichen, kein waagerechtes Scrollen, keine Konsolenfehler. Zoom, Schatten, Chip- und Knopf-Hover sowie das Klappmenü per Playwright nachgemessen. Keine Felddaten, kein INP. Das Formular wurde nicht abgeschickt (Mailversand nicht ausprobiert). + +**Nächster Schritt:** Livegang nach `docs/LIVEGANG.md` (Mailversand einmal echt testen). Die Hüfner-Datenbank braucht einen Kern mit ADR 107 (`tel:` an Knöpfen). + ## Kantarci Markt: Demo der Eröffnungsseite mit Angeboten aus der App (24.09.2026) **Anlass:** Umut: „Setze mal die demoseite um wie du es dir vorstellst ich überlasse dir alles. Links und logo zu playstore und ios app, wilhelm drapp str. in baden baden, Rest Platzhalter“. Die Angebote kommen aus der Web-Ansicht seiner App: `https://app.kantarci-markt.de/embed/angebote.php?ton=dunkel`, dazu sein Skript für die Höhe. diff --git a/public/assets/editor.js b/public/assets/editor.js index 6a9702c..dd876da 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -402,7 +402,7 @@ function renderContent(block, panel) { field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) }); field('Titel', 'title', block.props.title, { maxLength: 120 }); field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 }); - field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' }); + field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' }); } if (block.type === 'stat') { field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' }); @@ -418,7 +418,7 @@ function renderContent(block, panel) { field('Titel', 'title', block.props.title, { maxLength: 160 }); field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 }); field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 }); - field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' }); + field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' }); } if (block.type === 'contact') { field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' }); @@ -439,7 +439,7 @@ function renderContent(block, panel) { field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' }); field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' }); if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 }); - field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' }); + field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' }); } if (block.type === 'social') { field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' }); @@ -556,7 +556,7 @@ function renderContent(block, panel) { field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true }); field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' }); } - if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' }); + if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' }); if (block.type === 'image') { renderImageWidth(block, panel); imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption }); @@ -568,7 +568,7 @@ function renderContent(block, panel) { const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true; const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine')); field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions }); - field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF. Für ein Logo im Kopfbereich meist /.' }); + field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF. Für ein Logo im Kopfbereich meist /.' }); showLinkOptions(clickAction); panel.append(linkOptions); const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen'; picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker); diff --git a/public/assets/fonts/LIZENZ.md b/public/assets/fonts/LIZENZ.md index 8537b62..fecc530 100644 --- a/public/assets/fonts/LIZENZ.md +++ b/public/assets/fonts/LIZENZ.md @@ -28,6 +28,10 @@ in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/goo DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei, `fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700. +**Barlow** und **Barlow Condensed** (Fließtext und Überschriften der Seite „Hüfner Gartenbau“) +stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich). +Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow). + Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in diff --git a/public/assets/fonts/barlow-400-latin-ext.woff2 b/public/assets/fonts/barlow-400-latin-ext.woff2 new file mode 100644 index 0000000..5c6eb7a Binary files /dev/null and b/public/assets/fonts/barlow-400-latin-ext.woff2 differ diff --git a/public/assets/fonts/barlow-400-latin.woff2 b/public/assets/fonts/barlow-400-latin.woff2 new file mode 100644 index 0000000..3914af4 Binary files /dev/null and b/public/assets/fonts/barlow-400-latin.woff2 differ diff --git a/public/assets/fonts/barlow-600-latin-ext.woff2 b/public/assets/fonts/barlow-600-latin-ext.woff2 new file mode 100644 index 0000000..3d7c7a2 Binary files /dev/null and b/public/assets/fonts/barlow-600-latin-ext.woff2 differ diff --git a/public/assets/fonts/barlow-600-latin.woff2 b/public/assets/fonts/barlow-600-latin.woff2 new file mode 100644 index 0000000..b1a019f Binary files /dev/null and b/public/assets/fonts/barlow-600-latin.woff2 differ diff --git a/public/assets/fonts/barlow-700-latin-ext.woff2 b/public/assets/fonts/barlow-700-latin-ext.woff2 new file mode 100644 index 0000000..09f6871 Binary files /dev/null and b/public/assets/fonts/barlow-700-latin-ext.woff2 differ diff --git a/public/assets/fonts/barlow-700-latin.woff2 b/public/assets/fonts/barlow-700-latin.woff2 new file mode 100644 index 0000000..4ee6cd8 Binary files /dev/null and b/public/assets/fonts/barlow-700-latin.woff2 differ diff --git a/public/assets/fonts/barlow-OFL.txt b/public/assets/fonts/barlow-OFL.txt new file mode 100644 index 0000000..2f22ba6 --- /dev/null +++ b/public/assets/fonts/barlow-OFL.txt @@ -0,0 +1,93 @@ +Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/public/assets/fonts/barlow-condensed-700-latin-ext.woff2 b/public/assets/fonts/barlow-condensed-700-latin-ext.woff2 new file mode 100644 index 0000000..02f926e Binary files /dev/null and b/public/assets/fonts/barlow-condensed-700-latin-ext.woff2 differ diff --git a/public/assets/fonts/barlow-condensed-700-latin.woff2 b/public/assets/fonts/barlow-condensed-700-latin.woff2 new file mode 100644 index 0000000..3726e9d Binary files /dev/null and b/public/assets/fonts/barlow-condensed-700-latin.woff2 differ diff --git a/public/assets/fonts/barlow-condensed-800-latin-ext.woff2 b/public/assets/fonts/barlow-condensed-800-latin-ext.woff2 new file mode 100644 index 0000000..7c43025 Binary files /dev/null and b/public/assets/fonts/barlow-condensed-800-latin-ext.woff2 differ diff --git a/public/assets/fonts/barlow-condensed-800-latin.woff2 b/public/assets/fonts/barlow-condensed-800-latin.woff2 new file mode 100644 index 0000000..33c8a26 Binary files /dev/null and b/public/assets/fonts/barlow-condensed-800-latin.woff2 differ diff --git a/public/assets/fonts/fonts.css b/public/assets/fonts/fonts.css index 1737f39..efccc89 100644 --- a/public/assets/fonts/fonts.css +++ b/public/assets/fonts/fonts.css @@ -18,3 +18,23 @@ @font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF} /* latin */ @font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +/* latin-ext */ +@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF} +/* latin */ +@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +/* latin-ext */ +@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF} +/* latin */ +@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +/* latin-ext */ +@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF} +/* latin */ +@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +/* latin-ext */ +@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF} +/* latin */ +@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} +/* latin-ext */ +@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF} +/* latin */ +@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD} diff --git a/public/assets/site.css b/public/assets/site.css index f53396a..0c0462f 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -267,6 +267,9 @@ body.consent-open{padding-bottom:320px} /* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */ .block-icon .block-icon{color:inherit} +/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe + des Knopfs: grün auf Rot, auch beim Überfahren (Hüfner, 06.10.2026). */ +.button-arrow .block-icon{color:inherit} .icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px} .icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px} .icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px} diff --git a/scripts/build-huefner-assets.php b/scripts/build-huefner-assets.php new file mode 100644 index 0000000..780c192 --- /dev/null +++ b/scripts/build-huefner-assets.php @@ -0,0 +1,45 @@ + + */ +$quelle = rtrim($argv[1] ?? '', '/\\'); +$ziel = rtrim($argv[2] ?? '', '/\\'); +if ('' === $quelle || '' === $ziel || !is_dir($quelle.'/images')) { + fwrite(STDERR, "Aufruf: php scripts/build-huefner-assets.php \n"); + exit(1); +} +if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) { + fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n"); + exit(1); +} + +foreach (glob($quelle.'/images/*.{jpg,png}', GLOB_BRACE) ?: [] as $datei) { + copy($datei, $ziel.'/'.basename($datei)); +} + +// Baum und Steine: im 1600 x 607 px großen Logo etwa x 15–500, y 10–560. +$logo = imagecreatefrompng($quelle.'/images/logo.png'); +if (false === $logo) { + fwrite(STDERR, "logo.png lässt sich nicht lesen.\n"); + exit(1); +} +$kante = 512; +$symbol = imagecreatetruecolor($kante, $kante); +imagesavealpha($symbol, true); +imagealphablending($symbol, false); +imagefill($symbol, 0, 0, imagecolorallocatealpha($symbol, 0, 0, 0, 127)); +imagealphablending($symbol, true); +[$x, $y, $b, $h] = [15, 10, 490, 550]; +$massstab = min(($kante - 24) / $b, ($kante - 24) / $h); +$zb = (int) round($b * $massstab); +$zh = (int) round($h * $massstab); +imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h); +imagepng($symbol, $ziel.'/favicon.png'); +echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n"; diff --git a/scripts/reset-huefner.sh b/scripts/reset-huefner.sh new file mode 100644 index 0000000..0b9838a --- /dev/null +++ b/scripts/reset-huefner.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht. +# Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/). +set -e +root="C:/Django Project/KantSites" +php="${PHP_BINARY:-C:/php84/php.exe}" +export DATABASE_URL="sqlite:///$root/var/huefner.db" +export MEDIA_DIRECTORY="$root/var/huefner-media" +assets="$root/var/huefner-assets" +if [ -n "$1" ]; then + "$php" scripts/build-huefner-assets.php "$1" "$assets" +fi +if [ ! -f "$assets/favicon.png" ]; then + echo "Bilder fehlen: erst mit dem Ordner des Entwurfs als Argument aufrufen." >&2 + exit 1 +fi +rm -f "$root/var/huefner.db" +rm -rf "$root/var/huefner-media" +"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q +"$php" -d memory_limit=512M scripts/seed-huefner.php "$assets" +# Barlow und Barlow Condensed vom eigenen Server; der Abruf ergaenzt fonts.css nur. +if [ -f "$root/var/cacert.pem" ]; then + "$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch +else + "$php" bin/console cms:fonts:fetch +fi diff --git a/scripts/seed-huefner.php b/scripts/seed-huefner.php new file mode 100644 index 0000000..a5ec07c --- /dev/null +++ b/scripts/seed-huefner.php @@ -0,0 +1,1251 @@ +fetchOne('SELECT id FROM page LIMIT 1')) { + fwrite(STDERR, "Diese Datenbank enthält bereits Seiten. Abbruch, damit nichts überschrieben wird.\n"); + exit(1); +} + +$media = new MediaLibrary($db, $mediaDirectory); +$settings = new SiteSettings($db, $media); +$pages = new PageRepository($db, new DocumentValidator(), $settings); +$partials = new PartialRepository($db, new DocumentValidator()); +$menus = new MenuRepository($db, $settings); +$forms = new FormRepository($db); +$theme = new Theme($db); + +$firma = 'Garten- und Landschaftsbau Hüfner GmbH'; +$kurz = 'Hüfner Garten- und Landschaftsbau'; +$mail = 'info@huefner-gartenbau.de'; +$telefon = '07229 6992826'; +$tel = 'tel:+4972296992826'; +$strasse = 'Am Forlenspitzen 6'; +$plz = '76473'; +$ort = 'Iffezheim'; +$instagram = 'https://www.instagram.com/galabau_florianhuefner/'; +$facebook = 'https://www.facebook.com/huefnergalabau/'; +$SCHEMA = 24; +// Öffnungszeiten von Umut (06.10.2026, aus dem Google-Eintrag): Mo–Fr 8–17 Uhr, Sa 8–12 Uhr. +$zeiten = 'Mo–Fr 8–17 Uhr · Sa 8–12 Uhr'; +$zeitenDaten = [ + ...array_map(static fn (string $tag): array => ['day' => $tag, 'opens' => '08:00', 'closes' => '17:00'], ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday']), + ['day' => 'Saturday', 'opens' => '08:00', 'closes' => '12:00'], +]; + +// --------------------------------------------------------------------------------------------- +// Bausteine +// --------------------------------------------------------------------------------------------- + +$seiten = static fn (string $w): array => ['top' => $w, 'right' => $w, 'bottom' => $w, 'left' => $w]; +$nurOben = static fn (string $w): array => ['top' => $w, 'right' => '0px', 'bottom' => '0px', 'left' => '0px']; +$nurLinks = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $w]; +$nurUnten = static fn (string $w): array => ['top' => '0px', 'right' => '0px', 'bottom' => $w, 'left' => '0px']; +$rund = static fn (string $w): array => ['topLeft' => $w, 'topRight' => $w, 'bottomRight' => $w, 'bottomLeft' => $w]; + +$section = static function (string $id, array $children, array $style = [], ?string $anchor = null, string $css = ''): array { + $block = ['id' => $id, 'type' => 'section', 'children' => $children, 'style' => $style]; + if (null !== $anchor) { + $block['anchor'] = $anchor; + } + if ('' !== $css) { + $block['css'] = $css; + } + + return $block; +}; + +$row = static function (string $id, array $children, array $design = [], array $style = [], string $css = ''): array { + $block = ['id' => $id, 'type' => 'row', 'children' => $children]; + if ([] !== $design) { + $block['layout'] = ['design' => $design]; + } + if ([] !== $style) { + $block['style'] = $style; + } + if ('' !== $css) { + $block['css'] = $css; + } + + return $block; +}; + +/** Zwei Spalten auf dem Desktop, eine darunter. */ +$zweiSpalten = ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']]; + +/** Eine Zeile, die nebeneinander stellt statt zu rastern. */ +$zeile = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = [], string $css = '') 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, $css); +}; + +/** Wie $zeile, aber als Spalte: nur eine Spalte trägt eine Fläche (Zeilen sind im Kern durchsichtig). */ +$karte = static function (string $id, array $children, string $gap, array $extra = [], array $mobil = [], array $tablet = [], string $css = '') use (&$zeile): array { + $block = $zeile($id, $children, $gap, $extra, $mobil, $tablet, $css); + $block['type'] = 'column'; + + return $block; +}; + +$column = static function (string $id, array $children, array $style = [], string $css = '', string $sichtbar = 'all'): array { + $block = ['id' => $id, 'type' => 'column', 'children' => $children]; + if ([] !== $style) { + $block['style'] = $style; + } + if ('' !== $css) { + $block['css'] = $css; + } + if ('all' !== $sichtbar) { + $block['layout'] = ['visibility' => $sichtbar]; + } + + 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 = '', string $sichtbar = 'all'): array { + $block = ['id' => $id, 'type' => 'text', 'props' => ['text' => $inhalt]]; + if ([] !== $style) { + $block['style'] = $style; + } + if ('' !== $css) { + $block['css'] = $css; + } + if ('all' !== $sichtbar) { + $block['layout'] = ['visibility' => $sichtbar]; + } + + return $block; +}; + +/** Eine reine Fläche: Striche, Linien. */ +$flaeche = static function (string $id, array $style, string $css = '', string $sichtbar = 'all'): array { + $block = ['id' => $id, 'type' => 'spacer', 'props' => ['size' => 'small'], 'style' => $style]; + if ('' !== $css) { + $block['css'] = $css; + } + if ('all' !== $sichtbar) { + $block['layout'] = ['visibility' => $sichtbar]; + } + + return $block; +}; + +$bild = static function (string $id, array $eintrag, string $alt, array $style = [], ?string $href = null, string $css = ''): array { + $gross = end($eintrag['variants']); + $props = [ + 'src' => $gross['src'], 'alt' => $alt, 'decorative' => '' === $alt, 'width' => $gross['width'], 'height' => $gross['height'], + 'mediaId' => $eintrag['id'], + 'sources' => array_map(static fn (array $v): array => ['src' => $v['src'], 'width' => $v['width']], $eintrag['variants']), + ]; + if (null !== $href) { + $props['href'] = $href; + $props['click'] = 'link'; + } + $block = ['id' => $id, 'type' => 'image', 'props' => $props, 'style' => $style]; + if ('' !== $css) { + $block['css'] = $css; + } + + return $block; +}; + +$symbol = static 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]; +}; + +/** + * Ein Button. Der Pfeil des Kerns zeigt schräg nach oben; um 45 Grad gedreht zeigt er nach + * rechts wie in der Vorlage. Alle heben sich beim Überfahren leicht an. + * + * @param string $art rot (Theme), glas (durchsichtig auf Bild), weiss, dunkel + */ +$knopf = static function (string $id, string $inhalt, string $href, string $art = 'rot', bool $pfeil = true, array $style = [], string $sichtbar = 'all'): array { + $farbe = [ + 'rot' => '', + 'glas' => "background:rgba(255,255,255,.12);color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.5);\n&:hover{background:rgba(255,255,255,.22);color:#fff}\n", + 'weiss' => "background:#fff;color:#17221a;\n&:hover{background:#eef5e9;color:#17221a}\n", + 'dunkel' => "background:#17221a;color:#fff;\n&:hover{background:#2f6b22;color:#fff}\n", + 'linie' => "background:transparent;color:#fff;box-shadow:inset 0 0 0 1px rgba(255,255,255,.35);\n&:hover{background:rgba(255,255,255,.1);color:#fff}\n", + ][$art]; + $css = "gap:12px;min-height:58px;padding:0 30px;margin-top:0;transition:transform .2s ease,box-shadow .2s ease,background-color .2s ease;\n" + .$farbe + ."&:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(0,0,0,.18)}\n" + .($pfeil ? "& .button-arrow svg{transform:rotate(45deg)}\n& .button-arrow{display:inline-flex}" : '& .button-arrow{display:none}'); + $block = ['id' => $id, 'type' => 'button', 'props' => ['text' => $inhalt, 'href' => $href], 'css' => $css]; + if ([] !== $style) { + $block['style'] = $style; + } + if ('all' !== $sichtbar) { + $block['layout'] = ['visibility' => $sichtbar]; + } + + return $block; +}; + +/** + * Setzt die Einblendungen nach einer Liste. + * + * @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. +$h2Stil = [ + 'desktop' => ['fontSize' => '64px', 'fontWeight' => '800', 'lineHeight' => 0.98, 'letterSpacing' => '0px', 'textTransform' => 'uppercase', 'color' => '@tinte', 'margin' => ['top' => '18px', 'bottom' => '28px']], + 'tablet' => ['fontSize' => '46px'], + 'mobile' => ['fontSize' => '38px', 'margin' => ['top' => '14px', 'bottom' => '22px']], +]; +$h3Stil = static fn (string $groesse, string $farbe = '@tinte', string $unten = '10px'): array => [ + 'desktop' => ['fontSize' => $groesse, 'fontWeight' => '800', 'lineHeight' => 1.0, 'letterSpacing' => '0px', 'textTransform' => 'uppercase', 'color' => $farbe, 'margin' => ['top' => '0px', 'bottom' => $unten]], +]; +$fliess = static fn (string $groesse = '17px', string $farbe = '@text', string $unten = '18px', float $zeile = 1.7): array => [ + 'desktop' => ['fontSize' => $groesse, 'lineHeight' => $zeile, 'color' => $farbe, 'margin' => ['top' => '0px', 'bottom' => $unten]], +]; +$em = '& em{font-style:normal;color:#8fd16f}'; +$linkCss = "& a{color:#2f6b22;text-decoration:underline;text-underline-offset:3px}\n& a:hover{color:#1f4a16}"; +$emGruen = '& em{font-style:normal;color:#2f6b22}'; + +/** Die Kleinzeile in Versalien mit dem kurzen Strich davor („— LEISTUNGEN“), auf Wunsch auch dahinter. */ +$kleinzeile = static function (string $id, string $inhalt, string $farbe = '@rot', bool $beidseitig = false, string $unten = '0px') use ($text, $zeile, $flaeche): array { + $strich = static fn (string $sid): array => $flaeche($sid, ['desktop' => ['width' => '32px', 'height' => '2px', 'backgroundColor' => $farbe, 'flexShrink' => 0, 'margin' => ['top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px']]], 'min-height:0'); + $kinder = [$strich($id.'-strich'), $text($id.'-text', $inhalt, ['desktop' => [ + 'fontFamily' => 'heading', 'fontSize' => '17px', 'fontWeight' => '700', 'letterSpacing' => '0.2em', 'lineHeight' => 1.2, + 'textTransform' => 'uppercase', 'color' => $farbe, 'margin' => ['top' => '0px', 'bottom' => '0px'], + ]])]; + if ($beidseitig) { + $kinder[] = $strich($id.'-strich-2'); + } + + return $zeile($id, $kinder, '12px', ['justifyContent' => $beidseitig ? 'center' : 'flex-start', 'margin' => ['top' => '0px', 'bottom' => $unten]]); +}; + +/** Ein Symbol im grünen Kreis (Kontaktkarten, Direkter Draht). */ +$kreis = static fn (string $id, string $icon, string $groesse = '48px') => ['id' => $id, 'type' => 'column', 'children' => [ + ['id' => $id.'-icon', 'type' => 'icon', 'props' => ['icon' => $icon, 'size' => 20, 'decorative' => true, 'label' => ''], 'style' => ['desktop' => ['color' => '@papier']]], +], 'style' => ['desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => $groesse, 'height' => $groesse, 'flexShrink' => 0, 'backgroundColor' => '@gruen', 'borderRadius' => ['topLeft' => '50%', 'topRight' => '50%', 'bottomRight' => '50%', 'bottomLeft' => '50%']]]]; + +// Ein Link über die ganze Karte: Die Karte ist positioniert, der Text darin nicht, also spannt +// das ::after des Links die ganze Karte auf. Ein Link, ein Name, ganze Fläche klickbar. +$ganzeKarte = "position:relative;\n& a{color:inherit;text-decoration:none}\n& a::after{content:'';position:absolute;top:0;right:0;bottom:0;left:0;border-radius:inherit}\n& a:focus-visible{outline:none}\n&:focus-within{outline:3px solid #2f6b22;outline-offset:3px}"; + +// --------------------------------------------------------------------------------------------- +// Website, Gestaltung, Medien +// --------------------------------------------------------------------------------------------- + +$db->insert('website', ['id' => 'default', 'name' => $kurz, 'default_locale' => 'de', 'theme' => '{}']); + +$theme->save( + [ + ['key' => 'gruen', 'name' => 'Grün (Akzent)', 'value' => '#2f6b22'], + ['key' => 'gruen-dunkel', 'name' => 'Grün dunkel (Links beim Überfahren)', 'value' => '#1f4a16'], + ['key' => 'hellgruen', 'name' => 'Hellgrün (auf Dunkel)', 'value' => '#8fd16f'], + ['key' => 'rot', 'name' => 'Rot (Knöpfe, Kleinzeilen)', 'value' => '#b71c22'], + ['key' => 'rot-dunkel', 'name' => 'Rot dunkel (Knöpfe beim Überfahren)', 'value' => '#931419'], + ['key' => 'tinte', 'name' => 'Schrift', 'value' => '#17221a'], + ['key' => 'nacht', 'name' => 'Nachtgrün (Leiste, Referenzen)', 'value' => '#14201a'], + ['key' => 'fuss', 'name' => 'Fuß', 'value' => '#0f1813'], + ['key' => 'mint', 'name' => 'Hellgrün (Seitengrund)', 'value' => '#f3f8ef'], + ['key' => 'chip', 'name' => 'Hellgrün (Flächen)', 'value' => '#eef5e9'], + ['key' => 'linie', 'name' => 'Linien', 'value' => '#dce8d4'], + ['key' => 'text', 'name' => 'Fließtext', 'value' => '#55635a'], + ['key' => 'text-stark', 'name' => 'Fließtext kräftig', 'value' => '#3a483d'], + ['key' => 'auf-dunkel', 'name' => 'Schrift auf Dunkel', 'value' => '#b9c7b5'], + ['key' => 'papier', 'name' => 'Weiß', 'value' => '#ffffff'], + ], + [ + ['key' => 'body', 'name' => 'Fließtext', 'family' => 'Barlow', 'source' => 'google', 'weights' => [400, 600, 700], 'fallback' => 'sans-serif'], + ['key' => 'heading', 'name' => 'Überschriften', 'family' => 'Barlow Condensed', 'source' => 'google', 'weights' => [700, 800], 'fallback' => 'sans-serif'], + ], + ['fontFamily' => 'body', 'headingFontFamily' => 'heading', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@tinte', 'backgroundColor' => '@mint', 'linkColor' => '@gruen', 'containerWidth' => '1280px', 'headingScale' => 1.25, 'linkUnderline' => false, 'imageRadius' => '0px'], + ['shape' => 'pill', 'appearance' => 'solid', 'size' => 'normal', 'fontFamily' => 'heading', 'fontSize' => 19, 'fontWeight' => 700, 'letterSpacing' => 1.5, 'textTransform' => 'uppercase', 'backgroundColor' => '@rot', 'color' => '@papier', 'hoverBackgroundColor' => '@rot-dunkel', 'hoverColor' => '@papier'], +); +echo "Farben, Schriften und Buttons gesetzt\n"; + +$medien = []; +foreach ([ + 'logo' => ['logo.png', 'Garten- und Landschaftsbau Hüfner GmbH'], + 'favicon' => ['favicon.png', 'Baum aus dem Hüfner-Logo'], + 'hero' => ['hero-pool-stufen.jpg', 'Pool mit Sitzstufen in Anthrazit und Keramikumrandung'], + 'pool-stufen' => ['pool-stufen.jpg', 'Pool mit Sitzstufen und Keramikumrandung'], + 'plattenweg' => ['plattenweg.jpg', 'Plattenweg im Rasen entlang eines Neubaus'], + 'pool-fachwerk' => ['pool-fachwerk.jpg', 'Pool mit Natursteinterrasse am Fachwerkhaus'], + 'terrasse' => ['terrasse.jpg', 'Keramikterrasse am Pool'], + 'pflaster-verlegt' => ['pflaster-verlegt.jpg', 'Neu verlegtes Pflaster in Grau- und Brauntönen'], + 'naturstein' => ['naturstein.jpg', 'Natursteinmauer mit Trittplatten im Rasen'], + 'rasen' => ['rasen.jpg', 'Frisch verlegter Rollrasen'], + 'pool-garten' => ['pool-garten.jpg', 'Pool mit Hortensien und Gartenanlage'], + 'pool-terrasse' => ['pool-terrasse.jpg', 'Pool mit Natursteinterrasse und Gabionen'], + 'sichtschutz' => ['sichtschutz.jpg', 'Moderner Sichtschutz aus Aluminium'], + 'pflaster' => ['pflaster.jpg', 'Hanggarten mit Natursteinmauer und Treppe'], + 'baustelle' => ['poolbau-baustelle.jpg', 'Poolbau im Rohbau mit Minibagger und Radlader im Hintergrund'], + 'team' => ['team.jpg', 'Das Hüfner-Team in Arbeitsjacken mit Firmenlogo'], +] as $schluessel => [$datei, $alt]) { + $pfad = rtrim($assets, '/\\').'/'.$datei; + if (!is_file($pfad)) { + fwrite(STDERR, "Bild fehlt: $pfad\n"); + exit(1); + } + $eintrag = $media->upload(new UploadedFile($pfad, basename($datei), null, null, true)); + $media->update($eintrag['id'], ['title' => pathinfo($datei, PATHINFO_FILENAME), 'alt' => $alt, 'archived' => false, 'version' => $eintrag['version']]); + $medien[$schluessel] = $media->find($eintrag['id']); + echo "Bild übernommen: $datei\n"; +} + +// --------------------------------------------------------------------------------------------- +// Formular +// --------------------------------------------------------------------------------------------- + +$forms->save('anfrage', 'Projektanfrage', [ + ['name' => 'name', 'type' => 'text', 'label' => 'Name', 'required' => true, 'help' => 'Vor- und Nachname'], + ['name' => 'telefon', 'type' => 'phone', 'label' => 'Telefon', 'required' => false, 'help' => 'Für Rückfragen'], + ['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true], + ['name' => 'leistung', 'type' => 'select', 'label' => 'Leistung', 'required' => true, 'options' => ['Pool', 'Terrasse', 'Pflaster', 'Natursteine', 'Komplette Außenanlage', 'Sonstiges']], + ['name' => 'vorhaben', 'type' => 'textarea', 'label' => 'Ihr Vorhaben', 'required' => true, 'help' => 'Kurze Beschreibung, Größe der Fläche, Wunschtermin …'], + ['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Ich stimme der Verarbeitung meiner Angaben gemäß Datenschutzhinweisen zu.'], +], ['recipient' => $mail, 'subject' => 'Neue Projektanfrage über die Website', 'success' => 'Vielen Dank! Ihre Anfrage ist bei uns angekommen. Wir melden uns zeitnah und vereinbaren einen Termin bei Ihnen vor Ort.', 'replyToField' => 'email', 'buttonLabel' => 'Anfrage absenden'], 0); +echo "Formular angelegt: anfrage\n"; + +// --------------------------------------------------------------------------------------------- +// Kopfbereich: dunkle Leiste, darunter der weiße Kopf mit Menü, Profilen und Knopf +// --------------------------------------------------------------------------------------------- + +$leistenLink = "& a{color:#fff;text-decoration:none;transition:color .2s}\n& a:hover{color:#8fd16f}"; +$leistenPunkt = static fn (string $id, string $icon, string $inhalt, string $sichtbar = 'all') => $zeile($id, [ + $symbol($id.'-icon', $icon, 16, ['desktop' => ['color' => '#8fcb7c']]), + $text($id.'-text', $inhalt, ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.4, 'color' => '#c9d6c5', 'margin' => ['bottom' => '0px']]], $leistenLink), +], '8px') + ('all' === $sichtbar ? [] : ['layout' => ['visibility' => $sichtbar]]); + +$kopf = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Kopfbereich', 'description' => 'Erscheint auf jeder Seite über dem Inhalt.', + 'blocks' => [ + $section('kopf-leiste', [ + $zeile('kopf-leiste-zeile', [ + $zeile('kopf-leiste-kontakt', [ + $leistenPunkt('kopf-telefon', 'phone', ''.$telefon.''), + $leistenPunkt('kopf-mail', 'mail', ''.$mail.'', 'hide-mobile'), + $leistenPunkt('kopf-adresse', 'pin', $strasse.', '.$plz.' '.$ort, 'desktop'), + ], '28px', ['flexWrap' => 'wrap', 'rowGap' => '8px']), + ['id' => 'kopf-leiste-profile', 'type' => 'social', 'props' => ['size' => 16, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => true, 'networks' => ['instagram', 'facebook']], + 'style' => ['desktop' => ['fontSize' => '15px', 'color' => '#c9d6c5', 'gap' => '24px']], + 'css' => "& a{font-weight:400}\n& a:hover{color:#fff;transform:none}", + 'layout' => ['visibility' => 'hide-mobile']], + ], '28px', ['justifyContent' => 'space-between']), + ], [ + 'desktop' => ['backgroundColor' => '@nacht', 'color' => '#c9d6c5', 'padding' => ['top' => '9px', 'bottom' => '9px']], + 'mobile' => ['padding' => ['top' => '7px', 'bottom' => '7px']], + ]), + $section('kopf-haupt', [ + $zeile('kopf-haupt-zeile', [ + $bild('kopf-logo', $medien['logo'], $kurz.' – zur Startseite', [ + // Feste Breite: sonst bekommt das Logo seine Breite erst nach dem Laden (CLS). + 'desktop' => ['width' => '153px', 'maxWidth' => '153px', 'flexShrink' => 0, 'margin' => $seiten('0px')], + 'mobile' => ['width' => '121px', 'maxWidth' => '121px'], + ], '/', '& img{display:block;width:100%;height:auto;border-radius:0}'), + $column('kopf-navigation', [ + ['id' => 'kopf-menue', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet', 'burgerLabel' => 'icon'], + 'style' => ['desktop' => [ + 'fontFamily' => 'heading', 'fontSize' => '18px', 'fontWeight' => '700', 'letterSpacing' => '0.06em', 'textTransform' => 'uppercase', 'color' => '@tinte', + 'burgerColor' => '@papier', 'burgerBackgroundColor' => '@tinte', 'burgerBorderColor' => '@tinte', 'burgerBorderRadius' => '999px', + ]], + // Unterstrich, der beim Überfahren von links einläuft. Das Klappmenü auf + // Tablet und Telefon liegt als Fläche unter dem ganzen Kopf. + 'css' => "& .menu-list{gap:4px 34px;justify-content:center;align-items:center}\n" + ."& .menu-list a{position:relative;padding:6px 0;text-decoration:none;color:#17221a}\n" + ."& .menu-list a::after{content:'';position:absolute;left:0;right:100%;bottom:0;height:2px;background:#2f6b22;transition:right .25s ease}\n" + ."& .menu-list a:hover::after{right:0}\n" + ."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n" + ."& .menu-burger-body{position:absolute;left:0;right:0;top:100%;background:#fff;padding:4px 20px 20px;border-top:1px solid #dce8d4;box-shadow:0 18px 30px rgba(20,32,26,.12);font-size:22px}\n" + ."& .menu-burger-body .menu-list{flex-direction:column;align-items:stretch;gap:0}\n" + ."& .menu-burger-body .menu-list a{display:block;padding:14px 0;border-bottom:1px solid #e6efe0}\n" + .'& .menu-burger-body .menu-list a::after{display:none}'], + ], ['desktop' => ['flexGrow' => 1, 'display' => 'flex', 'justifyContent' => 'center'], 'tablet' => ['justifyContent' => 'flex-end', 'order' => 3, 'flexGrow' => 0], 'mobile' => ['justifyContent' => 'flex-end', 'order' => 3, 'flexGrow' => 0]]), + $zeile('kopf-aktionen', [ + ['id' => 'kopf-profile', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => false, 'networks' => ['instagram', 'facebook']], + 'style' => ['desktop' => ['color' => '@tinte', 'gap' => '10px']], + 'css' => "& a{width:46px;height:46px;justify-content:center;border-radius:50%;box-shadow:inset 0 0 0 1px #d3e3ca}\n& a:hover{background:#eef5e9;color:#2f6b22}", + 'layout' => ['visibility' => 'hide-mobile']], + $knopf('kopf-knopf', 'Angebot anfragen', '/#kontakt', 'rot', true, [], 'desktop'), + ], '10px', ['flexShrink' => 0], ['margin' => ['left' => 'auto']], ['margin' => ['left' => 'auto']]), + ], '24px', ['justifyContent' => 'space-between', 'minHeight' => '82px'], ['minHeight' => '66px', 'gap' => '12px']), + ], [ + 'desktop' => ['backgroundColor' => '#fffffff5', 'padding' => ['top' => '0px', 'bottom' => '0px'], 'borderWidth' => $nurUnten('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie'], + ], null, 'box-shadow:0 8px 24px rgba(20,32,26,.06)'), + ], +]; + +// Der rote Knopf im Kopf ist kleiner als die im Inhalt. +$kleinerKnopf = static fn (array $b): array => [...$b, 'css' => str_replace('min-height:58px;padding:0 30px', 'min-height:50px;padding:0 22px;font-size:16px;white-space:nowrap', $b['css'])]; +$kopf['blocks'][1]['children'][0]['children'][2]['children'][1]['css'] = str_replace('min-height:58px;padding:0 30px', 'min-height:46px;padding:0 24px;font-size:17px', $kopf['blocks'][1]['children'][0]['children'][2]['children'][1]['css']); + +// --------------------------------------------------------------------------------------------- +// Fußbereich +// --------------------------------------------------------------------------------------------- + +$fussLink = "& a{color:#b9c7b5;text-decoration:none;transition:color .2s}\n& a:hover{color:#fff}"; +$fussTitel = static fn (string $id, string $inhalt) => $text($id, $inhalt, ['desktop' => [ + 'fontFamily' => 'heading', 'fontSize' => '17px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'textTransform' => 'uppercase', 'color' => '@papier', 'margin' => ['bottom' => '16px'], +]]); +$fussListe = static fn (string $id, array $links) => $text($id, implode('
', array_map(static fn (array $l): string => ''.$l[0].'', $links)), [ + 'desktop' => ['fontSize' => '15px', 'lineHeight' => 2.1, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']], +], $fussLink); + +$fuss = [ + 'schemaVersion' => $SCHEMA, 'title' => 'Fußbereich', 'description' => 'Erscheint auf jeder Seite unter dem Inhalt.', + 'blocks' => [ + $section('fuss', [ + $column('fuss-raster', [ + $column('fuss-marke', [ + $column('fuss-logo-flaeche', [ + $bild('fuss-logo', $medien['logo'], $firma, ['desktop' => ['width' => '137px', 'maxWidth' => '137px', 'margin' => $seiten('0px')]], null, '& img{display:block;width:100%;height:auto;border-radius:0}'), + ], ['desktop' => ['display' => 'inline-block', 'backgroundColor' => '@papier', 'borderRadius' => $rund('12px'), 'padding' => ['top' => '12px', 'right' => '16px', 'bottom' => '12px', 'left' => '16px']]]), + $text('fuss-spruch', 'Garten- und Landschaftsbau auf Premium-Niveau – aus Iffezheim für die Region.', ['desktop' => ['maxWidth' => '300px', 'fontSize' => '15px', 'lineHeight' => 1.65, 'color' => '@auf-dunkel', 'margin' => ['top' => '20px', 'bottom' => '20px']]]), + ['id' => 'fuss-profile', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => false, 'networks' => ['instagram', 'facebook']], + 'style' => ['desktop' => ['color' => '@auf-dunkel', 'gap' => '10px']], + 'css' => "& a{width:44px;height:44px;justify-content:center;border-radius:50%;box-shadow:inset 0 0 0 1px #2c3a2f}\n& a:hover{color:#fff;box-shadow:inset 0 0 0 1px #8fd16f}"], + ], ['desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'alignItems' => 'flex-start']]), + $column('fuss-leistungen', [ + $fussTitel('fuss-leistungen-titel', 'Leistungen'), + $fussListe('fuss-leistungen-liste', [['Pools', '/#leistungen'], ['Terrassen', '/#leistungen'], ['Pflaster', '/#leistungen'], ['Natursteine', '/#leistungen'], ['Außenanlagen', '/#leistungen']]), + ]), + $column('fuss-unternehmen', [ + $fussTitel('fuss-unternehmen-titel', 'Unternehmen'), + $fussListe('fuss-unternehmen-liste', [['Über uns', '/#ueber-uns'], ['Referenzen', '/#projekte'], ['Ablauf', '/#ablauf'], ['Karriere', '/#karriere'], ['Kontakt', '/#kontakt']]), + ]), + $column('fuss-kontakt', [ + $fussTitel('fuss-kontakt-titel', 'Kontakt'), + $text('fuss-kontakt-text', 'Garten- und Landschaftsbau
Hüfner GmbH
'.$strasse.'
'.$plz.' '.$ort.'

'.$telefon.'
'.$mail.'', [ + 'desktop' => ['fontSize' => '15px', 'lineHeight' => 1.7, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']], + ], $fussLink."\n& a{overflow-wrap:anywhere}"), + ]), + ], [ + 'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '48px', 'padding' => ['top' => '80px', 'bottom' => '48px']], + 'tablet' => ['gridColumns' => 2], + 'mobile' => ['gridColumns' => 1, 'gap' => '36px', 'padding' => ['top' => '56px', 'bottom' => '36px']], + ]), + $zeile('fuss-unten', [ + $text('fuss-copyright', '© 2026 '.$firma, ['desktop' => ['fontSize' => '14px', 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']]]), + $zeile('fuss-links', [ + ['id' => 'fuss-rechtliches', 'type' => 'legal', 'props' => ['label' => 'Rechtliches'], + 'style' => ['desktop' => ['fontSize' => '14px', 'gap' => '24px', 'color' => '@auf-dunkel']], + 'css' => $fussLink], + $text('fuss-agentur', 'Website by Kant-IT Solutions', ['desktop' => ['fontSize' => '14px', 'margin' => ['bottom' => '0px']]], $fussLink), + ], '24px', ['flexWrap' => 'wrap'], ['rowGap' => '10px', 'columnGap' => '18px']), + ], '24px', ['justifyContent' => 'space-between', 'flexWrap' => 'wrap', 'rowGap' => '12px', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#22302a', 'padding' => ['top' => '24px', 'bottom' => '36px']], + ['flexDirection' => 'column', 'alignItems' => 'flex-start']), + ], [ + 'desktop' => ['backgroundColor' => '@fuss', 'color' => '@auf-dunkel', 'padding' => ['top' => '0px', 'bottom' => '0px']], + ]), + ], +]; + +$partials->create('header', 'default', 'Kopfbereich', $kopf); +$partials->create('footer', 'default', 'Fußbereich', $fuss); +echo "Kopf- und Fußbereich angelegt\n"; + +// --------------------------------------------------------------------------------------------- +// Startseite +// --------------------------------------------------------------------------------------------- + +/** + * Eine Bildkachel: Foto über die ganze Fläche, dunkler Verlauf von unten, Text darauf. Beim + * Überfahren zoomt nur das Foto, die Kachel bekommt Schatten. Mit $href ist das Foto der Link + * (sein Alternativtext ist dann der Name des Links), der Text darüber lässt Klicks durch. + */ +$kachel = static function (string $id, array $foto, string $alt, array $inhalt, string $innen, ?string $href = null, array $hoehe = [], string $verlauf = '', bool $mehr = false) use ($column, $bild, $seiten, $rund): array { + return $column($id, [ + // Foto, Verlauf und Fokusrahmen stehen einmal im CSS des Abschnitts ($kachelFlaeche), + // nicht in jeder Kachel: sonst wird die Seite über das HTML-Budget von 150 KB schwer. + $bild($id.'-foto', $foto, $alt, ['desktop' => ['position' => 'absolute', 'top' => '0px', 'right' => '0px', 'bottom' => '0px', 'left' => '0px', 'margin' => $seiten('0px'), 'zIndex' => 0]], $href), + ...$inhalt, + ], [ + 'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'display' => 'flex', 'flexDirection' => 'column', 'justifyContent' => 'flex-end', 'backgroundColor' => '#2a372c', 'color' => '@papier', + 'borderRadius' => $rund('18px'), 'padding' => $seiten($innen), ...($hoehe['desktop'] ?? [])], + ...array_diff_key($hoehe, ['desktop' => true]), + ], "transition:box-shadow .3s ease;\n&:hover{box-shadow:0 18px 40px rgba(20,30,22,.22)}\n&:hover img{transform:scale(1.06)}\n&:focus-within img{transform:scale(1.06)}".($mehr ? "\n& .block-row{position:relative;pointer-events:none;transition:gap .25s ease}\n&:hover .block-row{gap:14px}" : '')); +}; + +/** Was alle Kacheln eines Abschnitts teilen; die Kacheln sind dort die einzigen Bilder. */ +$kachelFlaeche = "& .block-image img{width:100%;height:100%;object-fit:cover;border-radius:0;transition:transform .7s cubic-bezier(.2,.7,.2,1)}\n" + ."& .image-link{height:100%}\n" + ."& .image-link:focus-visible{outline:3px solid #8fd16f;outline-offset:-8px}\n" + ."& .block-image::after{content:'';position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(0deg,rgba(10,18,13,.88) 0%,rgba(10,18,13,0) 58%);pointer-events:none}\n" + ."& .block-image+h3{position:relative;pointer-events:none}\n" + ."& .block-image~.block-text{position:relative;pointer-events:none}\n" + .'& .block-image~h3{position:relative;pointer-events:none}'; + +$kachelTitel = static fn (string $id, string $titel, string $groesse = '30px') => $heading($id, $titel, 3, [ + 'desktop' => ['fontSize' => $groesse, 'fontWeight' => '800', 'lineHeight' => 1.0, 'letterSpacing' => '0px', 'textTransform' => 'uppercase', 'color' => '@papier', 'margin' => ['top' => '4px', 'bottom' => '6px']], + 'mobile' => ['fontSize' => '30px'], +]); +$kachelSatz = static fn (string $id, string $satz, string $groesse = '15px', string $unten = '0px', string $breite = 'none') => $text($id, $satz, ['desktop' => ['fontSize' => $groesse, 'lineHeight' => 1.5, 'color' => '#dce6d8', 'maxWidth' => $breite, 'margin' => ['bottom' => $unten]]]); +$kachelKlein = static fn (string $id, string $klein, string $groesse = '13px') => $text($id, $klein, ['desktop' => ['fontSize' => $groesse, 'fontWeight' => '600', 'letterSpacing' => '0.06em', 'textTransform' => 'uppercase', 'color' => '@hellgruen', 'margin' => ['bottom' => '2px']]]); + +/** Ein Vorteil im Band unter dem Hero; die Trennlinien hängen am Platz im Raster. */ +$vorteil = static function (int $n, array $icon, string $titel, string $satz) use ($zeile, $text): array { + $rahmen = static fn (bool $oben, bool $links): array => ['borderWidth' => ['top' => $oben ? '1px' : '0px', 'right' => '0px', 'bottom' => '0px', 'left' => $links ? '1px' : '0px'], 'borderStyle' => 'solid', 'borderColor' => '#ffffff24']; + // Desktop: vier nebeneinander, Linie links ab dem zweiten. Darunter zwei mal zwei. + $zwei = $rahmen($n > 2, 0 === $n % 2); + $mobil = ['flexDirection' => 'column', 'alignItems' => 'flex-start', 'gap' => '8px', 'padding' => ['top' => '16px', 'right' => '16px', 'bottom' => '16px', 'left' => '16px'], ...$zwei]; + + return $zeile('vorteil-'.$n, [ + $icon, + $text('vorteil-'.$n.'-text', ''.$titel.''.$satz, [ + 'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.45, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']], + 'mobile' => ['fontSize' => '12.5px'], + ], "& strong{display:block;margin-bottom:2px;font-size:16px;color:#fff}"), + ], '14px', ['padding' => ['top' => '24px', 'right' => '32px', 'bottom' => '24px', 'left' => '32px'], ...$rahmen(false, $n > 1)], $mobil, $zwei); +}; + +$vorteilIcon = static fn (int $n, string $name) => $symbol('vorteil-'.$n.'-icon', $name, 32, ['desktop' => ['color' => '@hellgruen', 'flexShrink' => 0]]); +// Der LKW fehlt im Symbolsatz des Kerns; der Pfad ist der aus der Vorlage. +$lkw = ['id' => 'vorteil-3-icon', 'type' => 'html', 'props' => ['html' => ''], + 'style' => ['desktop' => ['flexShrink' => 0]], 'css' => '& svg{display:block}']; + +$haken = static fn (string $id, string $inhalt) => $zeile($id, [ + $symbol($id.'-icon', 'check', 24, ['desktop' => ['color' => '@gruen', 'flexShrink' => 0]]), + $text($id.'-text', $inhalt, ['desktop' => ['fontSize' => '16px', 'fontWeight' => '600', 'lineHeight' => 1.4, 'color' => '@tinte', 'margin' => ['bottom' => '0px']]]), +], '10px'); + +$chip = static fn (string $id, string $inhalt) => $text($id, $inhalt, [ + 'desktop' => ['fontSize' => '15px', 'fontWeight' => '600', 'lineHeight' => 1.3, 'color' => '@tinte', 'backgroundColor' => '@chip', 'borderRadius' => $rund('999px'), 'padding' => ['top' => '10px', 'right' => '18px', 'bottom' => '10px', 'left' => '18px'], 'margin' => ['bottom' => '0px']], +], "transition:background-color .2s ease,color .2s ease;\n&:hover{background-color:#2f6b22;color:#fff}"); + +$schritt = static fn (int $n, string $titel, string $satz, string $farbe = '@gruen') => $column('schritt-'.$n, [ + $text('schritt-'.$n.'-nummer', sprintf('%02d', $n), [ + 'desktop' => ['display' => 'flex', 'alignItems' => 'center', 'justifyContent' => 'center', 'width' => '72px', 'height' => '72px', 'margin' => ['top' => '0px', 'right' => 'auto', 'bottom' => '24px', 'left' => 'auto'], + 'borderRadius' => $rund('50%'), 'backgroundColor' => $farbe, 'color' => '@papier', 'fontFamily' => 'heading', 'fontSize' => '28px', 'fontWeight' => '800', 'lineHeight' => 1.0], + ], 'position:relative;z-index:1;box-shadow:0 0 0 10px #f3f8ef;transition:transform .3s cubic-bezier(.2,.7,.2,1)'), + $heading('schritt-'.$n.'-titel', $titel, 3, $h3Stil('26px')), + $text('schritt-'.$n.'-satz', $satz, ['desktop' => ['maxWidth' => '260px', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '0px', 'right' => 'auto', 'bottom' => '0px', 'left' => 'auto']]]), +], ['desktop' => ['textAlign' => 'center']], '&:hover [id="schritt-'.$n.'-nummer"]{transform:scale(1.08)}'); + +$geraet = static fn (string $id, string $titel, string $satz) => $text($id, ''.$titel.''.$satz, [ + 'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '@text', 'backgroundColor' => '@mint', 'borderRadius' => $rund('12px'), 'padding' => $seiten('18px'), 'margin' => ['bottom' => '0px']], +], "& strong{display:block;font-size:16px;color:#17221a;hyphens:auto;overflow-wrap:break-word}"); + +$kennzahl = static fn (string $id, string $zahl, string $satz, bool $linie) => $column($id, [ + $text($id.'-zahl', $zahl, ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '52px', 'fontWeight' => '800', 'lineHeight' => 1.0, 'color' => '@gruen', 'margin' => ['bottom' => '6px']], 'mobile' => ['fontSize' => '40px']]), + $text($id.'-satz', $satz, ['desktop' => ['fontSize' => '15px', 'lineHeight' => 1.4, 'color' => '@text', 'margin' => ['bottom' => '0px']]]), +], $linie ? ['desktop' => ['borderWidth' => $nurLinks('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['left' => '24px']], 'mobile' => ['padding' => ['left' => '14px']]] : []); + +$stelle = static fn (string $id, string $titel, string $satz, string $betreff) => $karte($id, [ + $text($id.'-text', ''.$titel.'
'.$satz, [ + 'desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '#cdebbe', 'margin' => ['bottom' => '0px']], + ], '& strong{font-size:18px;color:#fff}'), + $symbol($id.'-pfeil', 'arrow-right', 20, ['desktop' => ['color' => '@papier', 'flexShrink' => 0]]), +], '16px', [ + 'justifyContent' => 'space-between', 'backgroundColor' => '#ffffff1f', 'borderWidth' => $seiten('1px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff33', 'borderRadius' => $rund('14px'), + 'padding' => ['top' => '18px', 'right' => '22px', 'bottom' => '18px', 'left' => '22px'], +], [], [], $ganzeKarte."\ntransition:background-color .2s ease;\n&:hover{background-color:rgba(255,255,255,.2)}\n& .block-icon{transition:transform .25s ease}\n&:hover .block-icon{transform:translateX(4px)}"); + +$kontaktKarte = static fn (string $id, string $icon, string $klein, string $wert, ?string $href) => $karte($id, [ + $kreis($id.'-kreis', $icon), + $text($id.'-text', $klein.'
'.(null === $href ? $wert : ''.$wert.'').'', [ + 'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.5, 'color' => '#6a766c', 'minWidth' => '0', 'margin' => ['bottom' => '0px']], + ], '& strong{display:block;font-size:18px;color:#17221a;overflow-wrap:anywhere}'), +], '16px', [ + 'backgroundColor' => '@papier', 'borderRadius' => $rund('14px'), 'padding' => ['top' => '16px', 'right' => '18px', 'bottom' => '16px', 'left' => '18px'], + 'shadowY' => '6px', 'shadowBlur' => '18px', 'shadowColor' => '#14201a0d', +], [], [], null === $href ? '' : $ganzeKarte."\ntransition:transform .2s ease,box-shadow .2s ease;\n&:hover{transform:translateY(-2px);box-shadow:0 14px 30px rgba(20,32,26,.1)}"); + +$instaBild = static fn (string $id, array $foto, string $was) => $bild($id, $foto, $was.' – Beiträge auf Instagram ansehen', ['desktop' => ['margin' => $seiten('0px'), 'borderRadius' => $rund('12px'), 'overflow' => 'hidden']], $instagram, + "& img{width:100%;aspect-ratio:1;object-fit:cover;border-radius:0;transition:transform .5s cubic-bezier(.2,.7,.2,1)}\n&:hover img{transform:scale(1.05)}\n& .image-link:focus-visible{outline:3px solid #2f6b22;outline-offset:-6px}"); + +// Formular im Zuschnitt der Vorlage: volle Breite statt der 640 px des Kerns, Name und Telefon +// nebeneinander, Felder hellgrün auf weiß. +$formularCss = '& .block-form{max-width:none;grid-template-columns:repeat(2,minmax(0,1fr));column-gap:20px;row-gap:20px} +& .block-form>.form-field{grid-column:1/-1} +& .block-form>.cms-button{grid-column:1/-1} +& .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>[data-label="Name"]{grid-column:span 1} +& .block-form>[data-label="Telefon"]{grid-column:span 1} +& .form-field{align-content:start} +& .form-field label{font-size:14px;font-weight:700;letter-spacing:0.02em;color:#17221a} +& .form-field input{background:#f8fbf6;border:1px solid #cfe0c5;border-radius:10px;padding:14px 16px;font-size:16px;color:#17221a} +& .form-field select{background:#f8fbf6;border:1px solid #cfe0c5;border-radius:10px;padding:14px 16px;font-size:16px;color:#17221a} +& .form-field textarea{background:#f8fbf6;border:1px solid #cfe0c5;border-radius:10px;padding:14px 16px;font-size:16px;color:#17221a;min-height:140px} +& .form-field input:focus{border-color:#2f6b22} +& .form-field textarea:focus{border-color:#2f6b22} +& .field-note{font-size:13px;color:#55635a;opacity:1} +& .check-row label{font-weight:400;font-size:14px;line-height:1.6;color:#55635a} +& .check-row input{accent-color:#2f6b22} +& .cms-button{width:100%;justify-content:center;min-height:58px;transition:transform .2s ease,box-shadow .2s ease,background-color .2s ease} +& .cms-button:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(0,0,0,.18)} +& .cms-button .button-arrow{display:none}'; + +$abschnitt = static fn (string $farbe = '', array $extra = []) => [ + 'desktop' => [...('' === $farbe ? [] : ['backgroundColor' => $farbe]), 'padding' => ['top' => '128px', 'bottom' => '128px'], ...($extra['desktop'] ?? [])], + 'tablet' => ['padding' => ['top' => '80px', 'bottom' => '80px'], ...($extra['tablet'] ?? [])], + 'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px'], ...($extra['mobile'] ?? [])], +]; + +$startseite = [ + 'schemaVersion' => $SCHEMA, + 'title' => 'Gärten auf Premium-Niveau', + 'description' => 'Garten- und Landschaftsbau auf Premium-Niveau aus Iffezheim: Terrassen, Pools, Pflaster, Natursteine und komplette Außenanlagen – geplant, gebaut und übergeben aus einer Hand.', + 'blocks' => [ + // 1 – Hero: Foto über die ganze Fläche, Verlauf von links und von unten, Text unten links, + // rechts die Karte „Direkter Draht“, darunter das Band mit vier Vorteilen. + $section('hero', [ + $zeile('hero-zeile', [ + $column('hero-text', [ + $heading('hero-titel', 'Gärten auf
Premium-Niveau.', 1, [ + 'desktop' => ['fontSize' => '104px', 'fontWeight' => '800', 'lineHeight' => 0.92, 'letterSpacing' => '-0.01em', 'textTransform' => 'uppercase', 'color' => '@papier', 'margin' => ['bottom' => '0px']], + 'tablet' => ['fontSize' => '72px'], + 'mobile' => ['fontSize' => '50px'], + ], $em), + $text('hero-intro', 'Terrassen, Pools, Pflaster, Natursteine und komplette Außenanlagen – geplant, gebaut und übergeben aus einer Hand. Mit 100 % Detailarbeit.', [ + 'desktop' => ['maxWidth' => '580px', 'fontSize' => '21px', 'lineHeight' => 1.55, 'color' => '#e3ebe0', 'margin' => ['top' => '28px', 'bottom' => '40px']], + 'mobile' => ['fontSize' => '18px', 'margin' => ['top' => '22px', 'bottom' => '30px']], + ]), + $zeile('hero-knoepfe', [ + $knopf('hero-beratung', 'Kostenlose Beratung', '#kontakt', 'rot', true, ['mobile' => ['width' => '100%', 'justifyContent' => 'center']]), + $knopf('hero-referenzen', 'Referenzen ansehen', '#projekte', 'glas', false, ['mobile' => ['width' => '100%', 'justifyContent' => 'center']]), + ], '14px', ['flexWrap' => 'wrap']), + ], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '560px', 'maxWidth' => '760px'], 'mobile' => ['flexBasis' => 'auto']]), + $column('hero-karte', [ + $text('hero-karte-klein', 'Direkter Draht', ['desktop' => ['fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '0.12em', 'textTransform' => 'uppercase', 'color' => '@gruen', 'margin' => ['bottom' => '8px']]]), + $text('hero-karte-titel', 'Vor-Ort-Termin vereinbaren', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '30px', 'fontWeight' => '800', 'lineHeight' => 1.05, 'textTransform' => 'uppercase', 'color' => '@tinte', 'margin' => ['bottom' => '18px']]]), + $zeile('hero-karte-telefon', [ + $kreis('hero-karte-kreis', 'phone', '44px'), + $text('hero-karte-nummer', 'Rufen Sie uns an
'.$telefon.'', ['desktop' => ['fontSize' => '13px', 'lineHeight' => 1.45, 'color' => '@text', 'margin' => ['bottom' => '0px']]], '& strong{display:block;font-size:20px;color:#17221a}'), + ], '12px', [], [], [], $ganzeKarte), + $text('hero-karte-hinweis', 'Mo–Fr 8–17 Uhr · Sa 8–12 Uhr', ['desktop' => ['fontSize' => '14px', 'lineHeight' => 1.5, 'color' => '@text', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e3ecdd', 'padding' => ['top' => '14px'], 'margin' => ['top' => '14px', 'bottom' => '0px']]], '& strong{color:#17221a}'), + ], ['desktop' => ['flexBasis' => '300px', 'flexShrink' => 0, 'backgroundColor' => '#fffffff2', 'borderRadius' => $rund('16px'), 'padding' => $seiten('24px'), 'shadowY' => '24px', 'shadowBlur' => '60px', 'shadowColor' => '#0000004d']], '', 'desktop'), + ], '40px', ['justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'flexWrap' => 'wrap', 'padding' => ['bottom' => '72px']], ['padding' => ['bottom' => '36px']]), + // Das Band reicht über die ganze Breite; der Abschnitt schneidet ab, was übersteht. + $column('hero-vorteile', [ + $column('hero-vorteile-raster', [ + $vorteil(1, $vorteilIcon(1, 'home'), 'Alles aus einer Hand', 'Planung bis Übergabe'), + $vorteil(2, $vorteilIcon(2, 'star'), '100 % Detailarbeit', 'Saubere Ausführung'), + $vorteil(3, $lkw, 'Eigener Fuhrpark', 'LKW, Bagger & Maschinen'), + $vorteil(4, $vorteilIcon(4, 'pin'), 'Regional vor Ort', 'Iffezheim, Rastatt, Baden-Baden'), + ], [ + 'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '0px', 'maxWidth' => '1280px', 'margin' => ['left' => 'auto', 'right' => 'auto']], + 'tablet' => ['gridColumns' => 2], + 'mobile' => ['gridColumns' => 2], + ]), + ], ['desktop' => ['backgroundColor' => '#0c140f8c', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#ffffff24']], + 'margin-left:calc(50% - 50vw);margin-right:calc(50% - 50vw);backdrop-filter:blur(6px)'), + ], [ + 'desktop' => ['position' => 'relative', 'overflow' => 'hidden', 'display' => 'flex', 'flexDirection' => 'column', 'justifyContent' => 'flex-end', 'minHeight' => '800px', 'backgroundColor' => '#2a372c', + 'backgroundImage' => $medien['hero']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', + 'gradientFrom' => '#0c140fdb', 'gradientTo' => '#0c140f1a', 'gradientAngle' => 90, + 'padding' => ['top' => '160px', 'bottom' => '0px']], + 'tablet' => ['minHeight' => '680px', 'padding' => ['top' => '120px', 'bottom' => '0px']], + // Auf dem Telefon steht der Text über der ganzen Breite: dort deckt der Verlauf überall. + 'mobile' => ['minHeight' => '0px', 'gradientFrom' => '#0c140fd9', 'gradientTo' => '#0c140f8c', 'padding' => ['top' => '64px', 'bottom' => '0px']], + ], null, "&::before{content:'';position:absolute;top:0;right:0;bottom:0;left:0;background:linear-gradient(0deg,rgba(12,20,15,.9) 0%,rgba(12,20,15,0) 40%);pointer-events:none}\n& .section-inner{position:relative}"), + + // 2 – Einleitung: Text links, Collage rechts. + $section('willkommen', [ + $row('willkommen-zeile', [ + $column('willkommen-text', [ + $kleinzeile('willkommen-label', 'Willkommen bei Hüfner'), + $heading('willkommen-titel', 'Wir bauen Außenanlagen, die bleiben.', 2, $h2Stil), + $text('willkommen-lead', 'Ob neue Terrasse, Pool oder die komplette Außenanlage am Neubau: Wir begleiten Ihr Projekt vom ersten Gespräch bis zur Übergabe – mit eigenem Team, eigenem Fuhrpark und hochwertigen Materialien.', [ + ...$fliess('20px', '@text-stark', '18px', 1.65), 'mobile' => ['fontSize' => '18px'], + ]), + $text('willkommen-satz', 'Klare Linien, saubere Fugen, stimmige Übergänge. Darauf legen wir Wert – bei jedem Projekt, ob privat oder öffentlich.', $fliess('17px', '@text', '36px')), + $column('willkommen-haken', [ + $haken('haken-1', 'Persönliche Beratung'), + $haken('haken-2', 'Transparentes Angebot'), + $haken('haken-3', 'Geschultes Fachpersonal'), + $haken('haken-4', 'Saubere Baustelle'), + ], ['desktop' => ['display' => 'grid', 'gridColumns' => 2, 'rowGap' => '14px', 'columnGap' => '24px'], 'mobile' => ['gridColumns' => 1]]), + ]), + $column('willkommen-collage', [ + $bild('collage-pool', $medien['pool-stufen'], 'Pool mit Sitzstufen und Keramikumrandung', [ + 'desktop' => ['position' => 'absolute', 'top' => '0px', 'right' => '0px', 'width' => '78%', 'height' => '78%', 'margin' => $seiten('0px'), 'overflow' => 'hidden', 'borderRadius' => $rund('18px'), 'backgroundColor' => '#2a372c', + 'shadowY' => '30px', 'shadowBlur' => '60px', 'shadowColor' => '#14201a2e'], + ], null, "& img{width:100%;height:100%;object-fit:cover;border-radius:0;transition:transform .7s cubic-bezier(.2,.7,.2,1)}\n&:hover img{transform:scale(1.04)}"), + $bild('collage-weg', $medien['plattenweg'], 'Plattenweg im Rasen entlang eines Neubaus', [ + 'desktop' => ['position' => 'absolute', 'left' => '0px', 'bottom' => '0px', 'width' => '52%', 'height' => '56%', 'margin' => $seiten('0px'), 'overflow' => 'hidden', 'borderRadius' => $rund('18px'), 'zIndex' => 1, + 'borderWidth' => $seiten('8px'), 'borderStyle' => 'solid', 'borderColor' => '@mint', 'backgroundColor' => '#2a372c', 'shadowY' => '30px', 'shadowBlur' => '60px', 'shadowColor' => '#14201a33'], + 'mobile' => ['borderWidth' => $seiten('6px')], + ], null, "& img{width:100%;height:100%;object-fit:cover;border-radius:0;transition:transform .7s cubic-bezier(.2,.7,.2,1)}\n&:hover img{transform:scale(1.04)}"), + $column('collage-abzeichen', [ + $text('collage-abzeichen-zahl', '100 %', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '48px', 'fontWeight' => '800', 'lineHeight' => 1.0, 'color' => '@papier', 'margin' => ['bottom' => '2px']], 'mobile' => ['fontSize' => '38px']]), + $text('collage-abzeichen-text', 'Detailarbeit', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '600', 'color' => '#dcefd3', 'margin' => ['bottom' => '0px']]]), + ], [ + 'desktop' => ['position' => 'absolute', 'right' => '24px', 'bottom' => '36px', 'zIndex' => 2, 'backgroundColor' => '@gruen', 'borderRadius' => $rund('16px'), 'padding' => ['top' => '20px', 'right' => '24px', 'bottom' => '20px', 'left' => '24px'], + 'shadowY' => '20px', 'shadowBlur' => '40px', 'shadowColor' => '#14201a40'], + 'mobile' => ['right' => '0px', 'bottom' => '20px', 'padding' => ['top' => '14px', 'right' => '18px', 'bottom' => '14px', 'left' => '18px']], + ]), + ], ['desktop' => ['position' => 'relative', 'height' => '600px'], 'tablet' => ['height' => '520px'], 'mobile' => ['height' => '420px']]), + ], $zweiSpalten, [ + 'desktop' => ['gap' => '96px', 'alignItems' => 'center', 'margin' => ['top' => '0px']], + 'tablet' => ['gap' => '48px'], + ]), + ], $abschnitt()), + + // 3 – Leistungen: Kopf mit Satz rechts, links die große Pool-Kachel, rechts vier kleine, + // darunter weitere Leistungen als Chips. + $section('leistungen-bereich', [ + $row('leistungen-kopf', [ + $column('leistungen-kopf-titel', [ + $kleinzeile('leistungen-label', 'Leistungen'), + $heading('leistungen-titel', 'Alles für Ihren Außenbereich', 2, ['desktop' => [...$h2Stil['desktop'], 'margin' => ['top' => '18px', 'bottom' => '0px']], 'tablet' => $h2Stil['tablet'], 'mobile' => $h2Stil['mobile']]), + ], ['desktop' => ['maxWidth' => '680px']]), + $text('leistungen-kopf-text', 'Vom Erdbau über Pflaster- und Natursteinarbeiten bis zur fertigen Gartenanlage – alles aus einer Hand.', [ + 'desktop' => ['maxWidth' => '420px', 'fontSize' => '18px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['bottom' => '0px']], + ]), + ], [], [ + 'desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'rowGap' => '24px', 'columnGap' => '64px', 'margin' => ['top' => '0px', 'bottom' => '56px']], + 'mobile' => ['margin' => ['bottom' => '36px']], + ]), + $row('leistungen-raster', [ + $kachel('leistung-pool', $medien['pool-fachwerk'], 'Pools & Schwimmbecken – Beratung anfragen', [ + $kachelTitel('leistung-pool-titel', 'Pools & Schwimmbecken', '44px'), + $kachelSatz('leistung-pool-satz', 'Poolanlagen inklusive Umrandung, Technik und stimmiger Gestaltung rundherum – vom Aushub bis zur fertigen Terrasse.', '17px', '18px', '460px'), + $zeile('leistung-pool-mehr', [ + $text('leistung-pool-mehr-text', 'Beratung anfragen', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '700', 'letterSpacing' => '0.06em', 'textTransform' => 'uppercase', 'color' => '@papier', 'margin' => ['bottom' => '0px']]]), + $symbol('leistung-pool-mehr-pfeil', 'arrow-right', 16, ['desktop' => ['color' => '@papier']]), + ], '8px'), + ], '36px', '#kontakt', ['desktop' => ['minHeight' => '620px'], 'tablet' => ['minHeight' => '380px'], 'mobile' => ['minHeight' => '440px', 'padding' => $seiten('24px')]], '', true), + $column('leistungen-klein', [ + $kachel('leistung-terrasse', $medien['terrasse'], 'Terrassen – Beratung anfragen', [ + $kachelTitel('leistung-terrasse-titel', 'Terrassen'), + $kachelSatz('leistung-terrasse-satz', 'Keramik, Naturstein, Holz oder WPC.'), + ], '24px', '#kontakt'), + $kachel('leistung-pflaster', $medien['pflaster-verlegt'], 'Pflaster – Beratung anfragen', [ + $kachelTitel('leistung-pflaster-titel', 'Pflaster'), + $kachelSatz('leistung-pflaster-satz', 'Einfahrten, Wege und Plätze.'), + ], '24px', '#kontakt'), + $kachel('leistung-naturstein', $medien['naturstein'], 'Natursteine – Beratung anfragen', [ + $kachelTitel('leistung-naturstein-titel', 'Natursteine'), + $kachelSatz('leistung-naturstein-satz', 'Mauern, Stufen, Einfassungen.'), + ], '24px', '#kontakt'), + $kachel('leistung-aussen', $medien['rasen'], 'Außenanlagen – Beratung anfragen', [ + $kachelTitel('leistung-aussen-titel', 'Außenanlagen'), + $kachelSatz('leistung-aussen-satz', 'Rasen, Bepflanzung, Gesamtanlage.'), + ], '24px', '#kontakt'), + ], ['desktop' => ['display' => 'grid', 'gridColumns' => 2, 'gap' => '20px'], 'mobile' => ['gridColumns' => 1]], 'grid-auto-rows:300px'), + ], $zweiSpalten, ['desktop' => ['gap' => '20px', 'margin' => ['top' => '0px']]]), + $zeile('leistungen-chips', [ + $text('leistungen-chips-label', 'Außerdem', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '16px', 'fontWeight' => '700', 'letterSpacing' => '0.14em', 'textTransform' => 'uppercase', 'color' => '@text', 'margin' => ['right' => '8px', 'bottom' => '0px']]]), + $chip('chip-erdbau', 'Erdbau'), + $chip('chip-rollrasen', 'Rollrasen'), + $chip('chip-sichtschutz', 'Sichtschutz & Zäune'), + $chip('chip-gabionen', 'Gabionen'), + $chip('chip-bewaesserung', 'Bewässerung'), + $chip('chip-entwaesserung', 'Entwässerung & Rinnen'), + $chip('chip-spielplaetze', 'Spielplätze'), + ], '12px', ['flexWrap' => 'wrap', 'margin' => ['top' => '40px']]), + ], $abschnitt('@papier'), 'leistungen', $kachelFlaeche), + + // 4 – Referenzen auf Nachtgrün. + $section('referenzen-bereich', [ + $row('referenzen-kopf', [ + $column('referenzen-kopf-titel', [ + $kleinzeile('referenzen-label', 'Referenzen', '@hellgruen'), + $heading('referenzen-titel', 'Der Hüfner-Look', 2, ['desktop' => [...$h2Stil['desktop'], 'color' => '@papier', 'margin' => ['top' => '18px', 'bottom' => '0px']], 'tablet' => $h2Stil['tablet'], 'mobile' => $h2Stil['mobile']]), + ]), + $kleinerKnopf($knopf('referenzen-instagram', 'Alle Projekte auf Instagram', $instagram, 'linie', true, [])), + ], [], [ + 'desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'gap' => '24px', 'margin' => ['top' => '0px', 'bottom' => '56px']], + 'mobile' => ['margin' => ['bottom' => '36px']], + ]), + $row('referenzen-raster', [ + $kachel('referenz-garten', $medien['pool-garten'], 'Pool mit Hortensien und Gartenanlage', [ + $kachelKlein('referenz-garten-klein', 'Pool · Garten · Terrasse', '14px'), + $kachelTitel('referenz-garten-titel', 'Poolanlage mit Gartengestaltung', '36px'), + ], '32px', null, ['desktop' => ['minHeight' => '620px'], 'tablet' => ['minHeight' => '380px'], 'mobile' => ['minHeight' => '360px', 'padding' => $seiten('24px')]], '45%'), + $column('referenzen-klein', [ + $kachel('referenz-terrasse', $medien['pool-terrasse'], 'Pool mit Natursteinterrasse und Gabionen', [ + $kachelKlein('referenz-terrasse-klein', 'Pool · Naturstein'), + $kachelTitel('referenz-terrasse-titel', 'Pool & Terrasse', '26px'), + ], '24px', null, [], '55%'), + $kachel('referenz-zaun', $medien['sichtschutz'], 'Moderner Sichtschutz aus Aluminium', [ + $kachelKlein('referenz-zaun-klein', 'Zaun'), + $kachelTitel('referenz-zaun-titel', 'Sichtschutz', '26px'), + ], '24px', null, [], '55%'), + $kachel('referenz-hang', $medien['pflaster'], 'Hanggarten mit Natursteinmauer und Treppe', [ + $kachelKlein('referenz-hang-klein', 'Hanggarten'), + $kachelTitel('referenz-hang-titel', 'Mauer & Treppe', '26px'), + ], '24px', null, [], '55%'), + ], ['desktop' => ['display' => 'grid', 'gridColumns' => 2, 'gap' => '20px'], 'mobile' => ['gridColumns' => 1]], "grid-auto-rows:300px\n& [id=\"referenz-terrasse\"]{grid-column:1/-1}"), + ], $zweiSpalten, ['desktop' => ['gap' => '20px', 'margin' => ['top' => '0px']]]), + $karte('referenzen-kommunen', [ + $text('referenzen-kommunen-titel', 'Auch für Kommunen', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '22px', 'fontWeight' => '800', 'letterSpacing' => '0.04em', 'textTransform' => 'uppercase', 'color' => '@hellgruen', 'margin' => ['bottom' => '0px']]]), + $text('referenzen-kommunen-text', 'Zum Beispiel der Spielplatz an der Pagodenburg in Rastatt oder frischer Spielsand für die Spielplätze der Stadt Karlsruhe.', ['desktop' => ['flexGrow' => 1, 'flexBasis' => '300px', 'fontSize' => '16px', 'lineHeight' => 1.6, 'color' => '#c9d6c5', 'margin' => ['bottom' => '0px']], 'mobile' => ['flexBasis' => 'auto']]), + ], '40px', ['flexWrap' => 'wrap', 'rowGap' => '16px', 'margin' => ['top' => '32px'], 'padding' => ['top' => '28px', 'right' => '32px', 'bottom' => '28px', 'left' => '32px']], + ['padding' => $seiten('24px')], [], 'background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12);border-radius:18px'), + ], $abschnitt('@nacht', ['desktop' => ['color' => '@papier']]), 'projekte', $kachelFlaeche), + + // 5 – Ablauf in vier Schritten mit gestrichelter Linie hinter den Nummern. + $section('ablauf-bereich', [ + $column('ablauf-kopf', [ + $kleinzeile('ablauf-label', 'So arbeiten wir', '@rot', true), + $heading('ablauf-titel', 'In vier Schritten zum Gartentraum', 2, ['desktop' => [...$h2Stil['desktop'], 'textAlign' => 'center', 'margin' => ['top' => '18px', 'bottom' => '0px']], 'tablet' => $h2Stil['tablet'], 'mobile' => $h2Stil['mobile']]), + ], ['desktop' => ['maxWidth' => '760px', 'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '72px']], 'mobile' => ['margin' => ['bottom' => '48px']]]), + $column('ablauf-schritte', [ + $flaeche('ablauf-linie', ['desktop' => ['position' => 'absolute', 'left' => '12.5%', 'right' => '12.5%', 'top' => '36px', 'height' => '2px', 'margin' => $seiten('0px')]], + "min-height:0;background:repeating-linear-gradient(90deg,#9dc38c 0 10px,transparent 10px 18px)", 'desktop'), + $column('ablauf-raster', [ + $schritt(1, 'Beratung vor Ort', 'Wir schauen uns Ihr Grundstück an und hören zu, was Sie sich wünschen.'), + $schritt(2, 'Planung & Angebot', 'Sie erhalten ein klares Konzept und ein transparentes Angebot.'), + $schritt(3, 'Ausführung', 'Unser Team baut termintreu, sauber und mit Liebe zum Detail.'), + $schritt(4, 'Übergabe', 'Gemeinsame Abnahme Ihres fertigen Außenbereichs.', '@rot'), + ], [ + 'desktop' => ['display' => 'grid', 'gridColumns' => 4, 'gap' => '32px'], + 'tablet' => ['gridColumns' => 2, 'rowGap' => '48px'], + 'mobile' => ['gridColumns' => 1, 'rowGap' => '44px'], + ]), + ], ['desktop' => ['position' => 'relative']]), + ], $abschnitt(), 'ablauf'), + + // 6 – Fuhrpark: weiße Karte mit Baustellenfoto und vier Geräten. + $section('fuhrpark-bereich', [ + $column('fuhrpark-karte', [ + $row('fuhrpark-zeile', [ + $column('fuhrpark-foto-spalte', [ + $bild('fuhrpark-foto', $medien['baustelle'], 'Poolbau im Rohbau mit Minibagger und Radlader im Hintergrund', ['desktop' => ['height' => '100%', 'margin' => $seiten('0px')]], null, + '& img{width:100%;height:100%;min-height:360px;object-fit:cover;border-radius:0}'), + ], ['desktop' => ['backgroundColor' => '#2a372c']]), + $column('fuhrpark-text', [ + $kleinzeile('fuhrpark-label', 'Fuhrpark & Maschinen'), + $heading('fuhrpark-titel', 'Eigenes Gerät. Kurze Wege.', 2, ['desktop' => [...$h2Stil['desktop'], 'fontSize' => '52px', 'margin' => ['top' => '18px', 'bottom' => '20px']], 'tablet' => ['fontSize' => '44px'], 'mobile' => $h2Stil['mobile']]), + $text('fuhrpark-satz', 'Weil wir mit eigenem Fuhrpark arbeiten, sind wir unabhängig, flexibel und schnell auf der Baustelle – auch bei großen Flächen.', $fliess('17px', '@text', '28px', 1.65)), + $column('fuhrpark-geraete', [ + $geraet('geraet-lkw', 'LKW & Kipper', 'für Material & Aushub'), + $geraet('geraet-bagger', 'Bagger bis 15 t', 'inkl. Tieflader'), + $geraet('geraet-pflaster', 'Pflasterverlegemaschine', 'für große Flächen'), + $geraet('geraet-container', 'Abrollcontainer', 'für Entsorgung'), + ], ['desktop' => ['display' => 'grid', 'gridColumns' => 2, 'gap' => '12px']]), + ], ['desktop' => ['padding' => $seiten('56px')], 'mobile' => ['padding' => ['top' => '32px', 'right' => '24px', 'bottom' => '32px', 'left' => '24px']]]), + ], $zweiSpalten, ['desktop' => ['gap' => '0px', 'margin' => ['top' => '0px'], 'columnWidths' => '11 10', 'alignItems' => 'stretch']]), + ], ['desktop' => ['backgroundColor' => '@papier', 'borderRadius' => $rund('24px'), 'overflow' => 'hidden', 'shadowY' => '30px', 'shadowBlur' => '60px', 'shadowColor' => '#14201a14']]), + ], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '128px']], 'tablet' => ['padding' => ['top' => '0px', 'bottom' => '80px']], 'mobile' => ['padding' => ['top' => '0px', 'bottom' => '64px']]]), + + // 7 – Über uns. + $section('ueber-bereich', [ + $row('ueber-zeile', [ + $column('ueber-foto-spalte', [ + $bild('ueber-foto', $medien['team'], 'Das Hüfner-Team in Arbeitsjacken mit Firmenlogo', [ + 'desktop' => ['margin' => $seiten('0px'), 'overflow' => 'hidden', 'borderRadius' => $rund('18px'), 'backgroundColor' => '#2a372c', 'imageHeight' => '560px'], + 'mobile' => ['imageHeight' => '320px'], + ], null, '& img{width:100%;object-fit:cover;border-radius:0}'), + $text('ueber-abzeichen', 'Miteinander. 100 %.', [ + 'desktop' => ['position' => 'absolute', 'left' => '-1px', 'bottom' => '32px', 'backgroundColor' => '@rot', 'color' => '@papier', 'fontFamily' => 'heading', 'fontSize' => '22px', 'fontWeight' => '800', 'letterSpacing' => '0.06em', 'textTransform' => 'uppercase', 'lineHeight' => 1.2, + 'padding' => ['top' => '16px', 'right' => '24px', 'bottom' => '16px', 'left' => '24px'], 'borderRadius' => ['topLeft' => '0px', 'topRight' => '12px', 'bottomRight' => '12px', 'bottomLeft' => '0px'], 'margin' => ['bottom' => '0px']], + 'mobile' => ['fontSize' => '18px', 'bottom' => '20px'], + ]), + ], ['desktop' => ['position' => 'relative']], '', 'all'), + $column('ueber-text', [ + $kleinzeile('ueber-label', 'Über uns'), + $heading('ueber-titel', 'Handwerk mit Anspruch.', 2, $h2Stil), + $text('ueber-lead', 'Hinter Garten- und Landschaftsbau Hüfner steht Florian Hüfner mit seinem Team. Wir bauen Außenanlagen, die lange Freude machen – sauber geplant, präzise ausgeführt und bis ins Detail durchdacht.', [ + ...$fliess('20px', '@text-stark', '18px', 1.65), 'mobile' => ['fontSize' => '18px'], + ]), + $text('ueber-satz', 'Unsere Mitarbeiter bilden sich regelmäßig weiter – etwa in moderner Bewässerungstechnik oder im Einbau und der Verdichtung von Asphalt.', $fliess('17px', '@text', '18px')), + // Laut Handelsregister (North Data, 06.10.2026): bis April 2022 „Garten- und + // Landschaftsbau Florian Hüfner e. K.“, seither GmbH mit Sitz in Iffezheim. + $text('ueber-geschichte', 'Angefangen hat alles als Einzelunternehmen von Florian Hüfner. Seit 2022 sind wir die Garten- und Landschaftsbau Hüfner GmbH – mit Sitz in Iffezheim und Baustellen in der ganzen Region.', $fliess('17px', '@text', '36px')), + $column('ueber-zahlen', [ + $kennzahl('zahl-detail', '100 %', 'Detailarbeit', false), + $kennzahl('zahl-bagger', '15 t', 'Bagger im eigenen Fuhrpark', true), + $kennzahl('zahl-team', '1', 'Team von Planung bis Übergabe', true), + ], ['desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '0px', 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '28px']], 'mobile' => ['gridColumns' => 3]], 'column-gap:24px'), + ]), + ], $zweiSpalten, [ + 'desktop' => ['gap' => '96px', 'alignItems' => 'center', 'margin' => ['top' => '0px']], + 'tablet' => ['gap' => '48px'], + ]), + ], $abschnitt('@papier'), 'ueber-uns'), + + // 8 – Karriere: grüne Karte. + $section('karriere-bereich', [ + $karte('karriere-karte', [ + $column('karriere-text', [ + $text('karriere-klein', 'Karriere', ['desktop' => ['fontFamily' => 'heading', 'fontSize' => '17px', 'fontWeight' => '700', 'letterSpacing' => '0.2em', 'textTransform' => 'uppercase', 'color' => '#cdebbe', 'margin' => ['bottom' => '0px']]]), + $heading('karriere-titel', 'Werde Teil unseres Teams!', 2, ['desktop' => [...$h2Stil['desktop'], 'fontSize' => '52px', 'color' => '@papier', 'margin' => ['top' => '12px', 'bottom' => '14px']], 'tablet' => ['fontSize' => '44px'], 'mobile' => $h2Stil['mobile']]), + $text('karriere-satz', 'Du arbeitest gerne draußen, packst mit an und bist zuverlässig im Team? Dann melde dich bei uns – ein Anruf genügt.', $fliess('17px', '#e1f0da', '0px', 1.6)), + ], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '340px'], 'mobile' => ['flexBasis' => 'auto']]), + $column('karriere-stellen', [ + $stelle('stelle-helfer', 'Mitarbeiter & Helfer (m/w/d)', 'Vollzeit · Garten- und Landschaftsbau', 'Mitarbeiter und Helfer (m/w/d)'), + $stelle('stelle-lkw', 'LKW-Fahrer C/CE (m/w/d)', 'Vollzeit · Fuhrpark', 'LKW-Fahrer C/CE (m/w/d)'), + $knopf('karriere-anrufen', 'Jetzt anrufen: '.$telefon, $tel, 'weiss', false, ['desktop' => ['alignSelf' => 'flex-start', 'margin' => ['top' => '6px']], 'mobile' => ['fontSize' => '16px', 'width' => '100%', 'justifyContent' => 'center']]), + ], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '300px', 'display' => 'flex', 'flexDirection' => 'column', 'gap' => '12px'], 'mobile' => ['flexBasis' => 'auto']]), + ], '64px', [ + 'flexWrap' => 'wrap', 'rowGap' => '32px', 'justifyContent' => 'space-between', 'backgroundColor' => '@gruen', 'color' => '@papier', 'borderRadius' => $rund('24px'), + 'padding' => ['top' => '56px', 'right' => '64px', 'bottom' => '56px', 'left' => '64px'], + ], ['padding' => ['top' => '32px', 'right' => '24px', 'bottom' => '32px', 'left' => '24px']]), + ], ['desktop' => ['padding' => ['top' => '96px', 'bottom' => '96px']], 'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']]], 'karriere'), + + // 9 – Instagram. + $section('social-bereich', [ + $row('social-kopf', [ + $column('social-kopf-titel', [ + $kleinzeile('social-label', 'Social Media'), + $heading('social-titel', 'Live von der Baustelle', 2, ['desktop' => [...$h2Stil['desktop'], 'fontSize' => '48px', 'lineHeight' => 1.0, 'margin' => ['top' => '12px', 'bottom' => '0px']], 'mobile' => ['fontSize' => '36px']]), + ]), + $zeile('social-knoepfe', [ + ['id' => 'social-instagram', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => true, 'networks' => ['instagram']], + 'css' => "& a{min-height:50px;padding:0 24px;border-radius:999px;background:#17221a;color:#fff;font-family:var(--kf-heading);font-size:17px;font-weight:700;letter-spacing:0.06em}\n& a:hover{background:#2f6b22;box-shadow:0 10px 24px rgba(0,0,0,.18)}"], + ['id' => 'social-facebook', 'type' => 'social', 'props' => ['size' => 20, 'shape' => 'plain', 'layout' => 'horizontal', 'labels' => true, 'networks' => ['facebook']], + 'css' => "& a{min-height:50px;padding:0 24px;border-radius:999px;background:#1f4fa0;color:#fff;font-family:var(--kf-heading);font-size:17px;font-weight:700;letter-spacing:0.06em}\n& a:hover{background:#173d7d;box-shadow:0 10px 24px rgba(0,0,0,.18)}"], + ], '10px', ['flexWrap' => 'wrap']), + ], [], [ + 'desktop' => ['display' => 'flex', 'flexWrap' => 'wrap', 'justifyContent' => 'space-between', 'alignItems' => 'flex-end', 'gap' => '20px', 'margin' => ['top' => '0px', 'bottom' => '28px']], + ]), + $column('social-raster', [ + $instaBild('insta-1', $medien['pool-stufen'], 'Pool mit Sitzstufen'), + $instaBild('insta-2', $medien['naturstein'], 'Natursteinmauer mit Trittplatten'), + $instaBild('insta-3', $medien['pool-garten'], 'Pool mit Gartenanlage'), + $instaBild('insta-4', $medien['plattenweg'], 'Plattenweg im Rasen'), + $instaBild('insta-5', $medien['terrasse'], 'Keramikterrasse am Pool'), + $instaBild('insta-6', $medien['team'], 'Das Hüfner-Team'), + ], ['desktop' => ['display' => 'grid', 'gridColumns' => 6, 'gap' => '12px'], 'tablet' => ['gridColumns' => 3], 'mobile' => ['gridColumns' => 2]]), + ], ['desktop' => ['padding' => ['top' => '0px', 'bottom' => '128px']], 'tablet' => ['padding' => ['top' => '0px', 'bottom' => '80px']], 'mobile' => ['padding' => ['top' => '0px', 'bottom' => '64px']]]), + + // 10 – Abschlussband auf Foto. + $section('band', [ + $column('band-inhalt', [ + $heading('band-titel', 'Bereit für Ihren Gartentraum?', 2, [ + 'desktop' => ['fontSize' => '72px', 'fontWeight' => '800', 'lineHeight' => 0.95, 'letterSpacing' => '0px', 'textTransform' => 'uppercase', 'color' => '@papier', 'textAlign' => 'center', 'margin' => ['bottom' => '22px']], + 'tablet' => ['fontSize' => '56px'], + 'mobile' => ['fontSize' => '40px'], + ], $em), + $text('band-satz', 'Erzählen Sie uns von Ihrem Vorhaben. Wir melden uns und vereinbaren einen Termin bei Ihnen vor Ort.', [ + 'desktop' => ['maxWidth' => '560px', 'fontSize' => '19px', 'lineHeight' => 1.6, 'color' => '#dce6d8', 'textAlign' => 'center', 'margin' => ['left' => 'auto', 'right' => 'auto', 'bottom' => '36px']], + ]), + $zeile('band-knoepfe', [ + $knopf('band-anfragen', 'Projekt anfragen', '#kontakt', 'rot', false), + $knopf('band-anrufen', $telefon, $tel, 'weiss', false), + ], '14px', ['flexWrap' => 'wrap', 'justifyContent' => 'center']), + ], ['desktop' => ['maxWidth' => '820px', 'margin' => ['left' => 'auto', 'right' => 'auto']]]), + ], [ + 'desktop' => ['backgroundColor' => '#2a372c', 'backgroundImage' => $medien['pool-fachwerk']['id'], 'backgroundSize' => 'cover', 'backgroundPosition' => 'center', 'overlayColor' => '#0c140f', 'overlayOpacity' => 0.72, + 'color' => '@papier', 'padding' => ['top' => '120px', 'bottom' => '120px']], + 'mobile' => ['padding' => ['top' => '72px', 'bottom' => '72px']], + ]), + + // 11 – Kontakt mit Formular. + $section('kontakt-bereich', [ + $row('kontakt-zeile', [ + $column('kontakt-text', [ + $kleinzeile('kontakt-label', 'Kontakt'), + $heading('kontakt-titel', 'Ihr Projekt. Unser Handwerk.', 2, ['desktop' => [...$h2Stil['desktop'], 'margin' => ['top' => '18px', 'bottom' => '24px']], 'tablet' => $h2Stil['tablet'], 'mobile' => $h2Stil['mobile']]), + $text('kontakt-satz', 'Schreiben Sie uns oder rufen Sie direkt an – wir melden uns zeitnah und vereinbaren einen Termin bei Ihnen vor Ort.', $fliess('18px', '@text', '40px', 1.65)), + $column('kontakt-karten', [ + $kontaktKarte('kontakt-telefon', 'phone', 'Telefon', $telefon, $tel), + $kontaktKarte('kontakt-mail', 'mail', 'E-Mail', $mail, 'mailto:'.$mail), + $kontaktKarte('kontakt-adresse', 'pin', 'Adresse', $strasse.', '.$plz.' '.$ort, null), + $kontaktKarte('kontakt-zeiten', 'clock', 'Öffnungszeiten', $zeiten, null), + ], ['desktop' => ['display' => 'flex', 'flexDirection' => 'column', 'gap' => '14px']]), + ]), + $column('kontakt-formular', [ + $heading('kontakt-formular-titel', 'Anfrage senden', 3, $h3Stil('30px', '@tinte', '20px')), + ['id' => 'kontakt-formular-block', 'type' => 'form', 'props' => ['form' => 'anfrage'], 'css' => $formularCss], + $text('kontakt-formular-hinweis', 'Ihre Angaben verwenden wir nur für Ihre Anfrage. Mehr dazu in unseren Datenschutzhinweisen.', [ + 'desktop' => ['fontSize' => '13px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['top' => '16px', 'bottom' => '0px']], + ], $linkCss), + ], [ + 'desktop' => ['backgroundColor' => '@papier', 'borderRadius' => $rund('24px'), 'padding' => $seiten('48px'), 'shadowY' => '30px', 'shadowBlur' => '60px', 'shadowColor' => '#14201a14'], + 'mobile' => ['padding' => $seiten('24px')], + ]), + ], $zweiSpalten, [ + 'desktop' => ['gap' => '80px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'columnWidths' => '9 11'], + 'tablet' => ['gap' => '48px'], + ]), + ], $abschnitt(), 'kontakt'), + ], +]; + +// --------------------------------------------------------------------------------------------- +// Seiten anlegen und veröffentlichen +// --------------------------------------------------------------------------------------------- + +$anlegen = static function (string $slug, array $dokument, string $seoTitel = '', string $teilbild = '') use ($pages, $db): string { + $id = $pages->createPage($slug, null, 'page', $dokument); + if ('' !== $seoTitel) { + $db->executeStatement('UPDATE page SET seo_title = ? WHERE id = ?', [$seoTitel, $id]); + } + if ('' !== $teilbild) { + $db->executeStatement('UPDATE page SET og_media_id = ? WHERE id = ?', [$teilbild, $id]); + } + $seite = $db->fetchAssociative('SELECT draft_revision_id, version FROM page WHERE id = ?', [$id]); + $pages->publish($id, $seite['draft_revision_id'], (int) $seite['version']); + echo 'Seite angelegt: /'.$db->fetchOne('SELECT path FROM page WHERE id = ?', [$id])."\n"; + + return $id; +}; + +// Wie die Vorlage: der Hero-Text steigt beim Laden auf, das Foto nicht (LCP). Weiter unten +// erscheinen Köpfe und Raster beim Scrollen. +$startseite = $einblenden($startseite, [ + 'hero-titel' => ['rise', 'short'], + 'hero-intro' => ['rise', 'medium'], + 'hero-knoepfe' => ['rise', 'medium'], + 'hero-karte' => ['rise', 'medium'], + 'willkommen-text' => ['rise'], + 'willkommen-collage' => ['rise', 'short'], + 'leistungen-kopf' => ['rise'], + 'leistungen-raster' => ['rise', 'short'], + 'leistungen-chips' => ['fade', 'short'], + 'referenzen-kopf' => ['rise'], + 'referenzen-raster' => ['rise', 'short'], + 'ablauf-kopf' => ['rise'], + 'schritt-1' => ['rise'], + 'schritt-2' => ['rise', 'short'], + 'schritt-3' => ['rise', 'medium'], + 'schritt-4' => ['rise', 'long'], + 'fuhrpark-karte' => ['rise'], + 'ueber-foto-spalte' => ['rise'], + 'ueber-text' => ['rise', 'short'], + 'karriere-karte' => ['rise'], + 'social-kopf' => ['rise'], + 'social-raster' => ['fade', 'short'], + 'band-inhalt' => ['rise'], + 'kontakt-text' => ['rise'], + 'kontakt-formular' => ['rise', 'short'], +]); + +$homeId = $anlegen('home', $startseite, 'Garten- und Landschaftsbau Hüfner GmbH – Terrassen, Pools, Pflaster & Natursteine', $medien['pool-fachwerk']['id']); + +/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Nachtgrün, dann Absätze mit Zwischenüberschriften. */ +$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array { + $kopf = $section($praefix.'-kopf', [ + $kleinzeile($praefix.'-label', $label, '@hellgruen'), + $heading($praefix.'-h1', $titel, 1, [ + 'desktop' => ['fontSize' => '72px', 'fontWeight' => '800', 'lineHeight' => 0.95, 'letterSpacing' => '0px', 'textTransform' => 'uppercase', 'color' => '@papier', 'margin' => ['top' => '18px', 'bottom' => '20px']], + 'mobile' => ['fontSize' => '46px'], + ]), + $text($praefix.'-vorspann', $vorspann, [ + 'desktop' => ['maxWidth' => '640px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@auf-dunkel', 'margin' => ['bottom' => '0px']], + 'mobile' => ['fontSize' => '16px'], + ]), + ], [ + 'desktop' => ['backgroundColor' => '@nacht', 'color' => '@papier', 'padding' => ['top' => '88px', 'bottom' => '72px']], + 'mobile' => ['padding' => ['top' => '56px', 'bottom' => '48px']], + ]); + + $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' => '22px', 'fontWeight' => '800', 'lineHeight' => 1.2, 'letterSpacing' => '0.02em', 'textTransform' => 'uppercase', 'color' => '@tinte', 'margin' => ['bottom' => '0px']], + ]), + ], [ + 'desktop' => ['flexBasis' => '260px', 'flexShrink' => 0], + 'tablet' => ['flexBasis' => '200px'], + 'mobile' => ['flexBasis' => 'auto'], + ]), + $column($praefix.'-punkt-'.$i.'-text', [ + $text($praefix.'-p-'.$i, $inhalt, ['desktop' => ['fontSize' => '16px', 'lineHeight' => 1.75, 'color' => '@text', 'margin' => ['bottom' => '0px']]], $linkCss), + ], ['desktop' => ['flexGrow' => 1, 'flexBasis' => '0', 'minWidth' => '0'], 'mobile' => ['flexBasis' => 'auto']]), + ], [], [ + 'desktop' => ['display' => 'flex', 'alignItems' => 'flex-start', 'gap' => '44px', 'margin' => ['top' => '0px'], + 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '@linie', 'padding' => ['top' => '26px', 'bottom' => '26px']], + 'tablet' => ['gap' => '28px'], + 'mobile' => ['flexDirection' => 'column', 'gap' => '8px', 'padding' => ['top' => '20px', 'bottom' => '20px']], + ]); + } + + $inhalt = $section($praefix, [ + $column($praefix.'-inhalt', $punkte, ['desktop' => ['maxWidth' => '920px']]), + ], ['desktop' => ['padding' => ['top' => '48px', 'bottom' => '96px']], 'mobile' => ['padding' => ['top' => '24px', 'bottom' => '64px']]]); + + return [$kopf, $inhalt]; +}; + +$anlegen('impressum', [ + 'schemaVersion' => $SCHEMA, 'title' => 'Impressum', + 'description' => 'Anbieterangaben der Garten- und Landschaftsbau Hüfner GmbH, Iffezheim.', + 'blocks' => [ + ...$rechtsseite('impressum', 'Rechtliche Angaben', 'Impressum', 'Angaben gemäß § 5 DDG und § 18 Abs. 2 MStV.', [ + ['Anbieter', $firma.'
'.$strasse.'
'.$plz.' '.$ort], + // Von Umut am 06.10.2026: HRB 742159, Mannheim. Laut Netzwerkauszug ist heute Florian + // Hüfner mit der GmbH verbunden, Simone Hüfner nur früher. + ['Vertreten durch', 'Geschäftsführer: Florian Hüfner'], + ['Registereintrag', 'Eintragung im Handelsregister
Registergericht: Amtsgericht Mannheim
Registernummer: HRB 742159'], + ['Kontakt', 'Telefon: '.$telefon.'
E-Mail: '.$mail.''], + // Eine USt-IdNr. gehört nur hinein, wenn es eine gibt; weder Register noch North Data + // nennen eine (06.10.2026). Falls doch: hier als ['Umsatzsteuer', '… nach § 27a UStG: DE…']. + ['Verantwortlich für den Inhalt', 'Florian Hüfner, 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', 'Diese Website verweist auf unsere Profile bei Instagram und Facebook. Für deren Inhalte ist der jeweilige Anbieter verantwortlich.'], + ['Urheberrecht', 'Die von uns erstellten Inhalte, Fotos und Werke auf diesen Seiten unterliegen dem deutschen Urheberrecht. Vervielfältigung, Bearbeitung, Verbreitung und jede Art der Verwertung außerhalb der Grenzen des Urheberrechts bedürfen der schriftlichen Zustimmung.'], + ['Bildnachweis', 'Alle Fotos sind eigene Aufnahmen der '.$firma.'. Logo: '.$firma.'.'], + ]), + ], +], 'Impressum | '.$kurz); + +$anlegen('datenschutz', [ + 'schemaVersion' => $SCHEMA, 'title' => 'Datenschutz', + 'description' => 'Welche Daten beim Besuch der Website der Garten- und Landschaftsbau Hüfner GmbH und bei einer Anfrage verarbeitet werden.', + 'blocks' => [ + ...$rechtsseite('datenschutz', 'Ihre Daten', 'Datenschutz', 'Hier erfahren Sie, welche Daten beim Besuch dieser Website und bei einer Anfrage verarbeitet werden – und welche Rechte Sie haben.', [ + ['1. Verantwortlicher', $firma.', '.$strasse.', '.$plz.' '.$ort.'. Telefon '.$telefon.', E-Mail '.$mail.'.'], + ['2. Besuch dieser Website', 'Beim Aufruf verarbeitet der Hostingdienst technisch erforderliche Verbindungsdaten: IP-Adresse, Zeitpunkt, angeforderte Adresse, Browserangaben und HTTP-Status. Das dient der sicheren Bereitstellung und der Fehlersuche. Rechtsgrundlage ist unser berechtigtes Interesse an einem störungsfreien Betrieb, Art. 6 Abs. 1 lit. f DSGVO.

Unser Hostinganbieter ist die IONOS SE, Elgendorfer Straße 57, 56410 Montabaur. Mit IONOS besteht ein Vertrag über die Auftragsverarbeitung nach Art. 28 DSGVO. Die Serverprotokolle werden nach spätestens sieben Tagen gelöscht oder anonymisiert, sofern sie nicht zur Aufklärung eines Sicherheitsvorfalls länger gebraucht werden.'], + ['3. Cookies und externe Dienste', 'Diese Website setzt keine Analyse- oder Werbecookies. Bilder, Schriften und Gestaltung kommen vom eigenen Server; es werden keine Karten, Videos oder Schriften von Dritten nachgeladen. Deshalb gibt es auch keinen Einwilligungsdialog.'], + ['4. Anfrageformular', 'Wenn Sie uns über das Formular schreiben, verarbeiten wir Ihren Namen, Ihre E-Mail-Adresse, auf Wunsch Ihre Telefonnummer, die gewählte Leistung und Ihre Beschreibung, um Ihre Anfrage zu beantworten und ein Angebot vorzubereiten (Art. 6 Abs. 1 lit. b DSGVO). Die Anfrage wird in unserem System gespeichert und per E-Mail an uns weitergeleitet. Wir löschen sie, wenn sie erledigt ist und keine gesetzliche Aufbewahrungspflicht besteht.'], + ['5. Kontakt per E-Mail oder Telefon', 'Wenn Sie uns anrufen oder schreiben, verarbeiten wir Ihre Angaben, um Ihr Anliegen zu bearbeiten (Art. 6 Abs. 1 lit. b oder f DSGVO).'], + ['6. Instagram und Facebook', 'Auf dieser Seite stehen nur Links zu unseren Profilen. Erst wenn Sie einem Link folgen, gelangen Sie zum jeweiligen Anbieter (Meta Platforms Ireland Ltd.); dort gelten dessen Datenschutzhinweise.'], + ['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.'], + ['8. Beschwerderecht', 'Sie können sich bei einer Datenschutz-Aufsichtsbehörde beschweren, für uns beim Landesbeauftragten für den Datenschutz und die Informationsfreiheit Baden-Württemberg.'], + ['', 'Fragen zum Datenschutz beantworten wir gern unter '.$mail.'.'], + ]), + ], +], 'Datenschutz | '.$kurz); + +$fehlerId = $anlegen('404', [ + 'schemaVersion' => $SCHEMA, 'title' => 'Seite nicht gefunden', + 'description' => 'Die gesuchte Seite gibt es nicht oder sie wurde verschoben.', + 'blocks' => [ + $section('fehler', [ + $kleinzeile('fehler-label', 'Fehler 404'), + $heading('fehler-titel', 'Hier wächst noch nichts.', 1, [ + 'desktop' => ['fontSize' => '80px', 'fontWeight' => '800', 'lineHeight' => 0.95, 'letterSpacing' => '0px', 'textTransform' => 'uppercase', 'color' => '@tinte', 'margin' => ['top' => '18px', 'bottom' => '22px']], + 'mobile' => ['fontSize' => '48px'], + ], $emGruen), + $text('fehler-text', 'Diese Seite gibt es nicht oder sie wurde verschoben. Über die Startseite finden Sie zurück.', [ + 'desktop' => ['maxWidth' => '480px', 'fontSize' => '18px', 'lineHeight' => 1.7, 'color' => '@text', 'margin' => ['bottom' => '32px']], + ]), + $knopf('fehler-knopf', 'Zur Startseite', '/', 'rot', true), + ], [ + 'desktop' => ['padding' => ['top' => '120px', 'bottom' => '140px']], + 'mobile' => ['padding' => ['top' => '64px', 'bottom' => '80px']], + ]), + ], +]); +$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $fehlerId]); + +// --------------------------------------------------------------------------------------------- +// Menü, Einstellungen, Konto +// --------------------------------------------------------------------------------------------- + +$sprung = static fn (string $label, string $anchor): array => ['label' => $label, 'type' => 'page', 'pageId' => $homeId, 'anchor' => $anchor, 'children' => []]; +$menus->save('header', 'Hauptnavigation', [ + $sprung('Leistungen', 'leistungen'), + $sprung('Referenzen', 'projekte'), + $sprung('Ablauf', 'ablauf'), + $sprung('Über uns', 'ueber-uns'), + $sprung('Karriere', 'karriere'), + $sprung('Kontakt', 'kontakt'), +], 0); +echo "Menü angelegt\n"; + +$settings->assignNotFoundPage($fehlerId, ''); +$settings->save([ + 'legalName' => $firma, + 'description' => 'Garten- und Landschaftsbau auf Premium-Niveau aus Iffezheim: Terrassen, Pools, Pflaster, Natursteine und komplette Außenanlagen – geplant, gebaut und übergeben aus einer Hand.', + 'phone' => '+49 7229 6992826', 'email' => $mail, + 'street' => $strasse, 'postalCode' => $plz, 'city' => $ort, 'country' => 'DE', + 'social' => ['instagram' => $instagram, 'facebook' => $facebook], + 'areaServed' => implode("\n", ['Iffezheim', 'Rastatt', 'Baden-Baden', 'Karlsruhe', 'Mittelbaden']), + 'openingHours' => $zeitenDaten, + 'localBusiness' => true, 'llmsTxt' => true, + 'faviconMediaId' => $medien['favicon']['id'], + 'stickyHeader' => true, 'headerOverlay' => false, + 'motion' => 'always', + 'homePath' => 'home', 'imprintPath' => 'impressum', 'privacyPath' => 'datenschutz', + // Die Seite lädt nichts von Dritten; deshalb keine Einwilligungsleiste. + 'consentBanner' => false, 'scrollTop' => true, 'postMetaHeader' => false, +]); +echo "Einstellungen gespeichert\n"; + +$passwort = bin2hex(random_bytes(12)); +$db->insert('cms_user', ['id' => bin2hex(random_bytes(16)), 'username' => 'umut', 'password_hash' => password_hash($passwort, PASSWORD_BCRYPT), 'roles' => '["ROLE_ADMIN"]']); + +echo "\nFertig.\nVerwaltung: Benutzer „umut“, Passwort: $passwort\n"; diff --git a/src/Content/DocumentValidator.php b/src/Content/DocumentValidator.php index 99971ef..7cf4973 100644 --- a/src/Content/DocumentValidator.php +++ b/src/Content/DocumentValidator.php @@ -236,14 +236,14 @@ final class DocumentValidator } } if ('button' === $type) { - $this->url($props['href'] ?? null); + $this->link($props['href'] ?? null); } if ('iconcard' === $type) { $this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.'); $this->text($props['title'] ?? null, 120); $this->text($props['text'] ?? null, 600, true); if ('' !== ($props['href'] ?? '')) { - $this->url($props['href']); + $this->link($props['href']); } } if ('stat' === $type) { @@ -261,7 +261,7 @@ final class DocumentValidator $this->text($props['title'] ?? null, 160); $this->text($props['text'] ?? null, 600, true); $this->text($props['buttonText'] ?? null, 80); - $this->url($props['href'] ?? null); + $this->link($props['href'] ?? null); } if ('contact' === $type) { foreach (['phone', 'email', 'address'] as $field) { @@ -329,7 +329,7 @@ final class DocumentValidator $this->text($props['label'] ?? null, 120, $props['decorative']); $this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.'); if ('' !== ($props['href'] ?? '')) { - $this->url($props['href']); + $this->link($props['href']); } } if ('social' === $type) { @@ -414,7 +414,7 @@ final class DocumentValidator // Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht. if ('' !== ($props['href'] ?? '')) { $this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.'); - $this->url($props['href']); + $this->link($props['href']); } // Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher. if (isset($props['click'])) { @@ -498,6 +498,38 @@ final class DocumentValidator $this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.'); } + /** + * Ein Linkziel: alles, was url() erlaubt, dazu tel: und mailto: - dieselben Ziele, die Menü + * (MenuRepository::url) und formatierter Text (RichText::safeHref) schon kennen. Ein Knopf + * „Jetzt anrufen“ war sonst nur als Textlink im Knopf-Aussehen zu bauen (Hüfner, 06.10.2026). + * Bildquellen und eingebettete Inhalte bleiben bei url(): dort ist tel: kein Ziel. + */ + private function link(mixed $url): void + { + if (is_string($url) && strlen($url) <= 2048 && !preg_match('/[\x00-\x20\\\\]/', $url)) { + if (1 === preg_match('/^tel:\+?[0-9()-]{3,32}$/D', $url)) { + return; + } + // Adresse vor dem ersten ?, danach höchstens Betreff und Text, prozentkodiert. + if (1 === preg_match('/^mailto:([^?]+)(\?[A-Za-z0-9%._~=&+-]{0,500})?$/D', $url, $teile) + && false !== filter_var(rawurldecode($teile[1]), FILTER_VALIDATE_EMAIL)) { + return; + } + } + $this->require(true === $this->isUrl($url), 'Erlaubt sind lokale Pfade, Sprungmarken, HTTPS-Adressen sowie tel: und mailto:.'); + } + + private function isUrl(mixed $url): bool + { + try { + $this->url($url); + + return true; + } catch (InvalidDocument) { + return false; + } + } + private function require(bool $condition, string $message): void { if (!$condition) { diff --git a/tests/CmsTest.php b/tests/CmsTest.php index 12c5711..25ae2df 100644 --- a/tests/CmsTest.php +++ b/tests/CmsTest.php @@ -187,7 +187,10 @@ final class CmsTest extends TestCase $bad = $base; $bad['blocks'][] = $base['blocks'][0]; yield 'duplicate IDs' => [$bad]; - foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) { + // tel: und mailto: sind Linkziele, aber nur in sauberer Form: keine Buchstaben in der + // Nummer, eine echte Adresse, kein Weg aus dem Attribut. + foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo", + 'tel:ruf-mich-an', 'tel:+49 7229 6992826', 'mailto:keine-adresse', 'mailto:a@b.de?subject="x"', 'mailto:a@b.de?subject=