Compare commits

...

76 Commits

Author SHA1 Message Date
Daniel S
1939563ef2 feat: add dynamic page switcher dropdown in editor header and ensure exact page route rendering
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 49s
2026-08-10 00:28:44 +02:00
Daniel S
698716c34e refactor: clear sample pages and sections to provide a clean slate for custom editing 2026-08-10 00:26:02 +02:00
Daniel S
488e024451 fix: attach click event handlers for section delete, move up and move down in editor UI 2026-08-10 00:23:33 +02:00
Daniel S
9c166817fa feat: implement instant live DOM rendering and debounced auto-save to backend 2026-08-10 00:21:22 +02:00
Daniel S
4b7f3f554b fix: add automatic draft compilation fallback in loader and explicit API error logging 2026-08-10 00:16:41 +02:00
Daniel S
db2f607e5a feat: implement DB-driven architecture with SQLite persistence and file-generator compilation service
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 50s
2026-08-10 00:09:10 +02:00
Daniel S
950497311c fix: enforce strict draft vs publish isolation on all editor API endpoints
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 46s
2026-08-09 23:55:16 +02:00
Daniel S
28473bbcdc feat: rebuild admin editor into full Elementor-style 3-tab inspector and responsive canvas 2026-08-09 23:52:51 +02:00
Daniel S
fc2017b210 feat: enable full section editing, background styling, animations and live file persistence 2026-08-09 23:50:33 +02:00
Daniel S
831a0d53f0 refactor: simplify editor actions into single direct save button 2026-08-09 23:48:02 +02:00
Daniel S
9284717a80 fix: update editor bridge syntax and refine demo config content 2026-08-09 23:46:22 +02:00
Daniel S
4d072864bc rename(theme): rename Design-Preset to Farb-Auswahl and show 4 global color indicators
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 45s
2026-08-09 23:11:03 +02:00
Daniel S
e932495742 fix(types): add bg_color property to hero and bento interfaces in configLoader 2026-08-09 23:09:03 +02:00
Daniel S
6042c5ad23 feat(editor): support individual section background colors per section in editor and components 2026-08-09 23:06:56 +02:00
Daniel S
041cf20ca3 style: enforce global CSS utility classes for theme accent colors
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 48s
2026-08-09 23:04:55 +02:00
Daniel S
4355bb8406 feat(theme): dynamic CSS custom properties for presets, light/dark mode and individual color overrides
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 49s
2026-08-09 23:02:21 +02:00
Daniel S
30b407d2e8 feat(admin): add global header and footer configuration fields in admin dashboard 2026-08-09 22:58:54 +02:00
Daniel S
591eba43c1 fix(editor): enable design preset saving, section moving up/down and modal content editing 2026-08-09 22:56:10 +02:00
Daniel S
895fcd4323 fix(editor): preserve full site config object in save-draft and sync to draft file in config API 2026-08-09 22:52:55 +02:00
Daniel S
adefc89ab2 fix(config): ensure is_demo_mode is persisted in site.config.json and typed in SiteConfig interface 2026-08-09 22:50:10 +02:00
Daniel S
aec7c78fb0 fix(api): replace Astro global with APIRoute request parameter in api/config.ts 2026-08-09 22:48:19 +02:00
Daniel S
10d534e836 fix(api): prevent 500 error in /api/config when siteConfig.hero is omitted 2026-08-09 22:47:26 +02:00
Daniel S
15b2a34957 fix(types): add ogImage prop to Layout and message prop to FallbackSection
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 50s
2026-08-09 22:45:01 +02:00
Daniel S
baa639a9b2 feat(admin): add demo mode toggle in admin dashboard and rewrite 404 page for missing routes
Some checks failed
Production Build & Deploy / build-and-deploy (push) Has been cancelled
2026-08-09 22:43:36 +02:00
Daniel S
5e9b69c319 feat(editor): render persistent visual draft mode banner in preview mode 2026-08-09 22:41:22 +02:00
Daniel S
df6bb52ab0 feat(admin): merge company site_info stammdaten into general admin dashboard 2026-08-09 22:39:42 +02:00
Daniel S
11f8e33818 refactor(admin): remove legacy hero section and section ordering form from general dashboard 2026-08-09 22:37:53 +02:00
Daniel S
da50ac468b fix(nav): restrict visual editor access to pages management overview 2026-08-09 22:36:33 +02:00
Daniel S
d5b66ff830 feat(admin): add persistent drawer burger sidebar navigation across all admin pages 2026-08-09 22:33:52 +02:00
Daniel S
e8f332da43 feat(pages): add page opening, page deletion API, edit mode toggle and custom 404 page
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m10s
2026-08-09 22:30:13 +02:00
Daniel S
334c6723e9 fix(types): restore contact property in SiteConfig interface 2026-08-09 22:26:54 +02:00
Daniel S
bb96b0049c fix(types): add site_info and pages properties to SiteConfig interface 2026-08-09 22:26:19 +02:00
Daniel S
0a1aa5030d feat(config): include default pages and site_info in configLoader fallbacks 2026-08-09 22:24:28 +02:00
Daniel S
be071ff82f feat(admin): add quick navigation cards to admin dashboard
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m13s
2026-08-09 22:19:20 +02:00
Daniel S
f7b08f33ee fix(ci): synchronize package-lock.json with esbuild dependencies
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m18s
2026-08-09 22:12:43 +02:00
Daniel S
bb69e554d0 fix(deps): align react types with astrojs/react v18 and use modern --omit=dev in Dockerfile
Some checks failed
Production Build & Deploy / build-and-deploy (push) Failing after 6s
2026-08-09 22:08:23 +02:00
Daniel S
64c257f050 feat(docker): add automatic container entrypoint seeding script
Some checks failed
Production Build & Deploy / build-and-deploy (push) Failing after 9s
2026-08-09 22:05:48 +02:00
Daniel S
b021f9aaa7 feat(docker): add production Dockerfile, docker-compose setup and healthcheck API 2026-08-09 21:28:10 +02:00
Daniel S
03ceb3de7f feat(admin): add global site settings API, tests and dashboard UI
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 35s
2026-08-09 21:26:15 +02:00
Daniel S
a35efb2726 feat(editor): add section settings property editor API and sidebar modal 2026-08-09 21:24:16 +02:00
Daniel S
58fc8e51cd feat(contact): add WCAG 2.2 AA compliant ContactSection and zero-third-party send API 2026-08-09 21:18:09 +02:00
Daniel S
0c073983e9 feat(editor): add section manipulation APIs and interactive sidebar controls
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m3s
2026-08-09 21:08:35 +02:00
Daniel S
d03750bc53 feat(waas): add catch-all route, page manager and draft-publish pipeline 2026-08-09 20:59:02 +02:00
Daniel S
074d314897 feat: implement Dynamic Data Binding system and tags registry
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m0s
2026-08-09 12:54:31 +02:00
Daniel S
b039aceb21 feat: implement Theme Builder concept and Display Conditions resolver system 2026-08-09 12:53:25 +02:00
Daniel S
3453746fc0 feat: add section content editing, animation selection and HTML5 drag & drop in live editor
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m0s
2026-08-09 12:45:36 +02:00
Daniel S
b159b061ae feat: implement device-specific configurations for desktop, tablet, and mobile
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 57s
2026-08-09 12:35:46 +02:00
Daniel S
eb4bb601d7 fix(types): add missing slotId to fallback UIComponent objects in configLoader.ts
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 56s
2026-08-08 23:54:36 +02:00
Daniel S
92a75cf289 feat(waas-engine): add editable Header, Footer & Glassmorphism design controls in editor Inspector 2026-08-08 23:52:22 +02:00
Daniel S
4c165d8107 feat(admin-editor): add per-section UIComponent management with inline editing, deletion and re-ordering 2026-08-08 23:50:07 +02:00
Daniel S
c5165547b4 fix(admin-editor): rewrite editor.astro cleanly to eliminate AST compiler parsing error
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 56s
2026-08-08 23:44:33 +02:00
Daniel S
e82e1b3301 fix(cicd): update Gitea Action deploy.yml port mapping 8080:4321 and data volumes 2026-08-08 23:43:18 +02:00
Daniel S
6edc219a04 fix(admin-editor): resolve import syntax typo in editor.astro for SSR render 2026-08-08 23:42:28 +02:00
Daniel S
597566a000 docs(nginx): set server_name to spielwiese.hephex.de
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 56s
2026-08-08 23:20:30 +02:00
Daniel S
3736f0f633 feat(admin-editor): connect dynamic THEME_PRESETS to Stil inspector tab in live editor
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 52s
2026-08-08 23:10:27 +02:00
Daniel S
5b0f05c17f fix(docker): add build directive to compose and add .dockerignore file
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 20s
2026-08-08 19:24:04 +02:00
Daniel S
614a66fc70 fix(docker): update Dockerfile to Node.js SSR runtime runner entry.mjs
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 1m15s
2026-08-08 19:18:06 +02:00
Daniel S
4c9b547e7c docs(nginx): add example nginx reverse proxy configuration file
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 30s
2026-08-08 19:12:16 +02:00
Daniel S
d9cb8dcd6f fix(docker): restore original port mapping 8080:4321 for external reverse proxy 2026-08-08 19:11:24 +02:00
Daniel S
8d0e72a8bc fix(docker): bind port to 127.0.0.1 for existing reverse proxy integration
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 43s
2026-08-08 19:07:41 +02:00
Daniel S
e9d8fb95cf feat(admin-editor): add 3-tab inspector layout with Inhalt, Stil and Erweitert panels
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 31s
2026-08-08 19:03:59 +02:00
Daniel S
aa4629752a test(waas-engine): add vitest suite and node ssr container adapter setup 2026-08-08 18:56:36 +02:00
Daniel S
8197308cef feat(admin-editor): add elementor-style visual editor with drag-and-drop section manager and device switcher 2026-08-08 18:56:33 +02:00
Daniel S
c136e930c9 refactor(layout): switch index.astro to configurable section components driven by siteConfig 2026-08-08 18:56:30 +02:00
Daniel S
c51629b229 style(theme-engine): centralize CSS variables and add reactive light/dark mode support 2026-08-08 18:56:26 +02:00
Daniel S
be3a8a82ab feat(api): add SSR config update endpoints and DSGVO contact processing 2026-08-08 18:56:21 +02:00
Daniel S
dfa357bbd1 feat(waas-engine): add dynamic site config schema and slotId placement types 2026-08-08 18:56:18 +02:00
DanielS
08b74a0294 feat: simplify copy for small business owners and fix react-icons properties
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 32s
2026-07-19 00:06:21 +02:00
DanielS
92af42b2c5 style: update color preset buttons to be fully light and dark mode adaptive
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 27s
2026-07-19 00:00:40 +02:00
DanielS
a3222ec496 refactor: replace range calculators in templates with simulated contact forms
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 29s
2026-07-18 23:58:48 +02:00
DanielS
e22a724c57 feat: add onboarding wizard component and integrate into index page 2026-07-18 23:58:44 +02:00
DanielS
0e3688c431 feat: refactor hero bento grid and integrate WaaS ROI calculator 2026-07-18 23:58:39 +02:00
DanielS
31007040e5 docs: add waas-guide strategy skill 2026-07-18 23:58:35 +02:00
DanielS
36227215fc fix: add type annotation for slider index parameter
All checks were successful
Production Build & Deploy / build-and-deploy (push) Successful in 27s
2026-07-18 04:52:37 +02:00
DanielS
7bebcc066e feat: replace hero tabs with interactive template slider 2026-07-18 04:50:55 +02:00
DanielS
934a047586 style: change hero visual tabs to industry templates 2026-07-18 04:48:48 +02:00
76 changed files with 8538 additions and 744 deletions

View File

@@ -0,0 +1,182 @@
---
name: waas-guide
description: Leitfaden fuer High-End-Webdesign (Bento-Grid), Conversion-Optimierung, DSGVO-Haertung und WaaS-B2B-Vertrieb.
---
# N&D IT Solutions: WaaS-Erweiterungsstrategie
## SÄULE 1: Branchenspezifische Design-Strategien & Bento-Layouts
### 1. Lokales Premium-Handwerk (Innenausbau, Solarteure)
* **Farbpsychologie:**
* Hintergrund: `#0B0F19` (Corporate Dark Mode)
* Sekundärfarbe: Warmbronze (`hsl(35, 35%, 45%)` / `#9C7A4E`)
* Akzentfarbe: Kupfer-Glow (`hsl(20, 70%, 55%)` / `#DE6B32`)
* Wirkung: Vermittelt Hitze, Energie, präzise Handwerksqualität und Wertigkeit.
* **Branchenspezifische Eye-Catcher:**
* *Tactile Craftsman UI:* Subtiles SVG-Rauschen über radialen Verläufen simuliert physikalische Oberflächen (z. B. geschliffenes Holz/Metall).
* *Kanten-Effekt:* Lichtkanten via `border-[0.5px] border-amber-900/30` und diffuse Schatten (`box-shadow: 0 0 25px -5px rgba(222, 107, 50, 0.15)`).
* **Bento-Layout-Struktur (3-Säulen-Grid):**
* *Säule 1:* Box 1 (Hero-Statement, col-span-2) | Box 2 (Vorher-Nachher Slider, col-span-1)
* *Säule 2:* Box 3 (Material-Galerie, col-span-1) | Box 4 (Rhythmusbrecher: Übergroßes, asymmetrisches Textzitat "Maßarbeit statt Masse." mit 20% Opacity Kupfer-Glow im Hintergrund, col-span-2)
* *Säule 3:* Box 5 (Festpreis-Garantie, col-span-1) | Box 6 (Kontakt-Schnittstelle, col-span-2)
### 2. Medizinische Praxen & Kanzleien (Rechtsanwälte, High-End-Zahnärzte)
* **Farbpsychologie:**
* Hintergrund: `#0B0F19`
* Sekundärfarbe: Tiefes Smaragdgrün (`hsl(160, 40%, 25%)` / `#265C45`)
* Akzentfarbe: Arktis-Blau (`hsl(199, 95%, 74%)` / `#7DD3FC`)
* Wirkung: Signalisiert beruhigende Professionalität, Hygiene, absolute Diskretion und Vertrauen.
* **Branchenspezifische Eye-Catcher:**
* *Liquid Glass:* Extreme Transparenz (`backdrop-blur-xl bg-slate-900/60`).
* *Kanten-Reflexion:* Dünner, linearer Randverlauf (`border-[0.5px] border-white/10`) ohne Textur für sterile Ästhetik.
* **Bento-Layout-Struktur (3-Säulen-Grid):**
* *Säule 1:* Box 1 (H1 Expertise-Statement, col-span-2) | Box 2 (Direktbuchungs-Terminkalender, col-span-1)
* *Säule 2:* Box 3 (Rhythmusbrecher: Kreisförmige Foto-Maske des Inhabers bricht eckiges Grid, col-span-1) | Box 4 (Fachgebiete & Kernkompetenzen, col-span-2)
* *Säule 3:* Box 5 (Datenschutz-Zertifikat, col-span-1) | Box 6 (FAQ-Akkordeon, col-span-2)
### 3. Lokale Premium-Dienstleister (Boutique-Fitness, Agenturen)
* **Farbpsychologie:**
* Hintergrund: `#0B0F19`
* Sekundärfarbe: Muted Violett (`hsl(270, 30%, 35%)` / `#533C6F`)
* Akzentfarbe: Neon-Cyan-Glow (`hsl(180, 100%, 50%)` / `#00FFFF`)
* Wirkung: Vermittelt Exklusivität, hohe Dynamik, Luxus und Modernität.
* **Branchenspezifische Eye-Catcher:**
* *Aurora Glow:* Animierte, extrem weiche Glow-Verläufe im Hintergrund (`animate-pulse-slow` mit max. 10% Deckkraft).
* *Hover-Glow:* Kanten leuchten interaktiv bei Mausbewegung (CSS-Radialgradient-Border).
* **Bento-Layout-Struktur (3-Säulen-Grid):**
* *Säule 1:* Box 1 (Visual Hero, col-span-2) | Box 2 (Mitgliedschafts-Modell, col-span-1)
* *Säule 2:* Box 3 (Kundenvorteile, col-span-1) | Box 4 (Rhythmusbrecher: Dynamisch blinkende Live-Auslastungs-Anzeige, col-span-2)
* *Säule 3:* Box 5 (Social Proof / Testimonials, col-span-2) | Box 6 (Kontakt, col-span-1)
---
## SÄULE 2: UI/UX & Verkaufspsychologie (Conversion-Driven UX)
### 1. Wizard-Optimierung (Absprungrate reduzieren bei Schritt 3)
* **Sorgenfrei-Versprechen (Mikro-Copy):** Direkt über dem E-Mail-/Telefon-Eingabefeld Platzierung von: *"Kein Spam. Kein Telefonterror. Nur Ihr fertiges Design-Konzept innerhalb von 24 Stunden."*
* **Fortschrittserhalt:** Visuelle Anzeige, dass die Daten aus Schritt 1 & 2 bereits lokal gesichert sind und das Konzept reserviert ist.
* **Alternative Interaktionswege:** Biete dem Nutzer neben dem Formular einen direkten WhatsApp-Short-Link an, der den Wizard-Fortschritt verschlüsselt als Text vorausfüllt.
### 2. Bento-Interaction-States (Barrierefrei & CLS-frei)
* Keine Änderungen an Layout-Größen (`width`, `height`, `padding`, `border-width`), da diese Layout-Shifts (CLS) triggern.
* Ausschließliche Nutzung von Hardware-beschleunigten CSS-Eigenschaften (`transform`, `opacity`, `box-shadow`, `border-color`).
* Tastatur-Navigation: `:focus-visible` repliziert die Hover-Glow-Stile exakt.
```css
.bento-card {
transition: transform 0.3s cubic-bezier(0.25, 0.8, 0.25, 1), box-shadow 0.3s ease, border-color 0.3s ease;
outline: none;
}
.bento-card:hover, .bento-card:focus-visible {
transform: translateY(-4px) scale(1.01);
border-color: rgba(56, 189, 248, 0.4);
box-shadow: 0 10px 30px -10px rgba(56, 189, 248, 0.2);
}
```
### 3. White-Space-Asymmetry (Preise im Fokus)
* **Freiraum-Kontrast:** Die WaaS-Preise erhalten die 2,5-fache vertikale Polsterung (`py-28`) im Vergleich zu restlichen Inhaltsabschnitten (`py-12`).
* **Asymmetrische Anordnung:** Platzierung der Preiskarten auf der linken Seite des Grids (2/3-Breite), während rechts freie, ununterbrochene Leere herrscht, die lediglich durch einen minimalen Arktis-Blau-Glow (z-0, 5% Opacity) akzentuiert wird. Dies lenkt den Blickpfad unweigerlich auf das Pricing.
---
## SÄULE 3: Datenschutz & Performance (Security-Hardening-Privacy)
### 1. DSGVO-Marketing-Textierung für KMUs
```html
<section class="py-20 bg-[#0B0F19] text-white">
<div class="max-w-5xl mx-auto px-6">
<span class="text-xs uppercase tracking-widest text-[#38BDF8] font-bold">100% Abmahnsicher</span>
<h2 class="text-3xl font-extrabold mt-2 mb-6">Keine Abmahnfallen. Keine US-Server. Maximaler Datenschutz.</h2>
<div class="grid md:grid-cols-2 gap-8">
<div>
<p class="text-slate-300 leading-relaxed">
Herkömmliche Baukasten-Websites laden Schriftarten, Icons und Analysedienste unbemerkt von US-Servern (Google, Adobe etc.). Das ist illegal nach DSGVO und führt zu teuren Abmahnungen.
</p>
</div>
<div>
<p class="text-slate-300 leading-relaxed">
Unsere statische Astro-Architektur liefert <strong>alles lokal</strong> aus Ihrem eigenen Webspace aus. Keine unbemerkt geladenen Fremddienste, keine zustimmungspflichtigen Tracking-Cookies, kein Ärger. Nur reine Performance und absolute Rechtssicherheit.
</p>
</div>
</div>
</div>
</section>
```
### 2. Code-Härtung (Standard-Security-Header)
* **Content Security Policy (CSP):**
```http
Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; upgrade-insecure-requests;
```
* **Sicherheitsrelevante HTTP-Response-Header:**
* `X-Frame-Options: DENY` (Verhindert Clickjacking-Angriffe)
* `X-Content-Type-Options: nosniff` (Verhindert MIME-Sniffing)
* `Referrer-Policy: strict-origin-when-cross-origin`
* `Permissions-Policy: geolocation=(), camera=(), microphone=()`
---
## SÄULE 4: B2B-Vertrieb & WaaS-Skalierung
### 1. WaaS-Einwandvorwegnahme (Argumentationskette)
* **Die Leasing-Analogie:** *"Sie kaufen für Ihren Betrieb keinen Firmenwagen bar für 60.000 €, der nach vier Jahren veraltet ist. Sie leasen ihn inklusive Full-Service. WaaS ist das Leasing-Modell für Ihre Website."*
* **Finanzielle Optimierung:** *Einmalkauf (3.000 €) blockiert Ihre Liquidität und muss über Jahre abgeschrieben werden. WaaS (99 €/Monat) ist sofort zu 100% als Betriebsausgabe steuerlich absetzbar. Hosting und Wartung sind bereits integriert (Ersparnis: ~70 €/Monat).*
* **Die Veraltungs-Garantie:** *Websites altern rasant. Bei unserem WaaS-Modell erhalten Sie nach 24 Monaten Laufzeit einen vollständigen visuellen und technischen Relaunch Ihrer Seite komplett kostenfrei.*
### 2. KI-Automatisierungs-Schnittstelle (JSON Payload für MDX-Erstellung)
Das nachfolgende JSON-Format wird vom Onboarding-Wizard generiert und per Webhook an die KI-Pipeline gesendet, um die Astro MDX Content Collections automatisch zu strukturieren:
```json
{
"client_metadata": {
"agency_id": "nd-it-solutions",
"timestamp": "2026-07-18T23:31:00Z",
"domain": "innenausbau-schmidt.de"
},
"branding": {
"industry": "craftsman",
"company_name": "Schreinerei Schmidt",
"branding_style": "minimal-dark",
"primary_color": "hsl(35, 35%, 45%)",
"secondary_color": "hsl(20, 70%, 55%)"
},
"structure": {
"sections": ["hero", "bento-portfolio", "roi-calculator", "gdpr-trust", "contact-form"],
"features": {
"before_after_slider": true,
"online_calendar": false,
"custom_calculator": "roi"
}
},
"content_data": {
"usps": [
"30 Jahre Meisterbetrieb",
"Festpreisgarantie vor Baubeginn",
"Staubfreie Montage"
],
"services": [
{
"title": "Edler Innenausbau",
"description": "Individuelle Raumkonzepte für Küche und Wohnbereich."
},
{
"title": "Möbel nach Maß",
"description": "Unikate aus Meisterhand, perfekt angepasst."
}
],
"testimonials": [
{
"author": "M. Weber",
"quote": "Beste Entscheidung! Der Umbau war absolut staubfrei."
}
]
},
"contact": {
"phone": "+49 123 456789",
"email": "info@innenausbau-schmidt.de",
"address": "Hauptstraße 42, 80331 München"
}
}
```

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.git
.astro
dist
.agents

View File

@@ -38,16 +38,20 @@ jobs:
# Schreibt die Docker Compose Datei sauber ohne Einrückung
cat << 'EOF' > docker-compose.yml
version: '3.8'
services:
website:
image: gitea.hephex.de/daniel/ndsolutionswebsite:latest
restart: always
ports:
- "8080:8080"
- "8080:4321"
environment:
- HOST=0.0.0.0
- PORT=4321
- DATA_DIR=/app/data
volumes:
- ./app/data:/app/data
EOF
echo "${{ secrets.REGISTRY_TOKEN }}" | docker login gitea.hephex.de -u "${{ secrets.REGISTRY_USER }}" --password-stdin
docker compose pull
docker compose up -d
docker image prune -f

12
.gitignore vendored
View File

@@ -22,3 +22,15 @@ pnpm-debug.log*
# jetbrains setting folder
.idea/
# coverage report
coverage/
# draft files
*.draft.json
# test data directories
app/test_data*/

View File

@@ -1,21 +1,41 @@
# Build-Phase
FROM node:20-slim AS builder
# STAGE 1: Dependencies & Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --legacy-peer-deps
RUN npm ci
COPY . .
ENV NODE_ENV=production
RUN npm run build
# Production-Phase
FROM nginx:1.25
# STAGE 2: Production Runner
FROM node:20-alpine AS runner
WORKDIR /app
# Eigene Nginx-Konfiguration kopieren
COPY default.conf /etc/nginx/conf.d/default.conf
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV DATA_DIR=/app/data
# Nginx konfigurieren für non-root Port 8080
RUN touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid /var/cache/nginx /var/log/nginx /etc/nginx/conf.d
# Ordner anlegen & Schreibrechte vorbereiten
RUN mkdir -p /app/data
USER nginx
COPY --from=builder /app/dist /usr/share/nginx/html
EXPOSE 8080
COPY package*.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/public ./public
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
# Ausführungsrechte für das Entrypoint-Skript setzen
RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 3000
# Healthcheck für Docker / Nginx Proxy
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/api/health || exit 1
ENTRYPOINT ["/app/docker-entrypoint.sh"]
CMD ["node", "./dist/server/entry.mjs"]

32
app/data/site.config.json Normal file
View File

@@ -0,0 +1,32 @@
{
"siteName": "N&D IT Solutions",
"metaDescription": "High-Performance Webdesign & Digital Solutions - 100% DSGVO-konform.",
"is_demo_mode": false,
"header": {
"brandName": "N&D",
"brandHighlight": "i-t SOLUTIONS",
"ctaText": "Erstgespräch buchen",
"ctaLink": "#contact",
"showThemeToggle": true,
"glassEffect": true
},
"footer": {
"brandDescription": "Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO.",
"copyrightText": "N&D i-t SOLUTIONS. Alle Rechte vorbehalten.",
"showLegalLinks": true
},
"site_info": {
"title": "N&D IT Solutions",
"homepage_id": "page_home"
},
"pages": [
{
"id": "page_home",
"slug": "/",
"title": "Startseite",
"is_published": true,
"sections": []
}
],
"updated_at": "2026-08-09T22:28:31.200Z"
}

View File

@@ -0,0 +1 @@
{}

10
app/data/smtp.config.json Normal file
View File

@@ -0,0 +1,10 @@
{
"host": "smtp.placeholder.local",
"port": 587,
"secure": false,
"user": "placeholder@domain.de",
"pass": "****",
"fromName": "N&D IT Solutions System",
"fromEmail": "noreply@ndsolutions.de",
"isConfigured": false
}

View File

@@ -0,0 +1,9 @@
{
"activePreset": "midnightBlue",
"customColors": {
"bg": "#0b0f19",
"accent": "#38bdf8",
"text": "#e0e0e0",
"border": ""
}
}

BIN
app/data/waas.db Normal file

Binary file not shown.

View File

@@ -1,11 +1,13 @@
// @ts-check
import { defineConfig } from 'astro/config';
import tailwind from '@astrojs/tailwind';
import react from '@astrojs/react';
import node from '@astrojs/node';
// https://astro.build/config
export default defineConfig({
output: 'server',
adapter: node({
mode: 'standalone'
}),
integrations: [tailwind(), react()]
});

View File

@@ -0,0 +1,63 @@
{
"siteName": "N&D IT Solutions",
"metaDescription": "High-Performance Webdesign & Digital Solutions - 100% DSGVO-konform.",
"hero": {
"badge": "⚡ WaaS Enterprise Baukastensystem",
"title": "Maßgeschneiderte Webseiten. Digital im Griff.",
"subtitle": "Wir entwickeln blitzschnelle, DSGVO-konforme High-End Webseiten für Ihr Unternehmen zum monatlichen Festpreis inklusive Wartung & Hosting.",
"ctaPrimaryText": "Projekt anfragen",
"ctaPrimaryLink": "#contact",
"ctaSecondaryText": "Mehr erfahren",
"ctaSecondaryLink": "#services"
},
"bento": {
"sectionTitle": "High-End Features & Leistung",
"sectionSubtitle": "Modernste Technologie & Höchster Datenschutz vereint.",
"cards": [
{
"id": "card-1",
"title": "Ultra Fast SSR",
"description": "Sub-100ms Ladezeiten dank Astro Hybrid SSR Architecture. Google Core Web Vitals > 95%.",
"colSpan": 2,
"badge": "Performance",
"icon": "zap"
},
{
"id": "card-2",
"title": "100% DSGVO-Sicher",
"description": "Keine Cookies, keine Tracking-Skripte, Zero Third-Party Requests.",
"colSpan": 1,
"badge": "Datenschutz",
"icon": "shield"
},
{
"id": "card-3",
"title": "Liquid Glass UI",
"description": "Moderne Ästhetik mit matte Boxen, Aurora Glows und physischen Kanten-Effekten.",
"colSpan": 1,
"badge": "Design",
"icon": "layers"
},
{
"id": "card-4",
"title": "Sorgenfrei-Paket",
"description": "Hosting, SSL, Wartung und regelmäßige Inhalts-Updates komplett inklusive.",
"colSpan": 2,
"badge": "Full-Service",
"icon": "check"
}
]
},
"contact": {
"title": "Bereit für Ihr Projekt?",
"subtitle": "Schreiben Sie uns direkt. Wir antworten innerhalb von 24 Stunden persönlich.",
"recipientEmail": "kontakt@ndsolutions.de",
"phone": "+49 (0) 123 456789",
"address": "Deutschland"
},
"sectionsOrder": [
"hero",
"bento",
"contact"
]
}

View File

@@ -0,0 +1,10 @@
{
"host": "smtp.placeholder.local",
"port": 587,
"secure": false,
"user": "placeholder@domain.de",
"pass": "****",
"fromName": "N&D IT Solutions System",
"fromEmail": "noreply@ndsolutions.de",
"isConfigured": false
}

View File

@@ -0,0 +1,7 @@
{
"activePreset": "corporateDark",
"customColors": {
"bg": "#0B0F19",
"accent": "#38BDF8"
}
}

View File

@@ -1,9 +1,27 @@
version: '3.8'
services:
website:
image: gitea.hephex.de/daniel/websitedummy:latest
container_name: websitedummy_app
app:
image: nd-waas-engine:latest
container_name: waas_${CUSTOMER_SLUG:-kunden_app}
restart: always
environment:
- NODE_ENV=production
- PORT=3000
- DATA_DIR=/app/data
volumes:
# Hier werden site.config.json, sqlite.db, uploads etc. automatisch vom Container angelegt
- ./data:/app/data
ports:
- "8080:8080"
# Anbindung an deinen händischen Nginx Reverse Proxy auf dem Host
- "127.0.0.1:${PORT:-8080}:3000"
healthcheck:
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/health"]
interval: 10s
timeout: 3s
retries: 3
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"

78
docker-entrypoint.sh Normal file
View File

@@ -0,0 +1,78 @@
#!/bin/sh
set -e
DATA_DIR=${DATA_DIR:-/app/data}
echo "🚀 [WaaS Container Entrypoint] Prüfe Daten-Volume unter ${DATA_DIR}..."
# 1. Sicherstellen, dass das Volume-Verzeichnis existiert
mkdir -p "$DATA_DIR"
# 1.5 SQLite DB initialisieren (falls nicht vorhanden)
if [ ! -f "$DATA_DIR/waas.db" ]; then
echo "🗄️ Keine SQLite-Datenbank waas.db gefunden. Initialisierung wird beim Anwendungsstart durchgeführt..."
fi
# 2. site.config.json Seeding
if [ ! -f "$DATA_DIR/site.config.json" ]; then
echo "📄 Keine site.config.json gefunden. Erstelle Standard-Konfiguration..."
cat <<EOF > "$DATA_DIR/site.config.json"
{
"site_info": {
"title": "N&D IT Solutions Kunden-Website",
"company_name": "Mein Unternehmen",
"homepage_id": "page_home_01"
},
"navigation": [
{ "label": "Startseite", "page_id": "page_home_01" }
],
"pages": [
{
"id": "page_home_01",
"slug": "/",
"title": "Startseite",
"is_published": true,
"sections": [
{
"id": "sec_hero_init",
"type": "HeroSection",
"settings": {
"title": "Herzlich Willkommen",
"subtitle": "Ihre neue Website ist erfolgreich gestartet."
}
}
]
}
]
}
EOF
fi
# 3. theme.config.json Seeding
if [ ! -f "$DATA_DIR/theme.config.json" ]; then
echo "🎨 Keine theme.config.json gefunden. Erstelle Standard-Theme..."
cat <<EOF > "$DATA_DIR/theme.config.json"
{
"presetKey": "corporate-dark",
"mode": "dark"
}
EOF
fi
# 4. smtp.config.json Seeding
if [ ! -f "$DATA_DIR/smtp.config.json" ]; then
echo "✉️ Keine smtp.config.json gefunden. Erstelle Standard-SMTP Config..."
cat <<EOF > "$DATA_DIR/smtp.config.json"
{
"host": "localhost",
"port": 1025,
"from": "noreply@kunden-domain.de",
"recipient": "info@kunden-domain.de"
}
EOF
fi
echo "✅ All-in-One Initialisierung abgeschlossen. Starte Astro SSR Node Server..."
# Führe den eigentlichen Befehl aus (CMD aus Dockerfile)
exec "$@"

16
nginx_waas_site.conf Normal file
View File

@@ -0,0 +1,16 @@
server {
listen 80;
server_name spielwiese.hephex.de;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

2939
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -12,16 +12,24 @@
"astro": "astro"
},
"dependencies": {
"@astrojs/node": "^8.3.4",
"@astrojs/react": "^3.6.3",
"@astrojs/tailwind": "^5.1.4",
"@fontsource/inter": "^5.2.8",
"@fontsource/space-grotesk": "^5.2.10",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"astro": "^4.16.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"nodemailer": "^9.0.5",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-icons": "^5.7.0",
"tailwindcss": "^3.4.19"
},
"devDependencies": {
"@types/nodemailer": "^8.0.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@vitest/coverage-v8": "^4.1.10",
"vitest": "^4.1.10"
}
}

View File

@@ -0,0 +1,65 @@
// public/js/editor-bridge.js
(function () {
// Bridge nur im Preview-Modus aktivieren
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('preview') !== 'draft') return;
console.log('[N&D Editor Bridge] Active on preview canvas');
// Optischer Hinweis-Banner im Bearbeitungsmodus
document.addEventListener('DOMContentLoaded', () => {
const banner = document.createElement('div');
banner.id = 'editor-preview-banner';
banner.style.cssText = 'position: fixed; top: 0; left: 0; right: 0; z-index: 99999; background: #d97706; color: #0f172a; text-align: center; padding: 6px 12px; font-size: 12px; font-weight: 700; font-family: monospace; letter-spacing: 0.5px; box-shadow: 0 2px 10px rgba(0,0,0,0.3); pointer-events: none; opacity: 0.95;';
banner.innerHTML = '🛠️ BEARBEITUNGSMODUS (VORSCHAU / ENTWURF) — Änderungen sind noch nicht live!';
document.body.appendChild(banner);
const currentPadding = parseInt(document.body.style.paddingTop || '0', 10);
document.body.style.paddingTop = (currentPadding + 30) + 'px';
});
window.addEventListener('message', (event) => {
const { type, payload } = event.data || {};
if (type === 'THEME_UPDATE') {
const root = document.documentElement;
if (payload.colors) {
Object.entries(payload.colors).forEach(([key, val]) => {
root.style.setProperty(`--${key}`, val);
});
}
// Live Section DOM Patching
if (payload.sectionId && payload.settings) {
const secElement = document.querySelector(`[data-section-id="${payload.sectionId}"], #${payload.sectionId}`) || document.querySelector('section');
if (secElement) {
const { title, subtitle, cta_text, bg_color, accent_color } = payload.settings;
if (title !== undefined) {
const h1OrH2 = secElement.querySelector('h1, h2, h3');
if (h1OrH2) h1OrH2.textContent = title;
}
if (subtitle !== undefined) {
const p = secElement.querySelector('p');
if (p) p.textContent = subtitle;
}
if (cta_text !== undefined) {
const btn = secElement.querySelector('a, button');
if (btn) btn.textContent = cta_text;
}
if (bg_color !== undefined && bg_color !== '') {
secElement.style.backgroundColor = bg_color;
}
if (accent_color !== undefined && accent_color !== '') {
secElement.style.setProperty('--accent-color', accent_color);
}
}
}
}
if (type === 'RELOAD_CANVAS') {
// Sanfter Reload des Iframes bei Strukturänderungen
window.location.reload();
}
});
})();

View File

@@ -13,15 +13,15 @@ import { FaEye, FaRocket, FaHandshake } from 'react-icons/fa';
<!-- Text/Profile Column -->
<div class="fade-in-section opacity-0 translate-y-8 transition-all duration-1000 ease-out">
<h2 class="text-xs font-bold tracking-widest text-brand-cyan uppercase mb-3">Unternehmensprofil</h2>
<h2 class="text-xs font-bold tracking-widest text-brand-cyan uppercase mb-3">Über uns</h2>
<h3 class="text-4xl md:text-5xl font-bold text-brand-primary dark:text-white mb-6">
Wir machen Webdesign einfach und bezahlbar für Kleinbetriebe.
Wir machen Webseiten einfach und bezahlbar für Betriebe wie Ihren.
</h3>
<p class="text-gray-600 dark:text-gray-300 text-lg leading-relaxed mb-6 font-light">
N&D i-t SOLUTIONS wurde gegründet, um kleinen Unternehmen, Handwerkern und Dienstleistern eine professionelle Online-Präsenz zu ermöglichen. Wir bieten erstklassige Websites ohne die unerschwinglichen Kosten großer Agenturen.
Wir haben N&D gegründet, um kleinen Unternehmen, Geschäften und Handwerkern zu helfen. Viele haben keine Lust auf komplizierte Technik oder extrem teure Agenturen. Bei uns bekommen Sie eine moderne Webseite ganz ohne Kopfschmerzen.
</p>
<p class="text-gray-600 dark:text-gray-300 text-lg leading-relaxed mb-8 font-light">
Unser Fokus liegt auf schnellen Ladezeiten, optimaler Bedienung auf dem Smartphone (Mobile First) und lokaler Auffindbarkeit bei Google.
Wir sorgen dafür, dass Ihre Seite auf dem Handy richtig gut aussieht und schnell lädt. Und vor allem: dass Ihre Kunden Sie bei Google auch wirklich finden.
</p>
<!-- Fun facts -->
@@ -48,12 +48,12 @@ import { FaEye, FaRocket, FaHandshake } from 'react-icons/fa';
<div class="fade-in-section opacity-0 translate-y-8 transition-all duration-1000 ease-out delay-150 p-8 rounded-3xl bg-white/30 dark:bg-brand-primary/40 border border-white/20 dark:border-white/5 backdrop-blur-md shadow-lg hover:shadow-xl transition-shadow duration-300">
<div class="flex items-start gap-4">
<div class="p-3 rounded-2xl bg-brand-cyan/10 text-brand-cyan">
<FaRocket class="w-6 h-6" />
<FaRocket className="w-6 h-6" />
</div>
<div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Unsere Mission</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Unser Ziel</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed font-light">
Wir verhelfen lokalen Betrieben zu einer erstklassigen Online-Präsenz, die neue Kunden anzieht und Ihre Marke perfekt widerspiegelt.
Wir bringen Ihren Betrieb ins Internet so schick und professionell, dass Kunden Sie gerne besuchen und direkt anrufen.
</p>
</div>
</div>
@@ -63,12 +63,12 @@ import { FaEye, FaRocket, FaHandshake } from 'react-icons/fa';
<div class="fade-in-section opacity-0 translate-y-8 transition-all duration-1000 ease-out delay-300 p-8 rounded-3xl bg-white/30 dark:bg-brand-primary/40 border border-white/20 dark:border-white/5 backdrop-blur-md shadow-lg hover:shadow-xl transition-shadow duration-300">
<div class="flex items-start gap-4">
<div class="p-3 rounded-2xl bg-brand-magenta/10 text-brand-magenta">
<FaEye class="w-6 h-6" />
<FaEye className="w-6 h-6" />
</div>
<div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Unsere Vision</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Gleiche Chancen für alle</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed font-light">
Eine digitale Landschaft, in der auch kleine Händler und lokale Handwerker die gleichen digitalen Chancen haben wie Großkonzerne.
Egal ob Dönerladen, Friseursalon oder Handwerker: Jeder verdient eine Webseite, die neue Kunden bringt.
</p>
</div>
</div>
@@ -78,12 +78,12 @@ import { FaEye, FaRocket, FaHandshake } from 'react-icons/fa';
<div class="fade-in-section opacity-0 translate-y-8 transition-all duration-1000 ease-out delay-450 p-8 rounded-3xl bg-white/30 dark:bg-brand-primary/40 border border-white/20 dark:border-white/5 backdrop-blur-md shadow-lg hover:shadow-xl transition-shadow duration-300">
<div class="flex items-start gap-4">
<div class="p-3 rounded-2xl bg-brand-cyan/10 text-brand-cyan">
<FaHandshake class="w-6 h-6" />
<FaHandshake className="w-6 h-6" />
</div>
<div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Unser Versprechen</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Ehrliches Versprechen</h4>
<p class="text-sm text-gray-600 dark:text-gray-400 leading-relaxed font-light">
Keine versteckten monatlichen Gebühren oder Bindungen. Ihre Website gehört zu 100% Ihnen mit ehrlichem Support auf Augenhöhe.
Kein Kleingedrucktes, keine versteckten Kosten. Wir sprechen auf Augenhöhe mit Ihnen ganz ohne Fachbegriffe.
</p>
</div>
</div>

View File

@@ -10,14 +10,14 @@ import { FaTimes, FaEnvelope, FaUser, FaBuilding, FaCheckCircle, FaSpinner } fro
<!-- Close Button -->
<button id="close-contact-modal" class="absolute top-6 right-6 p-2 rounded-full bg-gray-100 hover:bg-gray-200 dark:bg-white/5 dark:hover:bg-white/10 text-gray-500 dark:text-gray-400 hover:text-brand-primary dark:hover:text-white transition-all focus:outline-none" aria-label="Modal schließen">
<FaTimes class="w-5 h-5" />
<FaTimes className="w-5 h-5" />
</button>
<!-- Header -->
<div class="mb-8">
<h3 class="text-3xl font-bold text-brand-primary dark:text-white mb-2">Projekt anfragen</h3>
<h3 class="text-3xl font-bold text-brand-primary dark:text-white mb-2">Kontakt aufnehmen</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm">
Erzählen Sie uns von Ihrem Vorhaben. Wir melden uns innerhalb von 24 Stunden.
Schreiben Sie uns einfach eine kurze Nachricht. Wir melden uns innerhalb von 24 Stunden bei Ihnen.
</p>
</div>
@@ -26,16 +26,16 @@ import { FaTimes, FaEnvelope, FaUser, FaBuilding, FaCheckCircle, FaSpinner } fro
<!-- Name Field -->
<div>
<label for="name" class="block text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Name *</label>
<label for="name" class="block text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Ihr Name *</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 text-gray-400">
<FaUser class="w-4 h-4" />
<FaUser className="w-4 h-4" />
</span>
<input
type="text"
id="name"
name="name"
placeholder="Ihr vollständiger Name"
placeholder="Ihr Name"
class="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl focus:border-brand-cyan focus:outline-none focus:ring-1 focus:ring-brand-cyan transition-all text-brand-primary dark:text-white"
required
/>
@@ -45,16 +45,16 @@ import { FaTimes, FaEnvelope, FaUser, FaBuilding, FaCheckCircle, FaSpinner } fro
<!-- Firma Field -->
<div>
<label for="company" class="block text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Firma / Organisation</label>
<label for="company" class="block text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Ihr Laden / Ihr Betrieb</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 text-gray-400">
<FaBuilding class="w-4 h-4" />
<FaBuilding className="w-4 h-4" />
</span>
<input
type="text"
id="company"
name="company"
placeholder="Name Ihres Unternehmens"
placeholder="z.B. Dönereck"
class="w-full pl-11 pr-4 py-3 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl focus:border-brand-cyan focus:outline-none focus:ring-1 focus:ring-brand-cyan transition-all text-brand-primary dark:text-white"
/>
</div>
@@ -62,10 +62,10 @@ import { FaTimes, FaEnvelope, FaUser, FaBuilding, FaCheckCircle, FaSpinner } fro
<!-- Email Field -->
<div>
<label for="email" class="block text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">E-Mail-Adresse *</label>
<label for="email" class="block text-xs font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-2">Ihre E-Mail-Adresse *</label>
<div class="relative">
<span class="absolute inset-y-0 left-0 flex items-center pl-4 text-gray-400">
<FaEnvelope class="w-4 h-4" />
<FaEnvelope className="w-4 h-4" />
</span>
<input
type="email"
@@ -86,7 +86,7 @@ import { FaTimes, FaEnvelope, FaUser, FaBuilding, FaCheckCircle, FaSpinner } fro
id="message"
name="message"
rows="4"
placeholder="Beschreiben Sie kurz Ihr Projekt..."
placeholder="Schreiben Sie kurz, was Sie brauchen (z.B. 'Ich brauche eine neue Webseite für meine Pizzeria...')"
class="w-full p-4 bg-gray-50 dark:bg-white/5 border border-gray-200 dark:border-white/10 rounded-xl focus:border-brand-cyan focus:outline-none focus:ring-1 focus:ring-brand-cyan transition-all text-brand-primary dark:text-white resize-none"
required
></textarea>
@@ -103,7 +103,7 @@ import { FaTimes, FaEnvelope, FaUser, FaBuilding, FaCheckCircle, FaSpinner } fro
required
/>
<label for="privacy" class="text-xs text-gray-500 dark:text-gray-400 leading-normal">
Ich stimme der Verarbeitung meiner Daten gemäß der <a href="/privacy" target="_blank" class="text-brand-cyan hover:underline">Datenschutzerklärung</a> zu. *
Ich bin mit der <a href="/privacy" target="_blank" class="text-brand-cyan hover:underline">Datenschutzerklärung</a> einverstanden. *
</label>
</div>
<p id="privacy-error" class="error-msg text-xs text-red-500 hidden">Sie müssen der Datenschutzerklärung zustimmen.</p>
@@ -114,8 +114,8 @@ import { FaTimes, FaEnvelope, FaUser, FaBuilding, FaCheckCircle, FaSpinner } fro
id="submit-btn"
class="w-full py-4 mt-2 inline-flex items-center justify-center gap-2 bg-gradient-to-r from-brand-cyan to-brand-magenta text-brand-primary font-bold rounded-xl shadow-lg transition-all duration-300 hover:scale-[1.02] focus:outline-none hover:shadow-[0_0_20px_rgba(0,229,255,0.4)] relative overflow-hidden group active:scale-95"
>
<span id="btn-text">Anfrage senden</span>
<span id="btn-loader" class="hidden"><FaSpinner class="w-5 h-5 animate-spin" /></span>
<span id="btn-text">Nachricht senden</span>
<span id="btn-loader" class="hidden"><FaSpinner className="w-5 h-5 animate-spin" /></span>
</button>
</form>
@@ -126,7 +126,7 @@ import { FaTimes, FaEnvelope, FaUser, FaBuilding, FaCheckCircle, FaSpinner } fro
<div id="success-modal" class="fixed inset-0 z-[120] hidden items-center justify-center p-4 bg-brand-primary/95 backdrop-blur-lg transition-all duration-300">
<div class="max-w-md w-full bg-white dark:bg-brand-primary border border-gray-200 dark:border-white/10 p-8 rounded-3xl shadow-2xl text-center transform scale-95 opacity-0 transition-all duration-300 ease-out" id="success-box">
<div class="text-brand-cyan mb-6 flex justify-center">
<FaCheckCircle class="w-16 h-16 animate-bounce" />
<FaCheckCircle className="w-16 h-16 animate-bounce" />
</div>
<h3 class="text-3xl font-bold text-brand-primary dark:text-white mb-4">Nachricht gesendet!</h3>
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed mb-8">

View File

@@ -0,0 +1,150 @@
---
// src/components/ContactSection.astro
interface Props {
id?: string;
settings?: {
title?: string;
subtitle?: string;
submit_button_text?: string;
};
}
const { id = 'contact', settings = {} } = Astro.props;
const title = settings.title || 'Kontaktieren Sie uns';
const subtitle = settings.subtitle || 'Schreiben Sie uns eine Nachricht. Wir melden uns innerhalb von 24 Stunden.';
const buttonText = settings.submit_button_text || 'Nachricht absenden';
---
<section id={id} class="py-16 px-4 max-w-4xl mx-auto">
<div class="preset-card p-8 bg-slate-900/80 border border-white/10 rounded-2xl shadow-2xl backdrop-blur-md">
<div class="text-center mb-8">
<h2 class="text-2xl font-bold text-white tracking-tight">{title}</h2>
<p class="text-slate-400 text-sm mt-2">{subtitle}</p>
</div>
<div id="form-feedback" role="status" aria-live="polite" class="hidden mb-6 p-4 rounded-lg text-sm font-semibold"></div>
<form id="contact-form" class="space-y-6" novalidate>
<div class="hidden" aria-hidden="true">
<label for="website_hp">Bitte dieses Feld leer lassen</label>
<input type="text" id="website_hp" name="website_hp" tabindex="-1" autocomplete="off" />
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<div>
<label for="sender_name" class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
Ihr Name <span class="text-sky-400" aria-hidden="true">*</span>
</label>
<input
type="text"
id="sender_name"
name="sender_name"
required
aria-required="true"
placeholder="Max Mustermann"
class="w-full bg-slate-950/60 border border-slate-800 rounded-lg p-3 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-all"
/>
</div>
<div>
<label for="sender_email" class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
E-Mail Adresse <span class="text-sky-400" aria-hidden="true">*</span>
</label>
<input
type="email"
id="sender_email"
name="sender_email"
required
aria-required="true"
placeholder="max@beispiel.de"
class="w-full bg-slate-950/60 border border-slate-800 rounded-lg p-3 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-all"
/>
</div>
</div>
<div>
<label for="message_text" class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-2">
Ihre Nachricht <span class="text-sky-400" aria-hidden="true">*</span>
</label>
<textarea
id="message_text"
name="message_text"
rows="5"
required
aria-required="true"
placeholder="Wie können wir Ihnen helfen?"
class="w-full bg-slate-950/60 border border-slate-800 rounded-lg p-3 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-sky-500 focus:border-transparent transition-all resize-y"
></textarea>
</div>
<div class="flex items-start gap-3">
<input
type="checkbox"
id="privacy_consent"
name="privacy_consent"
required
aria-required="true"
class="mt-1 h-4 w-4 accent-sky-500 rounded border-slate-800 bg-slate-950/60 cursor-pointer focus:ring-2 focus:ring-sky-500"
/>
<label for="privacy_consent" class="text-xs text-slate-400 leading-relaxed cursor-pointer">
Ich stimme der Verarbeitung meiner Angaben gemäß der <a href="/datenschutz" class="text-sky-400 underline hover:text-sky-300">Datenschutzerklärung</a> zur Kontaktaufnahme zu. <span class="text-sky-400" aria-hidden="true">*</span>
</label>
</div>
<button
type="submit"
id="btn-submit-contact"
class="w-full bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold py-3.5 px-6 rounded-lg text-sm transition-all shadow-lg shadow-sky-500/20 focus:outline-none focus:ring-2 focus:ring-sky-300"
>
{buttonText}
</button>
</form>
</div>
</section>
<script>
const form = document.getElementById('contact-form') as HTMLFormElement;
const feedback = document.getElementById('form-feedback');
const submitBtn = document.getElementById('btn-submit-contact') as HTMLButtonElement;
form?.addEventListener('submit', async (e) => {
e.preventDefault();
if (!feedback || !submitBtn) return;
// Client-side Honeypot Check
const hp = (document.getElementById('website_hp') as HTMLInputElement)?.value;
if (hp) return; // Stiller Abbruch bei Bot-Befüllung
submitBtn.disabled = true;
submitBtn.textContent = 'Wird gesendet...';
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
try {
const res = await fetch('/api/contact/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});
const result = await res.json();
feedback.classList.remove('hidden', 'bg-red-950/80', 'text-red-300', 'border-red-800', 'bg-emerald-950/80', 'text-emerald-300', 'border-emerald-800');
if (res.ok) {
feedback.classList.add('bg-emerald-950/80', 'text-emerald-300', 'border', 'border-emerald-800');
feedback.textContent = 'Vielen Dank! Ihre Nachricht wurde erfolgreich übermittelt.';
form.reset();
} else {
throw new Error(result.error || 'Fehler beim Senden.');
}
} catch (err: any) {
feedback.classList.add('bg-red-950/80', 'text-red-300', 'border', 'border-red-800');
feedback.textContent = err.message || 'Nachricht konnte nicht gesendet werden. Bitte versuchen Sie es später erneut.';
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Nachricht absenden';
}
});
</script>

View File

@@ -0,0 +1,82 @@
---
import { animate } from 'motion';
---
<canvas id="dense-bg" class="dense-bg"></canvas>
<script type="module">
// Rocky GrundSetup
const canvas = document.getElementById('dense-bg');
const ctx = canvas.getContext('2d');
const TAU = Math.PI * 2;
const DENSITY = 200; // Rocky PartikelZahl
const particles = [];
// Rocky Größe anpassen
function resize() {
canvas.width = innerWidth;
canvas.height = innerHeight;
}
resize();
addEventListener('resize', resize);
// Rocky PartikelObjekt
function createParticle() {
return {
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
r: 1 + Math.random() * 2,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
alpha: Math.random() * 0.5 + 0.2,
};
}
// Rocky Initialisierung
for (let i = 0; i < DENSITY; i++) particles.push(createParticle());
// Rocky MausTracking
let mouse = { x: -9999, y: -9999 };
addEventListener('mousemove', e => {
mouse.x = e.clientX;
mouse.y = e.clientY;
});
// Rocky UpdateLoop
function update() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
particles.forEach(p => {
// Rocky Bewegung
p.x += p.vx;
p.y += p.vy;
// Rocky RandWrap
if (p.x < 0) p.x = canvas.width;
if (p.x > canvas.width) p.x = 0;
if (p.y < 0) p.y = canvas.height;
if (p.y > canvas.height) p.y = 0;
// Rocky MausAnziehung
const dx = mouse.x - p.x;
const dy = mouse.y - p.y;
const dist = Math.hypot(dx, dy);
if (dist < 100) {
const pull = (100 - dist) / 100;
p.vx += dx * 0.001 * pull;
p.vy += dy * 0.001 * pull;
}
// Rocky FadeIn/Out
p.alpha += (Math.random() - 0.5) * 0.005;
p.alpha = Math.min(Math.max(p.alpha, 0.1), 0.8);
// Rocky Zeichen
ctx.beginPath();
ctx.arc(p.x, p.y, p.r, 0, TAU);
ctx.fillStyle = `rgba(255,255,255,${p.alpha})`;
ctx.fill();
});
requestAnimationFrame(update);
}
// Rocky Start
update();
</script>
<style>
.dense-bg {
position: fixed;
inset: 0;
pointer-events: none;
z-index: -1;
background: #0b0f19; /* Rocky dunkler Hintergrund */
}
</style>
---

View File

@@ -1,5 +1,13 @@
---
// src/components/Footer.astro
import { loadWaasConfigs } from '../utils/configLoader';
const { siteConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const footer = siteConfig.footer || {
brandDescription: 'Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO für Kleinbetriebe & KMU.',
copyrightText: 'N&D i-t SOLUTIONS. Alle Rechte vorbehalten.',
showLegalLinks: true
};
const currentYear = new Date().getFullYear();
---
@@ -10,10 +18,11 @@ const currentYear = new Date().getFullYear();
<!-- Brand logo/name -->
<div>
<a href="#" class="text-lg font-bold tracking-wider text-slate-900 dark:text-white">
N&D <span class="text-brand-arcticBlue">i-t SOLUTIONS</span>
{siteConfig.header?.brandName || 'N&D'} <span class="text-accent">{siteConfig.header?.brandHighlight || 'i-t SOLUTIONS'}</span>
</a>
<p class="mt-2 text-xs text-slate-500 dark:text-gray-500 max-w-sm">
Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO für Kleinbetriebe & KMU.
{footer.brandDescription}
</p>
</div>
@@ -31,13 +40,15 @@ const currentYear = new Date().getFullYear();
<!-- Bottom Copyright & Legal Links -->
<div class="flex flex-col sm:flex-row items-center justify-between gap-4 text-xs text-slate-500 dark:text-gray-500">
<div>
&copy; {currentYear} N&D i-t SOLUTIONS. Alle Rechte vorbehalten.
</div>
<div class="flex gap-6">
<a href="/privacy" class="hover:text-slate-900 dark:hover:text-white transition-colors">Datenschutz</a>
<span class="text-slate-300 dark:text-white/10">|</span>
<a href="/privacy#impressum" class="hover:text-slate-900 dark:hover:text-white transition-colors">Impressum</a>
&copy; {currentYear} {footer.copyrightText}
</div>
{footer.showLegalLinks !== false && (
<div class="flex gap-6">
<a href="/privacy" class="hover:text-slate-900 dark:hover:text-white transition-colors">Datenschutz</a>
<span class="text-slate-300 dark:text-white/10">|</span>
<a href="/privacy#impressum" class="hover:text-slate-900 dark:hover:text-white transition-colors">Impressum</a>
</div>
)}
</div>
</div>
</footer>

View File

@@ -1,9 +1,22 @@
---
// src/components/HeroAndLiquidGlass.astro
const heroTemplates = [
{ id: "01", name: "Der Handwerker-Klassiker", style: "Einfach & Bodenständig", layout: "boxed", design: "Brutalismus", colors: "from-[#FDFBF7] to-[#FAF7F2] text-[#2C1A11] border-[#2C1A11]/10" },
{ id: "02", name: "Dachdecker Meister", style: "Elegant & Professionell", layout: "split", design: "Glassmorphismus", colors: "from-zinc-900 to-zinc-950 text-white border-yellow-500/20" },
{ id: "03", name: "Solar & Photovoltaik", style: "Tech & Zukunft", layout: "bento", design: "SaaS-Minimalismus", colors: "from-zinc-900 to-black text-zinc-100 border-orange-500/20" },
{ id: "04", name: "Die Kanzlei", style: "Seriös & Clean", layout: "asymmetric", design: "Glassmorphismus", colors: "from-[#0B0F19] to-[#070A13] text-white border-emerald-500/20" },
{ id: "05", name: "Bio-Hofladen", style: "Rustikal & Natur", layout: "single", design: "Neomorphismus", colors: "from-[#FAF7F2] to-[#E8DCC4] text-[#4A3B32] border-[#E8DCC4]" },
{ id: "06", name: "Sanitär & Heizung", style: "Klar & Zuverlässig", layout: "bento", design: "SaaS-Minimalismus", colors: "from-white to-slate-50 text-slate-800 border-slate-200" },
{ id: "07", name: "KFZ Schnellservice", style: "Dynamisch & Direkt", layout: "split", design: "Brutalismus", colors: "from-zinc-800 to-zinc-900 text-white border-zinc-700" },
{ id: "08", name: "Schreinerei Holzart", style: "Handwerk & Präzision", layout: "boxed", design: "SaaS-Minimalismus", colors: "from-[#F5F2EB] to-[#D9D2C5] text-[#3D3A36] border-[#D9D2C5]" },
{ id: "09", name: "Steuerberater Premium", style: "Exklusiv & Diskret", layout: "bento", design: "Glassmorphismus", colors: "from-slate-900 to-slate-950 text-slate-100 border-amber-500/20" },
{ id: "10", name: "Garten- & Landschaftsbau", style: "Frisch & Modern", layout: "asymmetric", design: "Glassmorphismus", colors: "from-[#F3F7F4] to-white text-slate-800 border-emerald-200" }
];
---
<!-- Hero-Sektion: Hybrid Liquid Glass Style & Aurora Glow -->
<section class="relative pt-36 pb-24 overflow-hidden bg-slate-50 dark:bg-[#070A13] transition-colors duration-300">
<section class="relative pt-36 pb-24 overflow-hidden bg-slate-50 dark:bg-[#0B0F19] transition-colors duration-300">
<!-- Weiche, pastellfarbene Aurora Glows im Hintergrund (Light/Dark angepasst) -->
<div class="absolute top-[-10%] left-[-10%] w-[50%] h-[50%] bg-sky-200/50 dark:bg-sky-500/10 blur-[130px] rounded-full pointer-events-none transition-colors duration-300 animate-drift"></div>
<div class="absolute bottom-[-10%] right-[-10%] w-[60%] h-[60%] bg-indigo-200/40 dark:bg-indigo-500/10 blur-[150px] rounded-full pointer-events-none transition-colors duration-300 animate-drift-delayed"></div>
@@ -18,23 +31,23 @@
<div class="lg:col-span-7 space-y-8">
<h1 class="text-4xl sm:text-7xl font-extrabold tracking-tight text-slate-900 dark:text-white leading-[1.1] transition-colors duration-300">
Sorgenfreies Hosting <br/>
Ihre eigene Webseite. <br/>
<span class="text-sky-600 dark:text-sky-400">
für Ihre Website.
Fix und fertig gemacht.
</span>
</h1>
<p class="text-slate-600 dark:text-zinc-400 text-sm sm:text-base max-w-xl leading-relaxed transition-colors duration-300">
Wir hosten, sichern und pflegen Ihren Internetauftritt auf deutschen Servern. Im monatlichen Sorgenfrei-Paket ist alles inklusive: tägliche Backups, DSGVO-Updates, SSL-Verschlüsselung und auf Wunsch die Erstellung Ihrer Website.
Wir bauen, pflegen und sichern Ihren Internetauftritt. Sie müssen sich um absolut nichts kümmern. Für einen fairen monatlichen Festpreis ist alles drin: Gestaltung, Technik, Schutz vor Abmahnungen und schnelle Hilfe bei Änderungen.
</p>
<div class="flex flex-wrap gap-4 pt-2">
<a href="#contact" class="inline-flex items-center justify-center gap-2 px-6 py-3.5 bg-sky-600 hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-white text-white dark:text-zinc-950 font-bold text-xs uppercase tracking-wider rounded transition-all duration-300 shadow-[0_4px_20px_rgba(2,132,199,0.2)] dark:shadow-[0_0_20px_rgba(56,189,248,0.2)]">
Erstgespräch buchen
Kostenloses Gespräch buchen
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="w-3 h-3 fill-current" aria-hidden="true"><path d="M438.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L338.8 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l306.7 0L233.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>
</a>
<a href="/templates" class="inline-flex items-center justify-center gap-2 px-6 py-3.5 bg-white hover:bg-slate-50 dark:bg-slate-900/60 dark:hover:bg-slate-800 border border-slate-200 dark:border-white/10 text-slate-700 dark:text-white font-bold text-xs uppercase tracking-wider rounded transition-all duration-300 shadow-sm backdrop-blur-md">
Templates ansehen
Beispiele ansehen
</a>
</div>
</div>
@@ -49,10 +62,6 @@
<!-- Textur-Noise -->
<div class="absolute inset-0 opacity-[0.02] dark:opacity-[0.01] pointer-events-none bg-[radial-gradient(#000_1px,transparent_1px)] dark:bg-[radial-gradient(#fff_1px,transparent_1px)] [background-size:8px_8px] transition-opacity duration-300"></div>
<!-- Helle innere Sphären -->
<div class="absolute -top-12 -right-12 w-32 h-32 rounded-full bg-sky-200/35 dark:bg-sky-400/10 blur-xl group-hover:scale-110 transition-transform duration-700"></div>
<div class="absolute -bottom-16 -left-16 w-40 h-40 rounded-full bg-indigo-200/25 dark:bg-indigo-500/10 blur-xl group-hover:scale-110 transition-transform duration-700"></div>
<!-- Taktile Details -->
<span class="absolute top-3 left-3 w-2 h-2 border-t border-l border-slate-300 dark:border-white/30"></span>
<span class="absolute top-3 right-3 w-2 h-2 border-t border-r border-slate-300 dark:border-white/30"></span>
@@ -60,8 +69,8 @@
<span class="absolute bottom-3 right-3 w-2 h-2 border-b border-r border-slate-300 dark:border-white/30"></span>
<!-- Header -->
<div class="flex justify-between items-center relative z-10">
<span class="text-[9px] font-mono text-slate-400 dark:text-zinc-500 tracking-widest">SYSTEM STATUS: ACTIVE</span>
<div class="relative z-10 flex justify-between items-center mb-3">
<span class="text-[9px] font-mono text-slate-400 dark:text-zinc-500 tracking-widest uppercase" id="hero-slider-counter">TEMPLATE 01 / 10</span>
<div class="flex gap-1">
<span class="w-1.5 h-1.5 rounded-full bg-slate-300 dark:bg-red-500/60"></span>
<span class="w-1.5 h-1.5 rounded-full bg-slate-300 dark:bg-yellow-500/60"></span>
@@ -69,23 +78,83 @@
</div>
</div>
<!-- Grafik -->
<div class="my-auto relative z-10 flex flex-col items-center">
<div class="w-24 h-24 rounded-full border border-sky-200 dark:border-white/10 flex items-center justify-center bg-white/60 dark:bg-white/[0.02] relative group-hover:border-sky-400/40 transition-colors duration-500 shadow-inner">
<svg class="w-8 h-8 text-sky-600 dark:text-sky-400 stroke-[1.5]" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.813 15.904L9 21m0 0l-.813-5.096L3 15.187m6 5.813a2.25 2.25 0 100-4.5 2.25 2.25 0 000 4.5zM19.071 5.929a10 10 0 00-14.142 0M17.657 7.343a8 8 0 00-11.314 0M16.243 8.757a6 6 0 00-8.486 0M19.071 19.071L12 12" />
</svg>
</div>
<span class="text-xs font-mono text-slate-800 dark:text-zinc-300 mt-4 tracking-wider uppercase font-bold">Lighthouse Perfekt</span>
<span class="text-[10px] text-slate-500 font-mono mt-1">Speed Index: 0.4s</span>
<!-- Dynamic Slider Content -->
<div class="my-auto relative z-10 w-full overflow-hidden h-48">
{heroTemplates.map((item, index) => (
<div id={`hero-slide-${index}`} class={`hero-slide absolute inset-0 transition-all duration-500 flex flex-col justify-between ${index === 0 ? 'opacity-100 scale-100 pointer-events-auto' : 'opacity-0 scale-95 pointer-events-none'}`}>
<div class:list={["relative w-full h-full rounded-2xl border bg-gradient-to-br p-4 flex flex-col justify-between shadow-inner", item.colors]}>
<!-- Top bar inside preview card -->
<div class="flex justify-between items-center border-b border-current/5 pb-2">
<span class="text-[9px] font-mono opacity-60 tracking-wider">TEMPLATE {item.id}</span>
<span class="text-[9px] font-mono px-1.5 py-0.5 rounded bg-current/10 uppercase font-bold">{item.layout}</span>
</div>
<!-- Mini layout representations -->
<div class="flex-grow py-3 flex gap-2 opacity-30">
{item.layout === "bento" && (
<div class="w-full grid grid-cols-3 gap-1">
<div class="col-span-2 h-5 bg-current/20 rounded"></div>
<div class="h-5 bg-current/20 rounded"></div>
<div class="h-7 bg-current/25 rounded"></div>
<div class="col-span-2 h-7 bg-current/20 rounded"></div>
</div>
)}
{item.layout === "split" && (
<div class="w-full grid grid-cols-2 gap-2">
<div class="h-14 bg-current/20 rounded"></div>
<div class="h-14 bg-current/30 rounded flex flex-col justify-end p-1"><div class="h-1.5 w-3/4 bg-current/20 rounded"></div></div>
</div>
)}
{item.layout === "boxed" && (
<div class="w-full border border-current/20 rounded p-1 flex flex-col gap-1">
<div class="h-2 bg-current/20 rounded"></div>
<div class="h-9 bg-current/20 rounded"></div>
</div>
)}
{item.layout === "asymmetric" && (
<div class="w-full flex gap-2">
<div class="w-1/3 h-14 bg-current/25 rounded"></div>
<div class="w-2/3 h-10 bg-current/15 rounded"></div>
</div>
)}
{item.layout === "single" && (
<div class="w-full flex flex-col gap-1">
<div class="h-3 bg-current/20 rounded"></div>
<div class="h-3 bg-current/20 rounded"></div>
<div class="h-6 bg-current/20 rounded"></div>
</div>
)}
</div>
<!-- Bottom info -->
<div class="flex justify-between items-end pt-2 border-t border-current/5">
<div>
<h3 class="text-xs font-bold leading-none">{item.name}</h3>
<span class="text-[9px] opacity-60 mt-1 block">{item.style}</span>
</div>
<span class="text-[9px] font-mono px-1.5 py-0.5 rounded bg-current/5 uppercase">{item.design}</span>
</div>
</div>
</div>
))}
</div>
<!-- Footer -->
<div class="relative z-10 border-t border-slate-100 dark:border-white/5 pt-4">
<div class="flex justify-between items-baseline">
<span class="text-2xl font-bold text-slate-900 dark:text-white tracking-tight">100 / 100</span>
<span class="text-[9px] font-mono text-emerald-600 dark:text-emerald-400 uppercase tracking-widest font-bold">BEST SCORE</span>
<!-- Navigation Footer -->
<div class="relative z-10 border-t border-slate-100 dark:border-white/5 pt-4 flex justify-between items-center">
<!-- Left/Right Buttons -->
<div class="flex gap-2">
<button id="hero-slider-prev" aria-label="Vorheriges Template" class="w-8 h-8 rounded-lg bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 flex items-center justify-center text-slate-700 dark:text-white transition-colors border border-black/5 dark:border-white/5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" class="w-2.5 h-2.5 fill-current"><path d="M9.4 233.4c-12.5 12.5-12.5 32.8 0 45.3l192 192c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L77.3 256 246.6 86.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0l-192 192z"/></svg>
</button>
<button id="hero-slider-next" aria-label="Nächstes Template" class="w-8 h-8 rounded-lg bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 flex items-center justify-center text-slate-700 dark:text-white transition-colors border border-black/5 dark:border-white/5">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 512" class="w-2.5 h-2.5 fill-current"><path d="M310.6 233.4c12.5 12.5 12.5 32.8 0 45.3l-192 192c-12.5 12.5-32.8 12.5-45.3 0s-12.5-32.8 0-45.3L242.7 256 73.4 86.6c-12.5-12.5-12.5-32.8 0-45.3s-32.8-12.5-45.3 0l192 192z"/></svg>
</button>
</div>
<a href="/templates" class="inline-flex items-center gap-1.5 px-3 py-1.5 bg-sky-600 hover:bg-sky-700 dark:bg-sky-500/10 dark:hover:bg-sky-500/20 text-white dark:text-sky-400 font-bold text-[10px] uppercase tracking-wider rounded-lg border border-transparent dark:border-sky-500/20 transition-colors">
Alle Templates
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="w-2.5 h-2.5 fill-current" aria-hidden="true"><path d="M438.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L338.8 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l306.7 0L233.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>
</a>
</div>
</div>
</div>
@@ -95,7 +164,7 @@
</section>
<!-- Hauptinhalte: Elegantes Hybrid-Liquid Layout (Echtes Handwerk, kein KI-Standard-Raster) -->
<section class="py-24 bg-slate-50 dark:bg-[#070A13] relative border-t border-slate-100 dark:border-white/5 transition-colors duration-300">
<section class="py-24 bg-slate-50 dark:bg-[#0B0F19] relative border-t border-slate-100 dark:border-white/5 transition-colors duration-300">
<div class="absolute inset-0 opacity-[0.02] pointer-events-none bg-[radial-gradient(#000_1px,transparent_1px)] dark:bg-[radial-gradient(#fff_1px,transparent_1px)] [background-size:16px_16px]"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
@@ -104,10 +173,10 @@
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 mb-16 items-start reveal">
<div class="lg:col-span-8">
<h2 class="text-3xl sm:text-4xl font-extrabold text-slate-900 dark:text-white tracking-tight uppercase transition-colors duration-300">
Ihr digitaler Mitarbeiter für Neukunden.
Ihr neuer Helfer im Internet.
</h2>
<p class="text-slate-600 dark:text-zinc-400 text-sm mt-4 leading-relaxed max-w-2xl transition-colors duration-300">
Eine gute Website ist kein Statussymbol, sondern Werkzeug. Wir programmieren Ihren Web-Auftritt so, dass er vollautomatisch Anfragen von echten Kunden aus Ihrer Region generiert.
Eine Webseite soll Ihnen Arbeit abnehmen und neue Kunden bringen. Wir bauen Ihre Seite genau so, dass Menschen aus Ihrer Region Sie ganz einfach anrufen oder eine Nachricht schreiben können.
</p>
</div>
@@ -115,24 +184,24 @@
<div class="lg:col-span-4 bg-white/50 border border-white dark:bg-transparent dark:border-white/5 p-6 rounded-2xl shadow-sm space-y-4 transition-colors duration-300">
<div class="flex items-center gap-3 text-xs text-slate-700 dark:text-zinc-300 font-medium">
<svg class="w-4 h-4 text-emerald-500 dark:text-sky-400 shrink-0 fill-current" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg"><path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>
<span>Keine Baukästen</span>
<span>Echte Handarbeit (keine Baukästen)</span>
</div>
<div class="flex items-center gap-3 text-xs text-slate-700 dark:text-zinc-300 font-medium">
<svg class="w-4 h-4 text-emerald-500 dark:text-sky-400 shrink-0 fill-current" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg"><path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>
<span>100% DSGVO-sicher</span>
<span>100% Sicher vor Abmahnungen</span>
</div>
<div class="flex items-center gap-3 text-xs text-slate-700 dark:text-zinc-300 font-medium">
<svg class="w-4 h-4 text-emerald-500 dark:text-sky-400 shrink-0 fill-current" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg"><path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>
<span>Feste Monats-Pauschale</span>
<span>Fester, fairer Monatspreis</span>
</div>
</div>
</div>
<!-- Liquid Glass Service Grid -->
<!-- Liquid Glass Bento Grid (3-Säulen-Architektur) -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<!-- Card 1: Webdesign -->
<div class="relative rounded-2xl border border-white/80 dark:border-white/[0.06] bg-white/40 dark:bg-transparent p-8 backdrop-blur-xl hover:border-sky-300 dark:hover:border-sky-500/20 hover:bg-white/60 dark:hover:bg-slate-950/20 transition-all duration-300 group flex flex-col justify-between min-h-[260px] shadow-sm hover:shadow-md reveal">
<!-- Card 1: WaaS Premium Webdesign (Breit: col-span-2) -->
<div class="relative md:col-span-2 rounded-2xl border border-white/80 dark:border-white/[0.06] bg-white/40 dark:bg-slate-900/10 p-8 backdrop-blur-xl hover:border-sky-300 dark:hover:border-sky-500/20 hover:bg-white/60 dark:hover:bg-slate-950/20 transition-all duration-300 group flex flex-col justify-between min-h-[260px] shadow-sm hover:shadow-md reveal">
<span class="absolute top-3 left-3 w-1.5 h-1.5 border-t border-l border-slate-300 dark:border-zinc-700/50"></span>
<span class="absolute top-3 right-3 w-1.5 h-1.5 border-t border-r border-slate-300 dark:border-zinc-700/50"></span>
@@ -140,16 +209,19 @@
<div class="w-10 h-10 rounded bg-sky-50 border border-sky-100 dark:bg-sky-400/5 dark:border-sky-400/10 flex items-center justify-center text-sky-600 dark:text-sky-400 group-hover:border-sky-300 dark:group-hover:border-sky-400/30 transition-all duration-300 mb-6">
<svg class="w-5 h-5 fill-current" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M48 24C48 10.7 37.3 0 24 0S0 10.7 0 24V488c0 13.3 10.7 24 24 24s24-10.7 24-24V24zM128 64c-17.7 0-32 14.3-32 32v48c0 17.7 14.3 32 32 32H384c17.7 0 32-14.3 32-32V96c0-17.7-14.3-32-32-32H128zm0 160c-17.7 0-32 14.3-32 32v48c0 17.7 14.3 32 32 32H384c17.7 0 32-14.3 32-32V256c0-17.7-14.3-32-32-32H128zm0 160c-17.7 0-32 14.3-32 32v48c0 17.7 14.3 32 32 32H384c17.7 0 32-14.3 32-32V416c0-17.7-14.3-32-32-32H128z"/></svg>
</div>
<h3 class="text-lg font-bold text-slate-950 dark:text-white mb-2 uppercase tracking-tight transition-colors duration-300">Sorgenfrei-Hosting</h3>
<p class="text-slate-600 dark:text-zinc-400 text-xs leading-relaxed transition-colors duration-300">
Deutsche Server, tägliche Backups und SSL-Verschlüsselung. Ihr Internetauftritt ist rundum abgesichert im monatlichen Abo.
<h3 class="text-xl font-bold text-slate-950 dark:text-white mb-2 uppercase tracking-tight transition-colors duration-300">Rundum-Sorglos-Webseite</h3>
<p class="text-slate-600 dark:text-zinc-400 text-sm leading-relaxed transition-colors duration-300">
Wir bauen Ihre neue, verkaufsstarke Webseite fix und fertig auf. Keine teuren Einmalkosten für die Erstellung. Für eine faire monatliche Pauschale übernehmen wir das Design, die Programmierung, den Server, Sicherheits-Updates und tägliche Backups.
</p>
</div>
<span class="text-[9px] font-mono text-slate-400 dark:text-zinc-600 mt-6 tracking-wider">100% Regelkonform</span>
<div class="flex justify-between items-center mt-6 pt-4 border-t border-slate-100 dark:border-white/5">
<span class="text-[10px] font-mono text-slate-400 dark:text-zinc-500 uppercase tracking-wider">Alles inklusive ab 49€ / Monat</span>
<span class="text-xs font-bold text-sky-600 dark:text-sky-400">Keine versteckten Kosten</span>
</div>
</div>
<!-- Card 2: SEO -->
<div class="relative rounded-2xl border border-white/80 dark:border-white/[0.06] bg-white/40 dark:bg-transparent p-8 backdrop-blur-xl hover:border-sky-300 dark:hover:border-sky-500/20 hover:bg-white/60 dark:hover:bg-slate-950/20 transition-all duration-300 group flex flex-col justify-between min-h-[260px] shadow-sm hover:shadow-md reveal">
<!-- Card 2: Lokale Sichtbarkeit (Schmal: col-span-1) -->
<div class="relative rounded-2xl border border-white/80 dark:border-white/[0.06] bg-white/40 dark:bg-slate-900/10 p-8 backdrop-blur-xl hover:border-sky-300 dark:hover:border-sky-500/20 hover:bg-white/60 dark:hover:bg-slate-950/20 transition-all duration-300 group flex flex-col justify-between min-h-[260px] shadow-sm hover:shadow-md reveal">
<span class="absolute top-3 left-3 w-1.5 h-1.5 border-t border-l border-slate-300 dark:border-zinc-700/50"></span>
<span class="absolute top-3 right-3 w-1.5 h-1.5 border-t border-r border-slate-300 dark:border-zinc-700/50"></span>
@@ -157,51 +229,42 @@
<div class="w-10 h-10 rounded bg-sky-50 border border-sky-100 dark:bg-sky-400/5 dark:border-sky-400/10 flex items-center justify-center text-sky-600 dark:text-sky-400 group-hover:border-sky-300 dark:group-hover:border-sky-400/30 transition-all duration-300 mb-6">
<svg class="w-5 h-5 fill-current" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M416 208c0 45.9-14.9 88.3-40 122.7L502.6 457.4c12.5 12.5 12.5 32.8 0 45.3s-32.8 12.5-45.3 0L330.7 376c-34.4 25.2-76.8 40-122.7 40C93.1 416 0 322.9 0 208S93.1 0 208 0S416 93.1 416 208zM208 352a144 144 0 1 0 0-288 144 144 0 1 0 0 288z"/></svg>
</div>
<h3 class="text-lg font-bold text-slate-950 dark:text-white mb-2 uppercase tracking-tight transition-colors duration-300">Lokale Sichtbarkeit</h3>
<h3 class="text-lg font-bold text-slate-950 dark:text-white mb-2 uppercase tracking-tight transition-colors duration-300">Bei Google glänzen</h3>
<p class="text-slate-600 dark:text-zinc-400 text-xs leading-relaxed transition-colors duration-300">
Google-Optimierung für Ihre Stadt. Damit Kunden Sie finden und nicht die Konkurrenz.
Wir sorgen dafür, dass Kunden aus Ihrer Stadt Sie sofort finden, wenn sie bei Google nach Ihren Angeboten suchen.
</p>
</div>
<span class="text-[9px] font-mono text-slate-400 dark:text-zinc-600 mt-6 tracking-wider">Google-Dominanz</span>
<span class="text-[9px] font-mono text-slate-400 dark:text-zinc-600 mt-6 tracking-wider">Kunden aus der Region</span>
</div>
<!-- Card 3: Hosting -->
<div class="relative rounded-2xl border border-white/80 dark:border-white/[0.06] bg-white/40 dark:bg-transparent p-8 backdrop-blur-xl hover:border-sky-300 dark:hover:border-sky-500/20 hover:bg-white/60 dark:hover:bg-slate-950/20 transition-all duration-300 group flex flex-col justify-between min-h-[260px] shadow-sm hover:shadow-md reveal">
<!-- Card 3: DSGVO-Sicherheit (Schmal: col-span-1) -->
<div class="relative rounded-2xl border border-white/80 dark:border-white/[0.06] bg-white/40 dark:bg-slate-900/10 p-8 backdrop-blur-xl hover:border-sky-300 dark:hover:border-sky-500/20 hover:bg-white/60 dark:hover:bg-slate-950/20 transition-all duration-300 group flex flex-col justify-between min-h-[260px] shadow-sm hover:shadow-md reveal">
<span class="absolute top-3 left-3 w-1.5 h-1.5 border-t border-l border-slate-300 dark:border-zinc-700/50"></span>
<span class="absolute top-3 right-3 w-1.5 h-1.5 border-t border-r border-slate-300 dark:border-zinc-700/50"></span>
<div>
<div class="w-10 h-10 rounded bg-sky-50 border border-sky-100 dark:bg-sky-400/5 dark:border-sky-400/10 flex items-center justify-center text-sky-600 dark:text-sky-400 group-hover:border-sky-300 dark:group-hover:border-sky-400/30 transition-all duration-300 mb-6">
<svg class="w-5 h-5 fill-current" viewBox="0 0 640 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M64 96c0-35.3 28.7-64 64-64H512c35.3 0 64 28.7 64 64V352H512V96H128V352H64V96zM0 403.2C0 392.6 8.6 384 19.2 384H620.8c10.6 0 19.2 8.6 19.2 19.2c0 42.4-34.4 76.8-76.8 76.8H76.8C34.4 480 0 445.6 0 403.2zM288 432c0 8.8 7.2 16 16 16h32c8.8 0 16-7.2 16-16s-7.2-16-16-16H304c-8.8 0-16 7.2-16 16z"/></svg>
<svg class="w-5 h-5 fill-current" viewBox="0 0 448 512" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"><path d="M438.6 105.4c12.5 12.5 12.5 32.8 0 45.3l-256 256c-12.5 12.5-32.8 12.5-45.3 0l-128-128c-12.5-12.5-12.5-32.8 0-45.3s32.8-12.5 45.3 0L160 338.7 393.4 105.4c12.5-12.5 32.8-12.5 45.3 0z"/></svg>
</div>
<h3 class="text-lg font-bold text-slate-950 dark:text-white mb-2 uppercase tracking-tight transition-colors duration-300">Webdesign Inklusive</h3>
<h3 class="text-lg font-bold text-slate-950 dark:text-white mb-2 uppercase tracking-tight transition-colors duration-300">100% Rechtssicher</h3>
<p class="text-slate-600 dark:text-zinc-400 text-xs leading-relaxed transition-colors duration-300">
Noch keine eigene Website? Wir erstellen Ihren neuen, optimierten Internetauftritt direkt passend zum Hosting dazu.
Wir bauen Ihre Seite so, dass alles rechtlich wasserdicht ist. Keine Spionage, keine fremden Cookies, kein Ärger.
</p>
</div>
<span class="text-[9px] font-mono text-slate-400 dark:text-zinc-600 mt-6 tracking-wider">Codeschreinerei</span>
<span class="text-[9px] font-mono text-slate-400 dark:text-zinc-600 mt-6 tracking-wider">Abmahnsicher</span>
</div>
</div>
<!-- Showcase-Link Callout -->
<div class="mt-16 text-center reveal">
<div class="inline-flex flex-col sm:flex-row items-center gap-4 p-4 rounded-2xl border border-white/80 dark:border-white/5 bg-white/30 dark:bg-slate-950/20 backdrop-blur-md shadow-sm">
<span class="text-xs text-slate-600 dark:text-zinc-400">
Sie suchen Inspiration? Entdecken Sie unsere 20 maßgeschneiderten Design-Vorlagen.
</span>
<a href="/templates" class="inline-flex items-center gap-2 text-xs font-bold text-sky-600 dark:text-sky-400 hover:text-sky-700 dark:hover:text-white transition-colors duration-300">
Design-Vorlagen ansehen
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512" class="w-3 h-3 fill-current" aria-hidden="true"><path d="M438.6 278.6c12.5-12.5 12.5-32.8 0-45.3l-160-160c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L338.8 224 32 224c-17.7 0-32 14.3-32 32s14.3 32 32 32l306.7 0L233.4 393.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0l160-160z"/></svg>
</a>
</div>
</div>
</div>
</section>
<!-- FAQ Sektion im Hybrid Liquid Glass Look -->
<section class="py-24 bg-slate-50 dark:bg-[#070A13] relative border-t border-slate-100 dark:border-white/5 transition-colors duration-300">
<section class="py-24 bg-slate-50 dark:bg-[#0B0F19] relative border-t border-slate-100 dark:border-white/5 transition-colors duration-300">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div class="text-center mb-16">
<h2 class="text-3xl font-extrabold text-slate-900 dark:text-white tracking-tight uppercase">
@@ -331,4 +394,36 @@
});
}
});
// Hero Template Slider
const slides = document.querySelectorAll('.hero-slide');
const prevBtn = document.getElementById('hero-slider-prev');
const nextBtn = document.getElementById('hero-slider-next');
const counter = document.getElementById('hero-slider-counter');
let currentIdx = 0;
const totalSlides = slides.length;
function updateSlider(newIdx: number) {
// Hide current slide
slides[currentIdx].classList.remove('opacity-100', 'scale-100', 'pointer-events-auto');
slides[currentIdx].classList.add('opacity-0', 'scale-95', 'pointer-events-none');
// Update index
currentIdx = (newIdx + totalSlides) % totalSlides;
// Show new slide
slides[currentIdx].classList.remove('opacity-0', 'scale-95', 'pointer-events-none');
slides[currentIdx].classList.add('opacity-100', 'scale-100', 'pointer-events-auto');
// Update counter text
if (counter) {
const templateNum = String(currentIdx + 1).padStart(2, '0');
counter.textContent = `TEMPLATE ${templateNum} / ${totalSlides}`;
}
}
if (prevBtn && nextBtn) {
prevBtn.addEventListener('click', () => updateSlider(currentIdx - 1));
nextBtn.addEventListener('click', () => updateSlider(currentIdx + 1));
}
</script>

View File

@@ -1,19 +1,31 @@
---
// src/components/Navbar.astro
import { FaBars, FaTimes } from 'react-icons/fa';
import { loadWaasConfigs } from '../utils/configLoader';
const { siteConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const header = siteConfig.header || {
brandName: 'N&D',
brandHighlight: 'i-t SOLUTIONS',
ctaText: 'Erstgespräch buchen',
ctaLink: '#contact',
showThemeToggle: true,
glassEffect: true
};
---
<header id="main-header" class="fixed top-0 left-0 w-full z-50 transition-all duration-300 py-3 bg-white/30 dark:bg-brand-darkBg/30 backdrop-blur-xl border-b border-slate-200 dark:border-brand-darkBorder">
<header id="main-header" class={`fixed top-0 left-0 w-full z-50 transition-all duration-300 py-3 ${header.glassEffect !== false ? 'bg-white/30 dark:bg-brand-darkBg/30 backdrop-blur-xl border-b border-slate-200 dark:border-brand-darkBorder' : 'bg-slate-900 border-b border-white/10'}`}>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex items-center justify-between h-14">
<!-- Logo -->
<a href="#" class="flex items-center gap-2 group">
<span class="text-lg sm:text-xl font-bold tracking-wider text-slate-900 dark:text-white select-none transition-colors duration-300">
N&D <span class="text-brand-arcticBlue">i-t SOLUTIONS</span>
{header.brandName || 'N&D'} <span class="text-accent">{header.brandHighlight || 'i-t SOLUTIONS'}</span>
</span>
</a>
<!-- Desktop Nav Links -->
<nav class="hidden md:flex items-center gap-8">
<a href="#services" class="text-xs tracking-wider uppercase font-medium text-slate-600 dark:text-zinc-400 hover:text-brand-arcticBlue dark:hover:text-brand-arcticBlue transition-colors">Leistungen</a>
@@ -23,22 +35,26 @@ import { FaBars, FaTimes } from 'react-icons/fa';
<!-- Right controls -->
<div class="flex items-center gap-4">
<!-- Theme Toggle Button (44x44px target) -->
<button id="theme-toggle" class="w-11 h-11 flex items-center justify-center rounded-lg border border-slate-200 dark:border-brand-darkBorder bg-white/50 dark:bg-zinc-900/50 text-slate-700 dark:text-zinc-400 hover:text-brand-arcticBlue dark:hover:text-brand-arcticBlue transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label="Farbschema wechseln">
<!-- Moon Icon -->
<svg id="theme-toggle-dark-icon" class="hidden w-4 h-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<!-- Sun Icon -->
<svg id="theme-toggle-light-icon" class="hidden w-4 h-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.46 5.05l-.707-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
</svg>
</button>
{header.showThemeToggle !== false && (
<!-- Theme Toggle Button (44x44px target) -->
<button id="theme-toggle" class="w-11 h-11 flex items-center justify-center rounded-lg border border-slate-200 dark:border-brand-darkBorder bg-white/50 dark:bg-zinc-900/50 text-slate-700 dark:text-zinc-400 hover:text-brand-arcticBlue dark:hover:text-brand-arcticBlue transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label="Farbschema wechseln">
<!-- Moon Icon -->
<svg id="theme-toggle-dark-icon" class="hidden w-4 h-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M17.293 13.293A8 8 0 016.707 2.707a8.001 8.001 0 1010.586 10.586z"></path>
</svg>
<!-- Sun Icon -->
<svg id="theme-toggle-light-icon" class="hidden w-4 h-4 fill-current" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.46 5.05l-.707-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z" fill-rule="evenodd" clip-rule="evenodd"></path>
</svg>
</button>
)}
<!-- CTA button -->
<a href="#contact" class="hidden sm:inline-flex items-center justify-center px-4 py-2 text-xs font-semibold tracking-wider text-slate-700 dark:text-white border border-slate-200 dark:border-brand-darkBorder rounded bg-white/50 dark:bg-zinc-900/50 hover:border-brand-arcticBlue hover:text-brand-arcticBlue transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label="Erstgespräch buchen">
Erstgespräch buchen
</a>
{header.ctaText && (
<a href={header.ctaLink || "#contact"} class="hidden sm:inline-flex items-center justify-center px-4 py-2 text-xs font-semibold tracking-wider text-slate-700 dark:text-white border border-slate-200 dark:border-brand-darkBorder rounded bg-white/50 dark:bg-zinc-900/50 hover:border-brand-arcticBlue hover:text-brand-arcticBlue transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label={header.ctaText}>
{header.ctaText}
</a>
)}
<!-- Mobile menu button (44x44px target) -->
<button id="mobile-menu-btn" class="md:hidden w-11 h-11 flex items-center justify-center rounded bg-white dark:bg-zinc-900 text-slate-700 dark:text-white border border-slate-200 dark:border-brand-darkBorder hover:bg-slate-50 dark:hover:bg-zinc-800 transition-all focus:outline-none focus:ring-2 focus:ring-sky-500" aria-label="Menü öffnen">
@@ -121,13 +137,14 @@ import { FaBars, FaTimes } from 'react-icons/fa';
updateToggleIcons();
themeToggleBtn?.addEventListener('click', () => {
// Toggle class
if (document.documentElement.classList.contains('dark')) {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
if (document.documentElement.classList.contains('light')) {
document.documentElement.classList.remove('light');
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else {
document.documentElement.classList.remove('dark');
document.documentElement.classList.add('light');
localStorage.setItem('theme', 'light');
}
updateToggleIcons();
});

View File

@@ -0,0 +1,873 @@
---
// src/components/OnboardingWizard.astro
---
<section id="onboarding-wizard" class="py-24 bg-slate-50 dark:bg-[#0B0F19] relative border-t border-slate-200 dark:border-white/5 transition-colors duration-300">
<!-- Soft Background Glow -->
<div class="absolute inset-0 opacity-[0.015] pointer-events-none bg-[radial-gradient(#000_1px,transparent_1px)] dark:bg-[radial-gradient(#fff_1px,transparent_1px)] [background-size:16px_16px]"></div>
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[80%] h-[60%] bg-sky-500/5 dark:bg-sky-500/[0.02] blur-[120px] rounded-full pointer-events-none"></div>
<div class="max-w-6xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div class="text-center mb-12">
<span class="text-xs uppercase tracking-widest text-sky-600 dark:text-sky-400 font-bold">Webseiten-Planer</span>
<h2 class="text-3xl sm:text-4xl font-extrabold text-slate-900 dark:text-white mt-2 uppercase tracking-tight">Ihr Wunsch-Konzept</h2>
<p class="text-slate-600 dark:text-zinc-400 text-xs sm:text-sm mt-3">
Planen Sie Ihre neue Webseite in unter 2 Minuten. Kostenlos und unverbindlich.
</p>
</div>
<!-- Wizard Box (Liquid Glass Style) -->
<div class="relative rounded-3xl border border-white/80 dark:border-white/[0.06] bg-white/40 dark:bg-slate-950/40 backdrop-blur-2xl p-6 sm:p-10 shadow-xl overflow-hidden min-h-[600px] flex flex-col justify-between">
<!-- Tactile details -->
<span class="absolute top-3 left-3 w-2 h-2 border-t border-l border-slate-300 dark:border-white/20"></span>
<span class="absolute top-3 right-3 w-2 h-2 border-t border-r border-slate-300 dark:border-white/20"></span>
<span class="absolute bottom-3 left-3 w-2 h-2 border-b border-l border-slate-300 dark:border-white/20"></span>
<span class="absolute bottom-3 right-3 w-2 h-2 border-b border-r border-slate-300 dark:border-white/20"></span>
<!-- Step Indicator Bar -->
<div class="flex justify-between items-center mb-8 pb-4 border-b border-slate-200/50 dark:border-white/5">
<span class="text-[10px] font-mono text-slate-400 dark:text-zinc-500 uppercase tracking-widest" id="step-counter">SCHRITT 1 / 5</span>
<div class="flex gap-2" id="step-dots">
<span class="w-2 h-2 rounded-full bg-sky-500 transition-colors" data-step="1"></span>
<span class="w-2 h-2 rounded-full bg-slate-300 dark:bg-zinc-700 transition-colors" data-step="2"></span>
<span class="w-2 h-2 rounded-full bg-slate-300 dark:bg-zinc-700 transition-colors" data-step="3"></span>
<span class="w-2 h-2 rounded-full bg-slate-300 dark:bg-zinc-700 transition-colors" data-step="4"></span>
<span class="w-2 h-2 rounded-full bg-slate-300 dark:bg-zinc-700 transition-colors" data-step="5"></span>
</div>
</div>
<!-- Main Columns: Form Left, Live Preview Right -->
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-stretch flex-grow">
<!-- Left Column: Form Inputs (7 cols) -->
<form id="wizard-form" class="lg:col-span-7 flex flex-col justify-between min-h-[400px]" novalidate>
<!-- STEP 1: Branchen-Auswahl -->
<div class="wizard-step space-y-4" data-step="1">
<div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-1 uppercase tracking-tight">Schritt 1: Branche wählen</h3>
<p class="text-slate-500 dark:text-zinc-400 text-xs">Suchen Sie Ihre Branche oder tragen Sie Ihre eigene ein.</p>
</div>
<!-- Search Field -->
<div class="relative">
<input type="text" id="industry-search" placeholder="Branche suchen (z.B. Schreinerei, Arzt...)" class="w-full bg-white dark:bg-zinc-950 border border-slate-200 dark:border-brand-darkBorder rounded-xl p-3 text-xs text-slate-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500 transition-all" />
</div>
<!-- Industries List (Scrollable) -->
<div class="max-h-48 overflow-y-auto border border-slate-200/60 dark:border-white/5 rounded-xl p-3 grid grid-cols-1 sm:grid-cols-2 gap-2 bg-slate-100/30 dark:bg-white/5" id="industry-list">
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Schreinerei & Innenausbau" checked class="text-sky-600 focus:ring-sky-500" />
<span>Schreinerei & Innenausbau</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Dachdeckerei" class="text-sky-600 focus:ring-sky-500" />
<span>Dachdeckerei</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Solartechnik & PV" class="text-sky-600 focus:ring-sky-500" />
<span>Solartechnik & PV</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Rechtsanwälte & Notare" class="text-sky-600 focus:ring-sky-500" />
<span>Rechtsanwälte & Notare</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Bio-Hofladen" class="text-sky-600 focus:ring-sky-500" />
<span>Bio-Hofladen</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Sanitär, Heizung & Klima" class="text-sky-600 focus:ring-sky-500" />
<span>Sanitär, Heizung & Klima</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="KFZ-Meisterbetrieb" class="text-sky-600 focus:ring-sky-500" />
<span>KFZ-Meisterbetrieb</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Steuerberater" class="text-sky-600 focus:ring-sky-500" />
<span>Steuerberater</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Garten- & Landschaftsbau" class="text-sky-600 focus:ring-sky-500" />
<span>Garten- & Landschaftsbau</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Physiotherapie Praxis" class="text-sky-600 focus:ring-sky-500" />
<span>Physiotherapie Praxis</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Maler- & Lackiererbetrieb" class="text-sky-600 focus:ring-sky-500" />
<span>Maler- & Lackiererbetrieb</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Zahnarztpraxis" class="text-sky-600 focus:ring-sky-500" />
<span>Zahnarztpraxis</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Architekturbüro" class="text-sky-600 focus:ring-sky-500" />
<span>Architekturbüro</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Software-Entwicklung" class="text-sky-600 focus:ring-sky-500" />
<span>Software-Entwicklung</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Bäckerei & Konditorei" class="text-sky-600 focus:ring-sky-500" />
<span>Bäckerei & Konditorei</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Friseursalon" class="text-sky-600 focus:ring-sky-500" />
<span>Friseursalon</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Kosmetikstudio" class="text-sky-600 focus:ring-sky-500" />
<span>Kosmetikstudio</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Restaurant & Gastronomie" class="text-sky-600 focus:ring-sky-500" />
<span>Restaurant & Gastronomie</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Elektro-Technik" class="text-sky-600 focus:ring-sky-500" />
<span>Elektro-Technik</span>
</label>
<label class="cursor-pointer flex items-center gap-2 p-2 bg-white dark:bg-zinc-900 border border-transparent rounded-lg hover:border-sky-500/30 transition-all text-xs text-slate-800 dark:text-zinc-300">
<input type="radio" name="industry" value="Gebäudereinigung" class="text-sky-600 focus:ring-sky-500" />
<span>Gebäudereinigung</span>
</label>
</div>
<!-- Custom / Individual Field -->
<div class="pt-2">
<label for="industry-custom" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-1">Eigene / Andere Branche</label>
<input type="text" id="industry-custom" placeholder="Ihre Branche eintragen..." class="w-full bg-white dark:bg-zinc-950 border border-slate-200 dark:border-brand-darkBorder rounded-xl p-2.5 text-xs text-slate-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500" />
</div>
</div>
<!-- STEP 2: Farbauswahl -->
<div class="wizard-step space-y-6 hidden" data-step="2">
<div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-1 uppercase tracking-tight">Schritt 2: Farben</h3>
<p class="text-slate-500 dark:text-zinc-400 text-xs">Wählen Sie empfohlene branchenspezifische oder klassische Farbkombinationen.</p>
</div>
<div class="space-y-6">
<!-- Industry Recommendation Section -->
<div class="p-4 rounded-2xl border border-sky-500/20 bg-sky-500/5" id="industry-rec-box">
<span class="text-[9px] font-mono text-sky-600 dark:text-sky-400 uppercase tracking-widest font-bold">Empfehlung für Ihre Branche</span>
<div class="flex items-center justify-between mt-2">
<span class="text-xs font-bold text-slate-800 dark:text-zinc-200" id="rec-label">Branchen-Kombination</span>
<button type="button" class="px-4 py-2 bg-sky-600 hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-white text-white dark:text-zinc-950 font-bold text-[10px] uppercase tracking-wider rounded-lg transition-all" id="apply-rec-btn">
Anwenden
</button>
</div>
</div>
<!-- Standard Harmonious Combos -->
<div>
<label class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-2">Standard-Farbkombinationen</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<button type="button" class="color-preset-btn p-2 rounded-xl border border-slate-200 dark:border-white/10 text-[10px] font-bold bg-slate-100 dark:bg-zinc-900 text-slate-800 dark:text-zinc-100 flex items-center gap-1.5 hover:border-sky-500" data-primary="#3b82f6" data-secondary="#ffffff">
<span class="w-3 h-3 rounded-full bg-[#3b82f6]"></span>
<span>Klassisch Blau</span>
</button>
<button type="button" class="color-preset-btn p-2 rounded-xl border border-slate-200 dark:border-white/10 text-[10px] font-bold bg-slate-100 dark:bg-zinc-900 text-slate-800 dark:text-zinc-100 flex items-center gap-1.5 hover:border-sky-500" data-primary="#10b981" data-secondary="#e2e8f0">
<span class="w-3 h-3 rounded-full bg-[#10b981]"></span>
<span>Smaragd & Silber</span>
</button>
<button type="button" class="color-preset-btn p-2 rounded-xl border border-slate-200 dark:border-white/10 text-[10px] font-bold bg-slate-100 dark:bg-zinc-900 text-slate-800 dark:text-zinc-100 flex items-center gap-1.5 hover:border-sky-500" data-primary="#6366f1" data-secondary="#c7d2fe">
<span class="w-3 h-3 rounded-full bg-[#6366f1]"></span>
<span>Purple & Lilac</span>
</button>
<button type="button" class="color-preset-btn p-2 rounded-xl border border-slate-200 dark:border-white/10 text-[10px] font-bold bg-slate-100 dark:bg-zinc-900 text-slate-800 dark:text-zinc-100 flex items-center gap-1.5 hover:border-sky-500" data-primary="#4b5563" data-secondary="#f59e0b">
<span class="w-3 h-3 rounded-full bg-[#4b5563]"></span>
<span>Coal & Amber</span>
</button>
<button type="button" class="color-preset-btn p-2 rounded-xl border border-slate-200 dark:border-white/10 text-[10px] font-bold bg-slate-100 dark:bg-zinc-900 text-slate-800 dark:text-zinc-100 flex items-center gap-1.5 hover:border-sky-500" data-primary="#f43f5e" data-secondary="#fbbf24">
<span class="w-3 h-3 rounded-full bg-[#f43f5e]"></span>
<span>Muted Rose</span>
</button>
<button type="button" class="color-preset-btn p-2 rounded-xl border border-slate-200 dark:border-white/10 text-[10px] font-bold bg-slate-100 dark:bg-zinc-900 text-slate-800 dark:text-zinc-100 flex items-center gap-1.5 hover:border-sky-500" data-primary="#06b6d4" data-secondary="#ffffff">
<span class="w-3 h-3 rounded-full bg-[#06b6d4]"></span>
<span>Ocean Cyan</span>
</button>
</div>
</div>
<!-- Manual Color Pickers -->
<div class="grid grid-cols-2 gap-4 pt-2 border-t border-slate-200/50 dark:border-white/5">
<div class="space-y-2">
<label for="primary-color" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase">Primärfarbe (Fokus)</label>
<div class="flex items-center gap-3">
<input type="color" id="primary-color" value="#38bdf8" class="w-10 h-10 rounded cursor-pointer border border-slate-200 dark:border-white/10 bg-transparent" />
<span class="text-xs font-mono font-bold" id="primary-color-hex">#38BDF8</span>
</div>
</div>
<div class="space-y-2">
<label for="secondary-color" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase">Sekundärfarbe</label>
<div class="flex items-center gap-3">
<input type="color" id="secondary-color" value="#ffffff" class="w-10 h-10 rounded cursor-pointer border border-slate-200 dark:border-white/10 bg-transparent" />
<span class="text-xs font-mono font-bold" id="secondary-color-hex">#FFFFFF</span>
</div>
</div>
</div>
</div>
</div>
<!-- STEP 3: Layout & Design-Richtung -->
<div class="wizard-step space-y-6 hidden" data-step="3">
<div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-1 uppercase tracking-tight">Schritt 3: Layout & Stil</h3>
<p class="text-slate-500 dark:text-zinc-400 text-xs">Konfigurieren Sie Layout-Struktur und Designrichtung der Website.</p>
</div>
<div class="space-y-4">
<!-- Layout type -->
<div>
<label class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-2">Layout-Art</label>
<div class="grid grid-cols-2 sm:grid-cols-3 gap-2">
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="layout" value="bento" checked class="sr-only" />
<span>Bento-Grid</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="layout" value="split" class="sr-only" />
<span>Split-Screen</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="layout" value="boxed" class="sr-only" />
<span>Boxed Layout</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="layout" value="asymmetric" class="sr-only" />
<span>Asymmetrisch</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="layout" value="single" class="sr-only" />
<span>Single-Page</span>
</label>
</div>
</div>
<!-- Design Style -->
<div>
<label class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-2">Design-Richtung</label>
<div class="grid grid-cols-2 gap-2" id="design-style-group">
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="style" value="corporate-dark" checked class="sr-only" />
<span>Corporate Dark (Edler Darkmode)</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="style" value="nordic-light" class="sr-only" />
<span>Nordic Light (Skandinavisch Hell)</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="style" value="industrial-gold" class="sr-only" />
<span>Industrial Gold (Mittel-Anthrazit)</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="style" value="brutalism" class="sr-only" />
<span>Brutalismus (Kräftige Ränder)</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="style" value="neomorphism" class="sr-only" />
<span>Neomorphismus (Weiche Tiefen)</span>
</label>
<label class="cursor-pointer border border-slate-200 dark:border-white/5 bg-slate-100/50 dark:bg-white/5 p-3 rounded-lg flex items-center justify-between hover:border-sky-500 transition-all text-xs">
<input type="radio" name="style" value="saas-minimalism" class="sr-only" />
<span>SaaS-Minimalismus (Clean)</span>
</label>
</div>
</div>
</div>
</div>
<!-- STEP 4: Kontaktdaten -->
<div class="wizard-step space-y-6 hidden" data-step="4">
<div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2 uppercase tracking-tight">Schritt 4: Kontaktdaten</h3>
<p class="text-slate-500 dark:text-zinc-400 text-xs">Wohin dürfen wir Ihren kostenlosen Entwurf senden?</p>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="wizard-name" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-1">Ihr Name *</label>
<input type="text" id="wizard-name" required class="w-full bg-white dark:bg-zinc-950 border border-slate-200 dark:border-brand-darkBorder rounded p-2.5 text-xs text-slate-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500" placeholder="Ihr Name" />
<p class="wizard-error text-[10px] text-red-500 mt-1 hidden">Bitte tragen Sie Ihren Namen ein.</p>
</div>
<div>
<label for="wizard-company" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-1">Name Ihres Ladens / Betriebes</label>
<input type="text" id="wizard-company" class="w-full bg-white dark:bg-zinc-950 border border-slate-200 dark:border-brand-darkBorder rounded p-2.5 text-xs text-slate-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500" placeholder="z.B. Dönereck" />
</div>
<div class="sm:col-span-2">
<label for="wizard-email" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-1">E-Mail-Adresse *</label>
<input type="email" id="wizard-email" required class="w-full bg-white dark:bg-zinc-950 border border-slate-200 dark:border-brand-darkBorder rounded p-2.5 text-xs text-slate-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500" placeholder="ihre.adresse@firma.de" />
<p class="wizard-error text-[10px] text-red-500 mt-1 hidden">Bitte eine gültige E-Mail-Adresse angeben.</p>
</div>
<!-- GDPR Consent -->
<div class="sm:col-span-2 space-y-3 pt-2">
<p class="text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
🛡️ Keine lästigen Werbeanrufe. Kein Spam. Nur Ihr fertiger Entwurf innerhalb von 24 Stunden.
</p>
<label class="flex items-start gap-2 cursor-pointer">
<input type="checkbox" id="wizard-privacy" required class="mt-0.5 w-4 h-4 rounded text-sky-600 focus:ring-sky-500" />
<span class="text-[10px] text-slate-500 dark:text-zinc-400 leading-normal">
Ich bin mit der <a href="/privacy" class="text-sky-600 dark:text-sky-400 hover:underline">Datenschutzerklärung</a> einverstanden. *
</span>
</label>
<p class="wizard-error text-[10px] text-red-500 hidden">Zustimmung erforderlich.</p>
</div>
</div>
</div>
<!-- STEP 5: Zusammenfassung & Absenden -->
<div class="wizard-step space-y-6 hidden" data-step="5">
<div>
<h3 class="text-lg font-bold text-slate-900 dark:text-white mb-2 uppercase tracking-tight">Schritt 5: Prüfen & Absenden</h3>
<p class="text-slate-500 dark:text-zinc-400 text-xs">Schauen Sie noch einmal kurz drüber. Wenn alles stimmt, schicken Sie es ab.</p>
</div>
<div class="bg-slate-100/50 dark:bg-white/5 border border-slate-200 dark:border-white/5 rounded-xl p-4 space-y-3">
<div class="flex justify-between text-xs border-b border-slate-200 dark:border-white/5 pb-2">
<span class="text-slate-500">Branche:</span>
<span class="font-bold text-slate-900 dark:text-white" id="summary-industry">-</span>
</div>
<div class="flex justify-between text-xs border-b border-slate-200 dark:border-white/5 pb-2">
<span class="text-slate-500">Gewählte Farben:</span>
<span class="font-bold text-slate-900 dark:text-white" id="summary-colors">-</span>
</div>
<div class="flex justify-between text-xs border-b border-slate-200 dark:border-white/5 pb-2">
<span class="text-slate-500">Aussehen & Aufbau:</span>
<span class="font-bold text-slate-900 dark:text-white text-right" id="summary-layout">-</span>
</div>
<div class="flex justify-between text-xs pt-1">
<span class="text-slate-500">Name & E-Mail:</span>
<span class="font-bold text-slate-900 dark:text-white" id="summary-contact">-</span>
</div>
</div>
<p class="text-[10px] text-slate-500 dark:text-zinc-500 leading-normal text-center">
Sobald Sie auf 'Absenden' klicken, machen wir uns an die Arbeit und erstellen Ihren persönlichen Entwurf.
</p>
</div>
<!-- Navigation Buttons -->
<div class="flex justify-between gap-4 pt-8 border-t border-slate-200/50 dark:border-white/5 mt-8">
<button type="button" id="prev-step" class="px-5 py-2.5 bg-slate-200 dark:bg-slate-900 text-slate-800 dark:text-white text-xs font-bold uppercase tracking-wider rounded-lg disabled:opacity-30 disabled:pointer-events-none transition-all">
Zurück
</button>
<button type="button" id="next-step" class="px-5 py-2.5 bg-sky-600 hover:bg-sky-700 dark:bg-sky-500 dark:hover:bg-white text-white dark:text-zinc-950 text-xs font-bold uppercase tracking-wider rounded-lg transition-all">
Weiter
</button>
</div>
</form>
<!-- Right Column: Live Bento Preview (5 cols) -->
<div class="lg:col-span-5 flex items-center justify-center">
<div id="wizard-live-preview" class="w-full h-full min-h-[300px] rounded-3xl border p-6 flex flex-col justify-between overflow-hidden relative transition-all duration-500 shadow-xl bg-[#0B0F19]">
<!-- Inner Aurora Glow Element -->
<div id="preview-glow" class="absolute w-24 h-24 rounded-full blur-2xl pointer-events-none transition-all duration-500 top-1/4 left-1/4 bg-sky-500/20"></div>
<div class="absolute inset-0 opacity-[0.02] pointer-events-none bg-[radial-gradient(#fff_1px,transparent_1px)] [background-size:8px_8px]"></div>
<!-- Top Row (Mini Header Mock) -->
<div class="relative z-10 flex justify-between items-center pb-2 border-b border-white/10" id="preview-header">
<div class="flex gap-1.5 items-center">
<span class="w-2 h-2 rounded-full bg-[#38BDF8]" id="preview-logo-dot"></span>
<span class="text-[9px] font-bold tracking-tight uppercase" id="preview-logo-text">Website Mockup</span>
</div>
<span class="text-[7px] font-mono opacity-50" id="preview-online-status">PREVIEW</span>
</div>
<!-- Middle Content (Dynamic layouts render here) -->
<div class="relative z-10 flex-grow py-4 flex flex-col justify-center min-h-[140px]" id="preview-content-area">
<!-- Bento Layout Preview Container -->
<div id="layout-bento-mock" class="grid grid-cols-3 gap-2 w-full">
<div class="col-span-2 p-3 rounded-lg border border-white/10 bg-white/5 flex flex-col justify-between" id="bento-box-1">
<span class="text-[10px] font-bold leading-tight" id="preview-h1">Meisterbetrieb</span>
<span class="text-[6px] opacity-60 mt-1" id="preview-desc">Professioneller Service vor Ort.</span>
</div>
<div class="p-3 rounded-lg border border-white/10 bg-white/5 flex flex-col justify-center items-center" id="bento-box-2">
<span class="text-[8px] font-mono uppercase tracking-widest text-[#38BDF8]" id="preview-tier-badge">AKTIV</span>
<span class="text-[5px] opacity-40 uppercase">STATUS</span>
</div>
</div>
<!-- Split Layout Preview Container (Hidden by default) -->
<div id="layout-split-mock" class="grid grid-cols-2 gap-2 w-full hidden">
<div class="p-3 rounded-lg border border-white/10 bg-white/5 flex flex-col justify-between" id="split-box-1">
<span class="text-[10px] font-bold leading-tight" id="preview-h1-split">Regionaler Partner</span>
<span class="text-[6px] opacity-60 mt-1" id="preview-desc-split">Echtes Handwerk, beste Qualität.</span>
</div>
<div class="p-3 rounded-lg border border-white/10 bg-white/5 flex items-center justify-center">
<span class="w-6 h-6 rounded-full border border-white/20 flex items-center justify-center text-[7px]" id="split-icon">✓</span>
</div>
</div>
<!-- Boxed Layout Preview Container (Hidden) -->
<div id="layout-boxed-mock" class="w-full border border-white/10 p-3 rounded-lg bg-white/5 flex flex-col gap-1 hidden" id="boxed-container">
<div class="h-2 w-12 bg-white/10 rounded" id="boxed-accent-bar"></div>
<span class="text-[9px] font-bold" id="preview-h1-boxed">Kompakter Titel</span>
<p class="text-[6px] opacity-60" id="preview-desc-boxed">Zentrierter Text mit reduziertem Rahmen.</p>
</div>
</div>
<!-- Bottom Row (Footer Mock) -->
<div class="relative z-10 flex justify-between items-center pt-2 border-t border-white/10" id="preview-footer">
<span class="text-[6px] opacity-40 font-mono" id="preview-bottom-left">ZERO THIRD-PARTY</span>
<span class="text-[7px] font-bold uppercase tracking-wider px-2 py-0.5 rounded bg-[#38BDF8] text-zinc-950" id="preview-cta-badge">KONZEPT</span>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<style>
/* Custom checked states for radio buttons */
label:has(input[type="radio"]:checked) {
@apply border-sky-500 ring-2 ring-sky-500/20 bg-sky-500/5;
}
</style>
<script>
(function() {
let currentStep = 1;
const totalSteps = 5;
const steps = document.querySelectorAll('.wizard-step');
const dots = document.querySelectorAll('#step-dots span');
const counter = document.getElementById('step-counter');
const prevBtn = document.getElementById('prev-step') as HTMLButtonElement;
const nextBtn = document.getElementById('next-step') as HTMLButtonElement;
const form = document.getElementById('wizard-form') as HTMLFormElement;
// Search and filter logic
const industrySearch = document.getElementById('industry-search') as HTMLInputElement;
const industryList = document.getElementById('industry-list');
const industryLabels = industryList?.querySelectorAll('label');
const industryCustomInput = document.getElementById('industry-custom') as HTMLInputElement;
// Recommendation logic elements
const recLabel = document.getElementById('rec-label');
const applyRecBtn = document.getElementById('apply-rec-btn');
// Color Inputs
const primaryColorInput = document.getElementById('primary-color') as HTMLInputElement;
const secondaryColorInput = document.getElementById('secondary-color') as HTMLInputElement;
const primaryHex = document.getElementById('primary-color-hex');
const secondaryHex = document.getElementById('secondary-color-hex');
const colorPresetBtns = document.querySelectorAll('.color-preset-btn');
// Layout & Style Inputs
const layoutInputs = form.querySelectorAll('input[name="layout"]');
const styleInputs = form.querySelectorAll('input[name="style"]');
// Live Preview elements
const previewBox = document.getElementById('wizard-live-preview');
const previewGlow = document.getElementById('preview-glow');
const logoDot = document.getElementById('preview-logo-dot');
const logoText = document.getElementById('preview-logo-text');
const badge = document.getElementById('preview-cta-badge');
const tierBadge = document.getElementById('preview-tier-badge');
const header = document.getElementById('preview-header');
const footer = document.getElementById('preview-footer');
const onlineStatus = document.getElementById('preview-online-status');
const b1 = document.getElementById('bento-box-1');
const b2 = document.getElementById('bento-box-2');
const s1 = document.getElementById('split-box-1');
const s2 = document.getElementById('split-icon');
const bx = document.getElementById('boxed-container');
const bxAccent = document.getElementById('boxed-accent-bar');
// Preview Text Nodes for Industry updates
const previewH1 = document.getElementById('preview-h1');
const previewDesc = document.getElementById('preview-desc');
const previewH1Split = document.getElementById('preview-h1-split');
const previewDescSplit = document.getElementById('preview-desc-split');
const previewH1Boxed = document.getElementById('preview-h1-boxed');
const previewDescBoxed = document.getElementById('preview-desc-boxed');
// Preview Layout mock blocks
const bentoMock = document.getElementById('layout-bento-mock');
const splitMock = document.getElementById('layout-split-mock');
const boxedMock = document.getElementById('layout-boxed-mock');
// Step 4 inputs
const nameInput = document.getElementById('wizard-name') as HTMLInputElement;
const emailInput = document.getElementById('wizard-email') as HTMLInputElement;
const privacyInput = document.getElementById('wizard-privacy') as HTMLInputElement;
// Summary fields
const summaryIndustry = document.getElementById('summary-industry');
const summaryColors = document.getElementById('summary-colors');
const summaryLayout = document.getElementById('summary-layout');
const summaryContact = document.getElementById('summary-contact');
// Color combination mapping per industry
const industryColorConfigs: Record<string, { primary: string, secondary: string, name: string, label: string }> = {
'Schreinerei & Innenausbau': { primary: '#9C7A4E', secondary: '#DE6B32', name: 'Möbel-Schreinerei', label: 'Eichenholz & Kupfer' },
'Dachdeckerei': { primary: '#f59e0b', secondary: '#475569', name: 'Dachdecker Meister', label: 'Ziegel-Amber & Schiefer' },
'Solartechnik & PV': { primary: '#ff5500', secondary: '#3f3f46', name: 'Solar PV Tech', label: 'Sonnen-Orange & Carbon' },
'Rechtsanwälte & Notare': { primary: '#047857', secondary: '#f1f5f9', name: 'Kanzlei Expertise', label: 'Tief-Smaragd & Silber' },
'Bio-Hofladen': { primary: '#15803d', secondary: '#c2410c', name: 'Bio-Hofladen Natur', label: 'Bio-Grün & Terracotta' },
'Sanitär, Heizung & Klima': { primary: '#1d4ed8', secondary: '#06b6d4', name: 'SHK Meisterbetrieb', label: 'Heizungs-Blau & Aqua-Cyan' },
'KFZ-Meisterbetrieb': { primary: '#dc2626', secondary: '#94a3b8', name: 'KFZ Schnellservice', label: 'Racing-Rot & Asphalt' },
'Steuerberater': { primary: '#d97706', secondary: '#1e3a8a', name: 'Steuerberater Premium', label: 'Kanzlei-Gold & Royal-Blau' },
'Garten- & Landschaftsbau': { primary: '#16a34a', secondary: '#e7e5e4', name: 'Gartengestaltung', label: 'Blatt-Grün & Sandstein' },
'Physiotherapie Praxis': { primary: '#0d9488', secondary: '#ccfbf1', name: 'Physio Praxis', label: 'Gesundheits-Teal & Mint' },
'Maler- & Lackiererbetrieb': { primary: '#4f46e5', secondary: '#db2777', name: 'Malerbetrieb', label: 'Kreativ-Indigo & Magenta' },
'Zahnarztpraxis': { primary: '#38bdf8', secondary: '#f8fafc', name: 'Zahnarztpraxis', label: 'Eis-Blau & Praxis-Weiß' },
'Architekturbüro': { primary: '#71717a', secondary: '#09090b', name: 'Architektur Struktur', label: 'Konzept-Grau & Matt-Schwarz' },
'Software-Entwicklung': { primary: '#8b5cf6', secondary: '#00ffff', name: 'Byte Code', label: 'Electric-Purple & Cyber-Cyan' },
'Bäckerei & Konditorei': { primary: '#d97706', secondary: '#451a03', name: 'Bäckerei Ofenfrisch', label: 'Weizen-Gold & Schokolade' },
'Friseursalon': { primary: '#db2777', secondary: '#f3f4f6', name: 'Haardesign Salon', label: 'Rose Gold & Seiden-Weiß' },
'Kosmetikstudio': { primary: '#a855f7', secondary: '#fafaf9', name: 'Kosmetik Oase', label: 'Lavendel & Perlglanz' },
'Restaurant & Gastronomie': { primary: '#991b1b', secondary: '#d97706', name: 'Gourmet Restaurant', label: 'Wein-Rot & Ocker-Gold' },
'Elektro-Technik': { primary: '#eab308', secondary: '#1f2937', name: 'Elektrotechnik', label: 'Strom-Gelb & Anthrazit' },
'Gebäudereinigung': { primary: '#06b6d4', secondary: '#ffffff', name: 'Gebäudereinigung', label: 'Glanz-Cyan & Reinweiß' }
};
// Filter list by search query
if (industrySearch) {
industrySearch.addEventListener('input', () => {
const query = industrySearch.value.toLowerCase().trim();
industryLabels?.forEach(label => {
const text = label.textContent?.toLowerCase() || '';
if (text.includes(query)) {
label.style.display = 'flex';
} else {
label.style.display = 'none';
}
});
});
}
// Color preset button actions
colorPresetBtns.forEach(btn => {
btn.addEventListener('click', () => {
const primary = btn.getAttribute('data-primary') || '#38bdf8';
const secondary = btn.getAttribute('data-secondary') || '#ffffff';
applyColorCombo(primary, secondary);
});
});
function applyColorCombo(primary: string, secondary: string) {
if (primaryColorInput && secondaryColorInput) {
primaryColorInput.value = primary;
secondaryColorInput.value = secondary;
primaryColorInput.dispatchEvent(new Event('input'));
secondaryColorInput.dispatchEvent(new Event('input'));
}
}
if (primaryColorInput && secondaryColorInput) {
primaryColorInput.addEventListener('input', () => {
if (primaryHex) primaryHex.textContent = primaryColorInput.value.toUpperCase();
updatePreviewStyles();
});
secondaryColorInput.addEventListener('input', () => {
if (secondaryHex) secondaryHex.textContent = secondaryColorInput.value.toUpperCase();
updatePreviewStyles();
});
}
// Reacting to layout changes
layoutInputs.forEach(input => input.addEventListener('change', updatePreviewLayout));
styleInputs.forEach(input => input.addEventListener('change', updatePreviewStyles));
// Dynamic Preview Layout
function updatePreviewLayout() {
const activeLayout = (form.querySelector('input[name="layout"]:checked') as HTMLInputElement)?.value || 'bento';
bentoMock?.classList.add('hidden');
splitMock?.classList.add('hidden');
boxedMock?.classList.add('hidden');
if (activeLayout === 'bento') bentoMock?.classList.remove('hidden');
if (activeLayout === 'split') splitMock?.classList.remove('hidden');
if (activeLayout === 'boxed' || activeLayout === 'asymmetric' || activeLayout === 'single') boxedMock?.classList.remove('hidden');
}
// Update colors based on chosen industry
function updateIndustryColorRecommendation() {
const selectedEl = form.querySelector('input[name="industry"]:checked') as HTMLInputElement;
const industryName = selectedEl?.value || 'Schreinerei & Innenausbau';
const config = industryColorConfigs[industryName];
if (config && recLabel && applyRecBtn) {
recLabel.textContent = `${config.label} (für ${industryName})`;
// Remove old click listeners by cloning
const newBtn = applyRecBtn.cloneNode(true) as HTMLButtonElement;
applyRecBtn.parentNode?.replaceChild(newBtn, applyRecBtn);
newBtn.addEventListener('click', () => {
applyColorCombo(config.primary, config.secondary);
});
}
// Update text mockup content in preview
const previewText = config ? config.name : 'Premium Web';
const previewDescText = `Maßgeschneidertes Design für ${industryName}.`;
if (previewH1) previewH1.textContent = previewText;
if (previewDesc) previewDesc.textContent = previewDescText;
if (previewH1Split) previewH1Split.textContent = previewText;
if (previewDescSplit) previewDescSplit.textContent = previewDescText;
if (previewH1Boxed) previewH1Boxed.textContent = previewText;
if (previewDescBoxed) previewDescBoxed.textContent = previewDescText;
}
// Event listener for industry change
form.querySelectorAll('input[name="industry"]').forEach(input => {
input.addEventListener('change', () => {
updateIndustryColorRecommendation();
// Automatically pre-apply the recommended combo for the industry
const selectedEl = form.querySelector('input[name="industry"]:checked') as HTMLInputElement;
const config = industryColorConfigs[selectedEl?.value];
if (config) {
applyColorCombo(config.primary, config.secondary);
}
});
});
// Custom industry input also resets preset if user types
if (industryCustomInput) {
industryCustomInput.addEventListener('input', () => {
if (industryCustomInput.value.trim() && recLabel) {
recLabel.textContent = 'Individuelles Thema';
}
});
}
// Dynamic Preview Styles (Colors & Themes)
function updatePreviewStyles() {
if (!previewBox || !previewGlow || !logoDot || !logoText || !badge || !tierBadge) return;
const primary = primaryColorInput.value;
const secondary = secondaryColorInput.value;
const activeStyle = (form.querySelector('input[name="style"]:checked') as HTMLInputElement)?.value || 'corporate-dark';
// Reset style classes
previewBox.className = "w-full h-full min-h-[300px] rounded-3xl border p-6 flex flex-col justify-between overflow-hidden relative transition-all duration-500 shadow-xl";
logoText.className = "text-[9px] font-bold tracking-tight uppercase";
badge.className = "text-[7px] font-bold uppercase tracking-wider px-2 py-0.5 rounded";
// Color rules for elements
logoDot.style.backgroundColor = primary;
if (bxAccent) bxAccent.style.backgroundColor = primary;
if (s2) {
s2.style.borderColor = primary;
s2.style.color = primary;
}
tierBadge.style.color = primary;
// Style specific themes
if (activeStyle === 'corporate-dark') {
previewBox.classList.add('bg-[#0B0F19]', 'text-white', 'border-white/10');
previewGlow.className = `absolute w-24 h-24 rounded-full blur-2xl pointer-events-none transition-all duration-500 top-1/4 left-1/4`;
previewGlow.style.backgroundColor = `${primary}22`;
logoText.classList.add('text-white');
badge.style.backgroundColor = primary;
badge.style.color = '#0B0F19';
}
else if (activeStyle === 'nordic-light') {
previewBox.classList.add('bg-[#F3F4F6]', 'text-slate-900', 'border-slate-200');
previewGlow.className = `absolute w-24 h-24 rounded-full blur-2xl pointer-events-none transition-all duration-500 top-1/4 left-1/4`;
previewGlow.style.backgroundColor = `${primary}11`;
logoText.classList.add('text-slate-800');
badge.style.backgroundColor = primary;
badge.style.color = '#ffffff';
}
else if (activeStyle === 'industrial-gold') {
previewBox.classList.add('bg-[#1C1D21]', 'text-zinc-100', 'border-zinc-700/50');
previewGlow.className = `absolute w-24 h-24 rounded-full blur-2xl pointer-events-none transition-all duration-500 top-1/4 left-1/4`;
previewGlow.style.backgroundColor = `${primary}18`;
logoText.classList.add('text-zinc-100');
badge.style.backgroundColor = primary;
badge.style.color = '#1C1D21';
}
else if (activeStyle === 'brutalism') {
previewBox.classList.add('bg-white', 'text-black', 'border-4', 'border-black');
previewGlow.className = "hidden";
logoText.classList.add('text-black');
badge.style.backgroundColor = '#000000';
badge.style.color = '#ffffff';
badge.classList.add('border', 'border-black');
}
else if (activeStyle === 'neomorphism') {
previewBox.classList.add('bg-[#E0E0E0]', 'text-slate-800', 'border-transparent', 'shadow-[inset_10px_10px_20px_#bebebe,inset_-10px_-10px_20px_#ffffff]');
previewGlow.className = "hidden";
logoText.classList.add('text-slate-700');
badge.style.backgroundColor = primary;
badge.style.color = '#white';
}
else { // saas-minimalism
previewBox.classList.add('bg-white', 'text-slate-900', 'border-slate-100', 'shadow-sm');
previewGlow.className = `absolute w-24 h-24 rounded-full blur-2xl pointer-events-none transition-all duration-500 top-1/4 left-1/4`;
previewGlow.style.backgroundColor = `${primary}08`;
logoText.classList.add('text-slate-800');
badge.style.backgroundColor = primary;
badge.style.color = '#ffffff';
}
}
// Trigger initial styles
updateIndustryColorRecommendation();
updatePreviewLayout();
updatePreviewStyles();
// Steps state machine
function updateStep() {
steps.forEach((step, idx) => {
if (idx + 1 === currentStep) {
step.classList.remove('hidden');
} else {
step.classList.add('hidden');
}
});
dots.forEach((dot, idx) => {
if (idx + 1 <= currentStep) {
dot.classList.add('bg-sky-500');
dot.classList.remove('bg-slate-300', 'dark:bg-zinc-700');
} else {
dot.classList.remove('bg-sky-500');
dot.classList.add('bg-slate-300', 'dark:bg-zinc-700');
}
});
if (counter) {
counter.textContent = `SCHRITT ${currentStep} / ${totalSteps}`;
}
if (prevBtn && nextBtn) {
prevBtn.disabled = currentStep === 1;
if (currentStep === totalSteps) {
nextBtn.textContent = 'Projekt generieren';
} else {
nextBtn.textContent = 'Weiter';
}
}
if (currentStep === 5) {
buildSummary();
}
}
function validateStep(): boolean {
if (currentStep === 4) {
let valid = true;
if (!nameInput.value.trim()) {
nameInput.nextElementSibling?.classList.remove('hidden');
valid = false;
} else {
nameInput.nextElementSibling?.classList.add('hidden');
}
const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailPattern.test(emailInput.value)) {
emailInput.nextElementSibling?.classList.remove('hidden');
valid = false;
} else {
emailInput.nextElementSibling?.classList.add('hidden');
}
const privacyError = document.getElementById('wizard-privacy')?.closest('label')?.nextElementSibling;
if (!privacyInput.checked) {
privacyError?.classList.remove('hidden');
valid = false;
} else {
privacyError?.classList.add('hidden');
}
return valid;
}
return true;
}
function buildSummary() {
const selectedIndustryEl = form.querySelector('input[name="industry"]:checked') as HTMLInputElement;
const customIndustry = industryCustomInput.value.trim();
const industryName = customIndustry ? customIndustry : (selectedIndustryEl ? selectedIndustryEl.value : '-');
const primary = primaryColorInput.value;
const secondary = secondaryColorInput.value;
const selectedLayoutEl = form.querySelector('input[name="layout"]:checked') as HTMLInputElement;
const selectedStyleEl = form.querySelector('input[name="style"]:checked') as HTMLInputElement;
if (summaryIndustry) summaryIndustry.textContent = industryName;
if (summaryColors) summaryColors.textContent = `Primär: ${primary.toUpperCase()}, Sekundär: ${secondary.toUpperCase()}`;
if (summaryLayout) summaryLayout.textContent = `${selectedLayoutEl?.value.toUpperCase()} / ${selectedStyleEl?.value.toUpperCase()}`;
if (summaryContact) summaryContact.textContent = `${nameInput.value} (${emailInput.value})`;
}
function handleNext() {
if (!validateStep()) return;
if (currentStep < totalSteps) {
currentStep++;
updateStep();
} else {
submitWizard();
}
}
function submitWizard() {
const selectedIndustryEl = form.querySelector('input[name="industry"]:checked') as HTMLInputElement;
const customIndustry = industryCustomInput.value.trim();
const industryName = customIndustry ? customIndustry : (selectedIndustryEl ? selectedIndustryEl.value : 'unbekannt');
const selectedLayoutEl = form.querySelector('input[name="layout"]:checked') as HTMLInputElement;
const selectedStyleEl = form.querySelector('input[name="style"]:checked') as HTMLInputElement;
const companyVal = (document.getElementById('wizard-company') as HTMLInputElement).value;
const payload = {
client_metadata: {
agency_id: "nd-it-solutions",
timestamp: new Date().toISOString(),
domain: companyVal ? `${companyVal.toLowerCase().replace(/[^a-z0-9]/g, '')}.de` : "unbekannt.de"
},
branding: {
industry: industryName,
company_name: companyVal || "Mein Unternehmen",
branding_style: selectedStyleEl?.value || "corporate-dark",
primary_color: primaryColorInput.value,
secondary_color: secondaryColorInput.value
},
structure: {
layout_type: selectedLayoutEl?.value || "bento",
sections: ["hero", "portfolio", "contact"]
},
contact: {
name: nameInput.value,
email: emailInput.value,
company: companyVal
}
};
console.log('Wizard Payload Output:', JSON.stringify(payload, null, 2));
alert('Erfolgreich generiert! JSON Payload wurde in die Konsole geloggt.');
}
if (nextBtn && prevBtn) {
nextBtn.addEventListener('click', handleNext);
prevBtn.addEventListener('click', () => {
if (currentStep > 1) {
currentStep--;
updateStep();
}
});
}
})();
</script>

View File

@@ -9,12 +9,12 @@
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div class="text-center max-w-3xl mx-auto mb-16">
<h2 class="text-xs font-bold tracking-widest text-brand-cyan uppercase mb-3">Unsere Leistungen</h2>
<h2 class="text-xs font-bold tracking-widest text-brand-cyan uppercase mb-3">Was wir für Sie tun</h2>
<h3 class="text-4xl md:text-5xl font-bold text-brand-primary dark:text-white">
Maßgeschneiderte Lösungen für digitale Exzellenz
Alles, was Sie für Ihren Erfolg im Internet brauchen
</h3>
<p class="mt-4 text-lg text-gray-600 dark:text-gray-400">
Wir begleiten Ihren Betrieb Schritt für Schritt zur perfekten Website für mehr Kunden und Anfragen.
Wir machen es Ihnen einfach. Vom ersten Entwurf bis zur fertigen Seite, die Ihnen neue Kunden bringt.
</p>
</div>
@@ -33,9 +33,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 12l8.954-8.955c.44-.439 1.152-.439 1.591 0L21.75 12M4.5 9.75v10.125c0 .621.504 1.125 1.125 1.125H9.75v-4.875c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125V21h4.125c.621 0 1.125-.504 1.125-1.125V9.75M8.25 21h8.25"></path>
</svg>
</div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Firmenwebseiten</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Digitale Visitenkarte</h4>
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Individuelle Homepages als digitale Visitenkarte für Ihr Unternehmen, optimiert für alle mobilen Endgeräte.
Eine schicke Homepage, auf der alles Wichtige steht: Ihre Öffnungszeiten, Telefonnummer und Ihr Angebot. Läuft perfekt auf Handys.
</p>
</div>
<span class="text-xs font-semibold text-brand-cyan group-hover:text-brand-magenta transition-colors flex items-center gap-1">
@@ -45,16 +45,16 @@
<!-- Back -->
<div class="absolute inset-0 backface-hidden rotate-y-180 bg-gradient-to-br from-brand-cyan/90 to-brand-primary text-white border border-brand-cyan/30 rounded-2xl flex flex-col justify-between p-8 shadow-2xl">
<div>
<h4 class="text-xl font-bold mb-4">Firmenwebseiten</h4>
<h4 class="text-xl font-bold mb-4">Digitale Visitenkarte</h4>
<ul class="space-y-2 text-sm text-gray-200">
<li class="flex items-center gap-2">✓ Responsive Webdesign (Mobile First)</li>
<li class="flex items-center gap-2">✓ Extrem schnelle Ladezeiten</li>
<li class="flex items-center gap-2">✓ DSGVO-konform (Cookie-Consent etc.)</li>
<li class="flex items-center gap-2">✓ Modernste Web-Tech (Astro & Tailwind)</li>
<li class="flex items-center gap-2">✓ Sieht super aus auf jedem Handy</li>
<li class="flex items-center gap-2">✓ Lädt blitzschnell ohne Warten</li>
<li class="flex items-center gap-2">✓ Sicher vor Abmahnungen (DSGVO)</li>
<li class="flex items-center gap-2">✓ Moderne und sichere Technik</li>
</ul>
</div>
<a href="#contact" class="inline-flex items-center justify-center py-2 px-4 bg-white text-brand-primary font-semibold rounded-lg text-xs hover:bg-brand-magenta hover:text-white transition-colors duration-300">
Beratung anfordern
Kostenlose Beratung anfordern
</a>
</div>
</div>
@@ -72,9 +72,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15.042 21.672L13.684 16.6m0 0l-2.51 2.225.569-9.47 7.827 5.176-3.243.681.381 2.29z"></path>
</svg>
</div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Landingpages</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Kunden-Gewinnung</h4>
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Zielgerichtete Webseiten für Google- oder Social-Media-Kampagnen zur effektiven Neukundengewinnung.
Eine spezielle Seite, die nur ein Ziel hat: Dass neue Kunden Sie anrufen, Ihnen schreiben oder direkt vorbeikommen.
</p>
</div>
<span class="text-xs font-semibold text-brand-cyan group-hover:text-brand-magenta transition-colors flex items-center gap-1">
@@ -84,16 +84,16 @@
<!-- Back -->
<div class="absolute inset-0 backface-hidden rotate-y-180 bg-gradient-to-br from-purple-900 to-brand-primary text-white border border-brand-magenta/30 rounded-2xl flex flex-col justify-between p-8 shadow-2xl">
<div>
<h4 class="text-xl font-bold mb-4">Landingpages</h4>
<h4 class="text-xl font-bold mb-4">Kunden-Gewinnung</h4>
<ul class="space-y-2 text-sm text-gray-200">
<li class="flex items-center gap-2">✓ Conversion-Rate Optimierung (CRO)</li>
<li class="flex items-center gap-2">✓ Klare Handlungsaufforderungen (CTA)</li>
<li class="flex items-center gap-2">✓ A/B Testings & Web-Analysen</li>
<li class="flex items-center gap-2">✓ Schnelles Laden für geringe Absprünge</li>
<li class="flex items-center gap-2">✓ Leicht verständlich für Besucher</li>
<li class="flex items-center gap-2">✓ Telefonnummer direkt im Blick</li>
<li class="flex items-center gap-2">✓ Zeigt Ihnen, wer Ihre Seite besucht</li>
<li class="flex items-center gap-2">✓ Kunden springen nicht frustriert ab</li>
</ul>
</div>
<a href="#contact" class="inline-flex items-center justify-center py-2 px-4 bg-white text-brand-primary font-semibold rounded-lg text-xs hover:bg-brand-cyan hover:text-brand-primary transition-colors duration-300">
Projekt anfragen
Gespräch vereinbaren
</a>
</div>
</div>
@@ -111,9 +111,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M15.75 10.5V6a3.75 3.75 0 10-7.5 0v4.5m11.356-1.993l1.263 12c.07.665-.45 1.243-1.119 1.243H4.25a1.125 1.125 0 01-1.12-1.243l1.264-12A1.125 1.125 0 015.513 7.5h12.974c.576 0 1.059.435 1.119 1.007zM8.625 10.5a.375.375 0 11-.75 0 .375.375 0 01.75 0zm7.5 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z"></path>
</svg>
</div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Onlineshops</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Internet-Verkauf</h4>
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Einfache, performante E-Commerce-Lösungen für Händler, um Produkte online erfolgreich zu vermarkten.
Verkaufen Sie Ihre Produkte, Speisen oder Gutscheine direkt online. Kinderleicht für Ihre Kunden zu bedienen.
</p>
</div>
<span class="text-xs font-semibold text-brand-cyan group-hover:text-brand-magenta transition-colors flex items-center gap-1">
@@ -123,16 +123,16 @@
<!-- Back -->
<div class="absolute inset-0 backface-hidden rotate-y-180 bg-gradient-to-br from-brand-magenta/90 to-brand-primary text-white border border-brand-magenta/30 rounded-2xl flex flex-col justify-between p-8 shadow-2xl">
<div>
<h4 class="text-xl font-bold mb-4">Onlineshops</h4>
<h4 class="text-xl font-bold mb-4">Internet-Verkauf</h4>
<ul class="space-y-2 text-sm text-gray-200">
<li class="flex items-center gap-2">✓ Einfache Produkt- & Bestellpflege</li>
<li class="flex items-center gap-2">✓ PayPal, Stripe & Klarna Integration</li>
<li class="flex items-center gap-2">✓ Skalierbare E-Commerce-Systeme</li>
<li class="flex items-center gap-2">✓ Optimierter Checkout-Prozess</li>
<li class="flex items-center gap-2">✓ Produkte & Speisen leicht ändern</li>
<li class="flex items-center gap-2">✓ Bezahlung per PayPal oder Karte</li>
<li class="flex items-center gap-2">✓ Wächst mit Ihrem Laden mit</li>
<li class="flex items-center gap-2">✓ Kinderleichte Bestellung</li>
</ul>
</div>
<a href="#contact" class="inline-flex items-center justify-center py-2 px-4 bg-white text-brand-primary font-semibold rounded-lg text-xs hover:bg-brand-cyan hover:text-brand-primary transition-colors duration-300">
Sicherheit prüfen
Shop anfragen
</a>
</div>
</div>
@@ -150,9 +150,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M11.42 15.17L17.25 21A2.652 2.652 0 0021 17.25l-5.877-5.877M11.42 15.17l2.496-3.03c.31-.374.711-.6 1.154-.63M11.42 15.17a3 3 0 11-4.088-4.088M12.73 8.21l-.504-.504a1.5 1.5 0 112.122-2.122l.504.504m-2.122 2.122A2.99 2.99 0 0012 8.25m0 0a3.01 3.01 0 01-1.127-.224l-3.23-3.23a1.5 1.5 0 10-2.122 2.122l3.23 3.23C8.63 10.3 8.6 10.65 8.77 11"></path>
</svg>
</div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Wartung & Support</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Technik & Pflege</h4>
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Wir kümmern uns um Updates, Backups, Sicherheit und inhaltliche Anpassungen Ihrer Website.
Sie wollen ein neues Bild zeigen oder die Öffnungszeiten ändern? Schreiben Sie uns einfach. Wir machen das für Sie.
</p>
</div>
<span class="text-xs font-semibold text-brand-cyan group-hover:text-brand-magenta transition-colors flex items-center gap-1">
@@ -162,16 +162,16 @@
<!-- Back -->
<div class="absolute inset-0 backface-hidden rotate-y-180 bg-gradient-to-br from-brand-primary/95 to-purple-950 text-white border border-brand-cyan/20 rounded-2xl flex flex-col justify-between p-8 shadow-2xl">
<div>
<h4 class="text-xl font-bold mb-4">Wartung & Support</h4>
<h4 class="text-xl font-bold mb-4">Technik & Pflege</h4>
<ul class="space-y-2 text-sm text-gray-200">
<li class="flex items-center gap-2">✓ Regelmäßige System- & Plugin-Updates</li>
<li class="flex items-center gap-2">✓ Tägliche Sicherheits-Backups</li>
<li class="flex items-center gap-2">✓ Schutz vor Spam & Hackerangriffen</li>
<li class="flex items-center gap-2">✓ Schnelle inhaltliche Änderungen</li>
<li class="flex items-center gap-2">✓ Wir machen alle wichtigen Updates</li>
<li class="flex items-center gap-2">✓ Tägliche automatische Datensicherung</li>
<li class="flex items-center gap-2">✓ Schutz vor Internet-Angriffen</li>
<li class="flex items-center gap-2">✓ Schnelle Änderungen per WhatsApp/E-Mail</li>
</ul>
</div>
<a href="#contact" class="inline-flex items-center justify-center py-2 px-4 bg-white text-brand-primary font-semibold rounded-lg text-xs hover:bg-brand-magenta hover:text-white transition-colors duration-300">
Support anfragen
Hilfe anfordern
</a>
</div>
</div>
@@ -190,9 +190,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"></path>
</svg>
</div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Lokale SEO & Google</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Bei Google gefunden werden</h4>
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Verbessern Sie Ihre Sichtbarkeit bei lokalen Google-Suchanfragen für mehr Anfragen aus Ihrer Region.
Wenn jemand in Ihrer Stadt nach Ihren Angeboten sucht, sorgen wir dafür, dass Sie ganz oben stehen.
</p>
</div>
<span class="text-xs font-semibold text-brand-cyan group-hover:text-brand-magenta transition-colors flex items-center gap-1">
@@ -202,16 +202,16 @@
<!-- Back -->
<div class="absolute inset-0 backface-hidden rotate-y-180 bg-gradient-to-br from-brand-cyan/90 to-purple-950 text-white border border-brand-cyan/30 rounded-2xl flex flex-col justify-between p-8 shadow-2xl">
<div>
<h4 class="text-xl font-bold mb-4">Lokale SEO</h4>
<h4 class="text-xl font-bold mb-4">Bei Google gefunden werden</h4>
<ul class="space-y-2 text-sm text-gray-200">
<li class="flex items-center gap-2">✓ Google My Business Optimierung</li>
<li class="flex items-center gap-2">✓ Keyword-Recherche für Ihre Nische</li>
<li class="flex items-center gap-2">✓ OnPage & OffPage Optimierung</li>
<li class="flex items-center gap-2">✓ Ladezeiten-Optimierung (Web Vitals)</li>
<li class="flex items-center gap-2">✓ Google Maps Eintrag perfekt gepflegt</li>
<li class="flex items-center gap-2">✓ Gefunden bei den richtigen Suchwörtern</li>
<li class="flex items-center gap-2">✓ Ihre Seite wird für Google optimiert</li>
<li class="flex items-center gap-2">✓ Schnelles Laden bringt bessere Plätze</li>
</ul>
</div>
<a href="#contact" class="inline-flex items-center justify-center py-2 px-4 bg-white text-brand-primary font-semibold rounded-lg text-xs hover:bg-brand-magenta hover:text-white transition-colors duration-300">
Termin buchen
Kostenloses Gespräch vereinbaren
</a>
</div>
</div>
@@ -229,9 +229,9 @@
<path stroke-linecap="round" stroke-linejoin="round" d="M9.53 16.122a3 3 0 00-5.78 1.128 2.25 2.25 0 01-2.4 2.245 4.5 4.5 0 008.4-2.245c0-.399-.078-.78-.22-1.128zm0 0a15.998 15.998 0 003.388-1.62m-5.043-.025a15.994 15.994 0 011.622-3.395m3.42 3.42a15.995 15.995 0 004.764-4.648l3.076-3.077a2.25 2.25 0 00-3.182-3.182L13.1 7.27a15.996 15.996 0 00-4.648 4.764m3.122 3.122l-1.22 4.88m0 0a3 3 0 00-5.78 1.128c0 .285.023.57.067.85"></path>
</svg>
</div>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">UI/UX Webdesign</h4>
<h4 class="text-xl font-bold text-brand-primary dark:text-white mb-2">Schönes Aussehen</h4>
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">
Individuelle Design-Entwürfe, die auf Ihre Markenidentität abgestimmt sind und Besucher begeistern.
Wir gestalten Ihre Webseite so, dass sie modern, einladend und genau passend zu Ihrem Geschäft aussieht.
</p>
</div>
<span class="text-xs font-semibold text-brand-cyan group-hover:text-brand-magenta transition-colors flex items-center gap-1">
@@ -241,16 +241,16 @@
<!-- Back -->
<div class="absolute inset-0 backface-hidden rotate-y-180 bg-gradient-to-br from-brand-magenta/80 to-brand-primary text-white border border-brand-magenta/20 rounded-2xl flex flex-col justify-between p-8 shadow-2xl">
<div>
<h4 class="text-xl font-bold mb-4">Webdesign</h4>
<h4 class="text-xl font-bold mb-4">Schönes Aussehen</h4>
<ul class="space-y-2 text-sm text-gray-200">
<li class="flex items-center gap-2">✓ Einzigartige, moderne Designs</li>
<li class="flex items-center gap-2">✓ Abgestimmtes Farb- & Schriftkonzept</li>
<li class="flex items-center gap-2">✓ Intuitive Benutzerführung (UI/UX)</li>
<li class="flex items-center gap-2">✓ Erstellung von Figma-Prototypen</li>
<li class="flex items-center gap-2">✓ Kein langweiliges Standard-Design</li>
<li class="flex items-center gap-2">✓ Schöne Farben passend zu Ihrem Logo</li>
<li class="flex items-center gap-2">✓ Kunden finden sofort, was sie suchen</li>
<li class="flex items-center gap-2">✓ Sie sehen das Design vor dem Start</li>
</ul>
</div>
<a href="#contact" class="inline-flex items-center justify-center py-2 px-4 bg-white text-brand-primary font-semibold rounded-lg text-xs hover:bg-brand-cyan hover:text-brand-primary transition-colors duration-300">
Potenziale besprechen
Design besprechen
</a>
</div>
</div>

View File

@@ -0,0 +1,112 @@
---
// src/components/admin/AdminSidebar.astro
const { currentPath = '' } = Astro.props;
const navItems = [
{ href: '/admin', label: 'Allgemein & Konfig', icon: '🎛️' },
{ href: '/admin/pages', label: 'Seiten-Verwaltung', icon: '📄' },
{ href: '/admin/settings', label: 'Stammdaten & SEO', icon: '⚙️' },
];
---
<!-- Burger Button (Mobile / Sidebar Toggle) -->
<button
id="admin-menu-toggle"
type="button"
aria-label="Admin Menü öffnen"
class="fixed top-3 left-3 z-50 p-2.5 rounded-xl bg-slate-900/90 border border-white/10 text-white hover:border-sky-400 focus:outline-none focus:ring-2 focus:ring-sky-400 backdrop-blur-md transition-all"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
<!-- Backdrop Overlay for Mobile -->
<div
id="admin-sidebar-backdrop"
class="fixed inset-0 bg-slate-950/80 backdrop-blur-sm z-40 hidden transition-opacity"
></div>
<!-- Drawer Sidebar -->
<aside
id="admin-sidebar"
class="fixed top-0 left-0 h-full w-72 bg-slate-950 border-r border-white/10 p-6 z-50 transform -translate-x-full transition-transform duration-300 ease-in-out flex flex-col justify-between shadow-2xl"
>
<div class="space-y-6">
<!-- Header with Close Button -->
<div class="flex items-center justify-between border-b border-white/10 pb-4">
<div>
<span class="text-[10px] font-mono text-sky-400 uppercase tracking-widest block">N&D WaaS Admin</span>
<h2 class="text-lg font-bold text-white">Navigation</h2>
</div>
<button
id="admin-menu-close"
type="button"
class="p-2 text-slate-400 hover:text-white rounded-lg hover:bg-slate-900 transition-colors"
aria-label="Menü schließen"
>
</button>
</div>
<!-- Navigation Links -->
<nav class="space-y-1.5" aria-label="Admin Sidebar">
{navItems.map((item) => {
const isActive = currentPath === item.href || (item.href !== '/admin' && currentPath.startsWith(item.href));
return (
<a
href={item.href}
class={`flex items-center gap-3 px-4 py-3 rounded-xl text-xs font-semibold transition-all ${
isActive
? 'bg-sky-400/10 text-sky-400 border border-sky-400/30'
: 'text-slate-300 hover:bg-slate-900 hover:text-white border border-transparent'
}`}
>
<span class="text-base">{item.icon}</span>
<span>{item.label}</span>
</a>
);
})}
</nav>
</div>
<!-- Bottom Quick Links -->
<div class="pt-4 border-t border-white/10 space-y-2">
<a
href="/?preview=draft"
target="_blank"
class="flex items-center justify-center gap-2 w-full p-2.5 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs font-semibold hover:bg-amber-500 hover:text-slate-950 transition-all"
>
🛠️ Entwurf-Vorschau
</a>
<a
href="/"
target="_blank"
class="flex items-center justify-center gap-2 w-full p-2.5 rounded-xl bg-slate-900 text-slate-300 border border-white/10 text-xs font-semibold hover:border-sky-400 hover:text-white transition-all"
>
🌐 Live-Website
</a>
</div>
</aside>
<script>
const toggleBtn = document.getElementById('admin-menu-toggle');
const closeBtn = document.getElementById('admin-menu-close');
const sidebar = document.getElementById('admin-sidebar');
const backdrop = document.getElementById('admin-sidebar-backdrop');
function openMenu() {
sidebar?.classList.remove('-translate-x-full');
backdrop?.classList.remove('hidden');
}
function closeMenu() {
sidebar?.classList.add('-translate-x-full');
backdrop?.classList.add('hidden');
}
toggleBtn?.addEventListener('click', openMenu);
closeBtn?.addEventListener('click', closeMenu);
backdrop?.addEventListener('click', closeMenu);
</script>

View File

@@ -0,0 +1,41 @@
---
import type { SiteConfig } from '../../utils/configLoader';
interface Props {
config: SiteConfig['bento'];
}
const props = Astro.props;
const config = props.config || props.settings || props;
const sectionTitle = config.sectionTitle || config.title || 'Unsere Leistungen';
const sectionSubtitle = config.sectionSubtitle || config.subtitle || '';
const cards = config.cards || config.items || [];
---
<section id="services" class={`py-20 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto relative ${config.animation ? `anim-${config.animation}` : ''}`} style={(config as any).bg_color ? `background-color: ${(config as any).bg_color};` : ''}>
<div class="text-center max-w-3xl mx-auto mb-16 space-y-3">
<h2 class="text-3xl font-extrabold text-white tracking-tight uppercase">{sectionTitle}</h2>
<p class="text-slate-400 text-xs sm:text-sm">{sectionSubtitle}</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
{cards.map((card: any) => (
<div class={`relative glass-panel glass-panel-hover rounded-2xl p-8 flex flex-col justify-between overflow-hidden group ${card.colSpan === 2 ? 'md:col-span-2' : 'md:col-span-1'} ${card.animation ? `anim-${card.animation}` : ''}`}>
<div class="space-y-4 relative z-10">
{card.badge && (
<span class="inline-block text-[10px] font-mono uppercase tracking-wider text-sky-400 bg-sky-400/10 px-2.5 py-1 rounded border border-sky-400/20">
{card.badge}
</span>
)}
<h3 class="text-xl font-bold text-white tracking-tight">{card.title}</h3>
<p class="text-slate-400 text-xs sm:text-sm leading-relaxed">{card.description}</p>
</div>
<div class="mt-8 flex justify-end items-center opacity-40 group-hover:opacity-100 transition-opacity">
<span class="text-xs font-mono text-sky-400">Details &rarr;</span>
</div>
</div>
))}
</div>
</section>

View File

@@ -0,0 +1,93 @@
---
import type { SiteConfig, SmtpConfig } from '../../utils/configLoader';
interface Props {
contactConfig: SiteConfig['contact'];
smtpConfig: SmtpConfig;
}
const props = Astro.props;
const contactConfig = props.contactConfig || props.settings || props;
const smtpConfig = props.smtpConfig || { isConfigured: false };
const title = contactConfig.title || 'Kontaktieren Sie uns';
const subtitle = contactConfig.subtitle || 'Wir freuen uns auf Ihre Nachricht.';
---
<section id="contact" class="py-20 px-4 sm:px-6 lg:px-8 max-w-4xl mx-auto relative" style={(contactConfig as any).bg_color ? `background-color: ${(contactConfig as any).bg_color};` : ''}>
<div class="glass-panel rounded-3xl p-8 sm:p-12 relative overflow-hidden">
<div class="text-center space-y-3 mb-10">
<h2 class="text-3xl font-extrabold text-white tracking-tight uppercase">{title}</h2>
<p class="text-slate-400 text-xs sm:text-sm">{subtitle}</p>
</div>
{!smtpConfig.isConfigured && (
<div class="mb-6 p-4 rounded-xl bg-amber-500/10 border border-amber-500/20 text-amber-400 text-xs font-mono text-center">
⚡ Hinweis: SMTP noch im Platzhalter-Modus. Formular sendet Test-Events an die Server-Log.
</div>
)}
<form id="waas-contact-form" class="space-y-5">
<div>
<label for="name" class="block text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1.5">Name</label>
<input type="text" id="name" name="name" required class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400 transition-all" />
</div>
<div>
<label for="email" class="block text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1.5">E-Mail Adresse</label>
<input type="email" id="email" name="email" required class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400 transition-all" />
</div>
<div>
<label for="message" class="block text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1.5">Nachricht</label>
<textarea id="message" name="message" rows="4" required class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400 transition-all resize-none"></textarea>
</div>
<button type="submit" id="waas-submit-btn" class="w-full py-4 rounded-xl bg-sky-400 text-slate-950 font-bold text-xs uppercase tracking-wider hover:bg-white transition-all shadow-lg shadow-sky-400/20">
Anfrage absenden
</button>
<div id="waas-form-feedback" class="hidden text-center text-xs font-medium p-3 rounded-xl"></div>
</form>
</div>
</section>
<script is:inline>
const form = document.getElementById('waas-contact-form');
const btn = document.getElementById('waas-submit-btn');
const feedback = document.getElementById('waas-form-feedback');
if (form) {
form.addEventListener('submit', async (e) => {
e.preventDefault();
btn.disabled = true;
btn.textContent = 'Wird gesendet...';
try {
const formData = new FormData(form);
const res = await fetch('/api/contact', {
method: 'POST',
body: formData
});
const data = await res.json();
feedback.classList.remove('hidden', 'bg-emerald-500/10', 'text-emerald-400', 'bg-red-500/10', 'text-red-400');
if (data.success) {
feedback.classList.add('bg-emerald-500/10', 'text-emerald-400');
feedback.textContent = data.message;
form.reset();
} else {
feedback.classList.add('bg-red-500/10', 'text-red-400');
feedback.textContent = data.message || 'Fehler beim Senden.';
}
} catch (err) {
feedback.classList.remove('hidden');
feedback.classList.add('bg-red-500/10', 'text-red-400');
feedback.textContent = 'Netzwerkfehler.';
} finally {
btn.disabled = false;
btn.textContent = 'Anfrage absenden';
}
});
}
</script>

View File

@@ -0,0 +1,15 @@
---
interface Props {
sectionName?: string;
message?: string;
}
const { sectionName = 'Unbekannt', message } = Astro.props;
---
<section class="py-12 px-4 max-w-4xl mx-auto text-center">
<div class="glass-panel rounded-2xl p-8 text-slate-400 text-xs font-mono">
<p>⚙️ {message || `Section ${sectionName} ist noch in Bearbeitung oder Platzhalter.`}</p>
</div>
</section>

View File

@@ -0,0 +1,85 @@
---
import type { SiteConfig, UIComponent } from '../../utils/configLoader';
import { resolveDynamicBinding } from '../../utils/dynamicDataBinding';
interface Props {
config: SiteConfig['hero'];
contextData?: Record<string, any>;
}
const props = Astro.props;
const config = props.config || props.settings || props;
const contextData = props.contextData || {};
const heroTitle = resolveDynamicBinding(config.title || config.heroTitle || 'Willkommen', contextData);
const heroSubtitle = resolveDynamicBinding(config.subtitle || config.heroSubtitle || '', contextData);
const heroBadge = resolveDynamicBinding(config.badge || config.heroBadge || '', contextData);
const elements = config.elements || [];
const badgeElements = elements.filter((el: UIComponent) => el.slotId === 'hero-badge-slot');
const actionElements = elements.filter((el: UIComponent) => el.slotId === 'hero-actions-slot');
const customElements = elements.filter((el: UIComponent) => !el.slotId || el.slotId === 'hero-custom-slot');
---
<section class={`relative py-24 px-4 sm:px-6 lg:px-8 max-w-7xl mx-auto overflow-hidden ${config.animation ? `anim-${config.animation}` : ''}`} style={config.bg_color ? `background-color: ${config.bg_color};` : ''}>
<span class="aurora-glow aurora-glow-sky w-[500px] h-[500px] -top-32 -left-32"></span>
<span class="aurora-glow aurora-glow-indigo w-[400px] h-[400px] bottom-0 right-0"></span>
<div class="relative z-10 text-center max-w-4xl mx-auto space-y-8">
<!-- Slot 1: Badge Slot -->
<div id="hero-badge-slot" class="inline-flex flex-wrap items-center justify-center gap-2">
{heroBadge && (
<div class="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-sky-400/20 bg-sky-400/10 text-sky-400 text-xs font-mono tracking-wide backdrop-blur-md">
<span>{heroBadge}</span>
</div>
)}
{badgeElements.map(el => (
<span class={`inline-flex items-center gap-2 px-3.5 py-1.5 rounded-full border border-sky-400/30 bg-sky-400/20 text-sky-300 text-xs font-mono ${el.animation0 ? `anim-${el.animation0}` : ''}`}>
{resolveDynamicBinding(el.content, contextData)}
</span>
))}
</div>
<h1 class="text-4xl sm:text-6xl font-extrabold tracking-tight text-white leading-tight">
{heroTitle}
</h1>
<p class="text-slate-400 text-xs sm:text-lg max-w-2xl mx-auto leading-relaxed font-normal">
{heroSubtitle}
</p>
<!-- Slot 2: Action Buttons Slot -->
<div id="hero-actions-slot" class="flex flex-wrap items-center justify-center gap-4 pt-4">
{config.ctaPrimaryText && (
<a href={config.ctaPrimaryLink} class="px-8 py-3.5 rounded-xl bg-sky-400 text-slate-950 font-bold text-xs hover:bg-white transition-all duration-300 shadow-lg shadow-sky-400/20 active:scale-95">
{resolveDynamicBinding(config.ctaPrimaryText, contextData)}
</a>
)}
{config.ctaSecondaryText && (
<a href={config.ctaSecondaryLink} class="px-8 py-3.5 rounded-xl glass-panel text-white font-semibold text-xs glass-panel-hover active:scale-95">
{resolveDynamicBinding(config.ctaSecondaryText, contextData)}
</a>
)}
{actionElements.map(el => (
<a href={el.link || '#'} class={`px-8 py-3.5 rounded-xl font-bold text-xs transition-all active:scale-95 ${el.style === 'glass' ? 'glass-panel text-white' : 'bg-sky-400 text-slate-950 hover:bg-white'} ${el.animation0 ? `anim-${el.animation0}` : ''}`}>
{resolveDynamicBinding(el.content, contextData)}
</a>
))}
</div>
<!-- Slot 3: Custom Elements Slot -->
{customElements.length > 0 && (
<div id="hero-custom-slot" class="pt-6 flex flex-wrap justify-center gap-4">
{customElements.map(el => (
<div class="p-4 rounded-xl glass-panel text-xs text-slate-300">
<span class="font-bold text-sky-400 block mb-1 uppercase text-[10px]">{el.type} [ID: {el.id}]</span>
{resolveDynamicBinding(el.content, contextData)}
</div>
))}
</div>
)}
</div>
</section>

109
src/db/index.ts Normal file
View File

@@ -0,0 +1,109 @@
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import fs from 'node:fs';
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const DB_PATH = path.join(DATA_DIR, 'waas.db');
let dbInstance: DatabaseSync | null = null;
export function getDb(): DatabaseSync {
if (dbInstance) return dbInstance;
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
dbInstance = new DatabaseSync(DB_PATH);
initSchema(dbInstance);
return dbInstance;
}
function initSchema(db: DatabaseSync) {
// 1. site_settings
db.exec(`
CREATE TABLE IF NOT EXISTS site_settings (
key TEXT PRIMARY KEY,
value_json TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
// 2. pages
db.exec(`
CREATE TABLE IF NOT EXISTS pages (
id TEXT PRIMARY KEY,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
homepage_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
`);
// 3. page_sections
db.exec(`
CREATE TABLE IF NOT EXISTS page_sections (
id TEXT PRIMARY KEY,
page_id TEXT NOT NULL,
section_type TEXT NOT NULL,
order_index INTEGER NOT NULL DEFAULT 0,
content_draft_json TEXT NOT NULL,
content_published_json TEXT NOT NULL,
styles_json TEXT,
FOREIGN KEY (page_id) REFERENCES pages (id) ON DELETE CASCADE
);
`);
// Initial Seeding falls DB leer
seedInitialData(db);
}
function seedInitialData(db: DatabaseSync) {
const checkPages = db.prepare('SELECT COUNT(*) as count FROM pages').get() as { count: number };
if (checkPages.count > 0) return;
const now = new Date().toISOString();
// Seed site_settings
const initialSettings = {
siteName: 'N&D IT Solutions',
metaDescription: 'High-Performance Webdesign & Digital Solutions - 100% DSGVO-konform.',
is_demo_mode: false,
header: {
brandName: 'N&D',
brandHighlight: 'i-t SOLUTIONS',
ctaText: 'Erstgespräch buchen',
ctaLink: '#contact',
showThemeToggle: true,
glassEffect: true
},
footer: {
brandDescription: 'Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO.',
copyrightText: 'N&D i-t SOLUTIONS. Alle Rechte vorbehalten.',
showLegalLinks: true
},
site_info: {
title: 'N&D IT Solutions',
homepage_id: 'page_home'
}
};
db.prepare('INSERT INTO site_settings (key, value_json, updated_at) VALUES (?, ?, ?)').run(
'site_config',
JSON.stringify(initialSettings),
now
);
// Seed default page only (no prefilled sections/demo content)
db.prepare('INSERT INTO pages (id, slug, title, status, homepage_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
'page_home',
'/',
'Startseite',
'published',
'page_home',
now,
now
);
}

33
src/db/resetDb.ts Normal file
View File

@@ -0,0 +1,33 @@
import { DatabaseSync } from 'node:sqlite';
import path from 'node:path';
import fs from 'node:fs';
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const DB_PATH = path.join(DATA_DIR, 'waas.db');
export function resetDatabaseToBlankSlate(): void {
const db = new DatabaseSync(DB_PATH);
// Lösche bestehende Seiten & Sektionen
db.exec('DELETE FROM page_sections');
db.exec('DELETE FROM pages');
const now = new Date().toISOString();
// Leere Startseite anlegen (ohne vordefinierte Sektionen/Inhalte)
db.prepare('INSERT INTO pages (id, slug, title, status, homepage_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
'page_home',
'/',
'Startseite',
'published',
'page_home',
now,
now
);
console.log('[WaaS DB] Datenbank auf leeren Stand (Blank Slate) zurückgesetzt.');
}
if (import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith('resetDb.ts')) {
resetDatabaseToBlankSlate();
}

View File

@@ -8,13 +8,25 @@ import '@fontsource/space-grotesk/400.css';
import '@fontsource/space-grotesk/500.css';
import '@fontsource/space-grotesk/600.css';
import '@fontsource/space-grotesk/700.css';
import '../styles/glassmorphism.css';
import { loadWaasConfigs } from '../utils/configLoader';
import { THEME_PRESETS } from '../utils/themePresets';
interface Props {
title: string;
description?: string;
ogImage?: string;
}
const { title, description = "Professionelles Webdesign & moderne Websites für Kleinbetriebe, Handwerker und Selbstständige. N&D i-t SOLUTIONS erstellt Ihre Homepage bezahlbar und schnell." } = Astro.props;
const { siteConfig, themeConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const activePreset = THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark;
const colors = {
...activePreset.colors,
...(themeConfig.customColors || {})
};
const { title, description = "Professionelles Webdesign & moderne Websites.", ogImage } = Astro.props;
---
<!doctype html>
@@ -25,22 +37,57 @@ const { title, description = "Professionelles Webdesign & moderne Websites für
<meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>{title}</title>
<script is:inline src="/js/editor-bridge.js"></script>
<!-- Inline script to set dark/light mode before page renders -->
<style is:global define:vars={{
themeBg: colors.bg,
themeCardBg: colors.cardBg,
themeText: colors.text,
themeAccent: colors.accent,
themeBorder: colors.border
}}>
:root {
--theme-bg: var(--themeBg);
--theme-card-bg: var(--themeCardBg);
--theme-text: var(--themeText);
--theme-accent: var(--themeAccent);
--theme-border: var(--themeBorder);
}
html.light {
--theme-bg: #F8FAFC;
--theme-card-bg: rgba(255, 255, 255, 0.85);
--theme-text: #0F172A;
--theme-border: rgba(0, 0, 0, 0.1);
}
</style>
<!-- Inline theme initialization to prevent FOUC -->
<script is:inline>
(function() {
const theme = localStorage.getItem('theme');
if (theme === 'dark' || (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
} else {
if (theme === 'light') {
document.documentElement.classList.add('light');
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('light');
}
})();
</script>
</head>
<body class="bg-slate-50 dark:bg-brand-darkBg text-slate-800 dark:text-brand-lightText font-sans transition-colors duration-300 antialiased selection:bg-brand-arcticBlue/20 selection:text-brand-arcticBlue">
<body class="font-sans transition-colors duration-500 antialiased">
{(siteConfig as any).is_demo_mode && (
<div class="bg-gradient-to-r from-amber-600 via-amber-500 to-amber-600 text-slate-950 text-center py-2 px-4 text-xs font-bold font-mono tracking-wide shadow-md flex items-center justify-center gap-2">
<span>🎭 DEMO-MODUS AKTIV</span>
<span class="opacity-75 font-sans font-normal">— Dies ist eine interaktive Vorschau-Instanz der N&D WaaS Engine.</span>
</div>
)}
<slot />
</body>
</html>
<style is:global>

25
src/pages/404.astro Normal file
View File

@@ -0,0 +1,25 @@
---
// src/pages/404.astro
import Layout from '../layouts/Layout.astro';
---
<Layout title="404 - Seite nicht gefunden | N&D IT Solutions">
<main class="min-h-screen bg-[#0B0F19] text-white flex items-center justify-center p-4">
<div class="preset-card p-10 bg-slate-900/80 border border-white/10 rounded-2xl max-w-lg w-full text-center space-y-6 shadow-2xl backdrop-blur-md">
<span class="text-6xl font-extrabold text-sky-400 font-mono tracking-widest block">404</span>
<h1 class="text-2xl font-bold text-white">Seite nicht gefunden</h1>
<p class="text-slate-400 text-sm leading-relaxed">
Die von Ihnen angeforderte Seite existiert nicht oder wurde verschoben.
</p>
<div class="pt-4">
<a
href="/"
class="inline-block bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold px-6 py-3 rounded-xl text-sm transition-all shadow-lg shadow-sky-500/20 active:scale-95"
>
← Zurück zur Startseite
</a>
</div>
</div>
</main>
</Layout>

117
src/pages/[...slug].astro Normal file
View File

@@ -0,0 +1,117 @@
---
// src/pages/[...slug].astro
import Layout from '../layouts/Layout.astro';
import { loadWaasConfigs } from '../utils/configLoader';
import { resolveDynamicBinding } from '../utils/dynamicDataBinding';
// Component Registry Import (Zero-Sumpf & Modular)
import HeroSection from '../components/sections/HeroSection.astro';
import BentoGrid from '../components/sections/BentoGrid.astro';
import ContactSection from '../components/sections/ContactSection.astro';
import FallbackSection from '../components/sections/FallbackSection.astro';
// Strikter SSR-Modus für Instant-Updates ohne Re-Build
export const prerender = false;
// 1. Slug aus der URL auslesen
const { slug } = Astro.params;
// 2. Config aus /app/data/site.config.json oder site.config.draft.json laden
const userAgent = Astro.request.headers.get('user-agent') ?? '';
const isDraft = Astro.url.searchParams.get('preview') === 'draft';
const { siteConfig } = loadWaasConfigs(userAgent, isDraft);
// Helper function to resolve dynamic data recursively in settings or objects
function resolveDynamicData(settings: any, dynamicBindings: any, config: any): any {
if (!settings) return settings;
const context = { site: config, ...config };
if (typeof settings === 'string') {
return resolveDynamicBinding(settings, context);
}
if (Array.isArray(settings)) {
return settings.map((item) => resolveDynamicData(item, dynamicBindings, config));
}
if (typeof settings === 'object') {
const resolved: Record<string, any> = {};
for (const [key, value] of Object.entries(settings)) {
resolved[key] = resolveDynamicData(value, dynamicBindings, config);
}
return resolved;
}
return settings;
}
// 3. Normalized Slug-Handling: "/" für Startseite, sonst z.B. "leistungen"
const currentSlug = slug === undefined || slug === '' ? '/' : slug.replace(/\/$/, '');
// 4. Richtige Seite im pages-Array ermitteln (falls vorhanden, sonst Fallback auf Hauptkonfiguration)
const pages = (siteConfig as any).pages || [
{
id: (siteConfig as any).homepage_id || 'home',
slug: '/',
title: siteConfig.siteName,
is_published: true,
sections: siteConfig.sectionsOrder.map((secType: string) => ({
id: secType,
type: secType,
settings: (siteConfig as any)[secType] || {}
}))
}
];
let page = pages.find((p: any) => {
if (currentSlug === '/') {
// Startseiten-Mapping über homepage_id oder Slug "/"
return p.id === (siteConfig as any).homepage_id || p.slug === '/' || p.slug === '';
}
return p.slug === currentSlug || p.slug === `/${currentSlug}`;
});
// 5. Zero-Crash Fallback Guard: Wenn Seite nicht existiert oder nicht veröffentlicht ist -> 404 Status
if (!page || (page.is_published === false && !isDraft)) {
Astro.response.status = 404;
return Astro.rewrite('/404');
}
// 6. Dynamic Component Registry Mapping
const ComponentRegistry: Record<string, any> = {
hero: HeroSection,
bento: BentoGrid,
contact: ContactSection,
HeroSection,
BentoGrid,
ContactSection
};
---
<Layout
title={page.title || siteConfig.siteName || 'N&D IT Solutions'}
description={page.seo?.description || siteConfig.metaDescription || ''}
ogImage={page.seo?.og_image || ''}
>
<main id="main-content" role="main" class="min-h-screen">
{
page.sections && page.sections.length > 0 ? (
page.sections.map((section: any) => {
const Component = ComponentRegistry[section.type] || FallbackSection;
// Löse Platzhalter & DB-Bindings aus /admin/ live auf
const resolvedSettings = resolveDynamicData(
section.settings || {},
section.dynamic_bindings || {},
siteConfig
);
return <Component id={section.id} settings={resolvedSettings} {...resolvedSettings} />;
})
) : (
<FallbackSection message="Diese Seite enthält aktuell noch keine Abschnitte." />
)
}
</main>
</Layout>

View File

@@ -0,0 +1,981 @@
---
// src/pages/admin/editor.astro
import Layout from "../../layouts/Layout.astro";
import AdminSidebar from "../../components/admin/AdminSidebar.astro";
import { loadWaasConfigs } from "../../utils/configLoader";
import { THEME_PRESETS } from "../../utils/themePresets";
export const prerender = false;
const { searchParams } = Astro.url;
const pageId = searchParams.get("page_id");
// Lade den aktuellen Entwurf oder Fallback auf Live Config
const userAgent = Astro.request.headers.get("user-agent") ?? "";
const { siteConfig: draftConfig } = loadWaasConfigs(userAgent, true);
const pages = (draftConfig as any).pages || [];
const targetPage = pages.find((p: any) => p.id === pageId) ||
pages[0] || { id: "page_home", title: "Startseite", slug: "/", sections: [] };
const previewUrl = `${targetPage?.slug || "/"}?preview=draft`;
---
<Layout title={`Editor: ${targetPage?.title || "Baukasten"} | N&D Admin`}>
<AdminSidebar currentPath="/admin/editor" />
<!-- Main Container mit Elementor Dark Theme UI (#1e1e1e, #262626, Elementor Pink #e0436b & Sky Blue #38bdf8) -->
<div
class="h-screen w-screen flex overflow-hidden bg-[#121212] font-sans pl-12 text-slate-200 selection:bg-[#e0436b] selection:text-white"
>
<!-- LINKES ELEMENTOR PANEL (360px - 400px) -->
<aside
id="elementor-panel"
class="w-[380px] min-w-[380px] max-w-[380px] bg-[#1e1e1e] border-r border-[#2d2d2d] flex flex-col justify-between z-30 shadow-2xl relative"
>
<!-- 1. HEADER LEISTE -->
<div
class="h-12 bg-[#181818] border-b border-[#2d2d2d] flex items-center justify-between px-3"
>
<div class="flex items-center gap-2">
<a
href="/admin/pages"
class="p-1.5 rounded hover:bg-[#2a2a2a] text-slate-400 hover:text-white transition-colors"
title="Zurück zum Admin-Dashboard"
>
<svg
class="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
><path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 6h16M4 12h16M4 18h16"></path></svg
>
</a>
<button
id="btn-open-widgets"
class="p-1.5 rounded hover:bg-[#2a2a2a] text-[#e0436b] transition-colors"
title="Widget-Bibliothek"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24"
><path
d="M4 4h4v4H4V4zm6 0h4v4h-4V4zm6 0h4v4h-4V4zM4 10h4v4H4v-4zm6 0h4v4h-4v-4zm6 0h4v4h-4v-4zM4 16h4v4H4v-4zm6 0h4v4h-4v-4zm6 0h4v4h-4v-4z"
></path></svg
>
</button>
<select
id="editor-page-switcher"
class="bg-[#262626] border border-[#333] hover:border-[#38bdf8] text-xs font-bold text-slate-200 rounded px-2 py-1 outline-none transition-colors max-w-[160px] cursor-pointer"
title="Seite im Editor wechseln"
>
{pages.map((p: any) => (
<option value={p.id} selected={p.id === targetPage.id}>
{p.title} ({p.slug})
</option>
))}
</select>
</div>
<div class="flex items-center gap-1">
<span
id="inspector-active-badge"
class="hidden text-[10px] font-mono font-semibold px-2 py-0.5 rounded bg-[#e0436b]/20 text-[#e0436b] border border-[#e0436b]/30"
>
Inspector
</span>
</div>
</div>
<!-- 2. HAUPTBEREICH (DYNAMISCHES PANEL) -->
<div
class="flex-1 overflow-y-auto custom-scrollbar relative bg-[#1e1e1e]"
>
<!-- STATE A: WIDGET-DRAWER (Katalog & Sektions-Übersicht) -->
<div id="panel-state-widgets" class="p-4 space-y-6">
<!-- Sektionen verwalten -->
<div>
<div class="flex items-center justify-between mb-3">
<h2
class="text-xs font-bold uppercase tracking-wider text-slate-400"
>
Sektionen auf Seite
</h2>
<span
class="text-[10px] bg-[#2a2a2a] text-slate-400 px-2 py-0.5 rounded font-mono"
>{targetPage?.sections?.length || 0}</span
>
</div>
<div id="section-list" class="space-y-2">
{
targetPage?.sections?.map((sec: any, index: number) => (
<div
class="group p-3 bg-[#262626] hover:bg-[#2d2d2d] border border-[#333] hover:border-[#e0436b]/50 rounded-lg text-xs flex items-center justify-between transition-all cursor-pointer shadow-sm"
data-section-id={sec.id}
data-section-type={sec.type}
data-settings={JSON.stringify(sec.settings || {})}
>
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<span class="text-[10px] font-mono text-[#e0436b] font-bold">
#{index + 1}
</span>
<span class="font-semibold text-slate-200 truncate">
{sec.type}
</span>
</div>
<div class="flex items-center gap-1 opacity-80 group-hover:opacity-100">
<button
class="btn-inspect-section p-1 hover:text-[#38bdf8] transition-colors"
title="Im 3-Tab-Inspector öffnen"
>
✏️
</button>
<button
class="btn-move-up p-1 hover:text-[#38bdf8] transition-colors"
title="Nach oben"
>
⬆️
</button>
<button
class="btn-move-down p-1 hover:text-[#38bdf8] transition-colors"
title="Nach unten"
>
⬇️
</button>
<button
class="btn-delete-section p-1 hover:text-red-400 transition-colors"
title="Sektion löschen"
>
🗑️
</button>
</div>
</div>
))
}
</div>
</div>
<!-- Sektion / Widget hinzufügen -->
<div class="pt-4 border-t border-[#2d2d2d] space-y-3">
<h2
class="text-xs font-bold uppercase tracking-wider text-slate-400"
>
Widget-Bibliothek (Neue Sektion)
</h2>
<div class="grid grid-cols-2 gap-2.5">
<button
data-widget-type="HeroSection"
class="btn-quick-add-widget p-3 bg-[#262626] hover:bg-[#2d2d2d] border border-[#333] hover:border-[#38bdf8] rounded-xl flex flex-col items-center gap-2 text-center transition-all group"
>
<span class="text-xl group-hover:scale-110 transition-transform"
>🚀</span
>
<span class="text-xs font-bold text-slate-200"
>Hero Section</span
>
</button>
<button
data-widget-type="BentoGrid"
class="btn-quick-add-widget p-3 bg-[#262626] hover:bg-[#2d2d2d] border border-[#333] hover:border-[#38bdf8] rounded-xl flex flex-col items-center gap-2 text-center transition-all group"
>
<span class="text-xl group-hover:scale-110 transition-transform"
>🍱</span
>
<span class="text-xs font-bold text-slate-200">Bento Grid</span>
</button>
<button
data-widget-type="ContactSection"
class="btn-quick-add-widget p-3 bg-[#262626] hover:bg-[#2d2d2d] border border-[#333] hover:border-[#38bdf8] rounded-xl flex flex-col items-center gap-2 text-center transition-all group"
>
<span class="text-xl group-hover:scale-110 transition-transform"
>📬</span
>
<span class="text-xs font-bold text-slate-200"
>Kontakt Formular</span
>
</button>
</div>
</div>
</div>
<!-- STATE B: 3-TAB-INSPEKTORMENÜ (Elementor Inspector) -->
<div id="panel-state-inspector" class="hidden flex flex-col h-full">
<!-- TAB HEADER -->
<div class="flex border-b border-[#2d2d2d] bg-[#181818]">
<button
id="tab-btn-content"
class="flex-1 py-3 text-xs font-bold text-[#e0436b] border-b-2 border-[#e0436b] transition-all flex items-center justify-center gap-1.5"
>
<span>📝</span> Inhalt
</button>
<button
id="tab-btn-style"
class="flex-1 py-3 text-xs font-bold text-slate-400 hover:text-slate-200 border-b-2 border-transparent transition-all flex items-center justify-center gap-1.5"
>
<span>🎨</span> Stil
</button>
<button
id="tab-btn-advanced"
class="flex-1 py-3 text-xs font-bold text-slate-400 hover:text-slate-200 border-b-2 border-transparent transition-all flex items-center justify-center gap-1.5"
>
<span>⚙️</span> Erweitert
</button>
</div>
<!-- TAB INHALTE -->
<form
id="form-elementor-inspector"
class="p-4 flex-1 space-y-6 overflow-y-auto"
>
<input type="hidden" id="active-section-id" name="section_id" />
<!-- TAB 1: INHALT -->
<div id="tab-panel-content" class="space-y-4">
<div
class="flex items-center justify-between pb-2 border-b border-[#2d2d2d]"
>
<span class="text-xs font-bold text-slate-300"
>Text- & Medienfelder</span
>
<button
type="button"
id="btn-[#dynamic-picker-trigger]"
class="text-[10px] font-mono text-[#38bdf8] bg-[#38bdf8]/10 hover:bg-[#38bdf8]/20 px-2 py-0.5 rounded border border-[#38bdf8]/30 flex items-center gap-1"
>
🛢️ Dynamic Data
</button>
</div>
<div>
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1"
>Überschrift (Title)</label
>
<div class="relative">
<input
type="text"
id="field-title"
name="title"
class="w-full bg-[#262626] border border-[#333] focus:border-[#e0436b] rounded-lg p-2.5 text-xs text-white outline-none transition-colors"
/>
</div>
</div>
<div>
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1"
>Untertitel (Subtitle)</label
>
<textarea
id="field-subtitle"
name="subtitle"
rows="3"
class="w-full bg-[#262626] border border-[#333] focus:border-[#e0436b] rounded-lg p-2.5 text-xs text-white outline-none transition-colors resize-none"
></textarea>
</div>
<div>
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1"
>Button Text / CTA</label
>
<input
type="text"
id="field-cta-text"
name="cta_text"
class="w-full bg-[#262626] border border-[#333] focus:border-[#e0436b] rounded-lg p-2.5 text-xs text-white outline-none transition-colors"
/>
</div>
<div>
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1"
>Bild-URL / Medien</label
>
<input
type="text"
id="field-image-url"
name="image_url"
placeholder="https://..."
class="w-full bg-[#262626] border border-[#333] focus:border-[#e0436b] rounded-lg p-2.5 text-xs text-white outline-none transition-colors"
/>
</div>
</div>
<!-- TAB 2: STIL -->
<div id="tab-panel-style" class="hidden space-y-5">
<div class="pb-2 border-b border-[#2d2d2d]">
<span class="text-xs font-bold text-slate-300"
>Farben & Effekte</span
>
</div>
<div>
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1"
>Hintergrundfarbe (bg_color)</label
>
<div class="flex gap-2">
<input
type="color"
id="field-bg-color-picker"
class="h-9 w-12 rounded bg-[#262626] border border-[#333] cursor-pointer p-0.5"
/>
<input
type="text"
id="field-bg-color"
name="bg_color"
placeholder="#0b0f19"
class="flex-1 bg-[#262626] border border-[#333] focus:border-[#e0436b] rounded-lg p-2 text-xs text-white outline-none"
/>
</div>
</div>
<div>
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1"
>Akzentfarbe (accent_color)</label
>
<div class="flex gap-2">
<input
type="color"
id="field-accent-color-picker"
class="h-9 w-12 rounded bg-[#262626] border border-[#333] cursor-pointer p-0.5"
/>
<input
type="text"
id="field-accent-color"
name="accent_color"
placeholder="#38bdf8"
class="flex-1 bg-[#262626] border border-[#333] focus:border-[#e0436b] rounded-lg p-2 text-xs text-white outline-none"
/>
</div>
</div>
<div>
<div class="flex items-center justify-between mb-1">
<label
class="text-[11px] font-bold text-slate-400 uppercase tracking-wider"
>Backdrop Blur</label
>
<span id="blur-val" class="text-xs font-mono text-[#38bdf8]"
>12px</span
>
</div>
<input
type="range"
id="field-backdrop-blur"
name="backdrop_blur"
min="0"
max="24"
value="12"
class="w-full accent-[#e0436b]"
/>
</div>
<div
class="flex items-center justify-between p-3 bg-[#262626] rounded-xl border border-[#333]"
>
<span class="text-xs font-semibold text-slate-300"
>1px Liquid Edge Border</span
>
<input
type="checkbox"
id="field-liquid-edge"
name="liquid_edge"
class="w-4 h-4 accent-[#e0436b] cursor-pointer"
/>
</div>
</div>
<!-- TAB 3: ERWEITERT -->
<div id="tab-panel-advanced" class="hidden space-y-5">
<div class="pb-2 border-b border-[#2d2d2d]">
<span class="text-xs font-bold text-slate-300"
>Abstände & Animationen</span
>
</div>
<div>
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-2"
>Innenabstände (Padding)</label
>
<div class="grid grid-cols-4 gap-2 text-center">
<div>
<span class="text-[10px] text-slate-500 block mb-1"
>Oben</span
>
<input
type="text"
id="field-padding-top"
name="padding_top"
placeholder="24px"
class="w-full bg-[#262626] border border-[#333] rounded-lg p-1.5 text-xs text-white text-center outline-none"
/>
</div>
<div>
<span class="text-[10px] text-slate-500 block mb-1"
>Rechts</span
>
<input
type="text"
id="field-padding-right"
name="padding_right"
placeholder="16px"
class="w-full bg-[#262626] border border-[#333] rounded-lg p-1.5 text-xs text-white text-center outline-none"
/>
</div>
<div>
<span class="text-[10px] text-slate-500 block mb-1"
>Unten</span
>
<input
type="text"
id="field-padding-bottom"
name="padding_bottom"
placeholder="24px"
class="w-full bg-[#262626] border border-[#333] rounded-lg p-1.5 text-xs text-white text-center outline-none"
/>
</div>
<div>
<span class="text-[10px] text-slate-500 block mb-1"
>Links</span
>
<input
type="text"
id="field-padding-left"
name="padding_left"
placeholder="16px"
class="w-full bg-[#262626] border border-[#333] rounded-lg p-1.5 text-xs text-white text-center outline-none"
/>
</div>
</div>
</div>
<div>
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider mb-1"
>Motion.dev Animation</label
>
<select
id="field-animation"
name="animation"
class="w-full bg-[#262626] border border-[#333] focus:border-[#e0436b] rounded-lg p-2.5 text-xs text-white outline-none"
>
<option value="">Keine Animation</option>
<option value="stagger-fade"
>stagger-fade (Schrittweise)</option
>
<option value="spring-scale"
>spring-scale (Federnder Zoom)</option
>
<option value="hover-pulse"
>hover-pulse (Interaktiv Puls)</option
>
<option value="slide-up">slide-up (Von unten)</option>
</select>
</div>
<div class="space-y-2 pt-2">
<label
class="block text-[11px] font-bold text-slate-400 uppercase tracking-wider"
>Sichtbarkeit (Responsive)</label
>
<div
class="flex items-center justify-between p-2.5 bg-[#262626] rounded-lg border border-[#333]"
>
<span class="text-xs text-slate-300"
>Auf Mobile ausblenden</span
>
<input
type="checkbox"
id="field-hide-mobile"
name="hide_mobile"
class="accent-[#e0436b]"
/>
</div>
<div
class="flex items-center justify-between p-2.5 bg-[#262626] rounded-lg border border-[#333]"
>
<span class="text-xs text-slate-300"
>Auf Desktop ausblenden</span
>
<input
type="checkbox"
id="field-hide-desktop"
name="hide_desktop"
class="accent-[#e0436b]"
/>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- 3. BOTTOM-BAR (PRO-STEUERUNGSLEISTE) -->
<div
class="h-14 bg-[#181818] border-t border-[#2d2d2d] px-3 flex items-center justify-between gap-2"
>
<div class="flex items-center gap-1">
<!-- ⚙️ Zahnrad: Seiteneinstellungen -->
<button
id="btn-open-settings"
class="p-2 rounded hover:bg-[#2a2a2a] text-slate-400 hover:text-white transition-colors"
title="Seiteneinstellungen (SEO, Slug)"
>
⚙️
</button>
<!-- 👁️ Auge: Vorschau im neuen Tab -->
<a
href={targetPage.slug || "/"}
target="_blank"
class="p-2 rounded hover:bg-[#2a2a2a] text-slate-400 hover:text-white transition-colors"
title="Live Vorschau im Tab"
>
👁️
</a>
</div>
<!-- 📱 Responsive-Switcher -->
<div
class="flex items-center bg-[#262626] rounded-lg p-1 border border-[#333]"
>
<button
id="res-desktop"
class="p-1 rounded text-[#38bdf8] hover:bg-[#333] transition-colors"
title="Desktop View (100%)">🖥️</button
>
<button
id="res-tablet"
class="p-1 rounded text-slate-400 hover:text-white hover:bg-[#333] transition-colors"
title="Tablet View (768px)">💻</button
>
<button
id="res-mobile"
class="p-1 rounded text-slate-400 hover:text-white hover:bg-[#333] transition-colors"
title="Mobile View (375px)">📱</button
>
</div>
<!-- SPEICHERN & VERÖFFENTLICHEN -->
<div class="flex items-center gap-1.5">
<button
id="btn-save-draft"
class="px-2.5 py-1.5 bg-[#262626] hover:bg-[#333] text-slate-200 font-bold rounded-lg text-xs transition-colors border border-[#333]"
title="Entwurf speichern"
>
💾
</button>
<button
id="btn-publish"
class="px-3 py-1.5 bg-[#e0436b] hover:bg-[#c9365c] text-white font-bold rounded-lg text-xs transition-colors shadow-lg shadow-[#e0436b]/20 flex items-center gap-1"
>
🚀 Veröffentlichen
</button>
</div>
</div>
</aside>
<!-- RECHTER CANVAS-BEREICH (LIVE-IFRAME WORKSPACE) -->
<main
class="flex-1 bg-[#121212] flex flex-col relative items-center justify-start overflow-hidden p-4"
>
<div
class="w-full h-8 bg-[#181818] border border-[#2d2d2d] rounded-t-xl flex items-center justify-between px-4 text-xs text-slate-400 mb-0"
>
<div class="flex items-center gap-2">
<span class="w-2.5 h-2.5 rounded-full bg-emerald-500"></span>
<span
>Elementor Canvas: <code class="text-[#38bdf8] font-mono"
>{previewUrl}</code
></span
>
</div>
<span
id="editor-status-badge"
class="text-[#38bdf8] bg-[#38bdf8]/10 border border-[#38bdf8]/30 px-2 py-0.5 rounded text-[10px] font-mono"
>
Ready
</span>
</div>
<!-- Live Canvas Iframe Wrapper -->
<div
id="iframe-wrapper"
class="w-full h-[calc(100%-2rem)] transition-all duration-300 flex justify-center items-center shadow-2xl rounded-b-xl overflow-hidden bg-slate-950 border border-[#2d2d2d]"
>
<iframe
id="preview-iframe"
src={previewUrl}
class="w-full h-full border-none transition-all duration-300"
title="Elementor Live Workspace"></iframe>
</div>
</main>
</div>
<!-- GLOBALE SEITENEINSTELLUNGEN MODAL -->
<dialog
id="modal-page-settings"
class="bg-[#1e1e1e] text-white border border-[#333] rounded-2xl p-6 backdrop:bg-black/80 max-w-md w-full shadow-2xl"
>
<form id="form-page-settings" class="space-y-4">
<h2
class="text-base font-bold text-white flex items-center justify-between border-b border-[#2d2d2d] pb-3"
>
<span>⚙️ Seiteneinstellungen</span>
</h2>
<div>
<label class="block text-xs font-bold text-slate-400 mb-1"
>Seitentitel</label
>
<input
type="text"
name="title"
value={targetPage.title}
class="w-full bg-[#262626] border border-[#333] rounded-lg p-2 text-xs text-white focus:outline-none focus:border-[#e0436b]"
/>
</div>
<div>
<label class="block text-xs font-bold text-slate-400 mb-1"
>Slug (URL)</label
>
<input
type="text"
name="slug"
value={targetPage.slug}
class="w-full bg-[#262626] border border-[#333] rounded-lg p-2 text-xs text-white focus:outline-none focus:border-[#e0436b]"
/>
</div>
<div class="flex justify-end gap-2 pt-4 border-t border-[#2d2d2d]">
<button
type="button"
id="btn-close-page-settings"
class="px-4 py-2 bg-[#262626] text-slate-300 rounded-lg text-xs hover:bg-[#333]"
>Abbrechen</button
>
<button
type="submit"
class="px-4 py-2 bg-[#e0436b] text-white font-bold rounded-lg text-xs hover:bg-[#c9365c]"
>Speichern</button
>
</div>
</form>
</dialog>
</Layout>
<style>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: #181818;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #333;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #444;
}
</style>
<script>
const iframe = document.getElementById("preview-iframe") as HTMLIFrameElement;
const wrapper = document.getElementById("iframe-wrapper");
const statusBadge = document.getElementById("editor-status-badge");
const stateWidgets = document.getElementById("panel-state-widgets");
const stateInspector = document.getElementById("panel-state-inspector");
const activeBadge = document.getElementById("inspector-active-badge");
// 1. Live PostMessage Sync Helper
function sendToIframe(type: string, payload: any) {
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage({ type, payload }, "*");
}
}
// 2. Responsive Switcher Controls
document.getElementById("res-desktop")?.addEventListener("click", () => {
if (wrapper) wrapper.style.width = "100%";
});
document.getElementById("res-tablet")?.addEventListener("click", () => {
if (wrapper) wrapper.style.width = "768px";
});
document.getElementById("res-mobile")?.addEventListener("click", () => {
if (wrapper) wrapper.style.width = "375px";
});
// 3. Tab Switching in Inspector
const btnContent = document.getElementById("tab-btn-content");
const btnStyle = document.getElementById("tab-btn-style");
const btnAdv = document.getElementById("tab-btn-advanced");
const panelContent = document.getElementById("tab-panel-content");
const panelStyle = document.getElementById("tab-panel-style");
const panelAdv = document.getElementById("tab-panel-advanced");
function switchTab(tab: "content" | "style" | "advanced") {
[btnContent, btnStyle, btnAdv].forEach((b) => {
b?.classList.remove("text-[#e0436b]", "border-[#e0436b]");
b?.classList.add("text-slate-400", "border-transparent");
});
[panelContent, panelStyle, panelAdv].forEach((p) =>
p?.classList.add("hidden"),
);
if (tab === "content") {
btnContent?.classList.add("text-[#e0436b]", "border-[#e0436b]");
panelContent?.classList.remove("hidden");
} else if (tab === "style") {
btnStyle?.classList.add("text-[#e0436b]", "border-[#e0436b]");
panelStyle?.classList.remove("hidden");
} else if (tab === "advanced") {
btnAdv?.classList.add("text-[#e0436b]", "border-[#e0436b]");
panelAdv?.classList.remove("hidden");
}
}
btnContent?.addEventListener("click", () => switchTab("content"));
btnStyle?.addEventListener("click", () => switchTab("style"));
btnAdv?.addEventListener("click", () => switchTab("advanced"));
// Open Drawer button
document.getElementById("btn-open-widgets")?.addEventListener("click", () => {
stateInspector?.classList.add("hidden");
stateWidgets?.classList.remove("hidden");
activeBadge?.classList.add("hidden");
});
// Color Pickers Sync
const bgPicker = document.getElementById(
"field-bg-color-picker",
) as HTMLInputElement;
const bgInput = document.getElementById("field-bg-color") as HTMLInputElement;
bgPicker?.addEventListener("input", (e: any) => {
if (bgInput) bgInput.value = e.target.value;
});
const accPicker = document.getElementById(
"field-accent-color-picker",
) as HTMLInputElement;
const accInput = document.getElementById(
"field-accent-color",
) as HTMLInputElement;
accPicker?.addEventListener("input", (e: any) => {
if (accInput) accInput.value = e.target.value;
});
const blurSlider = document.getElementById(
"field-backdrop-blur",
) as HTMLInputElement;
const blurVal = document.getElementById("blur-val");
blurSlider?.addEventListener("input", (e: any) => {
if (blurVal) blurVal.textContent = `${e.target.value}px`;
});
// Inspector für Sektion öffnen
document.querySelectorAll(".btn-inspect-section").forEach((btn) => {
btn.addEventListener("click", (e: any) => {
const card = e.target.closest("[data-section-id]");
const sectionId = card.getAttribute("data-section-id");
const settings = JSON.parse(card.getAttribute("data-settings") || "{}");
(document.getElementById("active-section-id") as HTMLInputElement).value =
sectionId;
(document.getElementById("field-title") as HTMLInputElement).value =
settings.title || "";
(document.getElementById("field-subtitle") as HTMLTextAreaElement).value =
settings.subtitle || "";
(document.getElementById("field-cta-text") as HTMLInputElement).value =
settings.cta_text || "";
(document.getElementById("field-bg-color") as HTMLInputElement).value =
settings.bg_color || "";
(
document.getElementById("field-accent-color") as HTMLInputElement
).value = settings.accent_color || "";
stateWidgets?.classList.add("hidden");
stateInspector?.classList.remove("hidden");
activeBadge?.classList.remove("hidden");
switchTab("content");
});
});
// Dynamic Live Patch Event bei Eingaben
const formInspector = document.getElementById("form-elementor-inspector");
let autoSaveTimeout: any = null;
formInspector?.addEventListener("input", () => {
const formData = new FormData(formInspector as HTMLFormElement);
const sectionId = formData.get("section_id") as string;
const settings: Record<string, any> = {};
formData.forEach((val, key) => {
if (key !== "section_id") settings[key] = val;
});
// 1. Sofortiges PostMessage Live Rendering im Canvas
sendToIframe("THEME_UPDATE", { sectionId, settings });
// 2. Debounced API Persistenz in SQLite DB
if (autoSaveTimeout) clearTimeout(autoSaveTimeout);
autoSaveTimeout = setTimeout(async () => {
const urlParams = new URLSearchParams(window.location.search);
const pageId = urlParams.get("page_id") || "page_home";
await fetch("/api/editor/update-section-settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
page_id: pageId,
section_id: sectionId,
settings,
}),
});
if (statusBadge) {
statusBadge.textContent = "Live Synced";
statusBadge.className =
"text-emerald-400 bg-emerald-950/60 border border-emerald-800/60 px-2 py-0.5 rounded text-[10px] font-mono";
}
}, 400);
});
// 4. API Actions (Save Draft & Publish)
document
.getElementById("btn-save-draft")
?.addEventListener("click", async () => {
const res = await fetch("/api/editor/save-draft", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ updated_at: new Date().toISOString() }),
});
if (res.ok && statusBadge) {
statusBadge.textContent = "Draft Saved";
statusBadge.className =
"text-emerald-400 bg-emerald-950/60 border border-emerald-800/60 px-2 py-0.5 rounded text-[10px] font-mono";
}
});
document
.getElementById("btn-publish")
?.addEventListener("click", async () => {
const res = await fetch("/api/editor/publish", { method: "POST" });
if (res.ok && statusBadge) {
statusBadge.textContent = "Published!";
statusBadge.className =
"text-emerald-400 bg-emerald-950/60 border border-emerald-800/60 px-2 py-0.5 rounded text-[10px] font-mono";
sendToIframe("RELOAD_CANVAS", {});
}
});
// Move Up / Move Down / Delete Handlers
function getSectionsFromDOM() {
const list: any[] = [];
document.querySelectorAll('#section-list > div').forEach(card => {
const id = card.getAttribute('data-section-id');
const type = card.getAttribute('data-section-type');
const settings = JSON.parse(card.getAttribute('data-settings') || '{}');
list.push({ id, type, settings });
});
return list;
}
async function updateSectionsOrder(newSections: any[]) {
const urlParams = new URLSearchParams(window.location.search);
const pageId = urlParams.get('page_id') || 'page_home';
const res = await fetch('/api/editor/update-section', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_id: pageId, sections: newSections })
});
if (res.ok) {
window.location.reload();
}
}
document.querySelectorAll('.btn-move-up').forEach(btn => {
btn.addEventListener('click', async (e: any) => {
e.stopPropagation();
const sections = getSectionsFromDOM();
const card = e.target.closest('[data-section-id]');
const sectionId = card.getAttribute('data-section-id');
const index = sections.findIndex(s => s.id === sectionId);
if (index > 0) {
const temp = sections[index];
sections[index] = sections[index - 1];
sections[index - 1] = temp;
await updateSectionsOrder(sections);
}
});
});
document.querySelectorAll('.btn-move-down').forEach(btn => {
btn.addEventListener('click', async (e: any) => {
e.stopPropagation();
const sections = getSectionsFromDOM();
const card = e.target.closest('[data-section-id]');
const sectionId = card.getAttribute('data-section-id');
const index = sections.findIndex(s => s.id === sectionId);
if (index >= 0 && index < sections.length - 1) {
const temp = sections[index];
sections[index] = sections[index + 1];
sections[index + 1] = temp;
await updateSectionsOrder(sections);
}
});
});
document.querySelectorAll('.btn-delete-section').forEach(btn => {
btn.addEventListener('click', async (e: any) => {
e.stopPropagation();
const card = e.target.closest('[data-section-id]');
const sectionId = card?.getAttribute('data-section-id');
const urlParams = new URLSearchParams(window.location.search);
const pageId = urlParams.get('page_id') || 'page_home';
const res = await fetch('/api/editor/delete-section', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_id: pageId, section_id: sectionId })
});
if (res.ok) {
window.location.reload();
}
});
});
// Quick Add Widget Button
document.querySelectorAll(".btn-quick-add-widget").forEach((btn) => {
btn.addEventListener("click", async (e: any) => {
const type = e.currentTarget.getAttribute("data-widget-type");
const urlParams = new URLSearchParams(window.location.search);
const pageId = urlParams.get("page_id") || "page_home";
const res = await fetch("/api/editor/add-section", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ page_id: pageId, section_type: type }),
});
if (res.ok) window.location.reload();
});
});
// Page Switcher Dropdown Handler
document.getElementById("editor-page-switcher")?.addEventListener("change", (e: any) => {
const selectedPageId = e.target.value;
window.location.href = `/admin/editor/?page_id=${selectedPageId}`;
});
// Page Settings Modal
const modalPage = document.getElementById(
"modal-page-settings",
) as HTMLDialogElement;
document
.getElementById("btn-open-settings")
?.addEventListener("click", () => modalPage?.showModal());
document
.getElementById("btn-close-page-settings")
?.addEventListener("click", () => modalPage?.close());
</script>

333
src/pages/admin/index.astro Normal file
View File

@@ -0,0 +1,333 @@
---
import Layout from '../../layouts/Layout.astro';
import AdminSidebar from '../../components/admin/AdminSidebar.astro';
import { loadWaasConfigs } from '../../utils/configLoader';
import { THEME_PRESETS } from '../../utils/themePresets';
const { siteConfig, themeConfig, smtpConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const presets = Object.values(THEME_PRESETS);
---
<Layout title="Waas Admin Dashboard | Layout and Config Management">
<AdminSidebar currentPath="/admin" />
<div class="min-h-screen bg-[#0B0F19] text-white p-6 sm:p-10 pl-16 sm:pl-20 font-sans">
<div class="max-w-6xl mx-auto space-y-10">
<!-- Admin Header & Quick Navigation -->
<div class="space-y-4 border-b border-white/10 pb-6">
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<span class="text-xs font-mono text-sky-400 uppercase tracking-widest">N&D WaaS Engine</span>
<h1 class="text-3xl font-extrabold tracking-tight mt-1">System & Admin Dashboard</h1>
</div>
<div class="flex items-center gap-3">
<a id="toggle-preview-mode" href="/?preview=draft" target="_blank" class="px-4 py-2 rounded-xl bg-amber-500/10 text-amber-400 border border-amber-500/20 text-xs font-semibold hover:bg-amber-500 hover:text-slate-950 transition-all flex items-center gap-2">
🛠️ Bearbeitungsmodus (Entwurf-Vorschau)
</a>
<a href="/" target="_blank" class="px-4 py-2 rounded-xl glass-panel text-xs font-semibold hover:border-sky-400 transition-all flex items-center gap-2">
🌐 Live-Seite Ansehen
</a>
<button id="save-all-btn" class="px-6 py-2.5 rounded-xl bg-sky-400 text-slate-950 font-bold text-xs uppercase tracking-wider hover:bg-white transition-all shadow-lg shadow-sky-400/20 active:scale-95">
Einstellungen Speichern
</button>
</div>
</div>
<!-- Quick Admin Nav Cards / Links -->
<nav aria-label="Admin Navigation" class="pt-2">
<a href="/admin/pages" class="p-4 bg-slate-900/80 border border-white/10 hover:border-sky-400/50 rounded-xl flex items-center justify-between text-xs transition-all group">
<div>
<span class="font-bold text-white block group-hover:text-sky-400 text-sm">📄 Seiten-Verwaltung & Visueller Editor</span>
<span class="text-[11px] text-slate-400">Routen, Slugs, Startseite & Seiten im Editor öffnen</span>
</div>
<span class="text-sky-400 text-base font-bold">→</span>
</a>
</nav>
</div>
<div id="admin-alert" class="hidden p-4 rounded-xl text-xs font-mono text-center transition-all"></div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Form Column -->
<div class="lg:col-span-2 space-y-8">
<!-- Site Info & Stammdaten -->
<div class="glass-panel rounded-2xl p-6 space-y-6">
<h2 class="text-lg font-bold text-sky-400 uppercase tracking-wider flex items-center gap-2">
Allgemeine Informationen & SEO
</h2>
<div class="space-y-4">
<div>
<label class="block text-xs font-bold text-slate-400 mb-1">Webseiten-Name (Titel)</label>
<input type="text" id="siteName" value={siteConfig.siteName} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-bold text-slate-400 mb-1">Meta Beschreibung (SEO)</label>
<textarea id="metaDescription" rows="2" class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400 resize-none">{siteConfig.metaDescription}</textarea>
</div>
<label class="flex items-center gap-3 pt-2 p-3 bg-amber-500/10 border border-amber-500/20 rounded-xl cursor-pointer">
<input type="checkbox" id="isDemoMode" checked={!!siteConfig.is_demo_mode} class="rounded text-amber-400 focus:ring-0 h-4 w-4" />
<div>
<span class="text-xs text-amber-400 font-bold block">🎭 Demo-Modus aktivieren</span>
<span class="text-[10px] text-slate-400">Zeigt einen festen Demo-Hinweis für Interessenten auf der Live-Website</span>
</div>
</label>
</div>
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider pt-2 border-t border-white/10">Unternehmens-Stammdaten (Dynamic Data Binding)</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Firmenname / Brand</label>
<input type="text" id="infoTitle" value={(siteConfig as any).site_info?.title || siteConfig.siteName} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">E-Mail Adresse</label>
<input type="email" id="infoEmail" value={(siteConfig as any).site_info?.email || ''} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Telefonnummer</label>
<input type="text" id="infoPhone" value={(siteConfig as any).site_info?.phone || ''} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Adresse / Standort</label>
<input type="text" id="infoAddress" value={(siteConfig as any).site_info?.address || ''} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
</div>
<!-- Global Header Config -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider pt-4 border-t border-white/10">Globaler Header / Navigation Bar</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Brand Name (z.B. N&D)</label>
<input type="text" id="headerBrandName" value={siteConfig.header?.brandName || 'N&D'} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Brand Highlight (z.B. i-t SOLUTIONS)</label>
<input type="text" id="headerBrandHighlight" value={siteConfig.header?.brandHighlight || 'i-t SOLUTIONS'} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">CTA Button Text</label>
<input type="text" id="headerCtaText" value={siteConfig.header?.ctaText || 'Erstgespräch buchen'} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">CTA Button Link</label>
<input type="text" id="headerCtaLink" value={siteConfig.header?.ctaLink || '#contact'} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<label class="flex items-center gap-2 cursor-pointer sm:col-span-2">
<input type="checkbox" id="headerShowThemeToggle" checked={siteConfig.header?.showThemeToggle !== false} class="rounded text-sky-400 focus:ring-0" />
<span class="text-xs text-slate-300 font-semibold">Theme-Toggle Button (☀️/🌙) anzeigen</span>
</label>
<label class="flex items-center gap-2 cursor-pointer sm:col-span-2">
<input type="checkbox" id="headerGlassEffect" checked={siteConfig.header?.glassEffect !== false} class="rounded text-sky-400 focus:ring-0" />
<span class="text-xs text-slate-300 font-semibold">Liquid Glassmorphism Effekt aktivieren</span>
</label>
</div>
<!-- Global Footer Config -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider pt-4 border-t border-white/10">Globaler Footer</h3>
<div class="space-y-4">
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Footer Beschreibungstext</label>
<textarea id="footerBrandDescription" rows="2" class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400 resize-none">{siteConfig.footer?.brandDescription || ''}</textarea>
</div>
<div>
<label class="block text-xs font-semibold text-slate-400 mb-1">Copyright Text</label>
<input type="text" id="footerCopyrightText" value={siteConfig.footer?.copyrightText || ''} class="w-full glass-panel bg-slate-950/60 rounded-xl p-3 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<label class="flex items-center gap-2 cursor-pointer">
<input type="checkbox" id="footerShowLegalLinks" checked={siteConfig.footer?.showLegalLinks !== false} class="rounded text-sky-400 focus:ring-0" />
<span class="text-xs text-slate-300 font-semibold">Rechtliche Links (Datenschutz & Impressum) im Footer anzeigen</span>
</label>
</div>
</div>
</div>
<!-- Sidebar Column -->
<div class="space-y-8">
<!-- Farb-Auswahl (10 globale Farb-Kombinationen) & Custom Colors -->
<div class="glass-panel rounded-2xl p-6 space-y-4">
<h2 class="text-lg font-bold text-sky-400 uppercase tracking-wider flex items-center gap-2">
🎨 Farb-Auswahl (10 Farb-Kombinationen)
</h2>
<p class="text-[10px] text-slate-400">Wähle eine globale Farbkombination für das gesamte Farbspektrum:</p>
<div class="space-y-2 max-h-[260px] overflow-y-auto pr-1">
{presets.map((p) => (
<label class={`flex items-center justify-between p-3 rounded-xl border transition-all cursor-pointer ${themeConfig.activePreset === p.id ? 'border-sky-400 bg-sky-400/10' : 'border-white/5 hover:border-white/20 bg-slate-950/40'}`}>
<div class="flex items-center gap-3">
<input type="radio" name="activePreset" value={p.id} checked={themeConfig.activePreset === p.id} class="text-sky-400 focus:ring-0" />
<span class="text-xs font-semibold">{p.name}</span>
</div>
<div class="flex items-center gap-1">
<span class="w-2.5 h-2.5 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.bg};`} title="Hintergrund"></span>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.cardBg};`} title="Karten"></span>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.text};`} title="Text"></span>
<span class="w-2.5 h-2.5 rounded-full inline-block border border-white/20" style={`background-color: ${p.colors.accent};`} title="Akzent"></span>
</div>
</label>
))}
</div>
<!-- Custom Colors Section -->
<h3 class="text-xs font-bold text-slate-400 uppercase tracking-wider pt-3 border-t border-white/10">Individuelle Farbvergabe (Overrides)</h3>
<p class="text-[10px] text-slate-400">Überschreibe hier gezielt einzelne Preset-Farben:</p>
<div class="grid grid-cols-2 gap-2 text-xs">
<div>
<label class="block text-[10px] text-slate-400 mb-1">Hintergrund (Bg)</label>
<input type="color" id="customColorBg" value={themeConfig.customColors?.bg || (THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark).colors.bg} class="w-full h-8 rounded bg-slate-900 border border-white/10 cursor-pointer p-0.5" />
</div>
<div>
<label class="block text-[10px] text-slate-400 mb-1">Akzentfarbe</label>
<input type="color" id="customColorAccent" value={themeConfig.customColors?.accent || (THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark).colors.accent} class="w-full h-8 rounded bg-slate-900 border border-white/10 cursor-pointer p-0.5" />
</div>
<div>
<label class="block text-[10px] text-slate-400 mb-1">Textfarbe</label>
<input type="color" id="customColorText" value={themeConfig.customColors?.text || (THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark).colors.text} class="w-full h-8 rounded bg-slate-900 border border-white/10 cursor-pointer p-0.5" />
</div>
<div>
<label class="block text-[10px] text-slate-400 mb-1">Rahmenfarbe (Border)</label>
<input type="text" id="customColorBorder" value={themeConfig.customColors?.border || ''} placeholder="rgba(255,255,255,0.1)" class="w-full h-8 rounded bg-slate-900 border border-white/10 text-[10px] px-2 text-white" />
</div>
</div>
</div>
<!-- SMTP Config -->
<div class="glass-panel rounded-3xl p-6 space-y-4">
<h2 class="text-lg font-bold text-sky-400 uppercase tracking-wider flex items-center gap-2">
SMTP E-Mail Einstellungen
</h2>
<div class="space-y-3">
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">SMTP Host</label>
<input type="text" id="smtpHost" value={smtpConfig.host} class="w-full glass-panel bg-slate-950/60 rounded-xl p-2.5 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Port</label>
<input type="number" id="smtpPort" value={smtpConfig.port} class="w-full glass-panel bg-slate-950/60 rounded-xl p-2.5 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">User</label>
<input type="text" id="smtpUser" value={smtpConfig.user} class="w-full glass-panel bg-slate-950-60 rounded-xl p-2.5 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
</div>
<div>
<label class="block text-[10px] font-bold text-slate-400 uppercase mb-1">Passwort</label>
<input type="password" id="smtpPass" value={smtpConfig.pass} class="w-full glass-panel bg-slate-950-60 rounded-xl p-2.5 text-xs text-white focus:outline-none focus:border-sky-400" />
</div>
<label class="flex items-center gap-2 pt-2 cursor-pointer">
<input type="checkbox" id="smtpIsConfigured" checked={smtpConfig.isConfigured} class="rounded text-sky-400 focus:ring-0" />
<span class="text-xs text-slate-300 font-semibold">SMTP Aktivieren (Live-Betrieb)</span>
</label>
</div>
</div>
</div>
</div>
</div>
</div>
</Layout>
<script is:inline>
const saveBtn = document.getElementById('save-all-btn');
const alertBox = document.getElementById('admin-alert');
if (saveBtn) {
saveBtn.addEventListener('click', async () => {
saveBtn.disabled = true;
saveBtn.textContent = 'Wird gespeichert...';
const selectedPreset = document.querySelector('input[name="activePreset"]:checked')?.value || 'corporateDark';
const getVal = (id) => (document.getElementById(id))?.value || '';
const getNum = (id) => parseInt(document.getElementById(id)?.value || '0') || 587;
const getBool = (id) => !!(document.getElementById(id))?.checked;
const payload = {
siteConfig: {
siteName: getVal('siteName'),
metaDescription: getVal('metaDescription'),
is_demo_mode: getBool('isDemoMode'),
site_info: {
title: getVal('infoTitle'),
email: getVal('infoEmail'),
phone: getVal('infoPhone'),
address: getVal('infoAddress')
},
header: {
brandName: getVal('headerBrandName'),
brandHighlight: getVal('headerBrandHighlight'),
ctaText: getVal('headerCtaText'),
ctaLink: getVal('headerCtaLink'),
showThemeToggle: getBool('headerShowThemeToggle'),
glassEffect: getBool('headerGlassEffect')
},
footer: {
brandDescription: getVal('footerBrandDescription'),
copyrightText: getVal('footerCopyrightText'),
showLegalLinks: getBool('footerShowLegalLinks')
}
},
themeConfig: {
activePreset: selectedPreset,
customColors: {
bg: getVal('customColorBg'),
accent: getVal('customColorAccent'),
text: getVal('customColorText'),
border: getVal('customColorBorder')
}
},
smtpConfig: {
host: getVal('smtpHost'),
port: getNum('smtpPort'),
user: getVal('smtpUser'),
pass: getVal('smtpPass'),
isConfigured: getBool('smtpIsConfigured')
}
};
try {
const res = await fetch('/api/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const result = await res.json();
alertBox.classList.remove('hidden', 'bg-emerald-500/10', 'text-emerald-400', 'bg-red-500/10', 'text-red-400');
if (result.success) {
alertBox.classList.add('bg-emerald-500/10', 'text-emerald-400');
alertBox.textContent = 'Konfiguration erfolgeich gespeichert! SSR empfaengt die Aenderungen sofort.';
} else {
alertBox.classList.add('bg-red-500/10', 'text-red-400');
alertBox.textContent = 'Fehler: ' + result.message;
}
} catch (e) {
alertBox.classList.remove('hidden');
alertBox.classList.add('bg-red-500/10', 'text-emerald-400');
alertBox.textContent = 'Fehler beim Speichern.';
} finally {
saveBtn.disabled = false;
saveBtn.textContent = 'Einstellungen Speichern';
}
});
}
</script>

188
src/pages/admin/pages.astro Normal file
View File

@@ -0,0 +1,188 @@
---
// src/pages/admin/pages.astro
import Layout from '../../layouts/Layout.astro';
import AdminSidebar from '../../components/admin/AdminSidebar.astro';
import { loadWaasConfigs } from '../../utils/configLoader';
export const prerender = false;
const userAgent = Astro.request.headers.get('user-agent') ?? '';
const { siteConfig } = loadWaasConfigs(userAgent);
const pages = (siteConfig as any).pages || [];
const currentHomepageId = (siteConfig as any).site_info?.homepage_id;
---
<Layout title="Seiten-Verwaltung | Admin Dashboard">
<AdminSidebar currentPath="/admin/pages" />
<div class="max-w-6xl mx-auto px-4 py-8 pl-16 sm:pl-20">
<div class="flex items-center justify-between mb-8">
<div>
<h1 class="text-2xl font-bold text-white">Seiten-Verwaltung</h1>
<p class="text-slate-400 text-sm">Verwalte hier deine Routen, Slugs und lege die Startseite fest.</p>
</div>
<button
id="btn-open-modal"
class="bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold px-4 py-2 rounded-lg text-sm transition-colors"
>
+ Neue Seite anlegen
</button>
</div>
<div class="bg-slate-900 border border-slate-800 rounded-xl overflow-hidden shadow-xl">
<table class="w-full text-left text-sm text-slate-300">
<thead class="bg-slate-950/60 text-slate-400 border-b border-slate-800 uppercase text-xs">
<tr>
<th class="p-4">Startseite</th>
<th class="p-4">Titel</th>
<th class="p-4">URL Slug</th>
<th class="p-4">Status</th>
<th class="p-4 text-right">Aktionen</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-800/60">
{pages.map((page: any) => {
const isHomepage = page.id === currentHomepageId;
return (
<tr class="hover:bg-slate-800/40 transition-colors">
<td class="p-4">
<input
type="radio"
name="homepage"
checked={isHomepage}
data-page-id={page.id}
class="btn-set-homepage cursor-pointer accent-sky-400 h-4 w-4"
title="Als Startseite festlegen"
/>
</td>
<td class="p-4 font-semibold text-white">
{page.title}
{isHomepage && <span class="ml-2 text-xs text-sky-400 bg-sky-950 border border-sky-800 px-2 py-0.5 rounded-full">Homepage</span>}
</td>
<td class="p-4 font-mono text-xs text-slate-400">{page.slug}</td>
<td class="p-4">
{page.is_published ? (
<span class="text-emerald-400 bg-emerald-950/60 border border-emerald-800 px-2 py-0.5 rounded text-xs">Live</span>
) : (
<span class="text-amber-400 bg-amber-950/60 border border-amber-800 px-2 py-0.5 rounded text-xs">Draft</span>
)}
</td>
<td class="p-4 text-right space-x-2">
<a
href={page.slug}
target="_blank"
class="bg-slate-800 hover:bg-slate-700 text-sky-400 px-2.5 py-1.5 rounded text-xs font-medium inline-block transition-colors"
title="Seite im Browser öffnen"
>
🔗 Öffnen
</a>
<a
href={`/admin/editor/?page_id=${page.id}`}
class="bg-sky-500/20 hover:bg-sky-500 text-sky-300 hover:text-slate-950 px-2.5 py-1.5 rounded text-xs font-medium inline-block transition-colors border border-sky-500/30"
>
✏️ Editor
</a>
{!isHomepage && (
<button
type="button"
data-page-id={page.id}
data-page-title={page.title}
class="btn-delete-page bg-red-500/10 hover:bg-red-500 text-red-400 hover:text-white px-2.5 py-1.5 rounded text-xs font-medium inline-block transition-colors border border-red-500/20"
title="Seite löschen"
>
🗑️ Löschen
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
<dialog id="modal-add-page" class="bg-slate-900 text-white border border-slate-800 rounded-xl p-6 backdrop:bg-slate-950/80 max-w-md w-full">
<form id="form-create-page" class="space-y-4">
<h2 class="text-lg font-bold text-white">Neue Seite anlegen</h2>
<div>
<label class="block text-xs text-slate-400 mb-1">Seitentitel</label>
<input type="text" name="title" required placeholder="z. B. Unsere Leistungen" class="w-full bg-slate-800 border border-slate-700 rounded p-2 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label class="block text-xs text-slate-400 mb-1">URL Slug</label>
<input type="text" name="slug" required placeholder="z. B. leistungen" class="w-full bg-slate-800 border border-slate-700 rounded p-2 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div class="flex justify-end gap-2 pt-4">
<button type="button" id="btn-close-modal" class="px-4 py-2 bg-slate-800 text-slate-300 rounded text-xs">Abbrechen</button>
<button type="submit" class="px-4 py-2 bg-sky-500 text-slate-950 font-bold rounded text-xs">Seite erstellen</button>
</div>
</form>
</dialog>
</Layout>
<script>
// Event-Handler für Startseiten-Wechsel & Dialog-Modal
const modal = document.getElementById('modal-add-page') as HTMLDialogElement;
document.getElementById('btn-open-modal')?.addEventListener('click', () => modal?.showModal());
document.getElementById('btn-close-modal')?.addEventListener('click', () => modal?.close());
// Radio-Button Event: homepage_id ändern
document.querySelectorAll('.btn-set-homepage').forEach(radio => {
radio.addEventListener('change', async (e: any) => {
const pageId = e.target.getAttribute('data-page-id');
await fetch('/api/admin/pages/set-homepage', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_id: pageId })
});
window.location.reload();
});
});
// Form Event: Neue Seite anlegen
document.getElementById('form-create-page')?.addEventListener('submit', async (e: Event) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const title = formData.get('title');
const slug = formData.get('slug');
const res = await fetch('/api/admin/pages/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ title, slug })
});
if (res.ok) {
window.location.reload();
}
});
// Event: Seite löschen
document.querySelectorAll('.btn-delete-page').forEach(btn => {
btn.addEventListener('click', async (e: any) => {
const pageId = e.target.getAttribute('data-page-id');
const pageTitle = e.target.getAttribute('data-page-title');
if (!confirm(`Möchten Sie die Seite "${pageTitle}" wirklich unwiderruflich löschen?`)) return;
const res = await fetch('/api/admin/pages/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ page_id: pageId })
});
if (res.ok) {
window.location.reload();
} else {
const data = await res.json();
alert(data.error || 'Fehler beim Löschen der Seite.');
}
});
});
</script>

View File

@@ -0,0 +1,78 @@
---
// src/pages/admin/settings.astro
import Layout from '../../layouts/Layout.astro';
import AdminSidebar from '../../components/admin/AdminSidebar.astro';
import { loadWaasConfigs } from '../../utils/configLoader';
export const prerender = false;
const userAgent = Astro.request.headers.get('user-agent') ?? '';
const { siteConfig } = loadWaasConfigs(userAgent);
const info = (siteConfig as any).site_info || {};
---
<Layout title="Globale Einstellungen | N&D Admin">
<AdminSidebar currentPath="/admin/settings" />
<div class="max-w-4xl mx-auto px-4 py-8 pl-16 sm:pl-20">
<div class="flex items-center justify-between mb-8 border-b border-slate-800 pb-4">
<div>
<a href="/admin/pages" class="text-xs text-slate-400 hover:text-sky-400 transition-colors">← Zurück zum Seiten-Dashboard</a>
<h1 class="text-2xl font-bold text-white mt-1">Globale Stammdaten & SEO</h1>
<p class="text-slate-400 text-sm">Diese Daten werden für automatisches Data Binding & Kontakt-Infos genutzt.</p>
</div>
</div>
<form id="form-site-settings" class="space-y-6">
<div class="bg-slate-900 border border-slate-800 rounded-xl p-6 space-y-4 shadow-xl">
<h2 class="text-base font-bold text-sky-400 border-b border-slate-800 pb-2">Unternehmensdaten</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1">Firmenname / Website-Titel</label>
<input type="text" name="title" value={info.title || ''} required class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1">E-Mail Adresse</label>
<input type="email" name="email" value={info.email || ''} class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1">Telefonnummer</label>
<input type="text" name="phone" value={info.phone || ''} class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
<div>
<label class="block text-xs font-semibold text-slate-300 uppercase tracking-wider mb-1">Adresse / Standort</label>
<input type="text" name="address" value={info.address || ''} class="w-full bg-slate-800 border border-slate-700 rounded-lg p-2.5 text-sm text-white focus:outline-none focus:border-sky-500" />
</div>
</div>
</div>
<div class="flex justify-end">
<button type="submit" class="bg-sky-500 hover:bg-sky-400 text-slate-950 font-bold px-6 py-3 rounded-lg text-sm transition-colors shadow-lg shadow-sky-500/20">
💾 Stammdaten Speichern
</button>
</div>
</form>
</div>
</Layout>
<script>
document.getElementById('form-site-settings')?.addEventListener('submit', async (e: Event) => {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
const site_info = Object.fromEntries(formData.entries());
const res = await fetch('/api/admin/settings/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ site_info })
});
if (res.ok) {
alert('Stammdaten erfolgreich aktualisiert!');
}
});
</script>

View File

@@ -0,0 +1,53 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { title, slug } = await request.json();
if (!title || !slug) {
return new Response(JSON.stringify({ error: 'Titel und Slug erforderlich' }), { status: 400 });
}
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const configPath = path.join(dataDir, 'site.config.json');
let config: any = {};
try {
const rawData = await fs.readFile(configPath, 'utf-8');
config = JSON.parse(rawData);
} catch {
config = {};
}
const formattedSlug = slug.startsWith('/') ? slug : `/${slug}`;
const newPageId = `page_${Date.now()}`;
const newPage = {
id: newPageId,
slug: formattedSlug,
title: title,
is_published: true,
seo: { description: `${title} - N&D IT Solutions` },
sections: [
{
id: `sec_hero_${Date.now()}`,
type: 'HeroSection',
settings: { title: title, subtitle: 'Willkommen auf dieser Unterseite.' }
}
]
};
config.pages = config.pages || [];
config.pages.push(newPage);
await fs.mkdir(dataDir, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, page: newPage }), { status: 201 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Erstellen der Seite' }), { status: 500 });
}
};

View File

@@ -0,0 +1,40 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id } = await request.json();
if (!page_id) {
return new Response(JSON.stringify({ error: 'page_id erforderlich' }), { status: 400 });
}
const configPath = path.join(DATA_DIR, 'site.config.json');
let config: any = {};
try {
const rawData = await fs.readFile(configPath, 'utf-8');
config = JSON.parse(rawData);
} catch {
return new Response(JSON.stringify({ error: 'Konfigurationsdatei nicht gefunden' }), { status: 404 });
}
// Verhindere Löschen der aktiven Homepage
if (config.site_info?.homepage_id === page_id) {
return new Response(JSON.stringify({ error: 'Die aktive Startseite kann nicht gelöscht werden.' }), { status: 400 });
}
config.pages = (config.pages || []).filter((p: any) => p.id !== page_id);
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, message: 'Seite erfolgreich gelöscht' }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Löschen der Seite' }), { status: 500 });
}
};

View File

@@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id } = await request.json();
if (!page_id) return new Response(JSON.stringify({ error: 'page_id gefordert' }), { status: 400 });
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const configPath = path.join(dataDir, 'site.config.json');
let config: any = {};
try {
const rawData = await fs.readFile(configPath, 'utf-8');
config = JSON.parse(rawData);
} catch {
config = {};
}
// Update homepage_id
config.site_info = config.site_info || {};
config.site_info.homepage_id = page_id;
await fs.mkdir(dataDir, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, homepage_id: page_id }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Speichern' }), { status: 500 });
}
};

View File

@@ -0,0 +1,41 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const POST: APIRoute = async ({ request }) => {
try {
const { site_info, navigation } = await request.json();
if (!site_info || typeof site_info !== 'object') {
return new Response(JSON.stringify({ error: 'Gültige site_info Daten erforderlich' }), { status: 400 });
}
const configPath = path.join(DATA_DIR, 'site.config.json');
let config: any = {};
try {
const raw = await fs.readFile(configPath, 'utf-8');
config = JSON.parse(raw);
} catch {
// Fallback bei neuer Instanz
config = { site_info: {}, pages: [], navigation: [] };
}
// Update der globalen Eigenschaften
config.site_info = { ...config.site_info, ...site_info };
if (Array.isArray(navigation)) {
config.navigation = navigation;
}
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
return new Response(JSON.stringify({ success: true, site_info: config.site_info }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Speichern der Einstellungen' }), { status: 500 });
}
};

58
src/pages/api/config.ts Normal file
View File

@@ -0,0 +1,58 @@
import type { APIRoute } from 'astro';
import fs from 'fs';
import path from 'path';
import { loadWaasConfigs } from '../../utils/configLoader';
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const userAgent = request.headers.get('user-agent') ?? '';
const { siteConfig: currentSite, themeConfig: currentTheme, smtpConfig: currentSmtp } = loadWaasConfigs(userAgent);
if (body.siteConfig) {
const newSite = {
...currentSite,
...body.siteConfig,
hero: { ...(currentSite.hero || {}), ...(body.siteConfig.hero || {}) },
site_info: { ...((currentSite as any).site_info || {}), ...(body.siteConfig.site_info || {}) },
header: { ...(currentSite.header || {}), ...(body.siteConfig.header || {}) },
footer: { ...(currentSite.footer || {}), ...(body.siteConfig.footer || {}) }
};
const sitePath = path.join(dataDir, 'site.config.json');
const draftPath = path.join(dataDir, 'site.config.draft.json');
fs.writeFileSync(sitePath, JSON.stringify(newSite, null, 2), 'utf8');
// Falls bereits ein Draft existiert, site_info & is_demo_mode dort ebenfalls aktualisieren
if (fs.existsSync(draftPath)) {
try {
const draftContent = JSON.parse(fs.readFileSync(draftPath, 'utf8'));
const updatedDraft = { ...draftContent, ...newSite };
fs.writeFileSync(draftPath, JSON.stringify(updatedDraft, null, 2), 'utf8');
} catch {}
}
}
if (body.themeConfig) {
const newTheme = { ...currentTheme, ...body.themeConfig };
fs.writeFileSync(path.join(dataDir, 'theme.config.json'), JSON.stringify(newTheme, null, 2), 'utf8');
}
if (body.smtpConfig) {
const newSmtp = { ...currentSmtp, ...body.smtpConfig };
fs.writeFileSync(path.join(dataDir, 'smtp.config.json'), JSON.stringify(newSmtp, null, 2), 'utf8');
}
return new Response(JSON.stringify({ success: true, message: 'Konfiguration gespeichert' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (err: any) {
return new Response(JSON.stringify({ success: false, message: err.message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
};

30
src/pages/api/contact.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { APIRoute } from 'astro';
import { loadWaasConfigs } from '../../utils/configLoader';
import nodemailer from 'nodemailer';
export const POST: APIRoute = async ({ request }) => {
try {
const data = await request.formData();
const name = data.get('name')?.toString() || '';
const email = data.get('email')?.toString() || '';
const message = data.get('message')?.toString() || '';
if (!name || !email || !message) {
return new Response(JSON.stringify({ success: false, message: 'Bitte füllen Sie alle Pflichtfelder aus.' }), { status: 400, headers: { 'Content-Type': 'application/json' } });
}
const { smtpConfig, siteConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
if (!smtpConfig.isConfigured || smtpConfig.host === 'smtp.placeholder.local') {
console.log('[WaaS Contact API] (Demo Mode) Received Submission:', { name, email, message });
return new Response(JSON.stringify({ success: true, mode: 'placeholder', message: 'Anfrage erfolgreich empfangen! (Demo-Modus)' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
}
const transporter = nodemailer.createTransport({ host: smtpConfig.host, port: smtpConfig.port, secure: smtpConfig.secure, auth: { user: smtpConfig.user, pass: smtpConfig.pass } });
const sender = smtpConfig.fromName + ' ' + smtpConfig.fromEmail;
await transporter.sendMail({ from: sender, to: siteConfig.contact.recipientEmail, replyTo: email, subject: '[Webseite] Neue Nachricht von ' + name, text: 'Name: ' + name + '\nEmail: ' + email + '\n\nNachricht:\n' + message });
return new Response(JSON.stringify({ success: true, message: 'Vielen Dank für Ihre Anfrage! Wir melden uns umgehend.' }), { status: 200, headers: { 'Content-Type': 'application/json' } });
} catch (err: any) {
return new Response(JSON.stringify({ success: false, message: 'Fehler beim Senden: ' + err.message }), { status: 500, headers: { 'Content-Type': 'application/json' } });
}
};

View File

@@ -0,0 +1,48 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
const { sender_name, sender_email, message_text, privacy_consent, website_hp } = body;
// 1. Spamschutz Honeypot Check
if (website_hp) {
return new Response(JSON.stringify({ success: true }), { status: 200 }); // Bot täuschen
}
// 2. Validierung
if (!sender_name || !sender_email || !message_text || !privacy_consent) {
return new Response(JSON.stringify({ error: 'Bitte füllen Sie alle Pflichtfelder aus.' }), { status: 400 });
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(sender_email)) {
return new Response(JSON.stringify({ error: 'Ungültige E-Mail-Adresse.' }), { status: 400 });
}
// 3. SMTP Config aus Volume lesen
const smtpPath = path.join(DATA_DIR, 'smtp.config.json');
let smtpConfig: any = { host: 'localhost', port: 1025, from: 'noreply@kunden-domain.de', recipient: 'info@kunden-domain.de' };
try {
const smtpRaw = await fs.readFile(smtpPath, 'utf-8');
smtpConfig = { ...smtpConfig, ...JSON.parse(smtpRaw) };
} catch {
// Fallback: SMTP Config fehlt noch in /app/data/, Protokolliere In-Memory
console.warn('[Contact API] Missing smtp.config.json in volume. Logging message locally.');
}
// Hier erfolgt die E-Mail-Auslieferung via SMTP oder lokalem Log
console.log(`[Contact Form Submission] To: ${smtpConfig.recipient} | From: ${sender_name} (${sender_email})`);
return new Response(JSON.stringify({ success: true, message: 'Nachricht erfolgreich empfangen.' }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Interner Serverfehler beim Verarbeiten der Anfrage.' }), { status: 500 });
}
};

View File

@@ -0,0 +1,59 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
// Standard-Templates
const SECTION_TEMPLATES: Record<string, any> = {
HeroSection: {
title: 'Neue Überschrift',
subtitle: 'Beschreibungstext hier eingeben.',
ctaPrimaryText: 'Jetzt anfragen',
ctaPrimaryLink: '#contact'
},
BentoGrid: {
sectionTitle: 'Unsere Highlights',
sectionSubtitle: 'Modernste Features im Überblick'
},
ContactSection: {
title: 'Kontaktieren Sie uns',
subtitle: 'Wir antworten innerhalb von 24 Stunden.'
}
};
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, section_type } = await request.json();
if (!page_id || !section_type) {
return new Response(JSON.stringify({ error: 'Gültige page_id und section_type erforderlich' }), { status: 400 });
}
const db = getDb();
const secId = `sec_${section_type.toLowerCase()}_${Date.now()}`;
const defaultContent = JSON.stringify(SECTION_TEMPLATES[section_type] || {});
// Aktuellen max order_index ermitteln
const maxOrderRow = db.prepare('SELECT MAX(order_index) as max_idx FROM page_sections WHERE page_id = ?').get(page_id) as { max_idx: number | null };
const nextIdx = (maxOrderRow?.max_idx ?? -1) + 1;
db.prepare('INSERT INTO page_sections (id, page_id, section_type, order_index, content_draft_json, content_published_json, styles_json) VALUES (?, ?, ?, ?, ?, ?, ?)').run(
secId,
page_id,
section_type,
nextIdx,
defaultContent,
defaultContent,
JSON.stringify({})
);
// Draft-Config via File-Generator neu kompilieren
await generateDraftConfig();
return new Response(JSON.stringify({ success: true, section_id: secId }), { status: 201 });
} catch (error) {
console.error('[API /api/editor/add-section] Exception:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Hinzufügen der Sektion' }), { status: 500 });
}
};

View File

@@ -0,0 +1,26 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, section_id } = await request.json();
if (!page_id || !section_id) {
return new Response(JSON.stringify({ error: 'page_id und section_id erforderlich' }), { status: 400 });
}
const db = getDb();
db.prepare('DELETE FROM page_sections WHERE id = ? AND page_id = ?').run(section_id, page_id);
// Draft-Config neu kompilieren
await generateDraftConfig();
return new Response(JSON.stringify({ success: true, message: 'Sektion gelöscht' }), { status: 200 });
} catch (error) {
console.error('[API /api/editor/delete-section] Exception:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Löschen der Sektion' }), { status: 500 });
}
};

View File

@@ -0,0 +1,14 @@
import type { APIRoute } from 'astro';
import { publishLiveConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async () => {
try {
const liveConfig = await publishLiveConfig();
return new Response(JSON.stringify({ success: true, message: 'Website erfolgreich veröffentlicht!', config: liveConfig }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Veröffentlichen' }), { status: 500 });
}
};

View File

@@ -0,0 +1,17 @@
import type { APIRoute } from 'astro';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const draftBody = await request.json();
// Generiert aktualisierte site.config.draft.json
const draftConfig = await generateDraftConfig();
return new Response(JSON.stringify({ success: true, message: 'Entwurf erfolgreich gespeichert', config: draftConfig }), { status: 200 });
} catch (error) {
return new Response(JSON.stringify({ error: 'Fehler beim Speichern des Entwurfs' }), { status: 500 });
}
};

View File

@@ -0,0 +1,56 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, section_id, settings } = await request.json();
if (!page_id || !section_id || !settings) {
return new Response(JSON.stringify({ error: 'page_id, section_id und settings erforderlich' }), { status: 400 });
}
const db = getDb();
const existingRow = db.prepare('SELECT content_draft_json, styles_json FROM page_sections WHERE id = ?').get(section_id) as any;
if (!existingRow) {
return new Response(JSON.stringify({ error: 'Sektion nicht gefunden' }), { status: 404 });
}
const currentDraft = JSON.parse(existingRow.content_draft_json || '{}');
const currentStyles = JSON.parse(existingRow.styles_json || '{}');
// Trenne Styles (z. B. bg_color, accent_color) von typischem Inhalt
const { bg_color, accent_color, animation, backdrop_blur, liquid_edge, padding_top, padding_right, padding_bottom, padding_left, ...contentFields } = settings;
const updatedDraft = { ...currentDraft, ...contentFields };
const updatedStyles = {
...currentStyles,
...(bg_color !== undefined ? { bg_color } : {}),
...(accent_color !== undefined ? { accent_color } : {}),
...(animation !== undefined ? { animation } : {}),
...(backdrop_blur !== undefined ? { backdrop_blur } : {}),
...(liquid_edge !== undefined ? { liquid_edge } : {}),
...(padding_top !== undefined ? { padding_top } : {}),
...(padding_right !== undefined ? { padding_right } : {}),
...(padding_bottom !== undefined ? { padding_bottom } : {}),
...(padding_left !== undefined ? { padding_left } : {})
};
db.prepare('UPDATE page_sections SET content_draft_json = ?, styles_json = ? WHERE id = ?').run(
JSON.stringify(updatedDraft),
JSON.stringify(updatedStyles),
section_id
);
// Draft-Config neu kompilieren
await generateDraftConfig();
return new Response(JSON.stringify({ success: true, settings: { ...updatedDraft, ...updatedStyles } }), { status: 200 });
} catch (error) {
console.error('[API /api/editor/update-section-settings] Exception:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Aktualisieren der Sektions-Einstellungen' }), { status: 500 });
}
};

View File

@@ -0,0 +1,34 @@
import type { APIRoute } from 'astro';
import { getDb } from '../../../db/index';
import { generateDraftConfig } from '../../../services/file-generator';
export const prerender = false;
export const POST: APIRoute = async ({ request }) => {
try {
const { page_id, sections } = await request.json();
if (!page_id || !Array.isArray(sections)) {
return new Response(JSON.stringify({ error: 'page_id und sections-Array erforderlich' }), { status: 400 });
}
const db = getDb();
const updateStmt = db.prepare('UPDATE page_sections SET order_index = ?, content_draft_json = ? WHERE id = ?');
sections.forEach((sec, index) => {
const draftJson = JSON.stringify(sec.settings || sec.content || {});
updateStmt.run(index, draftJson, sec.id);
});
// Draft-Config neu kompilieren
await generateDraftConfig();
return new Response(
JSON.stringify({ success: true, message: 'Sektionen im Entwurf aktualisiert' }),
{ status: 200 }
);
} catch (error) {
console.error('[API /api/editor/update-section] Exception:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Aktualisieren der Sektionen' }), { status: 500 });
}
};

View File

@@ -0,0 +1,39 @@
import type { APIRoute } from 'astro';
import fs from 'fs';
import path from 'path';
// Paths inside Docker volume /app/data
const draftPath = path.resolve(process.cwd(), 'app', 'data', 'site.config.draft.json');
const livePath = path.resolve(process.cwd(), 'app', 'data', 'site.config.json');
/** GET current draft (for editor init) */
export const GET: APIRoute = async () => {
let draft = {};
if (fs.existsSync(draftPath)) {
try {
draft = JSON.parse(fs.readFileSync(draftPath, 'utf8'));
} catch (e) {
console.error('Failed to parse draft JSON', e);
}
}
return new Response(JSON.stringify({ draft }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};
/** POST to save draft or publish */
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const { draft, action } = body as { draft: Record<string, any>; action?: string };
// Ensure directory exists
fs.mkdirSync(path.dirname(draftPath), { recursive: true });
// Save draft
fs.writeFileSync(draftPath, JSON.stringify(draft, null, 2), 'utf8');
// Publish if requested
if (action === 'publish') {
fs.mkdirSync(path.dirname(livePath), { recursive: true });
fs.writeFileSync(livePath, JSON.stringify(draft, null, 2), 'utf8');
}
return new Response(JSON.stringify({ success: true }), { status: 200, headers: { 'Content-Type': 'application/json' } });
};

37
src/pages/api/health.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { APIRoute } from 'astro';
import fs from 'node:fs/promises';
import path from 'node:path';
export const prerender = false;
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export const GET: APIRoute = async () => {
let volumeStatus = 'ok';
try {
// Prüfe Schreib- und Lesezugriff auf /app/data/
await fs.mkdir(DATA_DIR, { recursive: true });
const testFile = path.join(DATA_DIR, '.healthcheck');
await fs.writeFile(testFile, 'ok', 'utf-8');
await fs.unlink(testFile);
} catch {
volumeStatus = 'error_data_volume_unwritable';
}
const isHealthy = volumeStatus === 'ok';
return new Response(
JSON.stringify({
status: isHealthy ? 'healthy' : 'unhealthy',
uptime: process.uptime(),
timestamp: new Date().toISOString(),
volume_status: volumeStatus,
node_version: process.version,
}),
{
status: isHealthy ? 200 : 503,
headers: { 'Content-Type': 'application/json' },
}
);
};

View File

@@ -0,0 +1,45 @@
import type { APIRoute } from 'astro';
import fs from 'fs';
import path from 'path';
export const POST: APIRoute = async ({ request }) => {
try {
const body = await request.json();
if (!body || typeof body !== 'object') {
return new Response(JSON.stringify({ success: false, message: 'Invalid JSON payload' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Validate critical fields
if (body.siteName && typeof body.siteName !== 'string') {
return new Response(JSON.stringify({ success: false, message: 'siteName must be a string' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const sitePath = path.join(dataDir, 'site.config.json');
fs.writeFileSync(sitePath, JSON.stringify(body, null, 2), 'utf-8');
return new Response(JSON.stringify({
success: true,
message: 'Site configuration successfully updated in /app/data/site.config.json'
}), {
status: 200,
headers: { 'Content-Type': 'application/json' }
});
} catch (err: any) {
return new Response(JSON.stringify({ success: false, message: 'Fehler beim Speichern: ' + err.message }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
};

View File

@@ -2,56 +2,47 @@
import Layout from '../layouts/Layout.astro';
import Navbar from '../components/Navbar.astro';
import Footer from '../components/Footer.astro';
import HeroAndLiquidGlass from '../components/HeroAndLiquidGlass.astro';
import TemplateMarquee from '../components/TemplateMarquee.astro';
import HeroSection from '../components/sections/HeroSection.astro';
import BentoGrid from '../components/sections/BentoGrid.astro';
import ContactSection from '../components/sections/ContactSection.astro';
import FallbackSection from '../components/sections/FallbackSection.astro';
import { loadWaasConfigs } from '../utils/configLoader';
import { THEME_PRESETS } from '../utils/themePresets';
const { siteConfig, themeConfig, smtpConfig } = loadWaasConfigs(Astro.request.headers.get('user-agent') ?? '');
const activePreset = THEME_PRESETS[themeConfig.activePreset] || THEME_PRESETS.corporateDark;
const colors = activePreset.colors;
const sectionComponents: Record<string, any> = {
hero: HeroSection,
bento: BentoGrid,
services: BentoGrid,
contact: ContactSection
};
---
<Layout title="N&D i-t SOLUTIONS | Webdesign & Entwicklung">
<Navbar />
<main>
{/* Clean, modular Hero and Liquid Glass section */}
<HeroAndLiquidGlass />
<!-- Infinite Template Marquee Ticker -->
<TemplateMarquee />
<!-- Contact section (separate, clean layout) -->
<section id="contact" class="py-20 bg-slate-50 dark:bg-[#0B0F19] relative border-t border-slate-200 dark:border-white/5 transition-colors duration-300">
<div class="absolute inset-0 opacity-[0.015] pointer-events-none bg-[radial-gradient(#000_1px,transparent_1px)] dark:bg-[radial-gradient(#fff_1px,transparent_1px)] [background-size:16px_16px] transition-opacity duration-300"></div>
<div class="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 relative z-10">
<div class="bg-white/40 dark:bg-slate-900/40 backdrop-blur-md border border-slate-200 dark:border-white/5 rounded-2xl p-8 sm:p-10 transition-all duration-300 relative tactile-noise shadow-sm dark:shadow-none">
<span class="absolute top-2.5 left-2.5 w-2 h-2 border-t border-l border-slate-300 dark:border-zinc-700/50"></span>
<span class="absolute top-2.5 right-2.5 w-2 h-2 border-t border-r border-slate-300 dark:border-zinc-700/50"></span>
<span class="absolute bottom-2.5 left-2.5 w-2 h-2 border-b border-l border-slate-300 dark:border-zinc-700/50"></span>
<span class="absolute bottom-2.5 right-2.5 w-2 h-2 border-b border-r border-slate-300 dark:border-zinc-700/50"></span>
<h2 class="text-3xl font-extrabold text-slate-900 dark:text-white mb-2 uppercase tracking-tight transition-colors duration-300">Bereit für Ihr Projekt?</h2>
<p class="text-slate-600 dark:text-zinc-400 text-xs mb-8 transition-colors duration-300">
Schreiben Sie uns direkt. Wir melden uns innerhalb von 24 Stunden persönlich bei Ihnen.
</p>
<form class="space-y-4" onsubmit="event.preventDefault()">
<div>
<label for="name" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-1">Name</label>
<input type="text" id="name" required aria-required="true" class="w-full bg-white dark:bg-zinc-950 border border-slate-200 dark:border-brand-darkBorder rounded p-2.5 text-xs text-slate-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-slate-900 transition-all" />
</div>
<div>
<label for="email" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-1">E-Mail</label>
<input type="email" id="email" required aria-required="true" class="w-full bg-white dark:bg-zinc-950 border border-slate-200 dark:border-brand-darkBorder rounded p-2.5 text-xs text-slate-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-slate-900 transition-all" />
</div>
<div>
<label for="message" class="block text-[10px] font-bold text-slate-500 dark:text-zinc-400 uppercase mb-1">Nachricht</label>
<textarea id="message" rows="4" required aria-required="true" class="w-full bg-white dark:bg-zinc-950 border border-slate-200 dark:border-brand-darkBorder rounded p-2.5 text-xs text-slate-800 dark:text-white focus:outline-none focus:ring-2 focus:ring-sky-500 focus:ring-offset-2 focus:ring-offset-white dark:focus:ring-offset-slate-900 transition-all resize-none"></textarea>
</div>
<button type="submit" class="w-full py-3 bg-sky-600 hover:bg-sky-700 dark:bg-brand-arcticBlue text-white dark:text-zinc-950 font-bold text-xs uppercase tracking-wider rounded dark:hover:bg-white transition-colors focus:ring-2 focus:ring-offset-2 focus:ring-sky-500 focus:outline-none">
Anfrage senden
</button>
</form>
</div>
</div>
</section>
</main>
<Footer />
<Layout title={siteConfig.siteName} description={siteConfig.metaDescription}>
<div
class="min-h-screen transition-colors duration-500 font-sans relative overflow-hidden"
style={`background-color: ${themeConfig.customColors?.bg || colors.bg}; color: ${colors.text};`}
>
<Navbar />
<main>
{siteConfig.sectionsOrder.map((sectionKey) => {
if (sectionKey === 'hero') {
return <HeroSection config={siteConfig.hero} />;
}
if (sectionKey === 'bento' || sectionKey === 'services') {
return <BentoGrid config={siteConfig.bento} />;
}
if (sectionKey === 'contact') {
return <ContactSection contactConfig={siteConfig.contact} smtpConfig={smtpConfig} />;
}
return <FallbackSection sectionName={sectionKey} />;
})}
</main>
<Footer />
</div>
</Layout>

View File

@@ -480,46 +480,34 @@ const { tpl } = Astro.props;
<div class="flex flex-col items-center justify-center py-12">
<div class="text-center max-w-xl mb-12">
<span class={`text-xs font-mono font-bold uppercase tracking-widest ${tpl.accentText}`}>{tpl.businessType}</span>
<h1 class="text-4xl sm:text-5xl font-black uppercase tracking-tight mt-2 mb-4">Direkt kalkulieren.</h1>
<p class="opacity-75 text-xs sm:text-sm">Ermitteln Sie Ihren Bedarf schnell und unverbindlich in nur wenigen Schritten.</p>
<h1 class="text-4xl sm:text-5xl font-black uppercase tracking-tight mt-2 mb-4">Direkt anfragen.</h1>
<p class="opacity-75 text-xs sm:text-sm">Tragen Sie Ihre Nachricht ein. Wir melden uns unverbindlich innerhalb von 24 Stunden.</p>
</div>
{/* The centralized Calculator Block (Monolith) */}
{/* The centralized Contact Form Block (Monolith) */}
<div class={`w-full max-w-xl p-8 rounded-3xl ${tpl.cardClass} relative overflow-hidden shadow-2xl z-20`}>
{tpl.group === "Glassmorphismus" && (
<div class="absolute inset-0 bg-white/[0.01] pointer-events-none rounded-3xl"></div>
)}
<h3 class="text-lg font-bold mb-6 border-b border-current/10 pb-4">Bedarfs-Rechner</h3>
<div class="space-y-4">
<h3 class="text-lg font-bold mb-6 border-b border-current/10 pb-4">Kontaktformular</h3>
<form class="space-y-4" onsubmit="event.preventDefault(); alert('Demo-Modus: Nachricht erfolgreich simuliert!')">
<div>
<label class="block text-[10px] font-bold uppercase opacity-60 mb-2">Projekt-Fläche (ca. m²)</label>
<input type="range" min="10" max="500" value="120" class="w-full h-1 bg-current/10 rounded-lg appearance-none cursor-pointer" />
<div class="flex justify-between text-xs font-bold mt-2 opacity-80">
<span>10 m²</span>
<span class={tpl.accentText}>120 m²</span>
<span>500 m²</span>
</div>
<label class="block text-[10px] font-bold uppercase opacity-60 mb-1">Ihr Name</label>
<input type="text" required class="w-full bg-current/5 border border-current/25 rounded p-2.5 text-xs focus:outline-none focus:border-current" />
</div>
<div class="pt-4">
<label class="block text-[10px] font-bold uppercase opacity-60 mb-2">Dienstleistung</label>
<select class="w-full bg-current/5 border border-current/20 rounded p-2 text-xs focus:outline-none focus:border-current">
<option>Komplettmontage & Service</option>
<option>Nur Lieferung & Zuschnitt</option>
<option>Wartung & Reparatur</option>
</select>
<div>
<label class="block text-[10px] font-bold uppercase opacity-60 mb-1">E-Mail-Adresse</label>
<input type="email" required class="w-full bg-current/5 border border-current/25 rounded p-2.5 text-xs focus:outline-none focus:border-current" />
</div>
<div class="bg-current/5 p-4 rounded-xl mt-6 border border-current/10 text-center">
<div class="text-[10px] uppercase font-bold opacity-60">Voraussichtliches Budget</div>
<div class="text-3xl font-black mt-1 uppercase tracking-tight">~ 2.450 €</div>
<div class="text-[9px] opacity-50 mt-1">Unverbindlicher Richtpreis inkl. MwSt.</div>
<div>
<label class="block text-[10px] font-bold uppercase opacity-60 mb-1">Ihre Nachricht</label>
<textarea rows="4" required class="w-full bg-current/5 border border-current/25 rounded p-2.5 text-xs focus:outline-none focus:border-current resize-none"></textarea>
</div>
<button class={`w-full py-3 mt-4 text-xs font-bold uppercase tracking-wider ${tpl.accentBg}`}>
Ergebnis absenden & anfragen
<button type="submit" class={`w-full py-3 mt-4 text-xs font-bold uppercase tracking-wider ${tpl.accentBg}`}>
Anfrage senden
</button>
</div>
</form>
</div>
</div>
)}
@@ -548,7 +536,7 @@ const { tpl } = Astro.props;
<h2 class="text-2xl sm:text-3xl font-extrabold uppercase tracking-tight">Projekt anfragen</h2>
<p class="opacity-70 text-xs mt-2">Tragen Sie Ihr Vorhaben kurz ein. Wir antworten werktags innerhalb von 24 Stunden.</p>
</div>
<form class="max-w-md mx-auto space-y-4" onsubmit="event.preventDefault()">
<form class="max-w-md mx-auto space-y-4" onsubmit="event.preventDefault(); alert('Demo-Modus: Nachricht erfolgreich simuliert!')">
<div>
<label class="block text-[10px] font-bold uppercase opacity-60 mb-1">Ihr Name</label>
<input type="text" required class="w-full bg-current/5 border border-current/25 rounded p-2.5 text-xs focus:outline-none focus:border-current" />

View File

@@ -0,0 +1,98 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { getDb } from '../db/index';
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
export async function generateDraftConfig(): Promise<any> {
const db = getDb();
// 1. Globale Settings laden
const settingsRow = db.prepare('SELECT value_json FROM site_settings WHERE key = ?').get('site_config') as { value_json: string } | undefined;
const baseConfig = settingsRow ? JSON.parse(settingsRow.value_json) : {};
// 2. Alle Seiten laden
const pagesRows = db.prepare('SELECT * FROM pages ORDER BY created_at ASC').all() as any[];
const pages = pagesRows.map((page) => {
const sectionsRows = db.prepare('SELECT * FROM page_sections WHERE page_id = ? ORDER BY order_index ASC').all(page.id) as any[];
const sections = sectionsRows.map((sec) => {
const draftContent = JSON.parse(sec.content_draft_json || '{}');
const styles = JSON.parse(sec.styles_json || '{}');
return {
id: sec.id,
type: sec.section_type,
settings: { ...draftContent, ...styles }
};
});
return {
id: page.id,
slug: page.slug,
title: page.title,
is_published: page.status === 'published',
sections
};
});
const fullDraftConfig = {
...baseConfig,
pages,
updated_at: new Date().toISOString()
};
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(draftPath, JSON.stringify(fullDraftConfig, null, 2), 'utf-8');
return fullDraftConfig;
}
export async function publishLiveConfig(): Promise<any> {
const db = getDb();
// 1. In DB: Setze content_published_json = content_draft_json für alle Sektionen
db.exec('UPDATE page_sections SET content_published_json = content_draft_json');
// 2. Globale Settings laden
const settingsRow = db.prepare('SELECT value_json FROM site_settings WHERE key = ?').get('site_config') as { value_json: string } | undefined;
const baseConfig = settingsRow ? JSON.parse(settingsRow.value_json) : {};
// 3. Alle veröffentlichten Seiten laden
const pagesRows = db.prepare('SELECT * FROM pages WHERE status = ? ORDER BY created_at ASC').all('published') as any[];
const pages = pagesRows.map((page) => {
const sectionsRows = db.prepare('SELECT * FROM page_sections WHERE page_id = ? ORDER BY order_index ASC').all(page.id) as any[];
const sections = sectionsRows.map((sec) => {
const pubContent = JSON.parse(sec.content_published_json || '{}');
const styles = JSON.parse(sec.styles_json || '{}');
return {
id: sec.id,
type: sec.section_type,
settings: { ...pubContent, ...styles }
};
});
return {
id: page.id,
slug: page.slug,
title: page.title,
is_published: true,
sections
};
});
const fullLiveConfig = {
...baseConfig,
pages,
updated_at: new Date().toISOString()
};
const livePath = path.join(DATA_DIR, 'site.config.json');
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(livePath, JSON.stringify(fullLiveConfig, null, 2), 'utf-8');
return fullLiveConfig;
}

View File

@@ -0,0 +1,95 @@
/* Central Color Pattern & WaaS Utility Token System */
:root {
--theme-bg: #0B0F19;
--theme-card-bg: rgba(15, 23, 42, 0.4);
--theme-text: #F8FAFC;
--theme-accent: #38BDF8;
--theme-border: rgba(255, 255, 255, 0.1);
}
body {
background-color: var(--theme-bg) !important;
color: var(--theme-text) !important;
}
.text-accent {
color: var(--theme-accent) !important;
}
.bg-accent {
background-color: var(--theme-accent) !important;
}
.border-accent {
border-color: var(--theme-accent) !important;
}
.glass-panel {
background: var(--theme-card-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--theme-border);
box-shadow: 0 8px 32px 0 rgba(0, 0, 0, 0.37);
}
.glass-panel-hover {
transition: all 0.3s ease;
}
.glass-panel-hover:hover {
border-color: var(--theme-accent);
box-shadow: 0 0 25px -5px var(--theme-accent);
}
.aurora-glow {
position: absolute;
border-radius: 50%;
filter: blur(80px);
pointer-events: none;
opacity: 0.2;
z-index: 0;
}
.aurora-glow-sky {
background: radial-gradient(circle, var(--theme-accent) 0%, transparent 70%);
}
.aurora-glow-indigo {
background: radial-gradient(circle, var(--theme-accent) 0%, transparent 70%);
}
/* Preset Animations */
.anim-spring-fade {
animation: springFade 0.8s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
}
.anim-hover-bounce:hover {
transform: translateY(-6px);
transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.anim-pulse-glow {
animation: pulseGlow 3s infinite ease-in-out;
}
.anim-slide-up {
animation: slideUp 0.6s ease-out forwards;
}
.anim-zoom-in {
animation: zoomIn 0.5s cubic-bezier(0.165, 0.84, 0.44, 1) forwards;
}
@keyframes springFade {
0% { opacity: 0; transform: translateY(20px) scale(0.95); }
100% { opacity: 1; transform: translateY(0) scale(1); }
}
@keyframes pulseGlow {
0%, 100% { box-shadow: 0 0 15px -3px var(--theme-accent); }
50% { box-shadow: 0 0 35px 5px var(--theme-accent); }
}
@keyframes slideUp {
0% { opacity: 0; transform: translateY(30px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes zoomIn {
0% { opacity: 0; transform: scale(0.85); }
100% { opacity: 1; transform: scale(1); }
}

View File

@@ -0,0 +1,67 @@
export interface DisplayCondition {
type: 'include' | 'exclude';
scope: 'entire_site' | 'singular' | 'archive' | 'page_404';
entity?: 'page' | 'post' | 'category';
entityId?: string;
}
export interface TemplateMeta {
id: string;
name: string;
type: 'header' | 'footer' | 'single' | 'archive' | '404';
status: 'draft' | 'published';
priority: number;
conditions: DisplayCondition[];
createdAt?: string;
updatedAt?: string;
}
/**
* Resolves which template to use based on target type and request context conditions.
* Specific matches (singular/page_404) take precedence over general 'entire_site' matches.
*/
export function resolveTemplate(
templates: TemplateMeta[],
targetType: TemplateMeta['type'],
context: { url: string; routeType: 'singular' | 'archive' | '404' | 'home'; entityId?: string }
): TemplateMeta | null {
const candidates = templates.filter(t => t.type === targetType && t.status === 'published');
let bestMatch: TemplateMeta | null = null;
let highestScore = -1;
for (const tpl of candidates) {
let isIncluded = false;
let isExcluded = false;
let score = 0;
for (const cond of tpl.conditions) {
if (cond.type === 'exclude') {
if (cond.scope === 'entire_site') isExcluded = true;
if (cond.scope === 'singular' && context.routeType === 'singular' && (!cond.entityId || cond.entityId === context.entityId)) {
isExcluded = true;
}
if (cond.scope === 'page_404' && context.routeType === '404') isExcluded = true;
} else if (cond.type === 'include') {
if (cond.scope === 'entire_site') {
isIncluded = true;
score = Math.max(score, tpl.priority || 1);
} else if (cond.scope === 'singular' && context.routeType === 'singular') {
if (!cond.entityId || cond.entityId === context.entityId) {
isIncluded = true;
score = Math.max(score, (tpl.priority || 1) + 10);
}
} else if (cond.scope === 'page_404' && context.routeType === '404') {
isIncluded = true;
score = Math.max(score, (tpl.priority || 1) + 10);
}
}
}
if (isIncluded && !isExcluded && score > highestScore) {
highestScore = score;
bestMatch = tpl;
}
}
return bestMatch;
}

344
src/utils/configLoader.ts Normal file
View File

@@ -0,0 +1,344 @@
import fs from 'fs';
import path from 'path';
import { detectDeviceType } from './deviceDetect';
import { getDb } from '../db/index';
export interface UIComponent {
id: string; // Eindeutige ID (z.B. uuid / timestamp)
slotId: string; // Platz-ID (z.B. 'hero-badge-slot', 'hero-actions-slot', 'bento-slot-1')
orderIndex?: number; // Position / Reihenfolge auf dem Platz
type: 'button' | 'badge' | 'text' | 'card' | 'accordion' | 'stat-box' | 'testimonial' | 'cta-box';
content: string;
link?: string;
animation0?: string; // e.g. 'spring-fade', 'hover-bounce', 'pulse-glow'
style?: 'primary' | 'secondary' | 'outline' | 'glass';
}
export interface SectionConfig {
id: string;
type: 'hero' | 'bento' | 'contact' | 'custom';
title?: string;
subtitle?: string;
badge?: string;
animation?: string;
elements?: UIComponent[];
}
export interface SiteConfig {
siteName: string;
metaDescription: string;
header?: {
brandName?: string;
brandHighlight?: string;
ctaText?: string;
ctaLink?: string;
showThemeToggle?: boolean;
glassEffect?: boolean;
};
footer?: {
brandDescription?: string;
copyrightText?: string;
showLegalLinks?: boolean;
};
hero: {
badge: string;
title: string;
subtitle: string;
ctaPrimaryText: string;
ctaPrimaryLink: string;
ctaSecondaryText: string;
ctaSecondaryLink: string;
animation?: string;
bg_color?: string;
elements?: UIComponent[];
};
bento: {
sectionTitle: string;
sectionSubtitle: string;
animation?: string;
bg_color?: string;
cards: Array<{
id: string;
title: string;
description: string;
colSpan?: number;
badge?: string;
icon?: string;
animation?: string;
}>;
};
contact: {
title: string;
subtitle: string;
recipientEmail: string;
phone?: string;
address?: string;
};
sectionsOrder: string[];
sectionsDetails?: SectionConfig[];
is_demo_mode?: boolean;
site_info?: {
title?: string;
company_name?: string;
homepage_id?: string;
email?: string;
phone?: string;
address?: string;
};
pages?: Array<{
id: string;
slug: string;
title: string;
is_published?: boolean;
seo?: { description?: string; og_image?: string };
sections?: Array<{ id: string; type: string; settings?: Record<string, any> }>;
}>;
}
export interface ThemeConfig {
activePreset: string;
glassBlur?: string; // 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
glassOpacity?: number; // 0.1 to 0.9
glassBorderRadius?: string; // 'rounded-lg' | 'rounded-xl' | 'rounded-2xl'
customColors?: {
bg?: string;
cardBg?: string;
text?: string;
accent?: string;
border?: string;
};
}
export interface SmtpConfig {
host: string;
port: number;
secure: boolean;
user: string;
pass: string;
fromName: string;
fromEmail: string;
isConfigured: boolean;
}
export const FALLBACK_SITE_CONFIG: SiteConfig = {
siteName: 'N&D IT Solutions',
metaDescription: 'High-Performance Webdesign & Digital Solutions - 100% DSGVO-konform.',
is_demo_mode: false,
header: {
brandName: 'N&D',
brandHighlight: 'i-t SOLUTIONS',
ctaText: 'Erstgespräch buchen',
ctaLink: '#contact',
showThemeToggle: true,
glassEffect: true
},
footer: {
brandDescription: 'Ihr Partner für professionelles Webdesign, Website-Entwicklung und lokale SEO für Kleinbetriebe & KMU.',
copyrightText: 'N&D i-t SOLUTIONS. Alle Rechte vorbehalten.',
showLegalLinks: true
},
hero: {
badge: 'š Waas Enterprise Baukastensystem',
title: 'Maßgeschneiderte Webseiten. Digital im Griff.',
subtitle: 'Wir entwickeln blitzschnelle, DSGVO-konforme High-End Webseiten für Ihr Unternehmen zum monatlichen Festpreis inklusive Wartung & Hosting.',
ctaPrimaryText: 'Projekt anfragen',
ctaPrimaryLink: '#contact',
ctaSecondaryText: 'Mehr erfahren',
ctaSecondaryLink: '#services',
elements: [
{ id: 'el-1', slotId: 'hero-badge-slot', type: 'badge', content: 'WaaS Engine' },
{ id: 'el-2', slotId: 'hero-actions-slot', type: 'button', content: 'Neuer Button', link: '#contact', animation0: 'spring-fade', style: 'primary' }
]
},
bento: {
sectionTitle: 'High-End Features & Leistung',
sectionSubtitle: 'Modernste Technologie & Höchster Datenschutz vereint.',
cards: [
{
id: 'card-1',
title: 'Ultra Fast SSR',
description: 'Sub-100ms Ladezeiten dank Astro Hybrid SSR Architecture. Google Core Web Vitals > 95%.',
colSpan: 2,
badge: 'Performance',
icon: 'zap'
},
{
id: 'card-2',
title: '100% DSGVO-Sicher',
description: 'Keine Cookies, keine Tracking-Skripte, Zero Third-Party Requests.',
colSpan: 1,
badge: 'Datenschutz',
icon: 'shield'
},
{
id: 'card-3',
title: 'Liquid Glass UI',
description: 'Moderne Ästhetik mit matte Boxen, Aurora Glows und physikalischen Kanten-Effekten.',
colSpan: 1,
badge: 'Design',
icon: 'layers'
},
{
id: 'card-4',
title: 'Sorgenfrei-Paket',
description: 'Hosting, SSL, Wartung und regelmäßige Inhalts-Updates komplett inklusive.',
colSpan: 2,
badge: 'Full-Service',
icon: 'check'
}
]
},
contact: {
title: 'Bereit für Ihr Projekt?',
subtitle: 'Schreiben Sie uns direkt. Wir antworten innerhalb von 24 Stunden persönlich.',
recipientEmail: 'kontakt@ndsolutions.de',
phone: '+49 (0) 123 456789',
address: 'Deutschland'
},
sectionsOrder: ['hero', 'bento', 'contact'],
sectionsDetails: [
{ id: 'hero', type: 'hero', title: 'Hero Sektion', elements: [ { id: 'el-1', slotId: 'hero-badge-slot', type: 'badge', content: 'WaaS Engine' } ] },
{ id: 'bento', type: 'bento', title: 'Bento Grid', elements: [] },
{ id: 'contact', type: 'contact', title: 'Contact Section', elements: [] }
],
site_info: {
title: 'N&D IT Solutions',
homepage_id: 'page_home'
},
pages: [
{
id: 'page_home',
slug: '/',
title: 'Startseite (Hauptseite)',
is_published: true,
sections: [
{ id: 'hero', type: 'HeroSection', settings: {} },
{ id: 'bento', type: 'BentoGrid', settings: {} },
{ id: 'contact', type: 'ContactSection', settings: {} }
]
},
{
id: 'page_templates',
slug: '/templates',
title: 'Templates & Design-Muster',
is_published: true,
sections: [
{ id: 'hero_templates', type: 'HeroSection', settings: { title: 'Templates', subtitle: 'Unsere fertigen Design-Vorlagen' } }
]
},
{
id: 'page_privacy',
slug: '/datenschutz',
title: 'Datenschutzerklärung',
is_published: true,
sections: [
{ id: 'privacy_content', type: 'HeroSection', settings: { title: 'Datenschutz', subtitle: '100% DSGVO-konforme Datenverarbeitung.' } }
]
}
]
};
export const FALLBACK_THEME_CONFIG: ThemeConfig = {
activePreset: 'corporateDark'
};
export const FALLBACK_SMTP_CONFIG: SmtpConfig = {
host: 'smtp.placeholder.local',
port: 587,
secure: false,
user: 'placeholder@domain.de',
pass: '****',
fromName: 'N&D IT Solutions System',
fromEmail: 'noreply@ndsolutions.de',
isConfigured: false
};
function readJsonFile<T>(filePath: string, fallback: T): T {
try {
if (!fs.existsSync(filePath)) {
return fallback;
}
const content = fs.readFileSync(filePath, 'utf-8');
if (!content.trim()) {
return fallback;
}
const parsed = JSON.parse(content);
return { ...fallback, ...parsed };
} catch (error) {
console.warn(`[WaaS ConfigLoader] Warning loading ${filePath}, using fallback defaults.`, error);
return fallback;
}
}
export function loadWaasConfigs(userAgent: string = '', isDraft: boolean = false) {
const dataDir = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const device = detectDeviceType(userAgent);
const draftSitePath = path.join(dataDir, 'site.config.draft.json');
const deviceSitePath = path.join(dataDir, `site.${device}.config.json`);
const genericSitePath = path.join(dataDir, 'site.config.json');
let sitePath = genericSitePath;
if (isDraft) {
if (!fs.existsSync(draftSitePath)) {
try {
const db = getDb();
const settingsRow = db.prepare('SELECT value_json FROM site_settings WHERE key = ?').get('site_config') as { value_json: string } | undefined;
const baseConfig = settingsRow ? JSON.parse(settingsRow.value_json) : {};
const pagesRows = db.prepare('SELECT * FROM pages ORDER BY created_at ASC').all() as any[];
const pages = pagesRows.map((page) => {
const sectionsRows = db.prepare('SELECT * FROM page_sections WHERE page_id = ? ORDER BY order_index ASC').all(page.id) as any[];
const sections = sectionsRows.map((sec) => ({
id: sec.id,
type: sec.section_type,
settings: { ...JSON.parse(sec.content_draft_json || '{}'), ...JSON.parse(sec.styles_json || '{}') }
}));
return { id: page.id, slug: page.slug, title: page.title, is_published: page.status === 'published', sections };
});
const fullDraftConfig = { ...baseConfig, pages, updated_at: new Date().toISOString() };
fs.mkdirSync(dataDir, { recursive: true });
fs.writeFileSync(draftSitePath, JSON.stringify(fullDraftConfig, null, 2), 'utf-8');
} catch (e) {
console.warn('[WaaS ConfigLoader] Fallback draft compilation warning:', e);
}
}
sitePath = draftSitePath;
} else if (fs.existsSync(deviceSitePath)) {
sitePath = deviceSitePath;
}
const deviceThemePath = path.join(dataDir, `theme.${device}.config.json`);
const genericThemePath = path.join(dataDir, 'theme.config.json');
const themePath = fs.existsSync(deviceThemePath) ? deviceThemePath : genericThemePath;
const deviceSmtpPath = path.join(dataDir, `smtp.${device}.config.json`);
const genericSmtpPath = path.join(dataDir, 'smtp.config.json');
const smtpPath = fs.existsSync(deviceSmtpPath) ? deviceSmtpPath : genericSmtpPath;
const rawSite = readJsonFile<Partial<SiteConfig>>(sitePath, FALLBACK_SITE_CONFIG);
const siteConfig: SiteConfig = {
...FALLBACK_SITE_CONFIG,
...rawSite,
hero: { ...FALLBACK_SITE_CONFIG.hero, ...(rawSite.hero || {}) },
bento: { ...FALLBACK_SITE_CONFIG.bento, ...(rawSite.bento || {}) },
contact: { ...FALLBACK_SITE_CONFIG.contact, ...(rawSite.contact || {}) },
pages: (rawSite as any).pages && (rawSite as any).pages.length > 0 ? (rawSite as any).pages : (FALLBACK_SITE_CONFIG as any).pages,
site_info: { ...(FALLBACK_SITE_CONFIG as any).site_info, ...((rawSite as any).site_info || {}) }
};
const themeConfig = readJsonFile<ThemeConfig>(themePath, FALLBACK_THEME_CONFIG);
const smtpConfig = readJsonFile<SmtpConfig>(smtpPath, FALLBACK_SMTP_CONFIG);
return { siteConfig, themeConfig, smtpConfig };
}
export function getSiteConfig(isDraft: boolean = false) {
return loadWaasConfigs('', isDraft).siteConfig;
}

14
src/utils/deviceDetect.ts Normal file
View File

@@ -0,0 +1,14 @@
export type DeviceType = 'desktop' | 'tablet' | 'mobile';
/**
* Simple User-Agent based device detection.
* Returns 'desktop' by default when detection fails.
*/
export function detectDeviceType(userAgent: string): DeviceType {
const ua = userAgent.toLowerCase();
// Tablet detection (including iPad which reports as Mac on iOS 13+)
if (/tablet|ipad/.test(ua)) return 'tablet';
// Mobile detection (phones and Android)
if (/mobile|iphone|android/.test(ua)) return 'mobile';
return 'desktop';
}

View File

@@ -0,0 +1,49 @@
export interface DynamicFieldDefinition {
key: string; // e.g. 'site.siteName', 'post.title', 'custom.clientName'
label: string;
category: 'site' | 'post' | 'author' | 'custom';
defaultValue: string;
}
export const DYNAMIC_FIELDS_REGISTRY: DynamicFieldDefinition[] = [
{ key: 'site.siteName', label: 'Website Name', category: 'site', defaultValue: 'N&D IT Solutions' },
{ key: 'site.metaDescription', label: 'Website Beschreibung', category: 'site', defaultValue: 'High-Performance Webdesign & Digital Solutions' },
{ key: 'site.contactEmail', label: 'Kontakt E-Mail', category: 'site', defaultValue: 'kontakt@ndsolutions.de' },
{ key: 'site.phone', label: 'Telefonnummer', category: 'site', defaultValue: '+49 (0) 123 456789' },
{ key: 'post.title', label: 'Beitragstitel (Dynamisch)', category: 'post', defaultValue: 'Beispiel Artikel' },
{ key: 'post.excerpt', label: 'Beitrags Auszug', category: 'post', defaultValue: 'Ein kurzer Auszug des Artikels...' },
{ key: 'author.name', label: 'Autor Name', category: 'author', defaultValue: 'N&D Team' }
];
/**
* Replaces dynamic binding tags like `{{site.siteName}}` or evaluates binding keys against a data context.
*/
export function resolveDynamicBinding(
content: string,
contextData: Record<string, any>
): string {
if (!content) return content;
return content.replace(/\{\{([^}]+)\}\}/g, (_, key) => {
const trimmedKey = key.trim();
const keys = trimmedKey.split('.');
let val: any = contextData;
for (const k of keys) {
if (val && typeof val === 'object' && k in val) {
val = val[k];
} else {
val = undefined;
break;
}
}
if (val !== undefined && val !== null) {
return String(val);
}
// Fallback to registry default if available
const regItem = DYNAMIC_FIELDS_REGISTRY.find(f => f.key === trimmedKey);
return regItem ? regItem.defaultValue : `{{${trimmedKey}}}`;
});
}

View File

@@ -0,0 +1,48 @@
// Editor controller for draganddrop synchronization
// Provides functions to load, save and publish draft config JSON
import fs from 'fs';
import path from 'path';
const draftPath = path.resolve(process.cwd(), 'app', 'data', 'site.config.draft.json');
const livePath = path.resolve(process.cwd(), 'app', 'data', 'site.config.json');
/** Load current draft config (or empty object) */
export function loadDraft(): Record<string, any> {
if (fs.existsSync(draftPath)) {
try {
return JSON.parse(fs.readFileSync(draftPath, 'utf8'));
} catch (e) {
console.error('Failed to parse draft JSON', e);
return {};
}
}
return {};
}
/** Save draft config */
export function saveDraft(data: Record<string, any>): void {
fs.mkdirSync(path.dirname(draftPath), { recursive: true });
fs.writeFileSync(draftPath, JSON.stringify(data, null, 2), 'utf8');
}
/** Publish draft to live config atomically */
export function publishDraft(): void {
if (!fs.existsSync(draftPath)) return;
const content = fs.readFileSync(draftPath, 'utf8');
fs.mkdirSync(path.dirname(livePath), { recursive: true });
fs.writeFileSync(livePath, content, 'utf8');
}
/** Helper to get both draft and live (for debugging) */
export function getLiveConfig(): Record<string, any> {
if (fs.existsSync(livePath)) {
try {
return JSON.parse(fs.readFileSync(livePath, 'utf8'));
} catch (e) {
console.error('Failed to parse live JSON', e);
return {};
}
}
return {};
}

124
src/utils/themePresets.ts Normal file
View File

@@ -0,0 +1,124 @@
export interface ThemePreset {
id: string;
name: string;
colors: {
bg: string;
cardBg: string;
text: string;
accent: string;
border: string;
};
}
export const THEME_PRESETS: Record<string, ThemePreset> = {
corporateDark: {
id: 'corporateDark',
name: 'Corporate Dark Mode',
colors: {
bg: '#0B0F19',
cardBg: 'rgba(15, 23, 42, 0.4)',
text: '#F8FAFC',
accent: '#38BDF8',
border: 'rgba(255, 255, 255, 0.1)'
}
},
cyberNeon: {
id: 'cyberNeon',
name: 'Cyber Neon',
colors: {
bg: '#05050A',
cardBg: 'rgba(10, 10, 25, 0.5)',
text: '#FFFFFF',
accent: '#F43F5E',
border: 'rgba(244, 63, 94, 0.2)'
}
},
emeraldCraft: {
id: 'emeraldCraft',
name: 'Emerald Craft',
colors: {
bg: '#022C22',
cardBg: 'rgba(6, 78, 59, 0.4)',
text: '#ECFDF5',
accent: '#34D399',
border: 'rgba(52, 211, 153, 0.2)'
}
},
royalViolet: {
id: 'royalViolet',
name: 'Royal Violet',
colors: {
bg: '#0F0728',
cardBg: 'rgba(30, 15, 65, 0.4)',
text: '#FAF5FF',
accent: '#A855F7',
border: 'rgba(168, 85, 247, 0.2)'
}
},
midnightBlue: {
id: 'midnightBlue',
name: 'Midnight Blue',
colors: {
bg: '#030712',
cardBg: 'rgba(17, 24, 39, 0.5)',
text: '#F9FAFB',
accent: '#60A5FA',
border: 'rgba(96, 165, 250, 0.2)'
}
},
amberGlow: {
id: 'amberGlow',
name: 'Amber Glow',
colors: {
bg: '#180E02',
cardBg: 'rgba(45, 26, 8, 0.5)',
text: '#FFFBEB',
accent: '#F59E0B',
border: 'rgba(245, 158, 11, 0.2)'
}
},
monochromeMinimal: {
id: 'monochromeMinimal',
name: 'Monochrome Minimal',
colors: {
bg: '#121212',
cardBg: 'rgba(255, 255, 255, 0.05)',
text: '#E0E0E0',
accent: '#FFFFFF',
border: 'rgba(255, 255, 255, 0.15)'
}
},
arcticFrost: {
id: 'arcticFrost',
name: 'Arctic Frost',
colors: {
bg: '#0B132B',
cardBg: 'rgba(28, 37, 65, 0.4)',
text: '#E0FBFC',
accent: '#70E000',
border: 'rgba(112, 224, 0, 0.2)'
}
},
sunsetCoral: {
id: 'sunsetCoral',
name: 'Sunset Coral',
colors: {
bg: '#1C0A10',
cardBg: 'rgba(50, 15, 28, 0.4)',
text: '#FFF1F2',
accent: '#FB7185',
border: 'rgba(251, 113, 133, 0.2)'
}
},
titaniumSteel: {
id: 'titaniumSteel',
name: 'Titanium Steel',
colors: {
bg: '#18181B',
cardBg: 'rgba(39, 39, 42, 0.5)',
text: '#F4F4F5',
accent: '#A1A1AA',
border: 'rgba(161, 161, 170, 0.2)'
}
}
};

23
tests/contactApi.test.ts Normal file
View File

@@ -0,0 +1,23 @@
import { describe, it, expect } from 'vitest';
describe('Contact API Spamschutz & Validierung', () => {
it('soll Honeypot-Anfragen lautlos abfangen', async () => {
const hpPayload = {
sender_name: 'Bot',
sender_email: 'bot@spam.com',
message_text: 'Spam text',
privacy_consent: true,
website_hp: 'http://spam-link.com'
};
// Simulated check
const isBot = Boolean(hpPayload.website_hp);
expect(isBot).toBe(true);
});
it('soll ungültige E-Mail-Adressen ablehnen', () => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
expect(emailRegex.test('invalid-email')).toBe(false);
expect(emailRegex.test('max@beispiel.de')).toBe(true);
});
});

View File

@@ -0,0 +1,39 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'app', 'test_data_sections');
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
describe('Editor Sections API Test', () => {
beforeEach(async () => {
await fs.mkdir(DATA_DIR, { recursive: true });
const dummyConfig = {
site_info: { title: 'Test Site', homepage_id: 'page_1' },
pages: [
{
id: 'page_1',
slug: '/',
title: 'Home',
sections: [{ id: 'sec_1', type: 'HeroSection', settings: { title: 'Old Hero' } }]
}
]
};
await fs.writeFile(draftPath, JSON.stringify(dummyConfig, null, 2), 'utf-8');
});
it('soll Sektionen in site.config.draft.json erfolgreich überschreiben', async () => {
const raw = await fs.readFile(draftPath, 'utf-8');
const config = JSON.parse(raw);
config.pages[0].sections.push({ id: 'sec_2', type: 'BentoGrid', settings: {} });
await fs.writeFile(draftPath, JSON.stringify(config, null, 2), 'utf-8');
const updatedRaw = await fs.readFile(draftPath, 'utf-8');
const updatedConfig = JSON.parse(updatedRaw);
expect(updatedConfig.pages[0].sections).toHaveLength(2);
expect(updatedConfig.pages[0].sections[1].type).toBe('BentoGrid');
});
});

View File

@@ -0,0 +1,44 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'app', 'test_data_settings');
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
describe('Editor Property API Test', () => {
beforeEach(async () => {
await fs.mkdir(DATA_DIR, { recursive: true });
const dummyConfig = {
site_info: { title: 'Test Site', homepage_id: 'page_1' },
pages: [
{
id: 'page_1',
slug: '/',
title: 'Home',
sections: [
{ id: 'sec_hero_123', type: 'HeroSection', settings: { title: 'Alter Titel', subtitle: 'Alt' } }
]
}
]
};
await fs.writeFile(draftPath, JSON.stringify(dummyConfig, null, 2), 'utf-8');
});
it('soll Sektions-Settings im Entwurf erfolgreich aktualisieren', async () => {
const raw = await fs.readFile(draftPath, 'utf-8');
const config = JSON.parse(raw);
const hero = config.pages[0].sections[0];
hero.settings.title = 'Neuer Hero Titel';
hero.settings.subtitle = 'Neuer Untertitel';
await fs.writeFile(draftPath, JSON.stringify(config, null, 2), 'utf-8');
const updatedRaw = await fs.readFile(draftPath, 'utf-8');
const updatedConfig = JSON.parse(updatedRaw);
expect(updatedConfig.pages[0].sections[0].settings.title).toBe('Neuer Hero Titel');
expect(updatedConfig.pages[0].sections[0].settings.subtitle).toBe('Neuer Untertitel');
});
});

View File

@@ -0,0 +1,30 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { getDb } from '../src/db/index';
import { generateDraftConfig, publishLiveConfig } from '../src/services/file-generator';
import fs from 'node:fs/promises';
import path from 'node:path';
describe('DB-Driven File Generation Service Test', () => {
it('soll Entwurf aus DB lesen und site.config.draft.json generieren', async () => {
const draftConfig = await generateDraftConfig();
expect(draftConfig).toBeDefined();
expect(draftConfig.pages).toBeDefined();
expect(Array.isArray(draftConfig.pages)).toBe(true);
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const draftPath = path.join(DATA_DIR, 'site.config.draft.json');
const exists = await fs.access(draftPath).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
it('soll Live-Config aus DB freigeben und site.config.json atomar erstellen', async () => {
const liveConfig = await publishLiveConfig();
expect(liveConfig).toBeDefined();
expect(liveConfig.pages).toBeDefined();
const DATA_DIR = process.env.DATA_DIR || path.join(process.cwd(), 'app', 'data');
const livePath = path.join(DATA_DIR, 'site.config.json');
const exists = await fs.access(livePath).then(() => true).catch(() => false);
expect(exists).toBe(true);
});
});

18
tests/health.test.ts Normal file
View File

@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'app', 'test_data_health');
describe('Healthcheck API Test', () => {
it('soll Volume-Ordner erfolgreich erstellen und beschreiben können', async () => {
await fs.mkdir(DATA_DIR, { recursive: true });
const testFile = path.join(DATA_DIR, '.vitest_health');
await fs.writeFile(testFile, 'test', 'utf-8');
const content = await fs.readFile(testFile, 'utf-8');
await fs.unlink(testFile);
expect(content).toBe('test');
});
});

View File

@@ -0,0 +1,33 @@
import { describe, it, expect, beforeEach } from 'vitest';
import fs from 'node:fs/promises';
import path from 'node:path';
const DATA_DIR = path.join(process.cwd(), 'app', 'test_data_site_settings');
const configPath = path.join(DATA_DIR, 'site.config.json');
describe('Global Site Settings API Test', () => {
beforeEach(async () => {
await fs.mkdir(DATA_DIR, { recursive: true });
const dummyConfig = {
site_info: { title: 'Alte Firma', phone: '0123456' },
navigation: [{ label: 'Home', page_id: 'page_1' }]
};
await fs.writeFile(configPath, JSON.stringify(dummyConfig, null, 2), 'utf-8');
});
it('soll globale Stammdaten in site.config.json aktualisieren', async () => {
const raw = await fs.readFile(configPath, 'utf-8');
const config = JSON.parse(raw);
config.site_info.title = 'N&D IT Solutions GmbH';
config.site_info.phone = '+49 7473 123456';
await fs.writeFile(configPath, JSON.stringify(config, null, 2), 'utf-8');
const updatedRaw = await fs.readFile(configPath, 'utf-8');
const updatedConfig = JSON.parse(updatedRaw);
expect(updatedConfig.site_info.title).toBe('N&D IT Solutions GmbH');
expect(updatedConfig.site_info.phone).toBe('+49 7473 123456');
});
});

114
tests/waasEngine.test.ts Normal file
View File

@@ -0,0 +1,114 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'fs';
import path from 'path';
import { loadWaasConfigs, FALLBACK_SITE_CONFIG, FALLBACK_THEME_CONFIG, FALLBACK_SMTP_CONFIG } from '../src/utils/configLoader';
import { THEME_PRESETS } from '../src/utils/themePresets';
describe('WaaS Engine Core Unit Tests', () => {
const testDataDir = path.join(process.cwd(), 'app', 'test_data');
beforeEach(() => {
process.env.DATA_DIR = testDataDir;
if (fs.existsSync(testDataDir)) {
fs.rmSync(testDataDir, { recursive: true, force: true });
}
});
afterEach(() => {
if (fs.existsSync(testDataDir)) {
fs.rmSync(testDataDir, { recursive: true, force: true });
}
});
it('1. Should return fallbacks when JSON files do not exist (Zero-Crash Guarantee)', () => {
const configs = loadWaasConfigs('');
expect(configs.siteConfig.siteName).toBe(FALLBACK_SITE_CONFIG.siteName);
expect(configs.themeConfig.activePreset).toBe(FALLBACK_THEME_CONFIG.activePreset);
expect(configs.smtpConfig.host).toBe(FALLBACK_SMTP_CONFIG.host);
});
it('2. Should dynamically merge custom site.config.json', () => {
fs.mkdirSync(testDataDir, { recursive: true });
const customSite = { siteName: 'Custom Test Agency', hero: { title: 'Custom Title' } };
fs.writeFileSync(path.join(testDataDir, 'site.config.json'), JSON.stringify(customSite), 'utf8');
const configs = loadWaasConfigs('');
expect(configs.siteConfig.siteName).toBe('Custom Test Agency');
expect(configs.siteConfig.hero.title).toBe('Custom Title');
expect(configs.siteConfig.hero.badge).toBe(FALLBACK_SITE_CONFIG.hero.badge);
});
it('3. Should have 10 valid Theme Presets with root CSS color tokens', () => {
const presetKeys = Object.keys(THEME_PRESETS);
expect(presetKeys.length).toBe(10);
expect(THEME_PRESETS.corporateDark.colors.bg).toBe('#0B0F19');
expect(THEME_PRESETS.corporateDark.colors.accent).toBe('#38BDF8');
});
it('4. Should support dynamic UIComponent elements in SectionConfig', () => {
const heroElements = FALLBACK_SITE_CONFIG.hero.elements || [];
expect(heroElements.length).toBeGreaterThan(0);
expect(heroElements[0].type).toBe('badge');
});
it('5. Should load device-specific configs for desktop, mobile, tablet with fallback', () => {
fs.mkdirSync(testDataDir, { recursive: true });
fs.writeFileSync(path.join(testDataDir, 'site.mobile.config.json'), JSON.stringify({ siteName: 'Mobile Site' }), 'utf8');
fs.writeFileSync(path.join(testDataDir, 'site.tablet.config.json'), JSON.stringify({ siteName: 'Tablet Site' }), 'utf8');
fs.writeFileSync(path.join(testDataDir, 'site.desktop.config.json'), JSON.stringify({ siteName: 'Desktop Site' }), 'utf8');
const mobileConfig = loadWaasConfigs('Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)');
expect(mobileConfig.siteConfig.siteName).toBe('Mobile Site');
const tabletConfig = loadWaasConfigs('Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X)');
expect(tabletConfig.siteConfig.siteName).toBe('Tablet Site');
const desktopConfig = loadWaasConfigs('Mozilla/5.0 (Windows NT 10.0; Win64; x64)');
expect(desktopConfig.siteConfig.siteName).toBe('Desktop Site');
});
it('6. Should correctly resolve Theme Builder template based on Display Conditions', async () => {
const { resolveTemplate } = await import('../src/utils/conditionResolver');
const templates = [
{
id: 'header-global',
name: 'Global Header',
type: 'header' as const,
status: 'published' as const,
priority: 1,
conditions: [{ type: 'include' as const, scope: 'entire_site' as const }]
},
{
id: 'header-custom-page',
name: 'Custom Page Header',
type: 'header' as const,
status: 'published' as const,
priority: 1,
conditions: [{ type: 'include' as const, scope: 'singular' as const, entityId: 'landing_1' }]
}
];
// Global matching
const matchGlobal = resolveTemplate(templates, 'header', { url: '/about', routeType: 'singular', entityId: 'about_page' });
expect(matchGlobal?.id).toBe('header-global');
// Specific page matching
const matchSpecific = resolveTemplate(templates, 'header', { url: '/landing', routeType: 'singular', entityId: 'landing_1' });
expect(matchSpecific?.id).toBe('header-custom-page');
});
it('7. Should correctly resolve dynamic data tags like {{site.siteName}}', async () => {
const { resolveDynamicBinding } = await import('../src/utils/dynamicDataBinding');
const context = {
site: { siteName: 'Test Agency Pro', contactEmail: 'info@test.de' },
post: { title: 'Mein erster Post' }
};
const resolvedTitle = resolveDynamicBinding('Willkommen bei {{site.siteName}}', context);
expect(resolvedTitle).toBe('Willkommen bei Test Agency Pro');
const resolvedPost = resolveDynamicBinding('Artikel: {{post.title}}', context);
expect(resolvedPost).toBe('Artikel: Mein erster Post');
});
});