Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4139f31925 | ||
|
|
c1d775e090 | ||
|
|
58ac7745e6 | ||
|
|
adb1587a7b | ||
|
|
2f4ef5309b | ||
|
|
9914002e74 | ||
|
|
f0bced164b | ||
|
|
72275db9fa | ||
|
|
c9ad9ba2b7 | ||
|
|
e0e81b65a8 | ||
|
|
6e8ba2cddb | ||
|
|
1c8cfa5c1c | ||
|
|
69d006334f | ||
|
|
822481f2e4 | ||
|
|
11fd118aa7 | ||
|
|
6eac6ef8fb | ||
|
|
80925c2a43 | ||
|
|
b46527e65d | ||
|
|
d78338a969 | ||
|
|
e6d603cd0c | ||
|
|
003f9e67aa | ||
|
|
897d594dde | ||
|
|
194301c6af | ||
|
|
0d12bbc06a | ||
|
|
b2e17d96c2 | ||
|
|
2e65829e15 | ||
|
|
77672b55d3 | ||
|
|
f0ae8c0be2 | ||
|
|
80099cbdb4 | ||
|
|
8d3424967c | ||
|
|
900d3a9ab5 | ||
|
|
c75befe8e5 | ||
|
|
f337102c7a | ||
|
|
b897ae834f | ||
|
|
c246b9811f | ||
|
|
a27b1fe255 | ||
|
|
79c3551934 | ||
|
|
72f278b478 | ||
|
|
034892c899 | ||
|
|
3c9a879d4a | ||
|
|
044f788835 | ||
|
|
2bffca4b33 | ||
|
|
2c1a4c150c | ||
|
|
dc27e508dd |
No files matched your search
@@ -23,3 +23,6 @@ dist-ssr
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
# Rohfotos der Beispiel-Bäckerei (werden von scripts/relaunch-baeckerei-fotos.py von Wikimedia Commons geladen)
|
||||||
|
docs/relaunch/assets/baeckerei/roh/
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# webklar.com
|
||||||
|
|
||||||
|
Website of WEBklar GbR (a small web agency in Winnweiler, Rhineland-Palatinate). Vite + React 18 + TypeScript + Tailwind + shadcn/ui, client-rendered SPA with React Router.
|
||||||
|
|
||||||
|
**Current project: the relaunch.** Read `docs/superpowers/specs/2026-10-01-relaunch-design.md` before any change. The material for it (Germany map, customer portal screenshots) is in `docs/relaunch/`.
|
||||||
|
|
||||||
|
Status: the spec is waiting for the owner's approval. Once it is approved, write an implementation plan with the `superpowers:writing-plans` skill and save it to `docs/superpowers/plans/`. Implement only after that.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev # local dev server
|
||||||
|
npm test # Vitest + Testing Library (must stay green)
|
||||||
|
npm run build # production build into dist/
|
||||||
|
npm run lint # ESLint (main already has old errors; add no new ones)
|
||||||
|
```
|
||||||
|
|
||||||
|
## How this repository relates to the live site
|
||||||
|
|
||||||
|
- This GitHub repository is the **workspace**. Its `main` is **not** live.
|
||||||
|
- webklar.com is built from a separate internal repository on the owner's server. Only the owner, or a session on that server, transfers an approved state there. Never try to deploy from here.
|
||||||
|
|
||||||
|
## Rules
|
||||||
|
|
||||||
|
- **Language:** everything user-facing is German, in the polite form **"Sie"**. Brand spelling is always **"WEBklar"**. Write commit messages, PR texts and notes for the owner in German.
|
||||||
|
- **The wording of the legal pages stays unchanged without the owner's explicit OK:** `src/pages/AGB.tsx`, `Impressum.tsx`, `WartungsAbo.tsx`, `HostingAbo.tsx`, and the privacy policy (the `/datenschutz` redirect to the customer portal).
|
||||||
|
- For the new layout you may change their markup and CSS classes: remove their own header block, replace uppercase and old font/button classes.
|
||||||
|
- Keep their wording, order and structure.
|
||||||
|
- The tests in `src/test/abos.test.tsx`, `agb-haftung.test.tsx` and `agb-verguetung.test.tsx` protect them; never weaken or delete these three files.
|
||||||
|
- `src/test/sofort-paket.test.tsx` may be adapted where it pins old copy or removed components. Keep its source-code scans against banned statements.
|
||||||
|
- **No invented content:**
|
||||||
|
- no numbers without a source (no "99,9 %", no project counters);
|
||||||
|
- no references without the customer's consent (AGB § 13);
|
||||||
|
- no stock or AI images of people;
|
||||||
|
- no promises of success (AGB § 2: no result is owed);
|
||||||
|
- SEO only as a separately ordered service;
|
||||||
|
- support after launch only through the subscriptions.
|
||||||
|
- **Fixed facts:**
|
||||||
|
- Phone: only 0176 23726355 (`tel:+4917623726355`). E-mail: support@webklar.com.
|
||||||
|
- Reply time: only "spätestens am nächsten Werktag".
|
||||||
|
- Servers: Hetzner in Falkenstein/Vogtland, Saxony. This applies **only to the website and the customer portal**. Never write "all data in Germany".
|
||||||
|
- Prices are final prices, small-business rule § 19 UStG, no VAT. Hosting-Abo 20 € per month, Wartungs-Abo 50 € per month, 12 months minimum term each. Extra work 100 € per hour in 15-minute steps, only after approval.
|
||||||
|
- **Customer portal screenshots** show only the invented demo customer "Bäckerei Sonnenschein". Never use real customer data.
|
||||||
|
- **Privacy:** no requests to third-party servers. Host fonts, images and scripts yourself; no Google Fonts, no CDNs, no tracking.
|
||||||
|
- **Secrets:** never commit them. `.env` stays local; `.env.example` holds only public client IDs.
|
||||||
|
- **This repository may be public.** Do not check in internal data: price calculations, customer names without consent, server details, logins.
|
||||||
|
- **Before every push:** run `npm test`, `npm run build`, and `npm run lint` (no new errors). For visual changes, put screenshots in the pull request: 1440 × 900 and 390 × 844, light and dark.
|
||||||
|
- **Work on branches and merge through pull requests.** The relaunch branch is `relaunch`.
|
||||||
|
- **Code style:**
|
||||||
|
- Match the existing code.
|
||||||
|
- German comments where the code already uses them.
|
||||||
|
- Only `transform`/`opacity` animations, always behind `prefers-reduced-motion`. The spec section 8.4 lists the only exceptions: map outline drawing, a 3× pulse around the map dot, the rotating hero headline (one pass, with a pause button), and the scroll-linked word highlight.
|
||||||
|
- Hover effects only for `(hover: hover) and (pointer: fine)`.
|
||||||
|
|
||||||
|
## Structure (short)
|
||||||
|
|
||||||
|
- `src/routes.tsx`: all routes. Subpages share one layout with the footer (`SubpageLayout`).
|
||||||
|
- `src/App.tsx`: providers, `ScrollToTop`. It jumps to anchors such as `/#leistungen` once the fonts have loaded.
|
||||||
|
- `src/hooks/use-page-meta.ts`: title and description per route.
|
||||||
|
- `src/pages/`: pages. `src/components/`: sections and parts. `src/components/ui/`: shadcn.
|
||||||
|
- `src/test/`: tests. `sofort-paket.test.tsx` holds source-code scans against banned statements: the old phone number, Unsplash, "24 Stunden", "99,9", success promises. Keep these scans when sections are removed.
|
||||||
@@ -1,73 +1,16 @@
|
|||||||
# Welcome to your Lovable project
|
# webklar.com
|
||||||
|
|
||||||
## Project info
|
Website der WEBklar GbR (Vite, React, TypeScript, Tailwind).
|
||||||
|
|
||||||
**URL**: https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID
|
```bash
|
||||||
|
npm install
|
||||||
## How can I edit this code?
|
npm run dev # Entwicklung
|
||||||
|
npm test # Tests
|
||||||
There are several ways of editing your application.
|
npm run build # Build nach dist/
|
||||||
|
|
||||||
**Use Lovable**
|
|
||||||
|
|
||||||
Simply visit the [Lovable Project](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and start prompting.
|
|
||||||
|
|
||||||
Changes made via Lovable will be committed automatically to this repo.
|
|
||||||
|
|
||||||
**Use your preferred IDE**
|
|
||||||
|
|
||||||
If you want to work locally using your own IDE, you can clone this repo and push changes. Pushed changes will also be reflected in Lovable.
|
|
||||||
|
|
||||||
The only requirement is having Node.js & npm installed - [install with nvm](https://github.com/nvm-sh/nvm#installing-and-updating)
|
|
||||||
|
|
||||||
Follow these steps:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
# Step 1: Clone the repository using the project's Git URL.
|
|
||||||
git clone <YOUR_GIT_URL>
|
|
||||||
|
|
||||||
# Step 2: Navigate to the project directory.
|
|
||||||
cd <YOUR_PROJECT_NAME>
|
|
||||||
|
|
||||||
# Step 3: Install the necessary dependencies.
|
|
||||||
npm i
|
|
||||||
|
|
||||||
# Step 4: Start the development server with auto-reloading and an instant preview.
|
|
||||||
npm run dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Edit a file directly in GitHub**
|
- Regeln für die Arbeit an diesem Repository: [`CLAUDE.md`](CLAUDE.md)
|
||||||
|
- Aktuelles Vorhaben, der Relaunch: [`docs/superpowers/specs/2026-10-01-relaunch-design.md`](docs/superpowers/specs/2026-10-01-relaunch-design.md)
|
||||||
|
- Material zum Relaunch: [`docs/relaunch/`](docs/relaunch/)
|
||||||
|
|
||||||
- Navigate to the desired file(s).
|
Die Kartendaten stammen von Natural Earth (gemeinfrei).
|
||||||
- Click the "Edit" button (pencil icon) at the top right of the file view.
|
|
||||||
- Make your changes and commit the changes.
|
|
||||||
|
|
||||||
**Use GitHub Codespaces**
|
|
||||||
|
|
||||||
- Navigate to the main page of your repository.
|
|
||||||
- Click on the "Code" button (green button) near the top right.
|
|
||||||
- Select the "Codespaces" tab.
|
|
||||||
- Click on "New codespace" to launch a new Codespace environment.
|
|
||||||
- Edit files directly within the Codespace and commit and push your changes once you're done.
|
|
||||||
|
|
||||||
## What technologies are used for this project?
|
|
||||||
|
|
||||||
This project is built with:
|
|
||||||
|
|
||||||
- Vite
|
|
||||||
- TypeScript
|
|
||||||
- React
|
|
||||||
- shadcn-ui
|
|
||||||
- Tailwind CSS
|
|
||||||
|
|
||||||
## How can I deploy this project?
|
|
||||||
|
|
||||||
Simply open [Lovable](https://lovable.dev/projects/REPLACE_WITH_PROJECT_ID) and click on Share -> Publish.
|
|
||||||
|
|
||||||
## Can I connect a custom domain to my Lovable project?
|
|
||||||
|
|
||||||
Yes, you can!
|
|
||||||
|
|
||||||
To connect a domain, navigate to Project > Settings > Domains and click Connect Domain.
|
|
||||||
|
|
||||||
Read more here: [Setting up a custom domain](https://docs.lovable.dev/features/custom-domain#custom-domain)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
# WEBklar – Cold-Email-Paket (B2B KMU)
|
# WEBklar – Cold-Email-Paket (B2B KMU)
|
||||||
|
|
||||||
Ziel: **kurze Antwort** oder **15-min. Potenzialanalyse** (unverbindlich).
|
Ziel: **kurze Antwort** oder **15-min. Potenzialanalyse** (unverbindlich).
|
||||||
Link: https://webklar.com/kontakt · E-Mail: support@webklar.com · Tel. 0170 4969375
|
Link: https://webklar.com/kontakt · E-Mail: support@webklar.com · Tel. 0176 23726355
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Material für den Relaunch
|
||||||
|
|
||||||
|
Dieses Verzeichnis gehört zur Spezifikation `docs/superpowers/specs/2026-10-01-relaunch-design.md`.
|
||||||
|
|
||||||
|
## Deutschland-Karte
|
||||||
|
|
||||||
|
- `assets/deutschland-umriss.svg` – Umriss von Deutschland mit rotem Punkt auf dem Serverstandort Falkenstein/Vogtland (Sachsen).
|
||||||
|
- Ein `<path>` (nur Linie, `stroke="currentColor"`, `vector-effect="non-scaling-stroke"`) plus ein `<circle>` (`#e5251c`), viewBox `0 0 663 892`, ca. 7 KB.
|
||||||
|
- Quelle: **Natural Earth**, „Admin 0 – Countries“, Maßstab 1:10 m. Gemeinfrei (Public Domain): „All versions of Natural Earth raster + vector map data … are in the public domain.“ Ein Quellenhinweis ist nicht nötig („Made with Natural Earth.“ ist freiwillig).
|
||||||
|
- Behalten: Festland, Rügen, Usedom, Fehmarn, Sylt, Föhr/Amrum. Vereinfacht (Douglas-Peucker, ca. 0,9 km Toleranz).
|
||||||
|
- Projektion: Lambert-Azimutal-flächentreu (wie ETRS89-LAEA), Mitte 10° O / 52° N, Kugelradius 6371 km. 1 Einheit ≈ 1 km. Geprüft: Berlin–München 504 km auf der Karte wie in echt.
|
||||||
|
- Punkt Falkenstein (50,477 N, 12,371 O): `cx = 469.6`, `cy = 520.5` (70,8 % von links, 58,4 % von oben; liegt im Festland, 13,9 km von der tschechischen Grenze).
|
||||||
|
|
||||||
|
**Weitere Punkte setzen** (z. B. Büro Winnweiler, ca. 49,567 N, 7,850 O – Koordinaten vor Einbau prüfen):
|
||||||
|
|
||||||
|
```js
|
||||||
|
const R = 6371, lon0 = 10 * Math.PI / 180, lat0 = 52 * Math.PI / 180;
|
||||||
|
// Grad -> SVG-Koordinaten in der viewBox 0 0 663 892
|
||||||
|
function project(lon, lat) {
|
||||||
|
const l = lon * Math.PI / 180, p = lat * Math.PI / 180;
|
||||||
|
const k = Math.sqrt(2 / (1 + Math.sin(lat0) * Math.sin(p) + Math.cos(lat0) * Math.cos(p) * Math.cos(l - lon0)));
|
||||||
|
const x = R * k * Math.cos(p) * Math.sin(l - lon0);
|
||||||
|
const y = R * k * (Math.cos(lat0) * Math.sin(p) - Math.sin(lat0) * Math.cos(p) * Math.cos(l - lon0));
|
||||||
|
return [x + 289.794 + 12, 341.920 - y + 12];
|
||||||
|
}
|
||||||
|
project(12.371, 50.477); // -> [469.6, 520.5] Falkenstein
|
||||||
|
```
|
||||||
|
|
||||||
|
Für das Zeichnen des Umrisses per `stroke-dasharray` das Attribut `vector-effect="non-scaling-stroke"` entfernen und `pathLength="1"` setzen (sonst stimmen die Strichlängen nicht).
|
||||||
|
|
||||||
|
## Kundenportal-Screenshots
|
||||||
|
|
||||||
|
`assets/portal/` – Aufnahmen des echten Kundenportals (project.webklar.com) mit **erfundenen Demodaten** („Bäckerei Sonnenschein“, Rechnungsbeträge, Chatverlauf). Es sind keine echten Kundendaten enthalten. Das Brotfoto ist eine selbst gezeichnete Grafik, die Demo-Website der Bäckerei eine selbst erstellte Seite; beides sind keine Stock- oder KI-Bilder. Aufgenommen am 2026-10-01 mit dem damaligen Stand des Portals.
|
||||||
|
|
||||||
|
Vor dem Livegang neu aufnehmen (das Aufnahme-Werkzeug liegt intern beim Inhaber): wenn sich das Portal sichtbar ändert, und damit Auftrags-, Rechnungs- und Kundennummern offensichtlich erfunden aussehen (z. B. Auftrag 1001, Rechnung 2026-0001). Die heutigen Nummern sind zwar erfunden, wirken aber wie echte Zählerstände.
|
||||||
|
|
||||||
|
| Datei | Größe (px) | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `00-uebersicht.png` | 2360 × 1229 | Übersicht aller Aufnahmen (nur zur Orientierung, nicht ausliefern) |
|
||||||
|
| `desktop-1-projekte.webp` | 2880 × 1800 | Projektübersicht mit Live-Vorschau der Website |
|
||||||
|
| `desktop-2-fortschritt.webp` | 2880 × 1800 | „Was wir zuletzt erledigt haben“ – Fortschritt in Klartext |
|
||||||
|
| `desktop-3-chat.webp` | 2880 × 1800 | Chat mit Sprachnachricht und Datei |
|
||||||
|
| `desktop-3b-chat-foto.webp` | 2880 × 1800 | Chat mit Foto |
|
||||||
|
| `desktop-4-rechnungen.webp` | 2880 × 1800 | Rechnungen |
|
||||||
|
| `desktop-5-stundenkonto.webp` | 2880 × 1800 | Stundenkonto des Wartungs-Abos (nur nach Freigabe zeigen) |
|
||||||
|
| `desktop-6-funktionen.webp` | 2880 × 1800 | Funktionen (Terminplaner/Analyse dort sind Platzhalter – nicht bewerben) |
|
||||||
|
| `desktop-7-news-pinnwand.webp` | 2880 × 1800 | News-Pinnwand mit Vorschau |
|
||||||
|
| `phone-0-login.webp` | 780 × 1688 | Anmeldung (Handy) |
|
||||||
|
| `phone-1-projekte.webp` | 780 × 1688 | Projekte mit Vorschau (Handy-App) |
|
||||||
|
| `phone-1b-tabbar.webp` | 780 × 192 | untere Tab-Leiste der Handy-App |
|
||||||
|
| `phone-2-projekte-lang.webp` | 780 × 5428 | lange Projektseite zum Durchscrollen im Handy-Rahmen |
|
||||||
|
| `phone-3-projektdetails.webp` | 780 × 1688 | Projektdetails mit letzten Änderungen |
|
||||||
|
| `phone-4-chat.webp` | 780 × 1688 | Chat |
|
||||||
|
| `phone-4b-chat-foto.webp` | 780 × 1688 | Chat mit Foto |
|
||||||
|
| `phone-5-rechnungen.webp` | 780 × 1688 | Rechnungen |
|
||||||
|
| `phone-6-vertraege.webp` | 780 × 1688 | Verträge (nur nach Freigabe zeigen) |
|
||||||
|
| `phone-7-news.webp` | 780 × 1688 | News-Pinnwand bearbeiten |
|
||||||
|
| `phone-8-news-vorschau.webp` | 780 × 1688 | News-Pinnwand, Vorschau „So sehen es alle“ |
|
||||||
|
|
||||||
|
Die Handy-Aufnahmen haben oben 47 px und unten 34 px Rand für Uhrzeit und Home-Leiste (Safe Area). Die Dateien sind Rohexporte in doppelter Auflösung; für die Website verkleinern (Budgets in der Spec, Abschnitt 9.5).
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
# Fotos der Beispiel-Website „Bäckerei Sonnenschein“
|
||||||
|
|
||||||
|
Die Bäckerei ist erfunden (Demo-Kundin des Kundenportals). Die Fotos zeigen nur Backwaren, keine Menschen.
|
||||||
|
Alle stammen von Wikimedia Commons und stehen unter **CC0 1.0** (gemeinfrei, keine Namensnennung nötig).
|
||||||
|
Verkleinert und zugeschnitten mit `scripts/relaunch-baeckerei-fotos.py` nach `public/relaunch/demos/baeckerei/`.
|
||||||
|
|
||||||
|
| Datei in public | Commons-Datei |
|
||||||
|
| --- | --- |
|
||||||
|
| roggenbrot-600.webp | [Rye bread Roggenbengel 2025-02-14 13.jpg](https://commons.wikimedia.org/wiki/File:Rye_bread_Roggenbengel_2025-02-14_13.jpg) |
|
||||||
|
| roggenbrot-anschnitt-400.webp | [Cut rye bread Kartoffelkruste 2025-02-20 02.jpg](https://commons.wikimedia.org/wiki/File:Cut_rye_bread_Kartoffelkruste_2025-02-20_02.jpg) |
|
||||||
|
| broetchen-400.webp | [Bread-rolls.jpg](https://commons.wikimedia.org/wiki/File:Bread-rolls.jpg) |
|
||||||
|
| croissants-400.webp | [Freshippo croissant bread.jpg](https://commons.wikimedia.org/wiki/File:Freshippo_croissant_bread.jpg) |
|
||||||
|
| zimtschnecken-400.webp | [IKEA cinnamon bun.jpg](https://commons.wikimedia.org/wiki/File:IKEA_cinnamon_bun.jpg) (Ausschnitt ohne Logo oder Schrift) |
|
||||||
|
| baguettes-700.webp | [Baguette-246424.jpg](https://commons.wikimedia.org/wiki/File:Baguette-246424.jpg) |
|
||||||
|
| zwetschgenkuchen-520.webp | [Plum cake, Ehrenbach.jpg](https://commons.wikimedia.org/wiki/File:Plum_cake,_Ehrenbach.jpg) |
|
||||||
|
|
||||||
|
Lizenz von jeder Datei am 2026-10-04 über die Commons-API geprüft (`LicenseShortName` = CC0).
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 663 892" role="img" aria-labelledby="t d">
|
||||||
|
<title id="t">Umriss von Deutschland mit Serverstandort Falkenstein</title>
|
||||||
|
<desc id="d">Roter Punkt: Hetzner-Rechenzentrum Falkenstein/Vogtland, Sachsen (50,477 N, 12,371 O). Umriss: Natural Earth 1:10m (Public Domain), Projektion ETRS89-LAEA, vereinfacht.</desc>
|
||||||
|
<path d="M581.4 706.2l-2 5.8 2.5 2-1 2.4 0.4 7-1.8 4.8-2.9 1.2-1.1 4.3-7-4.5-7.7-1.8-4.8 1.5-1 1.4 1.6 4.9-3.3 15.7-9.3 8.2-10.1 2.2-7.6 3.4-7.1 6.3-5.2 1.6-8.8 9.6 1.8 5.4 5.4 5.2 2.6 5.8 5.3 4 4.8 8.5-6.9 14 5.5 1.8 4.1-1 4.2 5.8 0.1 4.1-2.3 4.3-0.6 4.8 0.9 5.3-2.5 3.2-5.4-0.7-11.8-8.6 0.4-5.3 2-1.1-4.8-4 0.7-1.9-8.2-0.7-8.4 5.2-3.1 0.4-4-2.9-1.7-3.9-13.8 1.8-1.2-1.8 1.3-4.1-4.8 3.1 2.4 6.6-2.4 4.6-17.8-0.1-6.3 1.4-2.3 2.7-15-1.2-5 8.5-10.4 1.1-3.5 4.1 1.8 0.8-0.4 1.9-5.8 1.2-5.1 4.7-1.8-0.2 0.1-3-1.6-0.7-8.3 4.5-7.8 0-1.9-2.1 0.3-2.5-3.8-4-3.9-1.8-0.5-0.8 3.1-2.5-2.6-1.7-7.3 2-1.6-1.7-11.8-3.6-3.5 3.6-4.1-0.3-2.1-1.5 0.2-3.6-2.1 0.2-1.1 0.5 1.3 3.4-0.9 5.5 2.2 2.2 0.3 3.8-1.7 4.7-4.3 3.2-4.9 7.3-5 2.7-6 0.8 2.3-3 1.4-8.3-1.4-0.7-8.2 2.2-2.2-5.1 1.7-1.3-0.3-2.5-3.7-5.4-3 0.8-2.8-6.7-8.4 0.7-1.5-0.8-0.4-3.8-2-1.4-2.2 0.7-3.6 5.6-6.9 1.1-4.7-1.4-20.7-13.1-5.7-0.7-1-1.6-12.5-1.1-5.4 2.7-4.7-0.3-3.3-2.8 1.4-1.1-4.3-3.3-2-0.1-0.1 2.8-4 0-1.2-3.2 1-3.8-2.3-0.2-2.8-3.6-2 3.7-1.1-4.2-1.4-0.6-3.1 0.5 0.7 1.8-1.1 0.5-5.5 1-2 4.5-3.6 3.4 0 3 4.9 2.9 8.3-2.4 2.9 0.8-0.5 2.6-1.5-0.7-0.1 4.3-1.5 1.2-2.8-3.7-4.6 1.7-1 2.4-7.1 0.2-3.6-1.3-1.3-2.7-4.1-0.8-4.1 0.6-7 5.2-3.4 0.7-9.8-0.3-0.9-3-5.9-1-4.1 4.2-6.3 1.1-5.4-2.4 1.9 0 1.9-3.4-5.6 1.1 0.4-2.6-5.1-7.6-0.4-3.6 2.1-2.7-0.7-5.7 2.9-6.1-0.1-4.9 2.1-6.3 2.8-3.4 0-3.4-3.1-5.8 0.5-6.8 2.8-7.1 5.7-8 1.3-6.4 3.7-3.3-0.7-7.2 3.1-10.1 2.8-2.3 0.1-10.6 3.2-4.8 8-6.7 1-4 3.4-0.4 1.7-3.2 3.8-1.8 8.5-16.5-8-2.5-11.4-6.5-5.5 0.1-6-1.9-3.4 1.2-6.7-0.4-1.9-2.8-6.3-2.6-3.4-5.1 0.4-2.5-4.4-0.9-0.3-1.2-4.3 0.9-1.4 2.7-2.7 0.6-1.7 2.5-8.7-1.4-5.3-3.2-0.6 2.3-2.3 1.4-1.5-1.4-0.6-7-6.8-3-5.8-0.6 0.2 5.2-1.4 1.6-6.9-1.1-1.7-2 0.3-4.6-1.6 0.6-2.7-7.2-5.7-6.4 0-2.3 1.7-0.9-4.5-7.8-7.6-5-4.2 1 1-12.4 5.2-7.8-1-1.9 7.3-5.9-0.9-2.5 1.5-6.8-7.5-1.8-4.3-3.7-2.3 0.5-1-3-4.4-3.1-3.2-7.9-0.9 1.5-4-12 2.8-9.4-0.4-3.1 3.2-2.1-1.1-3.6 3.2-3 6-2.5 1.2-4 6.4-1-2.4-5.9 1.4-7.3-1.8-3.7 1-1.6-5.7-1.8-3.4 0.8-1-3.3-1.4-0.4-0.6-2.9 5.4-4.5 1.3-3-6.3-1.2-0.8-1.7 1-0.5-5.2-8.2-4.9 0.3 0.1-3.2-2.6-6.6 2.1-0.5 1-4.4 3.4-1.2 0.8-6-4.3-2.7 0.5-4.9-5.3-0.8-3.7 1.2-1.1-8.7 2-0.9 3.9 2.6 3.4-4.7 11.9-8-1.4-1.5 2.3-1.5-1.6-0.6-3.6 2.2-1.6-1 0-4.6 6.9-11.4 3.3-2.8 1.3-4.7-1-1.3 0.7-12.3-6.9-10 1.2-5.4-6-4.4 0.4-3.8-4.9-2.1 2-4.8-2-4.5 10.7-3.6 4.9 1.5-4.1-5 2.4-1.2 6.5 4.2 2.7-0.3 1.5 2.3 3.7 1.3 0.1 1.7 2.6-0.6-0.1-3.6 5.7 1.6 4.7-2.9 14.9-2.9 4.8-5.6 0-2.1-8.9-6.6 0.5-2.8 2.2-0.6 2.8-3.9 5.6-0.9 3.9-5.1 3-1.1 7.1-6.8-0.9-5.5 2.8-8.8-4.6-9.8-5.1 1.9-10.6-2.9-3.1-2.6-1.2-7.4 5-1.8-2.6-2.7 0-4 2.5-1.6 8.7-0.4 10.2 2.2 1.3-2.3 2.5-19.6 7.2-11.8 2.4-7.2-0.8-15.6 2-11.8 2.7-3.1-0.2-2.6 1.8-2.5 2.4-0.4 4.9 2.6-4.1-3.5-15-1.1-2.4-1.4-0.6-11.3 2.2-6.8 0.6-1 3.9 0.7 1.6-2.2-3.4-4.2 0.9-2.7 8.6-7.3 5.3-1.8 10.1-0.8 2.9 1.9 29.6-3.9 5.2 1.7-0.5 4 1.7 4 4.8 5.2-0.6 2.1 3.2 2.2-0.9 3-6.5 2.5 1.7 5.1 4.3 0.9 3.4 4.4 3.6-0.1 3.9-6.9 0.4-3.8-1.8-2.3-4 0.4 2.8-9.7 3.9-0.5 3.7 4.7 10.8 3.9-4.2 6.3-0.5 9.5 1.1 4.8-0.1-13 4-4.7 0.6-3.2-3.6-8.8-1.3-8.4 6.1-17.4 5.2-3.7 8.5 5.7 5.1 1.3 10.2-0.9 6.5-2.6 10.5 0.1 8.3 13.7 5.4 5.9 5.3 10.1 8.6 4.8 8.9 1.5-3.3-2.9-5.9-0.8-7.2-4.1-2.1-2.4-1.2-8.5-6.3-4.3-2.6-9.1-9-6.2-10.7-0.9-4.2-1.7-1.4 1.6-1.5-0.5-4.5-4.5-5.2-11.1 3.8-1.2 5.5 2.7 1.4-0.7 1.5-7.5-2.8-3.4 0.2-1.9-1.6-0.5-6.1 2.6-3-6.2 2.3-5.8-0.4-2.5 5.4-2 2.7-4.8-5.3 3 0.4-1.5-9.7 1-3.9 2.6-3.6-2.4-1.4-5.3 2.7-1.9 3.6-0.3-2.9-2.2-1.1 1.4 1-4.6 15.4-1 7.8-5.2 0.5-4.5-8.4-11.2-3.5 0.8 1.8-2.3-2.9-7.6-7.4-5.5 0.4-2.7-3.1-9.4 1.2-5.8 4.6 0.9 5.9-1.8 5.1 1 18.5 5.5 3.1 5.5 5.7-0.2 3.4-1.7 4.2 1 8.3-6.1 0.2 4 9.7 2.6 4.3 2.6 1.4 3 4.6-1.4-0.3-2.3 3.6 1.2 4.6 8.7-3.1 0-2.6 3 4.3-0.5 2.7 1.3-0.8 11.9-3.9 6-8.1 3.4 2.8 1.5 16.8-3.3 3.9 3.4-1.3 1.3 0.5 5.8-3.2 2.7 0 5.4 2.5-1.9 2.8-8.1 6.1-3.3 2.7 0.2 15.6 8.4 5.9 5.4 2.6 0.6 3-0.6 10.2-7.2 5.4-0.8-2.8 0.8 3.8 1.2 4.5-2.9 2 0.8-3 3.8-1.4-0.7 2.1 14.8-1.8 4.6-11.2 9.2-5.7 0.9-3.4 5 3.2 5.5 4.9 0.5 0.8 3.5 2.4 0.3 8.4-5.4 8-1.2 1.9 3.2 2.7 1.1-0.1 3.7 3.4 0.5 2.6-2.4 8.2 6 0.8-7 1.6-1.5-0.4-2.2 2.2-2.3-0.5-1.6 3.5-0.3 3.3-5.1-0.1-2.9 4.1-5.1 8 0.4 17.8-5.4 0.5 8.9 1.6 1.7-1.5-5.2 3-4-2.6 0.8 0.4-1 4.9-6.9 9.9-7 9.7-19.2 2-1.7 1 2.9 2.8 1 18.7-0.4 2.7 1.6-3.2 2-11.4-1.4-1.5 1.5-3-0.2-1 1.9-1.7 0.1 0.9 2.2-2.7-0.4-1.6 2.1-2.4-1.9-3.4 0.4-0.8 4.8-1.8-0.7-0.2 3.4-1.8 1.2 0.5 4.6 2.9 1.9 3-0.6-3.2-1.4 3.9-4.7 0.3-2.4 5.2-3.2-1.3-1.4 2.1-0.9 1.3 1.1 5.3-2.8 0.2-2.1 2.3-0.4-2 4.7 1.3-3.1 2 2.3 3.3-1.6 2.2 3.7 2.3-0.1 4.6-6.5 5-2.8 1.7 0.4-0.2 3.6 4.5 3.1-1.2 2.2 2.9 7.9 3.6 0.3-1.7 1.6 9.6 2.7 4.2 5.8-1.7 1.6 5.3-1-1.3 2.4 2.3-0.2 3.4 6 2.7 0.7-2-3.7 8-2 6.9-4.2-0.8 2.3 7.5 5.1-2 6.6-1.7 1.1 6.4 5.3 0.8 3.4 4 2.2-5.5 6.5 0.1 2.8 15 10 9.9 1.2 3-2.6 2 1-0.1 2.6-3 2.4 3.4 0.7 4 21.1 4.1 7 4 18.1 2.7 2.9-3.6 7.1 0.9 6.4-2.5 9.4-12.7 10.6 1.8 7.1-2.4 5.2 18 11.4 3.7 5.6 15.1 11.2-2 6.7 1.7 2.3-2.4 5.4-3.3 3.3 0.7 4.4 3.5 7.9-0.3 2 8 3.7 1.1 1.9-0.9 12.8 5.4 4.6-3.6 9.7-0.3 9-5.7 8.1-0.4 4.3 3.5 3.7 1.4 5.6 6.2 6.2 1.1 5.7-1.8 4.5 0 4.1 14.4 5.9 3.3 3.5 1.1 11.1 4.6 6.8 0.4 3.8-5.9 27.7-3 8.6-3.2 4.7-1.3 6.4-3.3 1.Line truncated
|
||||||
|
<circle cx="469.6" cy="520.5" r="5" fill="#e5251c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 178 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 151 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 158 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 266 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,633 @@
|
|||||||
|
# Relaunch webklar.com – Design-Spezifikation
|
||||||
|
|
||||||
|
- **Stand:** 2026-10-01
|
||||||
|
- **Status:** Entwurf, wartet auf Freigabe durch den Inhaber. Nach der Freigabe wird daraus ein Umsetzungsplan geschrieben (Skill `superpowers:writing-plans`, Ablage `docs/superpowers/plans/`).
|
||||||
|
- **Grundlage:** Prüfung der Live-Seite vom 2026-10-01 (Desktop, Handy, Texte, Code, Kundenportal, Design-Recherche) und die Wünsche des Inhabers. Die Prüfberichte selbst liegen intern; alles, was für die Umsetzung nötig ist, steht in diesem Dokument.
|
||||||
|
- **Material:** Deutschland-Karte und Kundenportal-Screenshots in `docs/relaunch/assets/` (Übersicht: `docs/relaunch/README.md`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Ziel
|
||||||
|
|
||||||
|
Wer eine Website oder eine digitale Dienstleistung sucht und auf webklar.com landet, versteht in den ersten Sekunden, wer WEBklar ist und was er bekommt. Das passiert über Bilder, Bewegung und kurze Sätze statt über Textblöcke. Die Seite wirkt modern und ruhig, mit Abständen und Typografie wie bei Apple-Produktseiten. Sie darf nicht nach KI-Vorlage aussehen, ist übersichtlich, sagt nichts doppelt und ist auf dem Handy kein Endlos-Scroll.
|
||||||
|
|
||||||
|
### Erfolgskriterien
|
||||||
|
|
||||||
|
| Nr. | Kriterium | Wer prüft, wie |
|
||||||
|
|---|---|---|
|
||||||
|
| E1 | Fünf-Sekunden-Test: Eine Testperson kann danach sagen „Die bauen Websites, Shops und Apps für kleine Betriebe, und man sieht alles in einem Kundenportal.“ | **Inhaber**, mit 2–3 Personen, die die Seite nicht kennen |
|
||||||
|
| E2 | Startseite auf dem Handy (390 × 844) höchstens 8 Bildschirmhöhen lang (heute 14) | Umsetzung: `document.documentElement.scrollHeight / innerHeight` |
|
||||||
|
| E3 | Jede Aussage steht genau einmal auf der Startseite (Regeln in 6.3) | Umsetzung: Textprüfung |
|
||||||
|
| E4 | Heller und dunkler Modus sind beide gestaltet, alle Texte erreichen mindestens 4,5 : 1 (ab 24 px mindestens 3 : 1) | Umsetzung: Kontrastmessung im Browser und Token-Test |
|
||||||
|
| E5 | Startseite lädt höchstens 130 KB JavaScript (gzip), kein WebGL; LCP auf dem Handy unter 2,5 s, CLS unter 0,1 | Umsetzung: `vite build`, Lighthouse Mobile |
|
||||||
|
| E6 | Alle Routen und Rechtsseiten funktionieren, die Texte der Rechtsseiten sind unverändert, die Rechtstext-Tests bleiben grün | Umsetzung: `npm test` |
|
||||||
|
| E7 | Austausch-Test: Mit einem anderen Agenturlogo passt die Seite nicht mehr, weil Texte, Karte und Portal nur zu WEBklar passen | **Inhaber**, Sichtprüfung |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Ausgangslage
|
||||||
|
|
||||||
|
- **Technik:** Vite, React 18, TypeScript, Tailwind, shadcn/ui, React Router. Reine Client-SPA. Das Kontaktformular schreibt per Appwrite-Client in eine Datenbank.
|
||||||
|
- **Live-Betrieb:** webklar.com wird aus einem internen Repository gebaut. **Dieses Repository ist der Arbeitsort für den Relaunch;** sein `main` ist nicht live. Was hier fertig ist, wird nach Freigabe des Inhabers übertragen (Abschnitt 12).
|
||||||
|
- **Befunde der Prüfung (Kurzfassung):**
|
||||||
|
- Die Startseite erzählt dieselbe Botschaft viermal (Problem, Lösung, Nutzen-Karten, Leistungen). „Leistungen“ und „Ablauf“ sind dieselben vier Schritte, „Potenzialanalyse“ steht fünfmal da.
|
||||||
|
- Positionierung passt nicht: Die Seite verkauft „Digitalisierung für KMU“ mit CRM, Pipeline und Kennzahlen. Tatsächlich baut WEBklar Websites, Online-Shops, Web-Apps und Automatisierungen für kleine Betriebe.
|
||||||
|
- Vorlagen-Look: drei Schriftfamilien, vier WebGL-/Effekt-Komponenten auf fast jedem Abschnitt, cyan-blaue Akzentfarbe, Karten mit Kreis-Symbolen, hochzählende Kennzahlen, Technik-Laufband.
|
||||||
|
- Handy: 14 Bildschirmhöhen bei rund 630 Wörtern, keine horizontale Interaktion, Hover-Effekte ohne Funktion auf Touch, Menü ohne Scrollsperre.
|
||||||
|
- Der helle Modus war nie gestaltet (dunkle Kopfleiste auf Weiß, Pixelpunkte über Text, unsichtbare Leuchtschrift).
|
||||||
|
- Die Startseite lädt 476 KB JavaScript (gzip), davon 232 KB nur für den 3D-Hintergrund im Hero. `prefers-reduced-motion` wird nirgends beachtet.
|
||||||
|
- **Bereits live behoben (Sofort-Paket, 2026-10-01):**
|
||||||
|
- Kontrast im hellen Modus, Theme-Schalter klickbar, Leuchtschrift-Fehler, Anker unter der Kopfleiste.
|
||||||
|
- Ehrliche Fakten statt „52+ Projekte“ und „99,9 %“; erfundene Referenzen und Unsplash-Bilder entfernt.
|
||||||
|
- Eine Telefonnummer; „spätestens am nächsten Werktag“ statt „24 Stunden“; Erfolgsversprechen entschärft.
|
||||||
|
- Footer und Rechtslinks auf allen Unterseiten (gemeinsames Layout in `src/routes.tsx`), Datenschutzhinweis am Kontaktformular, eigene Seitentitel, deutsche 404-Seite.
|
||||||
|
- Tests dazu: `src/test/sofort-paket.test.tsx`.
|
||||||
|
- **Übergangslösung:** Bis zum Relaunch kann die Live-Seite fest auf „dunkel“ stehen (nicht Teil dieses Repos). Der Relaunch gestaltet beide Modi neu.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Wünsche des Inhabers
|
||||||
|
|
||||||
|
1. Die leuchtenden Überschriften („Leistungen“, „Ablauf“, „Projekte“) reagieren erst, wenn die Maus schon im Wort ist. Das muss besser werden.
|
||||||
|
2. Auf dem Handy gibt es viele Fehler, und die Seite ist nur ein langer Scroll. Beispiel: die Schritte als Karten, die man von links nach rechts wischt.
|
||||||
|
3. „So arbeiten wir“ soll raus. Stattdessen wird das Kundenportal vorgestellt, mit Screenshots aus dem Portal, durch die man scrollen bzw. wischen kann.
|
||||||
|
4. Nichts soll doppelt vorkommen.
|
||||||
|
5. Das Programmiersprachen-Laufband: Hat das heute noch Sinn?
|
||||||
|
6. „Standort Deutschland“ ist sehr wichtig, aber nicht als Etikett in der unteren Leiste. Wunsch: rechts eine Umriss-Karte von Deutschland (SVG) mit einem roten Punkt dort, wo die Server stehen.
|
||||||
|
7. Mehr mit den Texten spielen, statt sie nur hinzustellen. Modern, aber nicht nach KI aussehend. Übersichtlicher. Eine echte „Experience“. Abstände und Texte wie auf der Apple-Website.
|
||||||
|
8. Ein Interessent soll sofort und spielerisch verstehen, was WEBklar ist und macht, am besten bildlich.
|
||||||
|
|
||||||
|
Wo jeder Wunsch umgesetzt wird: 1 → D10 und 8.4 · 2 → 7.2, 7.3, 9.4, 9.6 · 3 → 7.2 · 4 → 6.3 · 5 → D8 · 6 → 7.1 · 7 → 7.1, 7.3, Abschnitt 8 · 8 → 7.1 (wechselnde Überschrift), 7.2 direkt danach (Portal-Bilder als zweiter Abschnitt).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Bereits getroffene Entscheidungen
|
||||||
|
|
||||||
|
| Nr. | Entscheidung |
|
||||||
|
|---|---|
|
||||||
|
| D1 | **Positionierung:** „Websites, Shops und Apps für kleine Betriebe“. Das Kundenportal ist der sichtbare Unterschied zu anderen Agenturen. |
|
||||||
|
| D2 | **Anrede:** durchgehend „Sie“ (wie Verträge und Rechtstexte). |
|
||||||
|
| D3 | **Telefon:** nur 0176 23726355 (`tel:+4917623726355`). E-Mail: support@webklar.com. |
|
||||||
|
| D4 | **Keine erfundenen Zahlen oder Referenzen:** keine Verfügbarkeitsprozente, keine Projektzähler, keine Stockfotos und keine KI-Bilder von Personen. Referenzen nur mit Zustimmung des Kunden (AGB § 13); bis dahin gibt es keinen Projekte-Abschnitt. |
|
||||||
|
| D5 | **Antwortzeit:** nur „spätestens am nächsten Werktag“ (entspricht den Abo-Bedingungen). |
|
||||||
|
| D6 | **Keine Erfolgsversprechen** (AGB § 2: kein Erfolg geschuldet). SEO nur als gesondert beauftragte Leistung. Betreuung nach dem Start gibt es nur im Abo. |
|
||||||
|
| D7 | **Serverstandort:** Rechenzentrum von Hetzner in Falkenstein/Vogtland, Sachsen (50,477 N, 12,371 O). Die Aussage gilt nur für **Website und Kundenportal**. E-Mail-Postfächer und Zahlungsabwicklung laufen anders (siehe Datenschutzerklärung). Deshalb nie „alle Daten in Deutschland“ und nie „Ihre Daten aus Sachsen“ schreiben. |
|
||||||
|
| D8 | **Das Programmiersprachen-Laufband entfällt.** Es beantwortet keine Kundenfrage, und die Inhalte sind nicht belegt. |
|
||||||
|
| D9 | **„So arbeiten wir“ entfällt.** Der Ablauf wird eine kompakte Schrittzeile im Kundenportal-Abschnitt. |
|
||||||
|
| D10 | **Der Leuchtschrift-Effekt entfällt.** Abschnitte bekommen echte `h2`-Überschriften, die beim Scrollen sanft erscheinen. Das funktioniert auch auf dem Handy und für Screenreader. |
|
||||||
|
| D11 | **Heller und dunkler Modus** werden beide gestaltet. Standard folgt dem System, der Umschalter sitzt in der Kopfleiste (Handy: im Menü). |
|
||||||
|
| D12 | **Vorgehen:** Zuerst entsteht ein anklickbarer Entwurf der neuen Startseite (echte Seite auf einem Zweig), der Inhaber gibt Rückmeldung, danach folgt die Fertigstellung (Abschnitt 5). |
|
||||||
|
| D13 | **Die Texte der Rechtsseiten bleiben unverändert** (AGB, Impressum, Wartungs-Abo, Hosting-Abo, Datenschutz-Weiterleitung). Markup und CSS-Klassen dieser Seiten dürfen für das neue Layout angepasst werden: Der eigene Kopfbereich (`glass-nav`-Header) entfällt, weil das gemeinsame Layout ihn liefert; Versalien, alte Schriftklassen und alte Button-Klassen werden ersetzt. Wortlaut, Reihenfolge und Gliederung der Texte bleiben gleich. Die Rechtstext-Tests müssen ohne Änderung grün bleiben. |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Vorgehen in Phasen
|
||||||
|
|
||||||
|
### Phase 1 – Entwurf der Startseite
|
||||||
|
- Die neue Startseite wird **als echte Seite** im Code gebaut (kein Wegwerf-Mockup), auf dem Zweig `relaunch`. Sie ersetzt `src/pages/Index.tsx`.
|
||||||
|
- Dazu gehören die neue Kopfleiste (6.1), alle Abschnitte aus 6.2 außer „Projekte“, der neue Footer (7.8), beide Modi, Desktop und Handy, echte Texte, echte Portal-Screenshots und die Deutschland-Karte.
|
||||||
|
- Zum Vergleich gibt es **zwei Schriftvarianten** (8.1), umschaltbar über `?schrift=a` bzw. `?schrift=b`. Der Umschalter ist nur im Entwurf vorhanden.
|
||||||
|
- Unterseiten bekommen in Phase 1 nur so viel Anpassung, dass sie mit der neuen Kopfleiste und dem neuen Footer nicht kaputt aussehen.
|
||||||
|
- Die alten Startseiten-Abschnitte bleiben noch als Dateien liegen; Phase 2 räumt sie auf.
|
||||||
|
- Tests, die alte Texte oder alte Navigationspunkte festschreiben, werden in Phase 1 angepasst (9.8). Die Prüfungen gegen verbotene Aussagen bleiben.
|
||||||
|
- Für den Inhaber gibt es eine Vorschau (O10) und Screenshots jedes Abschnitts in 1440 × 900 und 390 × 844, hell und dunkel, **je Schriftvariante**, im Pull Request.
|
||||||
|
|
||||||
|
### Phase 2 – Fertigstellung
|
||||||
|
- Rückmeldungen des Inhabers einarbeiten und die gewählte Schrift fest einbauen; der Umschalter `?schrift` wird entfernt.
|
||||||
|
- `/kontakt`, `/ueber-uns` und die 404-Seite bekommen den neuen Stil. Die Rechtsseiten bekommen nur das neue Layout (D13).
|
||||||
|
- Aufräumen (9.2), Barrierefreiheit (9.6), Leistungsbudget (E5), Browserprüfung (9.8).
|
||||||
|
|
||||||
|
### Phase 3 – Livegang
|
||||||
|
- Nur mit ausdrücklichem OK des Inhabers. Übertragung ins Live-Repository (Abschnitt 12), danach Prüfung der Live-Seite.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Informationsarchitektur
|
||||||
|
|
||||||
|
### 6.1 Kopfleiste und Navigation
|
||||||
|
|
||||||
|
| Eintrag | Ziel | Hinweis |
|
||||||
|
|---|---|---|
|
||||||
|
| Logo „WEBklar“ | `/` | |
|
||||||
|
| Kundenportal | `/#kundenportal` | |
|
||||||
|
| Was wir bauen | `/#leistungen` | |
|
||||||
|
| Preise | `/#preise` | |
|
||||||
|
| Über uns | `/#ueber-uns` | Abschnitt auf der Startseite. Die ausführliche Seite `/ueber-uns` ist von dort und vom Footer verlinkt. |
|
||||||
|
| Projekte | `/#projekte` | erscheint nur, wenn es freigegebene Referenzen gibt |
|
||||||
|
| Button „Projekt besprechen“ | `/kontakt` | |
|
||||||
|
| Hell/Dunkel-Schalter | – | |
|
||||||
|
|
||||||
|
- **Desktop:** Alles in einer Leiste.
|
||||||
|
- **Handy (unter 900 px):** In der Leiste stehen nur Logo und Menü-Knopf. Der Menü-Knopf ist ein echter `button` mit `aria-expanded` und mindestens 44 × 44 px groß.
|
||||||
|
- **Menü-Panel auf dem Handy:**
|
||||||
|
- enthält die Navigation, den Button „Projekt besprechen“ und den Hell/Dunkel-Schalter;
|
||||||
|
- dunkelt den Hintergrund ab und sperrt das Scrollen der Seite;
|
||||||
|
- schließt per Escape, per Tipp daneben und bei Auswahl eines Links.
|
||||||
|
- Alle Anker-Ziele sind `<section id="…">`, damit `scroll-margin-top` greift (bereits global gesetzt für `section[id]`). Von Unterseiten aus springt `ScrollToTop` nach dem Laden der Schriften zum Anker (vorhanden in `src/App.tsx`).
|
||||||
|
|
||||||
|
### 6.2 Startseite (Reihenfolge)
|
||||||
|
|
||||||
|
| # | Abschnitt | Anker | Typ | Kernaussage |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Hero mit Deutschland-Karte | – | Bühne | Wir bauen Ihre Website, Ihren Shop, Ihre Web-App oder Ihre Automatisierung. Website und Kundenportal laufen auf dem Server in Falkenstein. |
|
||||||
|
| 2 | Kundenportal | `#kundenportal` | Bühne | Sie sehen Ihr Projekt live: Vorschau, Fortschritt, Chat, Rechnungen. Darunter die Schrittzeile. |
|
||||||
|
| 3 | Was wir bauen | `#leistungen` | Beleg | Vier Produkte, dazu der Umzug bestehender Websites |
|
||||||
|
| 4 | Projekte | `#projekte` | Beleg | **nur mit freigegebenen Referenzen**, sonst entfällt der Abschnitt komplett |
|
||||||
|
| 5 | Preise | `#preise` | Beleg | Angebot vorher, Hosting-Abo 20 €, Wartungs-Abo 50 €, weitere Arbeit nur nach Freigabe |
|
||||||
|
| 6 | Über uns | `#ueber-uns` | Beleg | Wer dahinter steckt, wo |
|
||||||
|
| 7 | Kontakt | `#kontakt` | Bühne | Ein Satz, ein Button, E-Mail und Telefon |
|
||||||
|
| – | Footer | – | – | Navigation, Rechtliches, Anschrift |
|
||||||
|
|
||||||
|
- **Bühne** = etwa eine Bildschirmhöhe auf dem Desktop (`min-height: 100svh`), ein Satz plus ein Bild oder Objekt.
|
||||||
|
- **Beleg** = Höhe nach Inhalt, Text in einer Spalte von höchstens 46 rem.
|
||||||
|
- Das Kundenportal steht an zweiter Stelle, damit gleich nach dem Hero ein Bild zeigt, was WEBklar anders macht (Wunsch 8).
|
||||||
|
|
||||||
|
### 6.3 Jede Aussage hat genau einen Ort
|
||||||
|
|
||||||
|
| Aussage | Einziger Ort auf der Startseite |
|
||||||
|
|---|---|
|
||||||
|
| Positionierung „Websites, Shops und Apps für kleine Betriebe“ | kleine Zeile im Hero |
|
||||||
|
| Serverstandort Falkenstein | Kartenpunkt im Hero |
|
||||||
|
| Kundenportal und Live-Vorschau | Abschnitt Kundenportal (im Hero nur ein Satz im Untertext, ohne Funktionsliste) |
|
||||||
|
| Ablauf | Schrittzeile im Kundenportal-Abschnitt |
|
||||||
|
| Kennenlernen (15 Minuten, kostenlos) | Kontakt-Abschnitt; die Schrittzeile sagt nur „Kennenlernen“ |
|
||||||
|
| Preise | Abschnitt Preise |
|
||||||
|
| Winnweiler / Pfalz | Abschnitt Über uns (die Anschrift im Footer ist Pflichtangabe und zählt nicht) |
|
||||||
|
| E-Mail und Telefon | Kontakt-Abschnitt (Footer ohne eigene Kontaktspalte) |
|
||||||
|
|
||||||
|
- Den Erstkontakt nennt der Button überall „Projekt besprechen“. Das Wort „Potenzialanalyse“ entfällt.
|
||||||
|
- Die heutige Faktenzeile im Hero („Falkenstein / Live-Vorschau / ab 20 €“) entfällt.
|
||||||
|
|
||||||
|
### 6.4 Unterseiten
|
||||||
|
|
||||||
|
| Route | Inhalt im Relaunch |
|
||||||
|
|---|---|
|
||||||
|
| `/kontakt` | Formular mit der bestehenden Logik (Appwrite), neue Gestaltung, der Datenschutzhinweis bleibt |
|
||||||
|
| `/ueber-uns` | ausführlichere Fassung des Team-Abschnitts; der Block „Der Unterschied“ entfällt |
|
||||||
|
| `/wartungs-abo`, `/hosting-abo`, `/agb`, `/impressum` | Texte unverändert, neues Layout (D13) |
|
||||||
|
| `/datenschutz` | bleibt Weiterleitung auf die Datenschutzerklärung im Kundenportal |
|
||||||
|
| `*` | 404 im neuen Layout |
|
||||||
|
|
||||||
|
### 6.5 Seitentitel, Beschreibung, Vorschaubild
|
||||||
|
- **Startseite:**
|
||||||
|
- Titel: „WEBklar – Websites, Shops und Apps für kleine Betriebe“
|
||||||
|
- Beschreibung: „WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website.“
|
||||||
|
- **Wo ändern:** `index.html` (`title`, `description`, `og:*`, `twitter:*`), `DEFAULT_TITLE` und `DEFAULT_DESCRIPTION` in `src/hooks/use-page-meta.ts` und `src/pages/Index.tsx`.
|
||||||
|
- **`/kontakt`:**
|
||||||
|
- Titel: „Projekt besprechen“
|
||||||
|
- Beschreibung: „Beschreiben Sie Ihr Vorhaben in zwei Sätzen. WEBklar meldet sich spätestens am nächsten Werktag.“
|
||||||
|
- **`public/og-image.png`** zeigt noch die alte Positionierung. Ein neues Bild (1200 × 630, höchstens 150 KB) im neuen Stil entsteht in Phase 2: Logo, Positionierungssatz und Karte, ohne Personen.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Abschnitte im Detail
|
||||||
|
|
||||||
|
Texte in **[eckigen Klammern]** brauchen die Freigabe des Inhabers (Abschnitt 11). Bis dahin gilt der jeweils genannte Ersatztext. Alle anderen Texte sind Entwürfe, die so eingebaut werden.
|
||||||
|
|
||||||
|
### 7.1 Hero mit Deutschland-Karte
|
||||||
|
|
||||||
|
**Text (links):**
|
||||||
|
- **Kleine Zeile darüber:** „Websites, Shops und Apps für kleine Betriebe“
|
||||||
|
- **Überschrift `h1` mit wechselndem Teil:** „Wir bauen **Ihre Website.**“ → „Wir bauen **Ihren Online-Shop.**“ → „Wir bauen **Ihre Web-App.**“ → „Wir bauen **Ihre Automatisierung.**“ → zurück zu „Wir bauen **Ihre Website.**“
|
||||||
|
- Es wechselt der ganze Teil nach „Wir bauen“ (wegen Ihre/Ihren).
|
||||||
|
- **Ablauf:** Wechsel alle 2,4 s mit 400 ms Überblendung. Nach **einem** Durchlauf bleibt „Ihre Website.“ stehen.
|
||||||
|
- **Anhalten:** Während des Durchlaufs gibt es einen unauffälligen Pause-Knopf (`aria-label` „Wechselnde Überschrift anhalten“, Tippfläche mindestens 44 × 44 px; WCAG 2.2.2). Hover oder Fokus auf der Überschrift halten den Wechsel ebenfalls an.
|
||||||
|
- **Ohne Bewegung:** Bei `prefers-reduced-motion: reduce` steht fest „Wir bauen Ihre Website.“, ohne Knopf. Ist der Tab nicht sichtbar, pausiert der Wechsel.
|
||||||
|
- **Screenreader:** Im `h1` steht ein fester, visuell versteckter Satz: „Wir bauen Websites, Online-Shops, Web-Apps und Automatisierungen.“ Der wechselnde Teil ist `aria-hidden`.
|
||||||
|
- **Layout:** Der Wechsel darf nichts springen lassen. Die Varianten liegen übereinander, die Breite richtet sich nach der längsten.
|
||||||
|
- **Untertext:** „In unserem Kundenportal sehen Sie jederzeit, wie weit Ihr Projekt ist – mit Live-Vorschau Ihrer Website.“
|
||||||
|
- **Buttons:** „Projekt besprechen“ (→ `/kontakt`, Hauptbutton) und „Kundenportal ansehen“ (→ `#kundenportal`, zweitrangig gestaltet).
|
||||||
|
|
||||||
|
**Karte (rechts):**
|
||||||
|
- **Datei:** `docs/relaunch/assets/deutschland-umriss.svg` (ein Pfad, nur Linie, `stroke="currentColor"`, viewBox `0 0 663 892`). Als Inline-SVG-Komponente einbauen.
|
||||||
|
- **Roter Punkt Falkenstein:** `cx = 469.6`, `cy = 520.5`. Ist schon in der Datei und geprüft: liegt im Südwesten Sachsens, 14 km von der tschechischen Grenze.
|
||||||
|
- **Beschriftung am Punkt:** „Falkenstein, Sachsen“, darunter klein „Hier laufen Ihre Website und das Kundenportal.“ Mindestgröße 14 px.
|
||||||
|
- **[Optional, O8]** zweiter, hohler Punkt für das Büro in Winnweiler (ca. 49,567 N, 7,850 O → `cx ≈ 146.7`, `cy ≈ 622.2`, Formel in der README). Falls ja, steht „Hier sitzen wir“ dort, und der Ort wird im Abschnitt Über uns nicht noch einmal genannt.
|
||||||
|
- **Farben:** Linie `var(--line-strong)`, Punkt `var(--accent)`.
|
||||||
|
- **Bewegung beim ersten Erscheinen:**
|
||||||
|
- Der Umriss zeichnet sich in 1,6 s (`stroke-dashoffset`; dafür `pathLength="1"` setzen und `vector-effect="non-scaling-stroke"` entfernen, die Strichstärke dann in der viewBox angeben).
|
||||||
|
- Danach erscheint der Punkt, dann pulsiert ein Ring um ihn dreimal (Skalierung 1 → 2,6, je 2,4 s, Deckkraft abnehmend) und bleibt dann stehen.
|
||||||
|
- Bei reduzierter Bewegung ist alles sofort sichtbar, ohne Ring.
|
||||||
|
- **Barrierefreiheit:** Die Karte ist Dekoration (`aria-hidden="true"`). Die Aussage steht als Text daneben.
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- **Desktop:** Zweispaltig, Text links (etwa 55 %), Karte rechts mit etwa 80 svh Höhe. Bei 1280 × 800 ist der ganze Hero ohne Scrollen sichtbar.
|
||||||
|
- **Handy:** Text oben, Karte darunter (40–46 svh hoch, mittig). Die Beschriftung am Punkt bleibt lesbar.
|
||||||
|
- **Hintergrund:** ruhige Fläche (`--bg`), kein WebGL-, Video- oder Effekthintergrund.
|
||||||
|
|
||||||
|
### 7.2 Kundenportal (`#kundenportal`)
|
||||||
|
|
||||||
|
**Text:**
|
||||||
|
- **Überschrift:** „Ihr Projekt. Live im Kundenportal.“
|
||||||
|
- **Einleitung:** „Sie sehen, was wir zuletzt erledigt haben, öffnen die Live-Vorschau Ihrer Website und schreiben uns im Chat – mit Fotos, Dateien oder Sprachnachricht. Ihre Rechnungen liegen ebenfalls dort, und aufs Handy installieren Sie das Portal wie eine App.“
|
||||||
|
- Die Installierbarkeit ist geprüft: Das Portal hat ein Web-App-Manifest und einen Service Worker und ist auf dem Handy als App installierbar.
|
||||||
|
|
||||||
|
**Funktionen in dieser Reihenfolge** (Bilder in `docs/relaunch/assets/portal/`, Dateiendung `.webp`):
|
||||||
|
|
||||||
|
| # | Titel | Bildunterschrift | Desktop-Bild | Handy-Bild |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 1 | Live-Vorschau | „Ihre Website, noch während wir daran bauen.“ | `desktop-1-projekte` | `phone-1-projekte` |
|
||||||
|
| 2 | Fortschritt | „Was wir zuletzt erledigt haben – in einfachen Sätzen.“ | `desktop-2-fortschritt` | `phone-3-projektdetails` |
|
||||||
|
| 3 | Chat | „Frage, Foto oder Sprachnachricht direkt ans Team.“ | `desktop-3b-chat-foto` | `phone-4b-chat-foto` |
|
||||||
|
| 4 | Rechnungen | „Ansehen, bezahlen, herunterladen.“ | `desktop-4-rechnungen` | `phone-5-rechnungen` |
|
||||||
|
| 5 | Funktionen | „Bausteine zum Dazubuchen, zum Beispiel eine News-Pinnwand für Ihre Website.“ | `desktop-7-news-pinnwand` | `phone-8-news-vorschau` |
|
||||||
|
| 6 | Als App | „Aufs Handy installieren, fertig.“ | – (siehe unten) | `phone-2-projekte-lang` |
|
||||||
|
|
||||||
|
- **[Nur wenn O9 = ja]:** Zeile 4 heißt dann „Rechnungen und Verträge“ und bekommt zusätzlich `phone-6-vertraege`. Eine weitere Zeile „Stundenkonto“ mit `desktop-5-stundenkonto` kommt hinzu, weil diese Ansichten im Portal noch nicht für alle Kunden freigeschaltet sind.
|
||||||
|
- **Nicht bewerben:** „Terminplaner“ und „Analyse“. Sie sind im Portal nur Platzhalter („kommt bald“).
|
||||||
|
|
||||||
|
**Desktop (ab 900 px):**
|
||||||
|
- Eine festgepinnte Bühne (`position: sticky`, Scrollstrecke etwa 3–4 Bildschirmhöhen).
|
||||||
|
- **Links** steht die Liste der Funktionen. Die aktive Funktion ist hervorgehoben, ein roter Punkt dient als Marker.
|
||||||
|
- **Rechts:**
|
||||||
|
- Ein Desktop-Fenster: 1 px Linie, 12–14 px Rundung, oben eine schlichte Leiste mit der Adresse **project.webklar.com**, keine Ampelpunkte.
|
||||||
|
- Unten rechts überlappt ein Handy-Rahmen das Fenster, etwa 30 % der Breite.
|
||||||
|
- Die Bilder wechseln per Überblendung, sobald die nächste Funktion zur Hälfte im Blick ist (`IntersectionObserver`).
|
||||||
|
- **Bei Schritt 6** blendet das Desktop-Fenster aus. Der Handy-Rahmen rückt in die Mitte und wird größer. Darin schiebt sich der lange Screenshot `phone-2-projekte-lang` mit dem Seiten-Scroll nach oben. Das ist `transform: translateY`, gekoppelt an den Scrollfortschritt, kein eigener Scrollbereich.
|
||||||
|
|
||||||
|
**Handy (unter 900 px):**
|
||||||
|
- Keine festgepinnte Bühne.
|
||||||
|
- Stattdessen eine **Wisch-Reihe** (9.4) mit je einem Handy-Screenshot im Rahmen und der Bildunterschrift darunter.
|
||||||
|
- Die letzte Karte „Als App“ zeigt `phone-2-projekte-lang`. Es schiebt sich langsam nach oben, solange die Karte sichtbar ist.
|
||||||
|
- Bei O9 = ja höchstens 7 Karten.
|
||||||
|
|
||||||
|
**Schrittzeile unter dem Portal** (ersetzt „So arbeiten wir“):
|
||||||
|
- Text: „**1** Kennenlernen · **2** Angebot mit Preis · **3** Bauen mit Vorschau und Ihrer Freigabe · **4** Online – Hosting und Pflege auf Wunsch im Abo“
|
||||||
|
- **Desktop:** eine Zeile mit vier Punkten.
|
||||||
|
- **Handy:** vier kleine Karten in einer Wisch-Reihe (Wunsch 2).
|
||||||
|
|
||||||
|
**Rahmen und Bildinhalt:**
|
||||||
|
- Es gibt **einen** Rahmenstil für alle Bilder. Er muss auf hellem und dunklem Grund funktionieren. Das Portal selbst ist dunkel und violett; das ist Bildinhalt, nicht Seitenfarbe.
|
||||||
|
- Die Screenshots zeigen nur die erfundene „Bäckerei Sonnenschein“. Nie echte Kundendaten verwenden, auch nicht verpixelt.
|
||||||
|
|
||||||
|
### 7.3 Was wir bauen (`#leistungen`)
|
||||||
|
|
||||||
|
**Text:**
|
||||||
|
- Überschrift: „Was wir für Sie bauen.“
|
||||||
|
- Einleitung:
|
||||||
|
- **Standard:** „Die meisten Betriebe starten mit einer Website. Wenn mehr nötig ist, bauen wir mehr.“
|
||||||
|
- **[Bei O2 = ja]:** „Die meisten Betriebe starten mit einer Website: Startseite und bis zu drei Unterseiten, mit Texten, Design und Anpassung für Handy und Computer. Wenn mehr nötig ist, bauen wir mehr.“
|
||||||
|
|
||||||
|
**Vier nummerierte Einträge**, als **Textzeilen statt Karten mit Symbolen**:
|
||||||
|
1. **Website**: „Startseite, Unterseiten, Impressum und Datenschutz – gestaltet, getextet und online.“
|
||||||
|
2. **Online-Shop**: „Warenkorb, Kasse, Zahlung und Versand. Auf Wunsch angebunden an Ihre Warenwirtschaft.“
|
||||||
|
3. **Web-App**: „Eigene Software für Ihren Betrieb mit Login, Verwaltung und Auswertungen, im Browser und auf dem Handy.“
|
||||||
|
4. **Automatisierung**: „Was sich ständig wiederholt, erledigt der Rechner: Bestätigungen, Rechnungen, Dokumente auslesen, ein KI-Assistent mit Ihrem Firmenwissen.“
|
||||||
|
|
||||||
|
**Schlusszeile:** „Sie haben schon eine Website? Wir ziehen sie um – mit Domain, Inhalten und Postfächern.“
|
||||||
|
|
||||||
|
**Mit Texten spielen:**
|
||||||
|
- Die Einleitung hellt sich beim Scrollen Wort für Wort auf (gedimmt 0,18 → voll), höchstens 30 Wörter.
|
||||||
|
- Nur wenn `animation-timeline` unterstützt wird und keine reduzierte Bewegung gewünscht ist. Sonst steht der Text normal da.
|
||||||
|
|
||||||
|
**Layout:**
|
||||||
|
- **Desktop:** große Überschrift links, nummerierte Liste rechts, viel Weißraum.
|
||||||
|
- **Handy:** die vier Einträge als **Wisch-Reihe** (9.4). Jede Karte ist 80 vw breit, die nächste ist angeschnitten sichtbar, dazu ein Zähler „1 / 4“.
|
||||||
|
|
||||||
|
### 7.4 Projekte (`#projekte`)
|
||||||
|
- **Solange keine Referenz freigegeben ist, entfällt der Abschnitt komplett.** Ist das Daten-Array leer, werden Abschnitt und Menüpunkt nicht gerendert.
|
||||||
|
- **Sobald Freigaben vorliegen:**
|
||||||
|
- Überschrift: „Echte Websites. Echte Betriebe.“
|
||||||
|
- Untertext: „Nur Projekte, die online sind und die wir zeigen dürfen.“
|
||||||
|
- Je Projekt ein **eigener** Screenshot (kein Stockfoto), ein echter Link und eine Zeile Beschreibung.
|
||||||
|
- Desktop mit großen Bildern, Handy als Wisch-Reihe, höchstens 5 Projekte.
|
||||||
|
|
||||||
|
### 7.5 Preise (`#preise`)
|
||||||
|
|
||||||
|
**Text:**
|
||||||
|
- Überschrift: „Was es kostet. Vorher.“
|
||||||
|
- Einleitung:
|
||||||
|
- **Standard:** „Für Ihr Projekt bekommen Sie vorher ein Angebot mit Preis. Für den Betrieb danach gibt es zwei Abos. Alle Preise sind Endpreise; als Kleinunternehmer (§ 19 UStG) berechnen wir keine Umsatzsteuer.“
|
||||||
|
- **[Bei O1 = ja]:** Der erste Satz wird ersetzt durch den freigegebenen Preisanker, z. B. „Eine Website gibt es ab … €.“
|
||||||
|
|
||||||
|
**Zwei Blöcke** (Desktop nebeneinander, Handy untereinander). Wortlaut aus den Abo-Seiten:
|
||||||
|
- **Hosting-Abo, 20 € im Monat:** „Für statische Websites: Hosting mit SSL-Zertifikat, täglicher Datensicherung und Überwachung der Erreichbarkeit, eine Domain und ein E-Mail-Postfach mit 5 GB. 12 Monate Mindestlaufzeit.“ → Link „Details zum Hosting-Abo“ (`/hosting-abo`)
|
||||||
|
- **Wartungs-Abo, 50 € im Monat:** „Für alle Websites und Web-Anwendungen, auch mit Login, Shop oder Datenbank: zusätzlich Sicherheitsupdates für die Software Ihrer Website, beliebig viele Postfächer mit zusammen 5 GB und eine Stunde Arbeitszeit pro Monat. 12 Monate Mindestlaufzeit.“ → Link „Details zum Wartungs-Abo“ (`/wartungs-abo`)
|
||||||
|
|
||||||
|
**Zeile darunter:** „Weitere Arbeit nur mit Ihrer Freigabe: 100 € pro Stunde, abgerechnet in 15-Minuten-Schritten.“ Das gilt für beide Abos, so steht es auf beiden Abo-Seiten.
|
||||||
|
|
||||||
|
**Eine Quelle für alle Angaben:**
|
||||||
|
- Preise, Laufzeiten und Leistungen müssen mit den Seiten `/hosting-abo` und `/wartungs-abo` übereinstimmen.
|
||||||
|
- Die Werte liegen in **einem** Datenmodul `src/data/abos.ts`, das die Startseite nutzt.
|
||||||
|
- Die Abo-Seiten selbst werden **nicht** auf das Modul umgebaut (D13). Stattdessen prüft ein Test, dass Preis, Laufzeit und Speicherangaben auf Startseite und Abo-Seiten gleich sind.
|
||||||
|
|
||||||
|
**Darstellung:** Preise zählen nicht hoch. Zahlen mit `tabular-nums`.
|
||||||
|
|
||||||
|
### 7.6 Über uns (`#ueber-uns`)
|
||||||
|
|
||||||
|
**Text:**
|
||||||
|
- Überschrift: „Wer hinter WEBklar steckt.“
|
||||||
|
- Text:
|
||||||
|
- **Standard:** „Wir sind ein kleines Team aus Winnweiler in der Pfalz. Sie sprechen bei uns direkt mit den Leuten, die Ihre Website bauen.“
|
||||||
|
- **[Bei O4 = ja]:** Namen und Rollen kommen dazu, z. B. „Justin kümmert sich um Websites, Angebote und Betreuung, Kenso um Shops, Software und Automatisierung.“
|
||||||
|
- **[Bei Freigabe]:** „Erreichbar Montag bis Freitag von 9 bis 17 Uhr.“
|
||||||
|
|
||||||
|
**Fotos:**
|
||||||
|
- Nur echte Fotos des Teams, die der Inhaber liefert.
|
||||||
|
- **Bis dahin keine Fotos**: keine Platzhalter-Gesichter, keine KI-Bilder, keine Initialen-Kreise.
|
||||||
|
|
||||||
|
**Link:** „Mehr über uns“ → `/ueber-uns`.
|
||||||
|
|
||||||
|
### 7.7 Kontakt (`#kontakt`)
|
||||||
|
- **Überschrift:** „Erzählen Sie uns, was Sie vorhaben.“
|
||||||
|
- **Text:** „Zwei Sätze genügen. Wir melden uns spätestens am nächsten Werktag. Im ersten Gespräch klären wir in 15 Minuten, ob und wie wir helfen können – kostenlos und unverbindlich.“
|
||||||
|
- **Button:** „Projekt besprechen“ (→ `/kontakt`), darunter E-Mail und Telefon.
|
||||||
|
- **Gestaltung:** Bühne mit viel Ruhe, kein Effektrahmen.
|
||||||
|
|
||||||
|
### 7.8 Footer
|
||||||
|
- **Links im Footer:** Logo, „WEBklar GbR“ und die Anschrift (Pflichtangabe, aus dem Impressum).
|
||||||
|
- **Spalte Seiten:**
|
||||||
|
- Startseite → `/`
|
||||||
|
- Kundenportal → `/#kundenportal`
|
||||||
|
- Was wir bauen → `/#leistungen`
|
||||||
|
- Preise → `/#preise`
|
||||||
|
- Über uns → `/ueber-uns`
|
||||||
|
- Kontakt → `/kontakt`
|
||||||
|
- Wartungs-Abo → `/wartungs-abo`
|
||||||
|
- Hosting-Abo → `/hosting-abo`
|
||||||
|
- **Spalte Rechtliches:**
|
||||||
|
- AGB, Impressum, Datenschutz
|
||||||
|
- „Verträge hier kündigen“ und „Vertrag widerrufen“: Beide bleiben hervorgehoben wie heute, weil sie rechtlich vorgeschrieben sind. Die Ziele bleiben wie im heutigen `Footer.tsx`.
|
||||||
|
- **Gestaltung:** Passt sich hellem und dunklem Modus an, also kein harter schwarzer Block im hellen Modus. Kein animiertes Raster.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Gestaltung
|
||||||
|
|
||||||
|
### 8.1 Schrift
|
||||||
|
- **Eine** Schriftfamilie, **zwei** Gewichte (400 und 600). Inter, Space Grotesk und Poppins entfallen.
|
||||||
|
- Im Entwurf gibt es zwei Varianten zum Vergleich:
|
||||||
|
- **A – Systemschrift:** `-apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", Roboto, sans-serif`. Auf Apple-Geräten ist das echtes SF Pro, ohne Lizenz- oder Ladefrage.
|
||||||
|
- **B – selbst gehostete Variable-Schrift unter SIL Open Font License**, z. B. „Instrument Sans“, als `woff2` in `public/fonts/`. Nichts von Google Fonts oder anderen fremden Servern laden (Datenschutz).
|
||||||
|
- Vor der Entscheidung an echten deutschen Überschriften mit Umlauten und ß prüfen.
|
||||||
|
- **Keine Versalien-Überschriften** mehr, sondern normale Satzschreibung. Überschriften mit `text-wrap: balance`, Absätze mit `text-wrap: pretty`, dazu `hyphens: auto` mit `lang="de"`.
|
||||||
|
|
||||||
|
**Größen (Token):**
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--fs-hero: clamp(2.5rem, 1.569rem + 3.81vw, 5rem); /* 40 → 80 px */
|
||||||
|
--fs-h2: clamp(2rem, 1.444rem + 2.29vw, 3.5rem); /* 32 → 56 px */
|
||||||
|
--fs-lead: clamp(1.1875rem, 1.05rem + 0.6vw, 1.625rem); /* 19 → 26 px */
|
||||||
|
--fs-body: 1.0625rem; /* 17 px, Zeilenhöhe 1.47 */
|
||||||
|
--fs-small: 0.875rem; /* 14 px, kleinste Textgröße überhaupt */
|
||||||
|
--measure-text: 46rem;
|
||||||
|
--container: 1100px;
|
||||||
|
}
|
||||||
|
h1, h2 { font-weight: 600; line-height: 1.05; letter-spacing: -0.015em; }
|
||||||
|
```
|
||||||
|
- Abstand von der Überschrift zum ersten Absatz: 1,2 em.
|
||||||
|
- Fließtext und Formularfelder mindestens 16 px, sonst zoomt iOS.
|
||||||
|
|
||||||
|
### 8.2 Farben
|
||||||
|
|
||||||
|
**Grundsatz:**
|
||||||
|
- Neutral, also nahezu Schwarz und Weiß mit wenigen Grautönen, plus **eine** Akzentfarbe: **Rot** wie der Kartenpunkt.
|
||||||
|
- Das cyan-blaue Primär und das Türkis im dunklen Modus entfallen.
|
||||||
|
- Rot nur für den Kartenpunkt, aktive Zustände und Marker. Der Hauptbutton ist neutral: dunkel auf hell, hell auf dunkel.
|
||||||
|
|
||||||
|
**Startwerte** (geprüfte Kontraste, Feinabstimmung im Entwurf, O7):
|
||||||
|
|
||||||
|
| Token | Hell | Dunkel | Zweck |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--bg` | `#ffffff` | `#0b0b0c` | Seitenfläche |
|
||||||
|
| `--bg-alt` | `#f5f5f7` | `#161618` | jeder zweite Abschnitt |
|
||||||
|
| `--fg` | `#1d1d1f` | `#f5f5f7` | Text (≥ 15 : 1) |
|
||||||
|
| `--fg-muted` | `#6e6e73` | `#a1a1a6` | Nebentext (hell ≥ 4,66 : 1 auch auf `--bg-alt`, dunkel ≥ 7 : 1) |
|
||||||
|
| `--line` | `#d2d2d7` | `#2c2c2e` | Trennlinien, Rahmen |
|
||||||
|
| `--line-strong` | `#86868b` | `#636366` | Kartenumriss |
|
||||||
|
| `--accent` | `#e5251c` | `#e5251c` | Punkt und Marker (Nicht-Text, ≥ 3 : 1 auf allen Flächen) |
|
||||||
|
| `--accent-text` | `#c81e16` | `#ff5a4f` | Rot als Text oder Link (hell ≥ 5,28 : 1, dunkel ≥ 5,88 : 1, auch auf `--bg-alt`) |
|
||||||
|
| `--focus` | `#c81e16` | `#ff5a4f` | Fokusrahmen |
|
||||||
|
|
||||||
|
- Alle Kombinationen prüft ein Test auf Kontrast. Heute geschieht das schon so in `src/test/sofort-paket.test.tsx`, über die Token in `index.css`.
|
||||||
|
- Das Logo bleibt (`src/components/Logo.tsx`), Schreibweise „WEBklar“.
|
||||||
|
|
||||||
|
### 8.3 Abstände und Flächen
|
||||||
|
- **Abstände:** zwischen Abschnitten 120–160 px auf dem Desktop und 72–96 px auf dem Handy. Container 1100 px breit, Textspalte 46 rem.
|
||||||
|
- **Flächen:** Flächenwechsel statt Rahmen. Abschnitte stehen abwechselnd auf `--bg` und `--bg-alt`, der Text steht frei auf der Fläche.
|
||||||
|
- **Formen:** Rundungen und Rahmen nur um Bilder und Bildschirme.
|
||||||
|
|
||||||
|
### 8.4 Bewegung
|
||||||
|
|
||||||
|
**Grundregeln:**
|
||||||
|
- Eine Kurve: `cubic-bezier(.2, .8, .2, 1)`. Zwei Dauern: 200 ms für Zustände, 600 ms für das Erscheinen. Versatz höchstens 60 ms. Kein Federn.
|
||||||
|
- **Erscheinen beim Scrollen** per CSS `animation-timeline: view()` innerhalb von `@supports`. Der Grundzustand ist **sichtbar**: Ohne Unterstützung steht alles einfach da.
|
||||||
|
- **Reduzierte Bewegung:** Alles läuft hinter `@media (prefers-reduced-motion: no-preference)`. Bei `reduce` gibt es keine Bewegung außer Farbwechseln.
|
||||||
|
- **Animiert werden** nur `transform` und `opacity`.
|
||||||
|
- **Nicht erlaubt:** Scroll-Hijacking und `scroll-snap` auf der ganzen Seite.
|
||||||
|
- **Sticky:** Vorfahren von `position: sticky` nutzen `overflow: clip` statt `hidden`.
|
||||||
|
- **Hover:** Hover-Effekte nur in `@media (hover: hover) and (pointer: fine)`.
|
||||||
|
|
||||||
|
**Ausdrücklich erlaubte Ausnahmen**, auch nur ohne reduzierte Bewegung:
|
||||||
|
|
||||||
|
| Element | Abweichung |
|
||||||
|
|---|---|
|
||||||
|
| Kartenumriss (7.1) | `stroke-dashoffset` über 1,6 s |
|
||||||
|
| Ring um den Punkt (7.1) | 3 × 2,4 s, dann Stillstand |
|
||||||
|
| Wechselnde Überschrift (7.1) | 400 ms Überblendung alle 2,4 s, ein Durchlauf |
|
||||||
|
| Wort-für-Wort-Aufhellen (7.3) | an den Scrollfortschritt gekoppelt |
|
||||||
|
|
||||||
|
### 8.5 Regeln gegen den Vorlagen-Look
|
||||||
|
1. **Schrift:** eine Familie, zwei Gewichte.
|
||||||
|
2. **Farbe:** eine Akzentfarbe (Rot), und zwar mit Bedeutung.
|
||||||
|
3. **Hintergründe:** keine Effekte, also kein WebGL, keine Glüh-Ränder, kein Pixelraster, kein Glas.
|
||||||
|
4. **Layout:** kein Raster aus drei Karten mit Kreis-Symbolen. Stattdessen nummerierte Textzeilen, eine große Aussage plus Liste oder asymmetrische Layouts.
|
||||||
|
5. **Texte, die nur WEBklar sagen kann:** Falkenstein, Kundenportal, Winnweiler. Verboten sind „maßgeschneidert“, „ganzheitlich“, „innovativ“, „nahtlos“, „auf das nächste Level“, „Hebel“, „Tool-Inseln“, „Pipeline“ und „Potenzialanalyse“.
|
||||||
|
6. **Zahlen:** keine Kennzahlen ohne Beleg, keine hochzählenden Zahlen.
|
||||||
|
7. **Bewegung:** nur mit Aufgabe (8.4).
|
||||||
|
8. **Flächen:** wenige Kästen, viel Fläche.
|
||||||
|
9. **Bilder:** nur echtes Bildmaterial, also Portal-Screenshots mit Demodaten, echte Teamfotos und echte Projekte.
|
||||||
|
10. **Austausch-Test:** siehe E7.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Technik
|
||||||
|
|
||||||
|
### 9.1 Bleibt
|
||||||
|
- **Grundgerüst:** Vite, React, TypeScript, Tailwind und React Router mit allen bestehenden Routen.
|
||||||
|
- **Layout:** `src/routes.tsx` mit dem gemeinsamen Unterseiten-Layout `SubpageLayout`. Es wird zum neuen Layout mit Kopfleiste und Footer.
|
||||||
|
- **Seiten-Helfer:** `usePageMeta` setzt Titel und Beschreibung je Route. `ScrollToTop` springt zu Ankern.
|
||||||
|
- **Kontaktformular:** Die Logik in `src/pages/Contact.tsx` und `src/lib/appwrite.ts` bleibt, mit dem Datenschutzhinweis.
|
||||||
|
- **Rechtsseiten:** Ihre Texte und Tests bleiben: `src/test/abos.test.tsx`, `agb-haftung.test.tsx`, `agb-verguetung.test.tsx`.
|
||||||
|
- **Hell/Dunkel:** `next-themes` mit der Klassen-Strategie und dem Standard „system“, dazu das Skript in `index.html` gegen Aufblitzen.
|
||||||
|
|
||||||
|
### 9.2 Fällt weg (Phase 2)
|
||||||
|
Vor jedem Löschen prüfen, dass nichts mehr die Datei importiert.
|
||||||
|
|
||||||
|
- **Effekt-Komponenten und ihre Pakete:**
|
||||||
|
- Komponenten: `Silk`, `PixelBlast`, `LightRays`, `BorderGlow`, `FlickeringGrid`, `TextHoverEffect`, `CountUp`.
|
||||||
|
- Pakete: `three`, `@react-three/fiber`, `postprocessing`, `ogl`.
|
||||||
|
- **Alte Startseiten-Abschnitte:**
|
||||||
|
- `Partners` (das Laufband) mit `public/svg/*`.
|
||||||
|
- `ProblemSection`, `SolutionSection`, `Values`, `Services`, `Process`.
|
||||||
|
- Die Hintergrundbilder `problem*.jpg`, `loesung.jpg`, `backgroud_effect*`.
|
||||||
|
- **Alte Schriften:** `src/fonts.css` und `public/fonts/inter-*`, `space-grotesk-*`. Sie werden durch die gewählte Schrift ersetzt.
|
||||||
|
- **Alte Buttons:** das CSS für die Buchstaben-Animation.
|
||||||
|
- **Analytics:** `AnalyticsProvider`, `src/lib/analytics.ts`, die `data-analytics-*`-Attribute und `gtag`. Heute ist das inaktiv; es würde aber Google laden, und der Relaunch nutzt kein Tracking (Abschnitt 13).
|
||||||
|
- **Ungenutzte shadcn-Komponenten und Pakete:** laut Prüfung rund 45 tote Dateien und 29 Pakete, die nur von toten Dateien genutzt werden.
|
||||||
|
- **Tests:**
|
||||||
|
- Tests, die nur gelöschte Komponenten prüfen, werden entfernt oder auf die neuen Abschnitte umgeschrieben.
|
||||||
|
- Die Quelltext-Prüfungen gegen verbotene Aussagen bleiben in jedem Fall: alte Telefonnummer, Unsplash, „24 Stunden“, „99,9“, Erfolgsversprechen.
|
||||||
|
|
||||||
|
### 9.3 Neu
|
||||||
|
- **Startseite:** `src/pages/Index.tsx` mit den Abschnitten aus 7, je Abschnitt eine Komponente in `src/components/home/`:
|
||||||
|
- `HeroKarte.tsx`
|
||||||
|
- `Kundenportal.tsx`
|
||||||
|
- `Leistungen.tsx`
|
||||||
|
- `Preise.tsx`
|
||||||
|
- `UeberUns.tsx`
|
||||||
|
- `KontaktBuehne.tsx`
|
||||||
|
- **`src/components/DeutschlandKarte.tsx`:** Inline-SVG aus dem Asset. Die Punkte kommen als Props, dazu die Formel aus der README.
|
||||||
|
- **`src/components/WischReihe.tsx`:** die wiederverwendbare horizontale Reihe (9.4).
|
||||||
|
- **`src/components/GeraeteRahmen.tsx`:** Desktop-Fenster mit Adressleiste und Handy-Rahmen.
|
||||||
|
- **`src/data/abos.ts`:** Preise und Leistungen für die Startseite (7.5).
|
||||||
|
- **Design-Token:** neu in `src/index.css` und `tailwind.config.ts` (8.1, 8.2).
|
||||||
|
|
||||||
|
### 9.4 Wisch-Reihe (unter 900 px)
|
||||||
|
- **Technik:** natives CSS ohne Bibliothek.
|
||||||
|
- Reihe: `display: flex; overflow-x: auto; scroll-snap-type: x mandatory; scroll-padding-inline: 16px; overscroll-behavior-x: contain;`
|
||||||
|
- Karten: `flex: 0 0 80vw; max-width: 380px; scroll-snap-align: start`.
|
||||||
|
- **Wischen erkennbar machen:**
|
||||||
|
- Die nächste Karte ist angeschnitten sichtbar. Das ist der wichtigste Hinweis.
|
||||||
|
- Dazu ein Zähler „2 / 4“. Die aktive Karte bestimmt ein `IntersectionObserver` (mindestens 60 % sichtbar).
|
||||||
|
- **Barrierefreiheit:**
|
||||||
|
- Liste mit `role="list"`.
|
||||||
|
- Bereich mit `tabindex="0"` und `aria-label`, z. B. „Vier Leistungen, seitlich wischen“.
|
||||||
|
- Die Fokusreihenfolge entspricht der Anzeige.
|
||||||
|
- Kein `aria-live`, keine automatische Bewegung.
|
||||||
|
- **Ab 900 px** zeigt jeder Abschnitt seine Desktop-Darstellung (Liste, Raster oder Bühne). Auf dem Desktop bleibt keine Wisch-Reihe übrig.
|
||||||
|
- **Grenze:** Höchstens 7 Karten je Reihe.
|
||||||
|
|
||||||
|
### 9.5 Bilder
|
||||||
|
- **Format und Auslieferung:**
|
||||||
|
- WebP mit Qualität 80–85.
|
||||||
|
- Über `srcset` ausliefern: Desktop 800, 1200 und 1600 w, Handy 390 und 780 w.
|
||||||
|
- `loading="lazy"` (außer Bildern im ersten Bildschirm), `decoding="async"`, feste `width`/`height`.
|
||||||
|
- **Budgets:**
|
||||||
|
- Ein einzelnes Desktop-Bild höchstens 120 KB, ein einzelnes Handy-Bild höchstens 60 KB.
|
||||||
|
- Was beim ersten Erreichen des Portal-Abschnitts sichtbar wird, höchstens 300 KB.
|
||||||
|
- Alle Bilder des Abschnitts für eine Gerätegröße zusammen höchstens 900 KB.
|
||||||
|
- **Rohdateien:** Die Dateien in `docs/relaunch/assets/portal/` sind Rohexporte in doppelter Auflösung und müssen verkleinert werden. Das geht einmalig vorab oder per Build-Skript mit `sharp`. In `public/` landen nur die verkleinerten Fassungen.
|
||||||
|
- **Alt-Texte:** jedes Bild bekommt einen, z. B. „Kundenportal: Projektübersicht mit Live-Vorschau der Website“.
|
||||||
|
|
||||||
|
### 9.6 Barrierefreiheit und Handy
|
||||||
|
- **Struktur:** genau ein `h1` je Seite, jeder Abschnitt ein `h2`, dazu die Landmarken `header`, `nav`, `main`, `footer`.
|
||||||
|
- **Bedienung:** sichtbarer Fokus (`--focus`), die Tastatur erreicht alles, Tippflächen mindestens 44 × 44 px.
|
||||||
|
- **Formulare:** mit `autocomplete` (name, email, organization) und `aria-live` für Fehler und Erfolg.
|
||||||
|
- **Handy-Checkliste** (aus der Prüfung):
|
||||||
|
- kein seitlicher Überlauf bei 360 und 390 px;
|
||||||
|
- `svh`/`dvh` statt `vh`;
|
||||||
|
- Safe-Area-Ränder (`env(safe-area-inset-*)`) bei festen Elementen;
|
||||||
|
- Menü mit Scrollsperre (6.1);
|
||||||
|
- Anker landen unter der Kopfleiste;
|
||||||
|
- Text mindestens 14 px, Fließtext 16–17 px;
|
||||||
|
- nichts ist nur per Hover erreichbar;
|
||||||
|
- Querformat 844 × 390 hat Inhalt und Buttons ohne erzwungene Höhe.
|
||||||
|
|
||||||
|
### 9.7 Leistung
|
||||||
|
- **Budgets:** E5, dazu CSS höchstens 15 KB gzip.
|
||||||
|
- **Routen per `React.lazy`:** Die Rechtsseiten müssen nicht im Startpaket sein. Die Render-Tests in `src/test/` fragen dann asynchron ab (`await screen.findBy…`).
|
||||||
|
- **Appwrite-SDK:** erst beim Absenden des Formulars laden (`import()`).
|
||||||
|
- **Keine fremden Server:** Schriften, Bilder und Skripte selbst hosten.
|
||||||
|
|
||||||
|
### 9.8 Tests und Prüfung
|
||||||
|
- **`npm test`** (Vitest und Testing Library) prüft:
|
||||||
|
- Abschnitte rendern mit `h2`, und die Anker passen zur Navigation.
|
||||||
|
- Wisch-Reihen haben `tabindex` und `aria-label`.
|
||||||
|
- Preise auf der Startseite stimmen mit den Abo-Seiten überein.
|
||||||
|
- Verbotene Aussagen und Wörter kommen nicht vor (8.5 Punkt 5).
|
||||||
|
- Die Rechtsseiten-Tests laufen unverändert grün.
|
||||||
|
- Die Kontrast-Token beider Modi erreichen mindestens 4,5 : 1.
|
||||||
|
- **Anpassung bestehender Tests in Phase 1:**
|
||||||
|
- In `src/test/sofort-paket.test.tsx` dürfen die Stellen geändert werden, die alte Texte oder Strukturen festschreiben: Meta-Beschreibung mit „Potenzialanalyse“, Navigationspunkte „Leistungen“ und „Ablauf“, Texte auf `/kontakt` und `/ueber-uns` sowie Tests gelöschter Komponenten.
|
||||||
|
- Nicht entfernt werden die Quelltext-Prüfungen gegen verbotene Aussagen.
|
||||||
|
- Die drei Rechtstext-Testdateien bleiben unverändert.
|
||||||
|
- **Browserprüfung** (Playwright) bei 390 × 844 und 1440 × 900, hell und dunkel:
|
||||||
|
- kein seitlicher Überlauf, keine Konsolenfehler;
|
||||||
|
- Seitenlänge auf dem Handy höchstens 8 Bildschirme;
|
||||||
|
- Karte und Punkt sind sichtbar, Wisch-Reihen lassen sich wischen;
|
||||||
|
- bei `prefers-reduced-motion: reduce` gibt es keine Bewegung;
|
||||||
|
- Screenshots für den Inhaber.
|
||||||
|
- **Vor jedem Push:** `npm test`, `npm run build` und `npm run lint` (keine **neuen** Fehler gegenüber `main`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Rechtliche Leitplanken
|
||||||
|
- **Rechtstexte:** Die Texte von AGB, Impressum, Abo-Seiten und Datenschutzerklärung nicht ohne ausdrückliche Freigabe des Inhabers ändern (D13). Die Tests sichern das ab.
|
||||||
|
- **Inhalte:**
|
||||||
|
- keine Erfolgsversprechen und keine unbelegten Zahlen;
|
||||||
|
- keine Referenzen ohne Zustimmung;
|
||||||
|
- keine Stockfotos und keine KI-Bilder von Personen.
|
||||||
|
- **Preise:** als Endpreise, mit Hinweis auf § 19 UStG.
|
||||||
|
- **Server:** Die Aussage zum Serverstandort gilt nur für Website und Kundenportal (D7).
|
||||||
|
- **Kontaktformular:** Der Datenschutzhinweis mit Link bleibt direkt am Formular.
|
||||||
|
- **Keine Drittanbieter-Anfragen,** damit die Datenschutzerklärung stimmt.
|
||||||
|
- **Portal-Screenshots:** nur mit Demodaten.
|
||||||
|
- **Footer:** „Verträge hier kündigen“ und „Vertrag widerrufen“ bleiben auf jeder Seite erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Offene Entscheidungen (Inhaber)
|
||||||
|
|
||||||
|
| Nr. | Frage | Bis zur Entscheidung gilt |
|
||||||
|
|---|---|---|
|
||||||
|
| O1 | Preisanker für Websites veröffentlichen? Welcher Betrag? | Standardtext in 7.5 (Angebot mit Preis, ohne Betrag) |
|
||||||
|
| O2 | Leistungsumfang „Startseite und bis zu drei Unterseiten“ als Einstieg nennen? | Standardtext in 7.3 |
|
||||||
|
| O3 | Zeitrahmen veröffentlichen (z. B. erste Vorschau nach wenigen Tagen)? | nicht nennen |
|
||||||
|
| O4 | Team: Wer wird mit Namen, Rolle und Foto gezeigt? | Standardtext in 7.6, keine Fotos |
|
||||||
|
| O5 | Welche Kunden stimmen einer Referenz zu? | kein Projekte-Abschnitt |
|
||||||
|
| O6 | Werden Eingänge über das Kontaktformular zuverlässig gemeldet? Nur dann ist „spätestens am nächsten Werktag“ haltbar | Formulierung bleibt; vor dem Livegang klären |
|
||||||
|
| O7 | Schrift A oder B, genauer Rotton | im Entwurf beide Schriften zeigen, Startwerte aus 8.2 |
|
||||||
|
| O8 | Zweiter Kartenpunkt für Winnweiler („Hier sitzen wir“)? | nur Falkenstein |
|
||||||
|
| O9 | Stundenkonto und Verträge im Portal-Abschnitt zeigen? Hängt an der Freischaltung im Portal | nicht zeigen |
|
||||||
|
| O10 | Wie sieht der Inhaber den Entwurf? | **GitHub Pages** über einen Actions-Workflow für den Zweig `relaunch` (siehe unten). Geht das nicht (z. B. privates Repo ohne Pages), Screenshots im Pull Request |
|
||||||
|
| O11 | Repository öffentlich oder privat? | nichts Internes einchecken: keine Preiskalkulation, keine Kundennamen, keine Serverdetails |
|
||||||
|
| O12 | Erreichbarkeit „Montag bis Freitag 9 bis 17 Uhr“ auf der Startseite nennen? | nicht nennen |
|
||||||
|
|
||||||
|
**Vorschau über GitHub Pages (O10):**
|
||||||
|
- Der Build bekommt den Unterpfad über `VITE_BASE` (`vite.config.ts`: `base`) und `BrowserRouter basename={import.meta.env.BASE_URL}`.
|
||||||
|
- Damit Unterseiten direkt aufrufbar sind, wird `dist/index.html` im Workflow nach `dist/404.html` kopiert.
|
||||||
|
- Das Kontaktformular sendet in der Vorschau nichts (ohne Appwrite-Variablen zeigt es eine Fehlermeldung, das ist in Ordnung).
|
||||||
|
- Schriftvarianten sind über `?schrift=a` bzw. `?schrift=b` erreichbar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 12. Arbeitsweise und Livegang
|
||||||
|
- **Zweige:** Gearbeitet wird auf Zweigen in diesem Repository, zusammengeführt per Pull Request nach `main` dieses Repos. **`main` hier ist nicht live.**
|
||||||
|
- **Pull Requests** enthalten Screenshots der geänderten Abschnitte: Desktop und Handy, hell und dunkel.
|
||||||
|
- **Geheimnisse:** Keine einchecken. `.env` bleibt lokal; `.env.example` enthält nur öffentliche Client-IDs.
|
||||||
|
- **Livegang:**
|
||||||
|
- Nach Freigabe des Inhabers wird der Stand ins interne Live-Repository übertragen, danach werden alle Routen live geprüft.
|
||||||
|
- Die Übertragung macht eine Sitzung auf dem Server oder der Inhaber; sie gehört nicht zur Cloud-Arbeit.
|
||||||
|
- Bei der Übertragung dürfen Dateien, die es nur im Live-Repository gibt (Server-Doku, Vorschau-Konfiguration), nicht gelöscht werden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 13. Nicht im Umfang
|
||||||
|
- Blog, CMS, Mehrsprachigkeit.
|
||||||
|
- Analytics, Tracking und Cookie-Banner.
|
||||||
|
- Vorab-Rendering (SSR/SSG), `sitemap.xml`, strukturierte Daten und echte 404-Statuscodes. Das ist ein eigenes Folgeprojekt; der Code soll dafür aber nichts verbauen.
|
||||||
|
- Änderungen am Kundenportal selbst, z. B. eine einheitliche Anrede oder der Mehrwertsteuer-Hinweis bei Funktionen.
|
||||||
|
- Änderungen an Server, Deploy und Kontakt-Backend.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 14. Abnahme
|
||||||
|
|
||||||
|
**Phase 1 (Entwurf) ist fertig, wenn:**
|
||||||
|
- auf dem Zweig `relaunch` laufen:
|
||||||
|
- die neue Kopfleiste,
|
||||||
|
- alle Abschnitte aus 6.2 außer „Projekte“,
|
||||||
|
- der neue Footer;
|
||||||
|
- der Hero mit wechselnder Überschrift, gezeichneter Karte und Pause-Knopf funktioniert, ebenso die Wisch-Reihen auf dem Handy und die Portal-Bühne auf dem Desktop;
|
||||||
|
- beide Schriftvarianten umschaltbar sind;
|
||||||
|
- die Vorschau (O10) erreichbar ist und Screenshots vorliegen: 1440 × 900 und 390 × 844, hell und dunkel, je Schriftvariante;
|
||||||
|
- `npm test` und `npm run build` grün sind.
|
||||||
|
|
||||||
|
**Phase 2 ist fertig, wenn:**
|
||||||
|
- die Erfolgskriterien E2–E6 gemessen erfüllt sind;
|
||||||
|
- der Inhaber E1 und E7 bestätigt hat;
|
||||||
|
- alle Unterseiten das neue Layout haben und die Texte der Rechtsseiten unverändert sind;
|
||||||
|
- der Schriftumschalter entfernt ist und die gewählte Schrift fest eingebaut ist;
|
||||||
|
- Effekt-Komponenten, Analytics und tote Abhängigkeiten gelöscht sind;
|
||||||
|
- die Rückmeldungen des Inhabers eingearbeitet sind und er den Stand freigibt.
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
# Funktions-Demos im Abschnitt Kundenportal (2026-10-04)
|
||||||
|
|
||||||
|
Auftrag des Inhabers (2026-10-04, sinngemäß): In der Handy-Sektion (Kundenportal-Bühne) die eine Station
|
||||||
|
„Funktionen“ herausnehmen und stattdessen die einzelnen Funktionen zeigen, die WEBklar anbietet:
|
||||||
|
**Terminplaner**, **Website-Analyse (Heatmap)** und **News-Pinnwand**. Je Funktion eine erfundene, interessante
|
||||||
|
Kunden-Website, bei der die Funktion Sinn ergibt. Auf dem **Laptop die richtige Website groß**, auf dem **Handy
|
||||||
|
das Kundenportal** (die Einrichtung/Verwaltung). Die Heatmap soll „fancy“ wirken: simulierte Klicks, die Heatmap
|
||||||
|
baut sich auf; **beim Hovern** sieht man, wo viel geklickt wurde, **darunter eine kleine Diagnose**. Beim
|
||||||
|
Terminplaner soll ein **Kalender (Google Kalender)** mit den Einträgen im Bild sein. Bei der Pinnwand eine
|
||||||
|
Website wie eine Gemeinde/Stadtverwaltung – „irgendwas Interessantes“.
|
||||||
|
|
||||||
|
Dieses Dokument ergänzt `2026-10-01-relaunch-design.md` (Abschnitt 7.2 Kundenportal). Wo es abweicht, gilt es.
|
||||||
|
|
||||||
|
## 1. Stationen der Bühne (Reihenfolge)
|
||||||
|
|
||||||
|
1. Live-Vorschau · 2. Fortschritt · 3. Chat · 4. Rechnungen (unverändert)
|
||||||
|
5. **Terminplaner** · 6. **Website-Analyse** · 7. **News-Pinnwand** – Gruppe „Weitere Funktionen“ (Inhaber 2026-10-04: so nennen, Unterpunkte nur in Stichpunkten)
|
||||||
|
8. Als App (unverändert, bleibt die letzte Station mit dem langen Screenshot)
|
||||||
|
|
||||||
|
Texte (Sie-Form, keine Erfolgsversprechen, keine Zahlen ohne Quelle):
|
||||||
|
|
||||||
|
| Station | Titel | Stichpunkte |
|
||||||
|
|---|---|---|
|
||||||
|
| 5 | Terminplaner | Gäste buchen direkt auf Ihrer Website · Termine und Anfragen im Portal · Kalender-Abo, z. B. Google Kalender |
|
||||||
|
| 6 | Website-Analyse | Heatmap: wohin Besucher klicken · Aufrufe, Herkunft und Geräte · Im Wartungs-Abo enthalten |
|
||||||
|
| 7 | News-Pinnwand | Text und Foto, vom Handy aus · Hängt direkt auf Ihrer Website · Laufzeit je Zettel wählbar |
|
||||||
|
|
||||||
|
Bedienhinweis der Heatmap (nicht in den Stichpunkten, sondern im Hinweis unter dem MacBook bzw. in der Karte):
|
||||||
|
„Zeigen Sie mit der Maus auf die Seite.“ (Maus) / „Tippen Sie auf eine Stelle der Seite.“ (Touch).
|
||||||
|
|
||||||
|
Wahrheit der Aussagen (geprüft im Portal-Code und in den Spezifikationen):
|
||||||
|
- Terminplaner: Buchung im Widget, Gast bestätigt per Link in der E-Mail, Betrieb bekommt eine Mail
|
||||||
|
„Neue Buchung: {Leistung} am {Datum} um {Zeit}“ (K20), Termine stehen im Portal (Reiter Termine). Kalender:
|
||||||
|
**Kalender-Abo** je Person/Platz (geheimer .ics-Link), den man in Google Kalender, Apple Kalender oder Outlook
|
||||||
|
einfügt; Google aktualisiert nur alle paar Stunden. Es gibt **keinen** Zwei-Wege-Sync. Kalendereinträge für den
|
||||||
|
Betrieb heißen „{Leistung}: {Gastname}“.
|
||||||
|
- Website-Analyse: Heatmap der Klicks (Farbskala blau → grün → gelb → rot, wie `analytics/client/h.js`),
|
||||||
|
Seitenaufrufe, Besucher, Herkunft, Geräte, Kontakt-Klicks; nur mit Einwilligung; im Wartungs-Abo enthalten.
|
||||||
|
- News-Pinnwand: kurze Neuigkeiten mit Text und/oder Foto, „hängen wie Zettel an einer Pinnwand auf deiner Website“.
|
||||||
|
|
||||||
|
## 2. Erfundene Beispiel-Kunden
|
||||||
|
|
||||||
|
Alle drei sind erfunden und werden als Beispiel gekennzeichnet (kleiner Hinweis „Erfundenes Beispiel“ an der
|
||||||
|
Bühne). Adressen in der Safari-Leiste mit der reservierten Endung `.example` (RFC 2606), damit kein echter
|
||||||
|
Betrieb gemeint sein kann. Keine Fotos von Menschen, keine Stockfotos, keine fremden Logos: Bilder sind
|
||||||
|
selbst gezeichnete SVG/CSS-Grafiken. Keine Anfragen an fremde Server.
|
||||||
|
|
||||||
|
| Funktion | Beispiel | Adresse | Warum |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Terminplaner | **Salon Schnittstelle** – Friseur, drei Personen (Mia, Can, Lea) | `salon-schnittstelle.example` | Klassischer Fall: Leistung, Person, Zeit |
|
||||||
|
| Website-Analyse | **Boulderhalle Überhang** – Kletterhalle | `boulderhalle-ueberhang.example` | Viele Klickziele (Tageskarte, Live-Auslastung, Kurse, Preise, Öffnungszeiten, Routenfoto) |
|
||||||
|
| News-Pinnwand | **Ortsgemeinde Musterbach** – Gemeinde-Website | `musterbach.example` | Ständig Neues: Kerwe, Straßensperrung, Bücherzelle, Sprechstunde |
|
||||||
|
|
||||||
|
## 3. Aufbau je Station
|
||||||
|
|
||||||
|
Ab 900 px (Bühne): **MacBook** = die Website als Live-Komponente (keine Bilddatei), gesetzt in 1512 x 893,
|
||||||
|
der Rahmen verkleinert sie (bei 1440 px Fensterbreite ca. Faktor 0,41). Darum große Schrift (Fließtext
|
||||||
|
mindestens 22 Designpunkte, Überschriften 64+), große Flächen, wenig Kleinkram. **iPhone** = Kundenportal-Ansicht
|
||||||
|
der Funktion als Screenshot aus dem echten Portal-Code mit erfundenen Daten (Fixture-Server), Format wie die
|
||||||
|
übrigen Handy-Bilder (804 x 1748 @2x, ausgeliefert 390/780 px webp). Darüber dürfen kleine Live-Ebenen liegen
|
||||||
|
(Mitteilung, Tipp-Welle), die mit dem Ablauf der Website synchron laufen.
|
||||||
|
|
||||||
|
Unter 900 px (Wisch-Reihe): je Funktion eine Karte; im iPhone-Rahmen läuft die **Website** in ihrer
|
||||||
|
Handy-Fassung (402 x 874, oben 59 / unten 34 frei für Statusleiste und Home-Balken). Die Heatmap-Karte
|
||||||
|
reagiert auf Tippen (Zone antippen ⇒ Diagnose unter der Karte).
|
||||||
|
|
||||||
|
### 3.1 Terminplaner (Salon Schnittstelle)
|
||||||
|
- Website: Kopf mit Name/Navigation, große Überschrift, rechts das **Buchungs-Widget** im Stil des echten
|
||||||
|
Widgets (`kundenbereich…/build/public/embed/termine.js`, `termine-demo.html`): Leistung wählen → Person
|
||||||
|
(„Egal“ möglich) → Tag und freie Zeit → Daten → „Termin buchen“ → Hinweis „Bitte bestätigen Sie den Termin
|
||||||
|
über den Link in Ihrer E-Mail.“ (der Gast bestätigt per Link).
|
||||||
|
- Kalender-Fenster über der Website (rechts oben/unten, im Stil einer Kalender-App, **ohne Google-Logo und ohne
|
||||||
|
Google-Farben/-Schrift als Marke**): Wochenansicht mit 6–9 Einträgen „{Leistung}: {Gastname}“, Kopfzeile
|
||||||
|
„Kalender-Abo · Salon Schnittstelle – Mia“, kleiner Hinweis „z. B. in Google Kalender“. Nach der Buchung
|
||||||
|
erscheint der neue Eintrag (weich eingeblendet).
|
||||||
|
- Ablauf (ca. 9–11 s, Schleife): Zeiger wählt Leistung → Person → Zeit → klickt „Termin buchen“ → Bestätigung
|
||||||
|
→ Eintrag erscheint im Kalender; auf dem iPhone gleitet eine Mitteilung herein „Neue Buchung: Damenhaarschnitt
|
||||||
|
am Do., 8. Okt. um 14:30“ (Absender WEBklar Termine). Ohne Bewegung: Endzustand (gebucht, Eintrag da).
|
||||||
|
- iPhone: Portal-Ansicht Terminplaner, Reiter „Termine“ (Liste der Woche mit Status, ein Eintrag „angefragt“).
|
||||||
|
|
||||||
|
### 3.2 Website-Analyse (Boulderhalle Überhang)
|
||||||
|
- Website: Navigation (Routen, Kurse, Preise, Öffnungszeiten), Held „Heute 14 neue Boulder“ mit großem Knopf
|
||||||
|
„Tageskarte kaufen“, Kachel **Live-Auslastung** (z. B. 42 %), Karten Schnupperkurs / Preise / Öffnungszeiten,
|
||||||
|
ein großes **Routenbild** (SVG-Wand mit bunten Griffen) – das ist kein Link, wird aber oft angeklickt
|
||||||
|
(„toter Klick“), Telefonnummer im Kopf. 8–10 Zonen mit `data-zone`.
|
||||||
|
- Heatmap: Leinwand über der Website, Zeichnung mit `heat.ts` (gleicher Algorithmus wie das echte Overlay).
|
||||||
|
Klickpunkte sind Beispieldaten aus einer festen Saat (`zufall`), je Zone ein Gewicht; Summe z. B. 1.284 Klicks.
|
||||||
|
- Ablauf (Schleife): Zeiger fliegt Zonen an, Klickwelle, die Heatmap wird mit jedem Klick dichter (je Klick ein
|
||||||
|
Schub Punkte), nach ca. 10 s ist sie voll, bleibt ca. 6 s stehen, blendet aus, neu. Ohne Bewegung: volle Heatmap.
|
||||||
|
- **Hover (nur Maus)**: Sobald die Maus über der Website ist, stoppt der simulierte Zeiger, die Heatmap ist voll
|
||||||
|
sichtbar; die Zone unter der Maus bekommt einen Rahmen und ein Schildchen „Tageskarte kaufen · 402 Klicks“.
|
||||||
|
**Diagnose darunter** (unter dem MacBook, links neben dem iPhone, normaler Seitenmaßstab, Seitenfarben hell/dunkel):
|
||||||
|
Kopf „Diagnose · letzte 30 Tage · Beispieldaten“, Rangliste der 4 meistgeklickten Zonen mit Balken und Prozent,
|
||||||
|
dazu ein Satz zur gewählten Zone (ohne Hover: zur meistgeklickten). Beispiel-Sätze: „Am meisten geklickt:
|
||||||
|
‚Tageskarte kaufen‘ – 31 % aller Klicks.“ / „Auffällig: 12 % klicken auf das Routenbild. Es ist kein Link –
|
||||||
|
hier lohnt sich ein Link zur Routenliste.“ Keine Erfolgsversprechen („mehr Umsatz“ o. Ä. verboten).
|
||||||
|
- Tastatur/Screenreader: die Ranglisten-Zeilen sind Knöpfe; Fokus/Klick wählt die Zone (Rahmen auf der Website).
|
||||||
|
Die Website selbst ist `aria-hidden` und enthält keine fokussierbaren Elemente.
|
||||||
|
- Handy-Karte: die Website in Handy-Fassung mit Heatmap; Tippen auf eine Zone zeigt die Diagnose unter der Karte.
|
||||||
|
- iPhone (Bühne): Portal-Ansicht Analyse (Kennzahlen + Diagramm).
|
||||||
|
|
||||||
|
### 3.3 News-Pinnwand (Ortsgemeinde Musterbach)
|
||||||
|
- Website: Kopf mit erfundenem, schlichtem Wappen (SVG), Navigation (Rathaus, Leben in Musterbach,
|
||||||
|
Veranstaltungen, Pinnwand), Bereich „Neues aus Musterbach“ als **Pinnwand** (Kork o. ä. per CSS) mit 4–5
|
||||||
|
leicht gedrehten Zetteln im Stil der echten News-Pinnwand (`…/public/embed/news.js`, `news-demo.html`):
|
||||||
|
„Kerwe 2026: Freitag bis Montag auf dem Dorfplatz“, „Hauptstraße ab 13.10. halbseitig gesperrt (Kanalbau)“,
|
||||||
|
„Neue Bücherzelle am Spielplatz“, „Sprechstunde des Ortsbürgermeisters: donnerstags 17–19 Uhr“ usw.
|
||||||
|
Mindestens ein Zettel mit „Foto“ (gezeichnete Grafik, z. B. Kerwe-Karussell oder Bücherzelle).
|
||||||
|
- Ablauf (Schleife): auf dem iPhone (Portal-Screenshot „News-Pinnwand“ mit dem neuen Zettel) tippt eine
|
||||||
|
Tipp-Welle auf „Veröffentlichen“ (Koordinaten aus der Aufnahme), kurz danach fällt der neue Zettel mit Nadel
|
||||||
|
auf die Pinnwand der Website (transform/opacity), Plakette „Neu“. Ohne Bewegung: Zettel hängt schon.
|
||||||
|
- iPhone: Portal-Ansicht News-Pinnwand (Musterbach-Daten).
|
||||||
|
|
||||||
|
## 4. Technik
|
||||||
|
|
||||||
|
- Ordner `src/components/home/demos/`: `gemeinsam.tsx/.css` (Taktgeber `useAblauf`, `useDemoLauf`,
|
||||||
|
`useBewegungErlaubt`, `useFeinerZeiger`, `useImBild`, `DemoZeiger`, `zufall`, `zahl`), `heat.ts`
|
||||||
|
(Heatmap-Zeichnung wie h.js). Je Demo ein Unterordner `termin/`, `heatmap/`, `pinnwand/`.
|
||||||
|
- Schnittstelle je Demo (eine Quelle der Wahrheit für Website und Handy-Ebene):
|
||||||
|
- `use<Name>Ablauf(laeuft: boolean, statisch: boolean)` liefert den Zustand (Phase usw.).
|
||||||
|
- `<Name>Seite` (`{ zustand, variante: "desktop" | "handy" }`) = die Website auf der Fläche.
|
||||||
|
- optional `<Name>Handy` (`{ zustand }`) = Live-Ebene über dem Portal-Screenshot im iPhone.
|
||||||
|
- Heatmap zusätzlich `HeatmapDiagnose` (`{ zustand }`) und Auswahl/Hover im Zustand.
|
||||||
|
- `laeuft` = Station aktiv **und** Bühne/Karte im Bild **und** Tab sichtbar **und** Bewegung erlaubt.
|
||||||
|
Nichts läuft im Hintergrund (Lehre aus dem Leistungs-Audit 2026-09-25).
|
||||||
|
- Bewegung nur `transform`/`opacity` hinter `prefers-reduced-motion: no-preference`; Ausnahme (Auftrag des
|
||||||
|
Inhabers, ergänzt 8.4): das Neuzeichnen der Heatmap-Leinwand (nur bei Klicks, nicht je Frame).
|
||||||
|
- Hover-Effekte nur bei `(hover: hover) and (pointer: fine)`.
|
||||||
|
- Die Demos werden erst geladen, wenn die Bühne die Station fast erreicht (wie die Bilder, `eingehaengt`),
|
||||||
|
möglichst als eigener Chunk (`React.lazy`), damit die Startseite nicht schwerer wird.
|
||||||
|
- Inaktive Ebenen der Bühne bekommen `pointer-events: none`, damit nur die sichtbare Website auf die Maus reagiert.
|
||||||
|
- Tests: Vitest je Demo (Rendern Desktop/Handy, statischer Endzustand ohne Bewegung, Diagnose-Logik,
|
||||||
|
keine fokussierbaren Elemente in der Website); `relaunch-kundenportal.test.tsx` auf 8 Stationen angepasst.
|
||||||
|
`sofort-paket.test.tsx` (Scans gegen verbotene Aussagen) bleibt grün.
|
||||||
|
|
||||||
|
## 5. Offen für den Inhaber
|
||||||
|
|
||||||
|
- Terminplaner ist erst im Pilotbetrieb (TP2 „Verkauf“ noch nicht live). Vor dem Livegang des Relaunchs:
|
||||||
|
entweder TP2 live oder die Station kennzeichnen („bald buchbar“).
|
||||||
|
- Namen der Beispiel-Kunden und ihre Texte sind Vorschläge.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# Kundenportal-Bühne: Live-Vorschau, Chat-Demo und weiches Zuklappen
|
||||||
|
|
||||||
|
Stand 2026-10-04. Wunsch des Inhabers (diktiert): Das Durchscrollen der Bühne von „Live-Vorschau“ bis „Als App“ soll schöner werden.
|
||||||
|
|
||||||
|
1. Die Testseite in der Live-Vorschau wirkt primitiv. Sie soll wie eine richtige Website aussehen, auch übergreifend im Chat.
|
||||||
|
2. Beim Herausscrollen aus „Funktionen“ soll das Zuklappen der Unterpunkte schöner sein.
|
||||||
|
3. Der Chat bekommt eine erklärende Animation: Nachrichten ploppen auf, es wird hineingezoomt, damit man sie lesen kann.
|
||||||
|
Die Texte sollen spielerisch sein, nicht kalt.
|
||||||
|
|
||||||
|
## 1. Live-Vorschau
|
||||||
|
|
||||||
|
- Im MacBook steht jetzt die Beispiel-Website selbst (`demos/baeckerei/BaeckereiSeite.tsx`), nicht mehr die Portal-Aufnahme.
|
||||||
|
Die Adresse in der Safari-Leiste ist `baeckerei-sonnenschein.project.webklar.com`, also dieselbe wie im Portal.
|
||||||
|
- Die Seite ist eine ganze Website: Leiste, Kopf, Startbereich („Frisch gebacken. Jeden Morgen.“), Band, Sortiment mit vier
|
||||||
|
Karten, Backstube, Torten, Öffnungszeiten mit Stadtplan und Fuß. Die Schrift ist Instrument Serif (selbst gehostet, OFL)
|
||||||
|
zusammen mit Instrument Sans.
|
||||||
|
- Die Fotos stammen von Wikimedia Commons und stehen unter CC0. Sie zeigen nur Backwaren, keine Menschen.
|
||||||
|
Quellen stehen in `docs/relaunch/assets/baeckerei/QUELLEN.md`, das Skript ist `scripts/relaunch-baeckerei-fotos.py`.
|
||||||
|
- Scroll-Erlebnis: Die Station ist länger (`--kp-l-vorschau: 110svh`). Beim Scrollen durch die Station läuft die Website im
|
||||||
|
MacBook einmal von oben nach unten durch. Die Bühne schreibt dafür `--kp-v` (0 bis 1); bewegt wird nur `transform`.
|
||||||
|
Bei reduzierter Bewegung steht die Website oben, und die Station ist kurz.
|
||||||
|
- Das iPhone zeigt die Projektübersicht des Portals. Die Aufnahme ist neu und zeigt dieselbe Website im Vorschaukasten.
|
||||||
|
Das gilt auch für die lange Aufnahme in „Als App“. Aufgenommen wurde mit der Fixture des Portals; `demo-site.html` wird mit
|
||||||
|
`gen-baeckerei.mjs` aus der Komponente erzeugt.
|
||||||
|
- Hinweiskarte: „Geschützt: Ihre Vorschau öffnet sich nur nach der Anmeldung im Kundenportal – Fremde sehen nichts.“
|
||||||
|
Das stimmt: `/api/auth/preview-access` lässt nur Kundensitzungen des Projekts und das Team durch.
|
||||||
|
|
||||||
|
## 2. Chat-Demo
|
||||||
|
|
||||||
|
- Im MacBook ist der Portal-Chat zu sehen. Darunter liegt die Aufnahme des leeren Chats (`desktop-3c-chat-leer`, auch in
|
||||||
|
2400 px, damit sie beim Zoomen scharf bleibt). Darüber liegt eine Live-Ebene (`demos/chat/`) mit den Blasen, dem
|
||||||
|
Eingabefeld, dem Anhang, der Sprachaufnahme, dem Senden-Knopf und einem Zeiger. Formen und Farben sind wie im Portal.
|
||||||
|
- Ablauf (etwa 28 s, Schleife): Zuerst die Übersicht, dann zoomt die Kamera auf das Gespräch (1,8-fach, nur `transform`).
|
||||||
|
Marlene hängt ein Foto an und tippt Zeichen für Zeichen. Danach wird gesendet, Kenso antwortet, und Marlene schickt eine
|
||||||
|
Sprachnachricht. Es folgen noch eine Antwort, eine Nachricht von Marlene und die letzte Antwort. Dann zoomt die Kamera
|
||||||
|
heraus, und die Nachrichten blenden aus.
|
||||||
|
Neue Blasen ploppen aus ihrer Ecke auf und federn leicht nach. Die älteren Blasen rutschen weich nach oben (FLIP).
|
||||||
|
- Texte (Sie-Form vom Team, warm und spielerisch):
|
||||||
|
- Marlene: „Guten Morgen! ☀️ Unser neues Roggenbrot ist gerade aus dem Ofen. Darf das auf die Startseite?“ (mit Foto)
|
||||||
|
- Kenso: „Und wie das darf! 😍 Das Foto ist so knusprig, ich rieche es bis hierher.“
|
||||||
|
- Marlene: Sprachnachricht 0:12
|
||||||
|
- Kenso: „Alles notiert: Brot ganz nach oben, Preis dazu, Öffnungszeiten etwas größer. Schauen Sie mal in die Vorschau! 🥖“
|
||||||
|
- Marlene: „Sieht super aus! Morgen gibt’s Croissants fürs ganze Team. 🥐“
|
||||||
|
- Kenso: „Dann stellen wir schon mal den Kaffee an. ☕“
|
||||||
|
- Das Foto im Chat ist dasselbe Roggenbrot wie auf der Website, denn die Geschichte geht über beide Stationen.
|
||||||
|
- Die Uhrzeiten (07:52 bis 10:31) versprechen keine Sofort-Antwort. Ein Tippen-Hinweis des Teams wird nicht gezeigt, weil
|
||||||
|
das Portal keinen hat.
|
||||||
|
- In der Station Chat gleitet das iPhone nach rechts unten weg. Sonst würde es die eigenen Nachrichten und den
|
||||||
|
Senden-Knopf verdecken.
|
||||||
|
- Hinweiskarte: „Antwortet das Team, kommt zusätzlich eine E-Mail – so verpassen Sie keine Nachricht.“
|
||||||
|
Das stimmt: Bei jeder Team-Nachricht geht die Mail K17 hinaus, gedrosselt.
|
||||||
|
- In der Wisch-Reihe auf dem Handy läuft dasselbe Gespräch über der Handy-Aufnahme des leeren Chats, dort ohne Zoom.
|
||||||
|
- Bei reduzierter Bewegung steht sofort der Endzustand da: alle Nachrichten, hineingezoomt.
|
||||||
|
|
||||||
|
## 3. Weiches Auf- und Zuklappen von „Funktionen“
|
||||||
|
|
||||||
|
- Zustand `zu`, `offen` oder `schliesst`. Beim Verlassen der drei Funktionen falten sich die Unterpunkte von unten nach
|
||||||
|
oben ein, und die Linie links zieht sich nach oben zurück. Das dauert etwa 260 ms. Danach verschwindet die Unterliste.
|
||||||
|
- „Als App“ springt dabei nicht, sondern gleitet per FLIP von der alten an die neue Stelle (Web Animations API, nur
|
||||||
|
`transform`). Beim Aufklappen gleitet es genauso nach unten, und die Unterpunkte gleiten nacheinander herein.
|
||||||
|
- Ein kleiner Pfeil hinter „Funktionen“ zeigt den Zustand: nach rechts heißt zu, nach unten heißt offen.
|
||||||
|
- Für Screenreader ist die Gruppe sofort zu (`aria-expanded="false"`, die Unterliste ist während des Einfaltens
|
||||||
|
`aria-hidden`). Bei reduzierter Bewegung klappt die Gruppe sofort zu.
|
||||||
@@ -25,18 +25,18 @@
|
|||||||
</script>
|
</script>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>WEBklar – Webagentur für KMU | Digitalisierung & Automatisierung</title>
|
<title>WEBklar – Websites, Shops und Apps für kleine Betriebe</title>
|
||||||
<meta
|
<meta
|
||||||
name="description"
|
name="description"
|
||||||
content="WEBklar digitalisiert KMU: Website, Prozesse und Systeme aus einer Hand. Kostenlose Potenzialanalyse – Antwort innerhalb von 24 Stunden."
|
content="WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website."
|
||||||
/>
|
/>
|
||||||
<meta name="author" content="WEBklar" />
|
<meta name="author" content="WEBklar" />
|
||||||
<link rel="canonical" href="https://webklar.com/" />
|
<link rel="canonical" href="https://webklar.com/" />
|
||||||
|
|
||||||
<meta property="og:title" content="WEBklar – Webagentur für KMU" />
|
<meta property="og:title" content="WEBklar – Websites, Shops und Apps für kleine Betriebe" />
|
||||||
<meta
|
<meta
|
||||||
property="og:description"
|
property="og:description"
|
||||||
content="Website, Automatisierung und vernetzte Prozesse für wachsende Unternehmen – ohne Mehraufwand im Alltag."
|
content="WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website."
|
||||||
/>
|
/>
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
<meta property="og:url" content="https://webklar.com/" />
|
<meta property="og:url" content="https://webklar.com/" />
|
||||||
@@ -45,10 +45,10 @@
|
|||||||
|
|
||||||
<meta name="twitter:card" content="summary_large_image" />
|
<meta name="twitter:card" content="summary_large_image" />
|
||||||
<meta name="twitter:site" content="@WEBklar" />
|
<meta name="twitter:site" content="@WEBklar" />
|
||||||
<meta name="twitter:title" content="WEBklar – Webagentur für KMU" />
|
<meta name="twitter:title" content="WEBklar – Websites, Shops und Apps für kleine Betriebe" />
|
||||||
<meta
|
<meta
|
||||||
name="twitter:description"
|
name="twitter:description"
|
||||||
content="Digitalisierung für KMU: Website, Prozesse und Systeme. Kostenlose Potenzialanalyse."
|
content="WEBklar baut Websites, Online-Shops und Web-Apps. Im Kundenportal sehen Sie jeden Schritt, mit Live-Vorschau Ihrer Website."
|
||||||
/>
|
/>
|
||||||
<meta name="twitter:image" content="https://webklar.com/og-image.png" />
|
<meta name="twitter:image" content="https://webklar.com/og-image.png" />
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans) InstrumentSans-Italic[wdth,wght].ttf: Copyright 2022 The Instrument Sans Project Authors (https://github.com/Instrument/instrument-sans)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright 2022 The Instrument Serif Project Authors (https://github.com/Instrument/instrument-serif)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 148 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,94 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Relaunch: Fotos der erfundenen Beispiel-Website „Bäckerei Sonnenschein“ (Station Live-Vorschau und Chat-Demo).
|
||||||
|
|
||||||
|
Alle Fotos stammen von Wikimedia Commons und stehen unter CC0 (gemeinfrei, keine Namensnennung nötig).
|
||||||
|
Es sind nur Backwaren zu sehen, keine Menschen. Quellen: docs/relaunch/assets/baeckerei/QUELLEN.md.
|
||||||
|
|
||||||
|
Wikimedia Commons (einmalig, Zwischenspeicher docs/relaunch/assets/baeckerei/roh/, nicht im Repository)
|
||||||
|
-> public/relaunch/demos/baeckerei/<name>-<breite>.webp
|
||||||
|
|
||||||
|
Aufruf (aus dem Projektverzeichnis): python3 scripts/relaunch-baeckerei-fotos.py
|
||||||
|
Braucht Python 3 und Pillow mit WebP-Unterstützung.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
WURZEL = Path(__file__).resolve().parent.parent
|
||||||
|
ROH = WURZEL / "docs" / "relaunch" / "assets" / "baeckerei" / "roh"
|
||||||
|
ZIEL = WURZEL / "public" / "relaunch" / "demos" / "baeckerei"
|
||||||
|
KENNUNG = {"User-Agent": "WEBklar-relaunch/1.0 (support@webklar.com)"}
|
||||||
|
|
||||||
|
KB = 1024
|
||||||
|
QUALITAET = 78
|
||||||
|
MIN_QUALITAET = 50
|
||||||
|
|
||||||
|
# Name -> (Datei auf Commons, Ausschnitt als Anteile (links, oben, rechts, unten), Zielbreiten, Seitenverhältnis B/H, Budget je Datei)
|
||||||
|
FOTOS = {
|
||||||
|
# Startseite und Chat-Foto: dasselbe Roggenbrot (Marlene schickt es im Chat, danach steht es auf der Website)
|
||||||
|
"roggenbrot": ("Rye bread Roggenbengel 2025-02-14 13.jpg", (0.0, 0.0, 1.0, 1.0), (600,), 1.08, 58 * KB),
|
||||||
|
"roggenbrot-anschnitt": ("Cut rye bread Kartoffelkruste 2025-02-20 02.jpg", (0.12, 0.12, 0.92, 0.95), (400,), 0.8, 30 * KB),
|
||||||
|
"broetchen": ("Bread-rolls.jpg", (0.0, 0.0, 1.0, 1.0), (400,), 0.8, 30 * KB),
|
||||||
|
"croissants": ("Freshippo croissant bread.jpg", (0.05, 0.0, 0.95, 1.0), (400,), 0.8, 34 * KB),
|
||||||
|
"zimtschnecken": ("IKEA cinnamon bun.jpg", (0.05, 0.0, 0.95, 1.0), (400,), 0.8, 30 * KB),
|
||||||
|
"baguettes": ("Baguette-246424.jpg", (0.0, 0.0, 1.0, 1.0), (700,), 1.2, 40 * KB),
|
||||||
|
"zwetschgenkuchen": ("Plum cake, Ehrenbach.jpg", (0.18, 0.08, 1.0, 1.0), (520,), 1.0, 50 * KB),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def lade(datei: str) -> Path:
|
||||||
|
"""Lädt das Foto einmal in 2000 px Breite von Commons (Special:FilePath) und hält es im Zwischenspeicher."""
|
||||||
|
ROH.mkdir(parents=True, exist_ok=True)
|
||||||
|
ziel = ROH / datei.replace(" ", "_")
|
||||||
|
if not ziel.exists():
|
||||||
|
url = "https://commons.wikimedia.org/w/index.php?title=Special:FilePath/" + urllib.parse.quote(datei) + "&width=2000"
|
||||||
|
with urllib.request.urlopen(urllib.request.Request(url, headers=KENNUNG), timeout=60) as antwort:
|
||||||
|
ziel.write_bytes(antwort.read())
|
||||||
|
return ziel
|
||||||
|
|
||||||
|
|
||||||
|
def zuschneiden(bild: Image.Image, anteile: tuple, verhaeltnis: float) -> Image.Image:
|
||||||
|
"""Schneidet erst den Ausschnitt aus, dann mittig auf das Seitenverhältnis."""
|
||||||
|
b, h = bild.size
|
||||||
|
l, o, r, u = anteile
|
||||||
|
bild = bild.crop((round(l * b), round(o * h), round(r * b), round(u * h)))
|
||||||
|
b, h = bild.size
|
||||||
|
if b / h > verhaeltnis:
|
||||||
|
neu = round(h * verhaeltnis)
|
||||||
|
return bild.crop(((b - neu) // 2, 0, (b - neu) // 2 + neu, h))
|
||||||
|
neu = round(b / verhaeltnis)
|
||||||
|
return bild.crop((0, (h - neu) // 2, b, (h - neu) // 2 + neu))
|
||||||
|
|
||||||
|
|
||||||
|
def speichern(bild: Image.Image, ziel: Path, budget: int) -> tuple[int, int]:
|
||||||
|
qualitaet = QUALITAET
|
||||||
|
while True:
|
||||||
|
bild.save(ziel, "WEBP", quality=qualitaet, method=6)
|
||||||
|
groesse = ziel.stat().st_size
|
||||||
|
if groesse <= budget or qualitaet - 4 < MIN_QUALITAET:
|
||||||
|
return groesse, qualitaet
|
||||||
|
qualitaet -= 4
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ZIEL.mkdir(parents=True, exist_ok=True)
|
||||||
|
for alt in ZIEL.glob("*.webp"):
|
||||||
|
alt.unlink()
|
||||||
|
summe = 0
|
||||||
|
for name, (datei, anteile, breiten, verhaeltnis, budget) in FOTOS.items():
|
||||||
|
roh = zuschneiden(Image.open(lade(datei)).convert("RGB"), anteile, verhaeltnis)
|
||||||
|
for breite in breiten:
|
||||||
|
klein = roh.resize((breite, round(breite / verhaeltnis)), Image.LANCZOS)
|
||||||
|
groesse, q = speichern(klein, ZIEL / f"{name}-{breite}.webp", budget)
|
||||||
|
summe += groesse
|
||||||
|
print(f"{name}-{breite}.webp {klein.size[0]}x{klein.size[1]} {groesse / KB:.1f} KB q{q}")
|
||||||
|
print(f"Summe: {summe / KB:.0f} KB")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Relaunch: verkleinert die Rohexporte der Kundenportal-Screenshots für die Website.
|
||||||
|
|
||||||
|
docs/relaunch/assets/portal/*.webp (Rohdateien, doppelte Auflösung)
|
||||||
|
-> public/relaunch/portal/<name>-<breite>.webp
|
||||||
|
|
||||||
|
Aufruf (aus dem Projektverzeichnis): python3 scripts/relaunch-bilder.py [NAME ...]
|
||||||
|
Ohne Argument werden alle Bilder neu geschrieben (alte Ausgaben vorher entfernt). Mit einem oder mehreren NAME-Filtern
|
||||||
|
(Dateiname ohne .webp, auch als Anfang, z. B. "phone-9" oder "phone-10-analyse") entstehen nur die passenden Bilder;
|
||||||
|
alle anderen Dateien in public/relaunch/portal bleiben unangetastet.
|
||||||
|
|
||||||
|
Nur die Bilder, die der Abschnitt "Kundenportal" (Spezifikation 7.2) wirklich zeigt, landen in public/.
|
||||||
|
Format und Budgets laut Spezifikation 9.5:
|
||||||
|
- WebP, Qualität etwa 80, Desktop-Bilder in 800 / 1200 / 1600 px Breite, Handy-Bilder in 390 / 780 px
|
||||||
|
- ein einzelnes Desktop-Bild höchstens 120 KB, ein einzelnes Handy-Bild höchstens 60 KB
|
||||||
|
(der lange Screenshot hat ein eigenes, größeres Budget, weil er mehr als dreimal so hoch ist)
|
||||||
|
- wird ein Budget überschritten, sinkt die Qualität pro Datei schrittweise (nicht unter MIN_QUALITAET)
|
||||||
|
Braucht Python 3 und Pillow mit WebP-Unterstützung.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
WURZEL = Path(__file__).resolve().parent.parent
|
||||||
|
QUELLE = WURZEL / "docs" / "relaunch" / "assets" / "portal"
|
||||||
|
ZIEL = WURZEL / "public" / "relaunch" / "portal"
|
||||||
|
|
||||||
|
QUALITAET = 80
|
||||||
|
MIN_QUALITAET = 46
|
||||||
|
SCHRITT = 4
|
||||||
|
|
||||||
|
KB = 1024
|
||||||
|
BUDGET_DESKTOP = 120 * KB
|
||||||
|
BUDGET_HANDY = 60 * KB
|
||||||
|
# Der lange Screenshot ist gut dreimal so hoch wie ein Handy-Bild (5131 statt 1748 px). Sein Budget
|
||||||
|
# wächst mit der Höhe (je Bildschirmhöhe weiterhin unter 60 KB), sonst wäre der Text nur noch Matsch.
|
||||||
|
BUDGET_LANG = {390: 80 * KB, 780: 150 * KB}
|
||||||
|
# Unter der letzten Inhaltszeile (Zeile 5091) ist der Rohexport nur noch schwarz. Abgeschnitten wird
|
||||||
|
# 40 px darunter, sonst endet die Verschiebung im Handy-Rahmen auf einem leeren Streifen.
|
||||||
|
LANG_HOEHE = 5131
|
||||||
|
|
||||||
|
# Dateiname (ohne .webp) -> (Art, Zielbreiten)
|
||||||
|
BILDER = {
|
||||||
|
# Desktop-Fenster (Roh 3024 x 1786 = MacBook-Safari-Fläche 1512 x 893 @2x); die Live-Vorschau zeigt im MacBook die
|
||||||
|
# Beispiel-Website selbst (BaeckereiSeite), kein Bild
|
||||||
|
"desktop-2-fortschritt": ("desktop", (800, 1200, 1600)),
|
||||||
|
# Chat-Demo (Station Chat): leerer Chat als Kulisse, die Nachrichten sind live; 2400 px, weil die Kamera hineinzoomt
|
||||||
|
"desktop-3c-chat-leer": ("desktop", (1600, 2400)),
|
||||||
|
"desktop-4-rechnungen": ("desktop", (800, 1200, 1600)),
|
||||||
|
# Handy (Roh 804 x 1748 = iPhone 16 Pro @2x, Safe Area 59/34; das lange Bild 804 x 5340, beschnitten auf 5131)
|
||||||
|
"phone-1-projekte": ("handy", (390, 780)),
|
||||||
|
"phone-3-projektdetails": ("handy", (390, 780)),
|
||||||
|
"phone-4c-chat-leer": ("handy", (390, 780)),
|
||||||
|
"phone-5-rechnungen": ("handy", (390, 780)),
|
||||||
|
# Funktions-Stationen (Spezifikation 2026-10-04-funktionen-demos.md): Handy-Ansicht der Funktion im Kundenportal
|
||||||
|
"phone-9-termine": ("handy", (390, 780)),
|
||||||
|
"phone-10-analyse": ("handy", (390, 780)),
|
||||||
|
"phone-11-news-musterbach": ("handy", (390, 780)),
|
||||||
|
# langer Screenshot: behält sein hohes Seitenverhältnis (eigenes Budget, siehe BUDGET_LANG)
|
||||||
|
"phone-2-projekte-lang": ("handy", (390, 780)),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def speichern(bild: Image.Image, ziel: Path, budget: int) -> tuple[int, int]:
|
||||||
|
"""Speichert als WebP; senkt die Qualität, bis das Budget passt. Gibt (Bytes, Qualität) zurück."""
|
||||||
|
qualitaet = QUALITAET
|
||||||
|
while True:
|
||||||
|
bild.save(ziel, "WEBP", quality=qualitaet, method=6)
|
||||||
|
groesse = ziel.stat().st_size
|
||||||
|
if groesse <= budget or qualitaet - SCHRITT < MIN_QUALITAET:
|
||||||
|
return groesse, qualitaet
|
||||||
|
qualitaet -= SCHRITT
|
||||||
|
|
||||||
|
|
||||||
|
def waehle(filter_: list[str]) -> dict:
|
||||||
|
"""Alle Bilder ohne Filter, sonst die, deren Name genau so heißt oder mit einem Filter beginnt (z. B. "phone-9")."""
|
||||||
|
if not filter_:
|
||||||
|
return BILDER
|
||||||
|
# "phone-1" soll nicht auch "phone-10-analyse" treffen: Anfang nur bis zum nächsten Bindestrich-Segment
|
||||||
|
treffer = {
|
||||||
|
name: wert for name, wert in BILDER.items()
|
||||||
|
if any(name == f or name.startswith(f + "-") for f in filter_)
|
||||||
|
}
|
||||||
|
unbekannt = [f for f in filter_ if not any(n == f or n.startswith(f + "-") for n in BILDER)]
|
||||||
|
if unbekannt:
|
||||||
|
raise SystemExit(f"Unbekannter Bildname: {', '.join(unbekannt)}")
|
||||||
|
return treffer
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str]) -> int:
|
||||||
|
ZIEL.mkdir(parents=True, exist_ok=True)
|
||||||
|
bilder = waehle(argv)
|
||||||
|
if not argv:
|
||||||
|
# alte Ausgaben entfernen, damit nichts Verwaistes liegen bleibt (nur bei einem Lauf über alle Bilder)
|
||||||
|
for alt in ZIEL.glob("*.webp"):
|
||||||
|
alt.unlink()
|
||||||
|
|
||||||
|
fehler = 0
|
||||||
|
summe = 0
|
||||||
|
print(f"{'Datei':44} {'Pixel':>11} {'KB':>7} {'Q':>3} Budget")
|
||||||
|
for name, (art, breiten) in bilder.items():
|
||||||
|
quelle = QUELLE / f"{name}.webp"
|
||||||
|
if not quelle.exists():
|
||||||
|
print(f"FEHLT: {quelle}", file=sys.stderr)
|
||||||
|
fehler += 1
|
||||||
|
continue
|
||||||
|
roh = Image.open(quelle).convert("RGB")
|
||||||
|
if name.endswith("-lang"):
|
||||||
|
roh = roh.crop((0, 0, roh.width, LANG_HOEHE))
|
||||||
|
for breite in breiten:
|
||||||
|
if name.endswith("-lang"):
|
||||||
|
budget = BUDGET_LANG[breite]
|
||||||
|
else:
|
||||||
|
budget = BUDGET_DESKTOP if art == "desktop" else BUDGET_HANDY
|
||||||
|
if breite > roh.width:
|
||||||
|
print(f"übersprungen: {name} {breite} (Rohbild nur {roh.width} px breit)")
|
||||||
|
continue
|
||||||
|
hoehe = round(roh.height * breite / roh.width)
|
||||||
|
klein = roh if breite == roh.width else roh.resize((breite, hoehe), Image.LANCZOS)
|
||||||
|
datei = ZIEL / f"{name}-{breite}.webp"
|
||||||
|
groesse, qualitaet = speichern(klein, datei, budget)
|
||||||
|
summe += groesse
|
||||||
|
ok = "ok" if groesse <= budget else f"ÜBER {budget // KB} KB"
|
||||||
|
print(f"{datei.name:44} {breite:>5}x{hoehe:<5} {groesse / KB:7.1f} {qualitaet:>3} {ok}")
|
||||||
|
print(f"Summe aller Dateien: {summe / KB:.0f} KB")
|
||||||
|
return 1 if fehler else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
@@ -2,20 +2,51 @@ import { Toaster } from "@/components/ui/toaster";
|
|||||||
import { Toaster as Sonner } from "@/components/ui/sonner";
|
import { Toaster as Sonner } from "@/components/ui/sonner";
|
||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
import { useEffect } from "react";
|
||||||
|
import { BrowserRouter, useLocation } from "react-router-dom";
|
||||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
import { AnalyticsProvider } from "@/components/AnalyticsProvider";
|
||||||
import Index from "./pages/Index";
|
import { AppRoutes } from "./routes";
|
||||||
import ContactPage from "./pages/Contact";
|
|
||||||
import AGBPage from "./pages/AGB";
|
|
||||||
import ImpressumPage from "./pages/Impressum";
|
|
||||||
import AboutPage from "./pages/About";
|
|
||||||
import WartungsAboPage from "./pages/WartungsAbo";
|
|
||||||
import DatenschutzPage from "./pages/Datenschutz";
|
|
||||||
import NotFound from "./pages/NotFound";
|
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrollt bei einem Seitenwechsel nach oben. Mit Anker (URL mit #) springt es zum Abschnitt,
|
||||||
|
* sobald er gerendert ist, z. B. beim Footer-Link „/#services“ von einer Unterseite aus.
|
||||||
|
*/
|
||||||
|
export const ScrollToTop = () => {
|
||||||
|
const { pathname, hash } = useLocation();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hash) {
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = decodeURIComponent(hash.slice(1));
|
||||||
|
let versuche = 0;
|
||||||
|
let frame = 0;
|
||||||
|
let abgebrochen = false;
|
||||||
|
const springe = () => {
|
||||||
|
const ziel = document.getElementById(id);
|
||||||
|
if (!ziel) {
|
||||||
|
if (versuche++ < 60) frame = requestAnimationFrame(springe); // höchstens ca. 1 s warten
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Erst springen, wenn die Schriften geladen sind: Vorher ist der Text darüber höher,
|
||||||
|
// und der Abschnitt landet nach dem Nachladen ein Stück zu weit oben.
|
||||||
|
const schriften = document.fonts?.ready ?? Promise.resolve();
|
||||||
|
schriften.then(() => {
|
||||||
|
if (!abgebrochen) ziel.scrollIntoView();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
springe();
|
||||||
|
return () => {
|
||||||
|
abgebrochen = true;
|
||||||
|
cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [pathname, hash]);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
const App = () => (
|
const App = () => (
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
@@ -23,18 +54,9 @@ const App = () => (
|
|||||||
<Toaster />
|
<Toaster />
|
||||||
<Sonner />
|
<Sonner />
|
||||||
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
<BrowserRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<ScrollToTop />
|
||||||
<AnalyticsProvider>
|
<AnalyticsProvider>
|
||||||
<Routes>
|
<AppRoutes />
|
||||||
<Route path="/" element={<Index />} />
|
|
||||||
<Route path="/kontakt" element={<ContactPage />} />
|
|
||||||
<Route path="/agb" element={<AGBPage />} />
|
|
||||||
<Route path="/impressum" element={<ImpressumPage />} />
|
|
||||||
<Route path="/ueber-uns" element={<AboutPage />} />
|
|
||||||
<Route path="/wartungs-abo" element={<WartungsAboPage />} />
|
|
||||||
<Route path="/datenschutz" element={<DatenschutzPage />} />
|
|
||||||
{/* ADD ALL CUSTOM ROUTES ABOVE THE CATCH-ALL "*" ROUTE */}
|
|
||||||
<Route path="*" element={<NotFound />} />
|
|
||||||
</Routes>
|
|
||||||
</AnalyticsProvider>
|
</AnalyticsProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
const AgitationSection = () => {
|
|
||||||
return (
|
|
||||||
<section className="py-24 md:py-32 bg-secondary/30 relative overflow-hidden">
|
|
||||||
{/* Decorative element */}
|
|
||||||
<div className="absolute inset-0 opacity-5">
|
|
||||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[800px] rounded-full border border-foreground" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="container mx-auto px-6 relative z-10">
|
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
|
||||||
<div className="label-tag mb-4">Die Realität</div>
|
|
||||||
|
|
||||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-12">
|
|
||||||
Mehr Arbeit darf nicht die einzige Antwort auf Wachstum sein.
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="space-y-8 text-lg md:text-xl text-muted-foreground leading-relaxed">
|
|
||||||
<p>
|
|
||||||
Neue Kunden sollten Ihr Unternehmen nicht ins Chaos stürzen.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<p>
|
|
||||||
Wenn mehr Umsatz automatisch mehr Stress bedeutet, liegt das Problem nicht an Ihrem Markt – <span className="text-foreground font-medium">sondern an fehlenden Systemen.</span>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="pt-8 border-t border-border">
|
|
||||||
<p className="text-2xl md:text-3xl text-foreground font-display font-medium uppercase tracking-tight">
|
|
||||||
Erfolgreiche Unternehmen skalieren Prozesse.
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl md:text-3xl text-muted-foreground font-display font-medium uppercase tracking-tight mt-2">
|
|
||||||
Nicht Arbeitsstunden.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="text-foreground font-medium pt-4">
|
|
||||||
Genau dort setzen wir an.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AgitationSection;
|
|
||||||
@@ -6,6 +6,8 @@
|
|||||||
--border-radius: 28px;
|
--border-radius: 28px;
|
||||||
--glow-padding: 40px;
|
--glow-padding: 40px;
|
||||||
--cone-spread: 25;
|
--cone-spread: 25;
|
||||||
|
/* Kartenfarbe nach Theme: --card-bg-dark / --card-bg-light kommen aus BorderGlow.tsx */
|
||||||
|
--card-bg: var(--card-bg-dark, #000);
|
||||||
position: relative;
|
position: relative;
|
||||||
border-radius: var(--border-radius);
|
border-radius: var(--border-radius);
|
||||||
isolation: isolate;
|
isolation: isolate;
|
||||||
@@ -25,6 +27,12 @@
|
|||||||
rgba(0, 0, 0, 0.1) 0px 32px 64px;
|
rgba(0, 0, 0, 0.1) 0px 32px 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Heller Modus: helle Kartenfarbe und ein sichtbarer Rand (weiß mit 8 % Deckkraft wäre auf Weiß unsichtbar) */
|
||||||
|
html:not(.dark) .border-glow-card {
|
||||||
|
--card-bg: var(--card-bg-light, var(--card-bg-dark, #000));
|
||||||
|
border-color: hsl(var(--border));
|
||||||
|
}
|
||||||
|
|
||||||
.border-glow-card::before,
|
.border-glow-card::before,
|
||||||
.border-glow-card::after,
|
.border-glow-card::after,
|
||||||
.border-glow-card > .edge-light {
|
.border-glow-card > .edge-light {
|
||||||
@@ -147,6 +155,20 @@
|
|||||||
0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%));
|
0 0 50px 2px var(--glow-color-10, hsl(40deg 80% 80% / 10%));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Heller Modus: "plus-lighter" ginge auf einer hellen Karte im Weiß unter, und die blasse Glow-Farbe wäre kaum sichtbar.
|
||||||
|
Der Kantenschein wird deshalb normal gemischt und in einem kräftigeren Violett gezeichnet. */
|
||||||
|
html:not(.dark) .border-glow-card > .edge-light {
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
html:not(.dark) .border-glow-card > .edge-light::before {
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px hsl(268deg 83% 62% / 70%),
|
||||||
|
inset 0 0 3px 0 hsl(268deg 83% 62% / 45%),
|
||||||
|
inset 0 0 12px 0 hsl(268deg 83% 62% / 30%),
|
||||||
|
inset 0 0 25px 2px hsl(268deg 83% 62% / 15%);
|
||||||
|
}
|
||||||
|
|
||||||
.border-glow-inner {
|
.border-glow-inner {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -56,7 +56,10 @@ interface BorderGlowProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
edgeSensitivity?: number;
|
edgeSensitivity?: number;
|
||||||
glowColor?: string;
|
glowColor?: string;
|
||||||
|
/** Kartenfarbe im dunklen Modus (und Standard, wenn lightBackgroundColor fehlt). */
|
||||||
backgroundColor?: string;
|
backgroundColor?: string;
|
||||||
|
/** Kartenfarbe im hellen Modus; ohne Angabe gilt backgroundColor in beiden Themes. */
|
||||||
|
lightBackgroundColor?: string;
|
||||||
borderRadius?: number;
|
borderRadius?: number;
|
||||||
glowRadius?: number;
|
glowRadius?: number;
|
||||||
glowIntensity?: number;
|
glowIntensity?: number;
|
||||||
@@ -72,6 +75,7 @@ const BorderGlow = ({
|
|||||||
edgeSensitivity = 30,
|
edgeSensitivity = 30,
|
||||||
glowColor = '40 80 80',
|
glowColor = '40 80 80',
|
||||||
backgroundColor = '#120F17',
|
backgroundColor = '#120F17',
|
||||||
|
lightBackgroundColor,
|
||||||
borderRadius = 28,
|
borderRadius = 28,
|
||||||
glowRadius = 40,
|
glowRadius = 40,
|
||||||
glowIntensity = 1.0,
|
glowIntensity = 1.0,
|
||||||
@@ -183,7 +187,8 @@ const BorderGlow = ({
|
|||||||
onPointerMove={animated ? undefined : handlePointerMove}
|
onPointerMove={animated ? undefined : handlePointerMove}
|
||||||
className={`border-glow-card ${className}`}
|
className={`border-glow-card ${className}`}
|
||||||
style={{
|
style={{
|
||||||
'--card-bg': backgroundColor,
|
'--card-bg-dark': backgroundColor,
|
||||||
|
'--card-bg-light': lightBackgroundColor ?? backgroundColor,
|
||||||
'--edge-sensitivity': edgeSensitivity,
|
'--edge-sensitivity': edgeSensitivity,
|
||||||
'--border-radius': `${borderRadius}px`,
|
'--border-radius': `${borderRadius}px`,
|
||||||
'--glow-padding': `${glowRadius}px`,
|
'--glow-padding': `${glowRadius}px`,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const Contact = () => {
|
|||||||
edgeSensitivity={30}
|
edgeSensitivity={30}
|
||||||
glowColor="40 80 80"
|
glowColor="40 80 80"
|
||||||
backgroundColor="#120F17"
|
backgroundColor="#120F17"
|
||||||
|
lightBackgroundColor="hsl(0 0% 96%)"
|
||||||
borderRadius={28}
|
borderRadius={28}
|
||||||
glowRadius={40}
|
glowRadius={40}
|
||||||
glowIntensity={1}
|
glowIntensity={1}
|
||||||
@@ -38,9 +39,11 @@ const Contact = () => {
|
|||||||
data-analytics-cta="Potenzialanalyse starten"
|
data-analytics-cta="Potenzialanalyse starten"
|
||||||
data-analytics-location="contact_section"
|
data-analytics-location="contact_section"
|
||||||
>
|
>
|
||||||
|
{/* Etwas dunkleres Blau als der Standard-Button (3,45:1 -> 4,76:1 mit weißem Text, Hover 6,08:1);
|
||||||
|
die Farbe gilt in beiden Themes, weil der Button nicht von Theme-Farben abhängt. */}
|
||||||
<Button
|
<Button
|
||||||
size="lg"
|
size="lg"
|
||||||
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group"
|
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group bg-[hsl(198,93%,35%)] hover:bg-[hsl(198,93%,30%)]"
|
||||||
>
|
>
|
||||||
Potenzialanalyse starten
|
Potenzialanalyse starten
|
||||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
||||||
@@ -54,9 +57,9 @@ const Contact = () => {
|
|||||||
<Mail className="w-5 h-5" />
|
<Mail className="w-5 h-5" />
|
||||||
<span>support@webklar.com</span>
|
<span>support@webklar.com</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="tel:+491704969375" className="flex items-center gap-3 hover:text-foreground transition-colors group">
|
<a href="tel:+4917623726355" className="flex items-center gap-3 hover:text-foreground transition-colors group">
|
||||||
<Phone className="w-5 h-5" />
|
<Phone className="w-5 h-5" />
|
||||||
<span>0170 4969375</span>
|
<span>0176 23726355</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import "./deutschland-karte.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Umriss von Deutschland als Inline-SVG (Spezifikation 7.1).
|
||||||
|
* Quelle der Pfaddaten: docs/relaunch/assets/deutschland-umriss.svg (Natural Earth 1:10m, Public Domain,
|
||||||
|
* Lambert-Azimutal-flächentreu, viewBox 0 0 663 892, 1 Einheit ≈ 1 km).
|
||||||
|
*
|
||||||
|
* Der Umriss besteht aus sechs geschlossenen Teilpfaden (Festland, Rügen, Usedom, Fehmarn, Sylt, Föhr/Amrum).
|
||||||
|
* Jeder ist ein eigener <path> mit pathLength="1", damit alle beim Zeichnen gleichzeitig fertig werden
|
||||||
|
* (bei einem einzigen Pfad wären die kleinen Inseln schon nach den ersten Millisekunden da).
|
||||||
|
* Die Pfade beginnen mit absolutem M; die Befehle danach sind unverändert aus der Datei übernommen.
|
||||||
|
*/
|
||||||
|
const UMRISS = [
|
||||||
|
"M581.4 706.2l-2 5.8 2.5 2-1 2.4 0.4 7-1.8 4.8-2.9 1.2-1.1 4.3-7-4.5-7.7-1.8-4.8 1.5-1 1.4 1.6 4.9-3.3 15.7-9.3 8.2-10.1 2.2-7.6 3.4-7.1 6.3-5.2 1.6-8.8 9.6 1.8 5.4 5.4 5.2 2.6 5.8 5.3 4 4.8 8.5-6.9 14 5.5 1.8 4.1-1 4.2 5.8 0.1 4.1-2.3 4.3-0.6 4.8 0.9 5.3-2.5 3.2-5.4-0.7-11.8-8.6 0.4-5.3 2-1.1-4.8-4 0.7-1.9-8.2-0.7-8.4 5.2-3.1 0.4-4-2.9-1.7-3.9-13.8 1.8-1.2-1.8 1.3-4.1-4.8 3.1 2.4 6.6-2.4 4.6-17.8-0.1-6.3 1.4-2.3 2.7-15-1.2-5 8.5-10.4 1.1-3.5 4.1 1.8 0.8-0.4 1.9-5.8 1.2-5.1 4.7-1.8-0.2 0.1-3-1.6-0.7-8.3 4.5-7.8 0-1.9-2.1 0.3-2.5-3.8-4-3.9-1.8-0.5-0.8 3.1-2.5-2.6-1.7-7.3 2-1.6-1.7-11.8-3.6-3.5 3.6-4.1-0.3-2.1-1.5 0.2-3.6-2.1 0.2-1.1 0.5 1.3 3.4-0.9 5.5 2.2 2.2 0.3 3.8-1.7 4.7-4.3 3.2-4.9 7.3-5 2.7-6 0.8 2.3-3 1.4-8.3-1.4-0.7-8.2 2.2-2.2-5.1 1.7-1.3-0.3-2.5-3.7-5.4-3 0.8-2.8-6.7-8.4 0.7-1.5-0.8-0.4-3.8-2-1.4-2.2 0.7-3.6 5.6-6.9 1.1-4.7-1.4-20.7-13.1-5.7-0.7-1-1.6-12.5-1.1-5.4 2.7-4.7-0.3-3.3-2.8 1.4-1.1-4.3-3.3-2-0.1-0.1 2.8-4 0-1.2-3.2 1-3.8-2.3-0.2-2.8-3.6-2 3.7-1.1-4.2-1.4-0.6-3.1 0.5 0.7 1.8-1.1 0.5-5.5 1-2 4.5-3.6 3.4 0 3 4.9 2.9 8.3-2.4 2.9 0.8-0.5 2.6-1.5-0.7-0.1 4.3-1.5 1.2-2.8-3.7-4.6 1.7-1 2.4-7.1 0.2-3.6-1.3-1.3-2.7-4.1-0.8-4.1 0.6-7 5.2-3.4 0.7-9.8-0.3-0.9-3-5.9-1-4.1 4.2-6.3 1.1-5.4-2.4 1.9 0 1.9-3.4-5.6 1.1 0.4-2.6-5.1-7.6-0.4-3.6 2.1-2.7-0.7-5.7 2.9-6.1-0.1-4.9 2.1-6.3 2.8-3.4 0-3.4-3.1-5.8 0.5-6.8 2.8-7.1 5.7-8 1.3-6.4 3.7-3.3-0.7-7.2 3.1-10.1 2.8-2.3 0.1-10.6 3.2-4.8 8-6.7 1-4 3.4-0.4 1.7-3.2 3.8-1.8 8.5-16.5-8-2.5-11.4-6.5-5.5 0.1-6-1.9-3.4 1.2-6.7-0.4-1.9-2.8-6.3-2.6-3.4-5.1 0.4-2.5-4.4-0.9-0.3-1.2-4.3 0.9-1.4 2.7-2.7 0.6-1.7 2.5-8.7-1.4-5.3-3.2-0.6 2.3-2.3 1.4-1.5-1.4-0.6-7-6.8-3-5.8-0.6 0.2 5.2-1.4 1.6-6.9-1.1-1.7-2 0.3-4.6-1.6 0.6-2.7-7.2-5.7-6.4 0-2.3 1.7-0.9-4.5-7.8-7.6-5-4.2 1 1-12.4 5.2-7.8-1-1.9 7.3-5.9-0.9-2.5 1.5-6.8-7.5-1.8-4.3-3.7-2.3 0.5-1-3-4.4-3.1-3.2-7.9-0.9 1.5-4-12 2.8-9.4-0.4-3.1 3.2-2.1-1.1-3.6 3.2-3 6-2.5 1.2-4 6.4-1-2.4-5.9 1.4-7.3-1.8-3.7 1-1.6-5.7-1.8-3.4 0.8-1-3.3-1.4-0.4-0.6-2.9 5.4-4.5 1.3-3-6.3-1.2-0.8-1.7 1-0.5-5.2-8.2-4.9 0.3 0.1-3.2-2.6-6.6 2.1-0.5 1-4.4 3.4-1.2 0.8-6-4.3-2.7 0.5-4.9-5.3-0.8-3.7 1.2-1.1-8.7 2-0.9 3.9 2.6 3.4-4.7 11.9-8-1.4-1.5 2.3-1.5-1.6-0.6-3.6 2.2-1.6-1 0-4.6 6.9-11.4 3.3-2.8 1.3-4.7-1-1.3 0.7-12.3-6.9-10 1.2-5.4-6-4.4 0.4-3.8-4.9-2.1 2-4.8-2-4.5 10.7-3.6 4.9 1.5-4.1-5 2.4-1.2 6.5 4.2 2.7-0.3 1.5 2.3 3.7 1.3 0.1 1.7 2.6-0.6-0.1-3.6 5.7 1.6 4.7-2.9 14.9-2.9 4.8-5.6 0-2.1-8.9-6.6 0.5-2.8 2.2-0.6 2.8-3.9 5.6-0.9 3.9-5.1 3-1.1 7.1-6.8-0.9-5.5 2.8-8.8-4.6-9.8-5.1 1.9-10.6-2.9-3.1-2.6-1.2-7.4 5-1.8-2.6-2.7 0-4 2.5-1.6 8.7-0.4 10.2 2.2 1.3-2.3 2.5-19.6 7.2-11.8 2.4-7.2-0.8-15.6 2-11.8 2.7-3.1-0.2-2.6 1.8-2.5 2.4-0.4 4.9 2.6-4.1-3.5-15-1.1-2.4-1.4-0.6-11.3 2.2-6.8 0.6-1 3.9 0.7 1.6-2.2-3.4-4.2 0.9-2.7 8.6-7.3 5.3-1.8 10.1-0.8 2.9 1.9 29.6-3.9 5.2 1.7-0.5 4 1.7 4 4.8 5.2-0.6 2.1 3.2 2.2-0.9 3-6.5 2.5 1.7 5.1 4.3 0.9 3.4 4.4 3.6-0.1 3.9-6.9 0.4-3.8-1.8-2.3-4 0.4 2.8-9.7 3.9-0.5 3.7 4.7 10.8 3.9-4.2 6.3-0.5 9.5 1.1 4.8-0.1-13 4-4.7 0.6-3.2-3.6-8.8-1.3-8.4 6.1-17.4 5.2-3.7 8.5 5.7 5.1 1.3 10.2-0.9 6.5-2.6 10.5 0.1 8.3 13.7 5.4 5.9 5.3 10.1 8.6 4.8 8.9 1.5-3.3-2.9-5.9-0.8-7.2-4.1-2.1-2.4-1.2-8.5-6.3-4.3-2.6-9.1-9-6.2-10.7-0.9-4.2-1.7-1.4 1.6-1.5-0.5-4.5-4.5-5.2-11.1 3.8-1.2 5.5 2.7 1.4-0.7 1.5-7.5-2.8-3.4 0.2-1.9-1.6-0.5-6.1 2.6-3-6.2 2.3-5.8-0.4-2.5 5.4-2 2.7-4.8-5.3 3 0.4-1.5-9.7 1-3.9 2.6-3.6-2.4-1.4-5.3 2.7-1.9 3.6-0.3-2.9-2.2-1.1 1.4 1-4.6 15.4-1 7.8-5.2 0.5-4.5-8.4-11.2-3.5 0.8 1.8-2.3-2.9-7.6-7.4-5.5 0.4-2.7-3.1-9.4 1.2-5.8 4.6 0.9 5.9-1.8 5.1 1 18.5 5.5 3.1 5.5 5.7-0.2 3.4-1.7 4.2 1 8.3-6.1 0.2 4 9.7 2.6 4.3 2.6 1.4 3 4.6-1.4-0.3-2.3 3.6 1.2 4.6 8.7-3.1 0-2.6 3 4.3-0.5 2.7 1.3-0.8 11.9-3.9 6-8.1 3.4 2.8 1.5 16.8-3.3 3.9 3.4-1.3 1.3 0.5 5.8-3.2 2.7 0 5.4 2.5-1.9 2.8-8.1 6.1-3.3 2.7 0.2 15.6 8.4 5.9 5.4 2.6 0.6 3-0.6 10.2-7.2 5.4-0.8-2.8 0.8 3.8 1.2 4.5-2.9 2 0.8-3 3.8-1.4-0.7 2.1 14.8-1.8 4.6-11.2 9.2-5.7 0.9-3.4 5 3.2 5.5 4.9 0.5 0.8 3.5 2.4 0.3 8.4-5.4 8-1.2 1.9 3.2 2.7 1.1-0.1 3.7 3.4 0.5 2.6-2.4 8.2 6 0.8-7 1.6-1.5-0.4-2.2 2.2-2.3-0.5-1.6 3.5-0.3 3.3-5.1-0.1-2.9 4.1-5.1 8 0.4 17.8-5.4 0.5 8.9 1.6 1.7-1.5-5.2 3-4-2.6 0.8 0.4-1 4.9-6.9 9.9-7 9.7-19.2 2-1.7 1 2.9 2.8 1 18.7-0.4 2.7 1.6-3.2 2-11.4-1.4-1.5 1.5-3-0.2-1 1.9-1.7 0.1 0.9 2.2-2.7-0.4-1.6 2.1-2.4-1.9-3.4 0.4-0.8 4.8-1.8-0.7-0.2 3.4-1.8 1.2 0.5 4.6 2.9 1.9 3-0.6-3.2-1.4 3.9-4.7 0.3-2.4 5.2-3.2-1.3-1.4 2.1-0.9 1.3 1.1 5.3-2.8 0.2-2.1 2.3-0.4-2 4.7 1.3-3.1 2 2.3 3.3-1.6 2.2 3.7 2.3-0.1 4.6-6.5 5-2.8 1.7 0.4-0.2 3.6 4.5 3.1-1.2 2.2 2.9 7.9 3.6 0.3-1.7 1.6 9.6 2.7 4.2 5.8-1.7 1.6 5.3-1-1.3 2.4 2.3-0.2 3.4 6 2.7 0.7-2-3.7 8-2 6.9-4.2-0.8 2.3 7.5 5.1-2 6.6-1.7 1.1 6.4 5.3 0.8 3.4 4 2.2-5.5 6.5 0.1 2.8 15 10 9.9 1.2 3-2.6 2 1-0.1 2.6-3 2.4 3.4 0.7 4 21.1 4.1 7 4 18.1 2.7 2.9-3.6 7.1 0.9 6.4-2.5 9.4-12.7 10.6 1.8 7.1-2.4 5.2 18 11.4 3.7 5.6 15.1 11.2-2 6.7 1.7 2.3-2.4 5.4-3.3 3.3 0.7 4.4 3.5 7.9-0.3 2 8 3.7 1.1 1.9-0.9 12.8 5.4 4.6-3.6 9.7-0.3 9-5.7 8.1-0.4 4.3 3.5 3.7 1.4 5.6 6.2 6.2 1.1 5.7-1.8 4.5 0 4.1 14.4 5.9 3.3 3.5 1.1 11.1 4.6 6.8 0.4 3.8-5.9 27.7-3 8.6-3.2 4.7-1.3 6.4-3.3 1.3-8.9-3.Line truncated
|
||||||
|
"M577.2 130.4l-2 3.6 1.8 3.1-7.4 1.3-2.6-0.9-3.2 3.1-3.7 1.2-6.5-0.3-1.3-1.6 7-4.2 0.3-3.2-2.7-4.5 0-2.8 4.5 0.4-0.6 5.4 1.7-2.4 2.7-0.1-0.8 1.6 2.2 0.6-0.2-7.1-1-1.8-1.5 0.7-2.3-4.4-2.4-1-1.9 1.2 0.7 2.2-2.1 3.1-2.2 1 1.1-3.9-0.9-1.5-4.3 3.8-1.4-0.4 2.2-8.9-4.3-5.5 0.5-1.3 2.2-1.7 1.2 0.5 4.8 7.6 9 4.3z",
|
||||||
|
"M382.9 77.6l3.9 7.2-8.1-1.3-1.2 0.7 0.6 0.7-4.3-0.1-1-4-4.2 0.5-0.5-1.2-1.1 0.4 1.5-6 2.5-2.5 3.1-0.6 5.6 2.3z",
|
||||||
|
"M541.2 81.8l4.6 5-2.4 7.6-2.1-0.5 1.3-2.3-3.9 0.9 3.7-4-6.1 1.8 4.7-3.2-1.8-0.9-11.7 2.3-7.6 7.3-0.1 3 3.9-2.1-0.5 4.2-6.3-2.2 1.3-3.7-4.2 3.2-3.8-2-0.5-2.3-3.5 0.9 1.7-2.3-4.2-2.9 2-4.5 7.6-1.3-3.8-4.8-3.5 0.6 7.2-6.5-2.6-1.1-0.2-2.5-3 1-2.9-3.7 7.5-1.6 2.6 3.7 0-3.4 4.3-4.1-1 6.9 2.2-1-0.6-3.6 3.8 7.8 5 0.2 0.7-3.1-1.6-3.7 1.3-2.4-5 1.8-5-5.9-7.2 5.6 2.2-8.7-4.4 1 1-2.8 11.2-3.7 1.8 1.5-3.8 4.4 2.9 5.5 13.9-0.7 2.6 4.9-5.6 6.3-0.2 3.5 2.2 4.5z",
|
||||||
|
"M205.4 46.4l3.5 0.3 2.6 3-1.9 4.5-5.2-0.2-5.7-3.1 2.7-3.7z",
|
||||||
|
"M197.1 24.9l-0.5 4 2.1 2.1 3.1 0.8 13.9-0.4-1.6 1.2-8-0.6-3.8 2.2-4.4-0.1-2.1-1.5-2.4 1.5-1.7 13.6 1.3-18.9 5.1-13.5 2.8-3.3 3 1.7-4.4 0.7 1.7 0.2 0.5 1.3-4.4 4.8z",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const KARTE_BREITE = 663;
|
||||||
|
export const KARTE_HOEHE = 892;
|
||||||
|
|
||||||
|
/** Grad -> SVG-Koordinaten in der viewBox 0 0 663 892 (Formel aus docs/relaunch/README.md). */
|
||||||
|
const R = 6371;
|
||||||
|
const LON0 = (10 * Math.PI) / 180;
|
||||||
|
const LAT0 = (52 * Math.PI) / 180;
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- Hilfsfunktion und Daten gehören zur Karte (Spezifikation 9.3)
|
||||||
|
export function project(lon: number, lat: number): [number, number] {
|
||||||
|
const l = (lon * Math.PI) / 180;
|
||||||
|
const p = (lat * Math.PI) / 180;
|
||||||
|
const k = Math.sqrt(2 / (1 + Math.sin(LAT0) * Math.sin(p) + Math.cos(LAT0) * Math.cos(p) * Math.cos(l - LON0)));
|
||||||
|
const x = R * k * Math.cos(p) * Math.sin(l - LON0);
|
||||||
|
const y = R * k * (Math.cos(LAT0) * Math.sin(p) - Math.sin(LAT0) * Math.cos(p) * Math.cos(l - LON0));
|
||||||
|
return [x + 289.794 + 12, 341.92 - y + 12];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type KartenPunkt = {
|
||||||
|
/** Position in der viewBox (siehe project) */
|
||||||
|
cx: number;
|
||||||
|
cy: number;
|
||||||
|
/** server = roter Punkt mit Ring, buero = hohler Punkt */
|
||||||
|
art: "server" | "buero";
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Serverstandort Hetzner Falkenstein/Vogtland (50,477 N, 12,371 O), nur Website und Kundenportal. */
|
||||||
|
// eslint-disable-next-line react-refresh/only-export-components -- siehe project()
|
||||||
|
export const FALKENSTEIN: KartenPunkt = {
|
||||||
|
cx: 469.6,
|
||||||
|
cy: 520.5,
|
||||||
|
art: "server",
|
||||||
|
label: "Falkenstein, Sachsen",
|
||||||
|
sublabel: "Hier laufen Ihre Website und das Kundenportal.",
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeutschlandKarteProps = {
|
||||||
|
punkte?: KartenPunkt[];
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ablauf der Bewegung: aus = alles sofort sichtbar (reduzierte Bewegung, Grundzustand),
|
||||||
|
* wartet = versteckt, bis die Karte im Bild ist, laeuft = Umriss zeichnet sich, dann Punkt, Ring, Beschriftung.
|
||||||
|
* Die Animationen selbst stehen in deutschland-karte.css hinter prefers-reduced-motion: no-preference.
|
||||||
|
*/
|
||||||
|
type Lauf = "aus" | "wartet" | "laeuft";
|
||||||
|
|
||||||
|
const DeutschlandKarte = ({ punkte = [FALKENSTEIN], className }: DeutschlandKarteProps) => {
|
||||||
|
const wurzel = useRef<HTMLDivElement>(null);
|
||||||
|
const [lauf, setLauf] = useState<Lauf>("aus");
|
||||||
|
|
||||||
|
// Vor dem ersten Zeichnen entscheiden, damit die fertige Karte nicht kurz aufblitzt
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const reduziert = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
if (reduziert) return;
|
||||||
|
const el = wurzel.current;
|
||||||
|
if (!el || typeof IntersectionObserver === "undefined") {
|
||||||
|
setLauf("laeuft");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLauf("wartet");
|
||||||
|
const beobachter = new IntersectionObserver(
|
||||||
|
([eintrag]) => {
|
||||||
|
if (eintrag?.isIntersecting) {
|
||||||
|
setLauf("laeuft");
|
||||||
|
beobachter.disconnect();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ threshold: 0.3 },
|
||||||
|
);
|
||||||
|
beobachter.observe(el);
|
||||||
|
return () => beobachter.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={wurzel} className={`wk-karte${className ? ` ${className}` : ""}`} data-lauf={lauf}>
|
||||||
|
{/* Dekoration: die Aussage steht als Text daneben */}
|
||||||
|
<svg className="wk-karte__svg" viewBox={`0 0 ${KARTE_BREITE} ${KARTE_HOEHE}`} aria-hidden="true" focusable="false">
|
||||||
|
<g className="wk-karte__umriss">
|
||||||
|
{UMRISS.map((d, i) => (
|
||||||
|
<path key={i} d={d} pathLength={1} />
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
{punkte.map((p) => (
|
||||||
|
<g key={`${p.cx}-${p.cy}`} className={`wk-karte__punkt wk-karte__punkt--${p.art}`}>
|
||||||
|
{p.art === "server" && <circle className="wk-karte__ring" cx={p.cx} cy={p.cy} r={7} />}
|
||||||
|
<circle className="wk-karte__kern" cx={p.cx} cy={p.cy} r={7} />
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
{punkte.map((p) => (
|
||||||
|
<div
|
||||||
|
key={`${p.cx}-${p.cy}`}
|
||||||
|
className="wk-karte__label"
|
||||||
|
style={{ left: `${(p.cx / KARTE_BREITE) * 100}%`, top: `${(p.cy / KARTE_HOEHE) * 100}%` }}
|
||||||
|
>
|
||||||
|
<p className="wk-karte__titel">{p.label}</p>
|
||||||
|
{p.sublabel && <p className="wk-karte__zusatz">{p.sublabel}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default DeutschlandKarte;
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { Link } from "react-router-dom";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { ArrowRight } from "lucide-react";
|
|
||||||
import CountUp from "@/components/CountUp";
|
|
||||||
|
|
||||||
const DifferentiationSection = () => {
|
|
||||||
return (
|
|
||||||
<section className="py-24 md:py-32 bg-secondary/20 relative">
|
|
||||||
<div className="container mx-auto px-6">
|
|
||||||
<div className="max-w-4xl mx-auto text-center">
|
|
||||||
<div className="label-tag mb-4">Der Unterschied</div>
|
|
||||||
|
|
||||||
<h2 className="text-4xl md:text-5xl lg:text-6xl font-display font-medium text-foreground tracking-tight uppercase mb-12">
|
|
||||||
Warum Unternehmen zu uns wechseln.
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8 mb-12">
|
|
||||||
<div className="p-6 border border-border rounded-lg bg-background">
|
|
||||||
<div className="text-4xl font-display font-medium text-foreground mb-2">
|
|
||||||
<CountUp from={0} to={1} duration={1} padMinLength={2} startWhen={true} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
|
|
||||||
Alles aus einer Hand
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Website, Automation und Schnittstellen aus einer Hand – ein Ansprechpartner statt fünf Agenturen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border border-border rounded-lg bg-background">
|
|
||||||
<div className="text-4xl font-display font-medium text-foreground mb-2">
|
|
||||||
<CountUp from={0} to={2} duration={1} padMinLength={2} startWhen={true} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
|
|
||||||
Systeme statt Inseln
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
CRM, Formulare und Website tauschen Daten – ohne manuelles Nachpflegen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-6 border border-border rounded-lg bg-background">
|
|
||||||
<div className="text-4xl font-display font-medium text-foreground mb-2">
|
|
||||||
<CountUp from={0} to={3} duration={1} padMinLength={2} startWhen={true} />
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg font-display font-medium text-foreground uppercase tracking-tight mb-2">
|
|
||||||
Langfristige Partnerschaft
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm">
|
|
||||||
Nach dem Go-live bleiben wir dran: Optimierung, wenn Ihr Team und Umsatz wachsen.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Link
|
|
||||||
to="/kontakt"
|
|
||||||
data-analytics-cta="Potenzialanalyse starten"
|
|
||||||
data-analytics-location="differentiation_section"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="btn-minimal rounded-full px-8 py-6 text-base font-medium group"
|
|
||||||
>
|
|
||||||
Potenzialanalyse starten
|
|
||||||
<ArrowRight className="w-4 h-4 ml-2 group-hover:translate-x-1 transition-transform" />
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DifferentiationSection;
|
|
||||||
@@ -25,13 +25,14 @@ const footerLinks: { title: string; links: FooterLink[] }[] = [
|
|||||||
{ id: 3, title: "Leistungen", href: "/#services" },
|
{ id: 3, title: "Leistungen", href: "/#services" },
|
||||||
{ id: 4, title: "Kontakt", to: "/kontakt" },
|
{ id: 4, title: "Kontakt", to: "/kontakt" },
|
||||||
{ id: 9, title: "Wartungs-Abo", to: "/wartungs-abo" },
|
{ id: 9, title: "Wartungs-Abo", to: "/wartungs-abo" },
|
||||||
|
{ id: 13, title: "Hosting-Abo", to: "/hosting-abo" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Kontakt",
|
title: "Kontakt",
|
||||||
links: [
|
links: [
|
||||||
{ id: 5, title: "support@webklar.com", href: "mailto:support@webklar.com" },
|
{ id: 5, title: "support@webklar.com", href: "mailto:support@webklar.com" },
|
||||||
{ id: 6, title: "0170 4969375", href: "tel:+491704969375" },
|
{ id: 6, title: "0176 23726355", href: "tel:+4917623726355" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MacBookFrame, IPhoneFrame } from "./device-frames/DeviceFrames";
|
||||||
|
import "./geraete-rahmen.css";
|
||||||
|
|
||||||
|
/** Ein Bild samt Größenangaben. Alle Felder landen 1:1 im <img> (Spezifikation 9.5). */
|
||||||
|
export type RahmenBildDaten = {
|
||||||
|
src: string;
|
||||||
|
srcSet: string;
|
||||||
|
sizes: string;
|
||||||
|
/** Breite und Höhe des größten Rohbilds: sie legen nur das Seitenverhältnis fest, damit nichts springt. */
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
alt: string;
|
||||||
|
/** Standard: lazy. Nur Bilder im ersten Bildschirm bekommen "eager". */
|
||||||
|
loading?: "lazy" | "eager";
|
||||||
|
};
|
||||||
|
|
||||||
|
type BildProps = {
|
||||||
|
bild: RahmenBildDaten;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Das <img> eines Rahmens: immer mit Alt-Text, fester Größe, srcset und asynchroner Dekodierung. */
|
||||||
|
export const RahmenBild = ({ bild, className }: BildProps) => (
|
||||||
|
<img
|
||||||
|
className={className}
|
||||||
|
src={bild.src}
|
||||||
|
srcSet={bild.srcSet}
|
||||||
|
sizes={bild.sizes}
|
||||||
|
width={bild.width}
|
||||||
|
height={bild.height}
|
||||||
|
alt={bild.alt}
|
||||||
|
loading={bild.loading ?? "lazy"}
|
||||||
|
decoding="async"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
type RahmenProps = {
|
||||||
|
/** Ein einzelnes Bild. Für überblendete Bilder stattdessen children (mehrere Ebenen) übergeben. */
|
||||||
|
bild?: RahmenBildDaten;
|
||||||
|
children?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FensterProps = RahmenProps & {
|
||||||
|
/** Schlichter Adresstext in der Leiste, keine Ampelpunkte. */
|
||||||
|
adresse?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Desktop: MacBook Pro 14" mit Safari und der Adresse project.webklar.com (Rahmen aus
|
||||||
|
* components/device-frames, übernommen aus KNSONWS/homepage „device-mockups“). Der Inhalt wird
|
||||||
|
* in 1512 x 893 gesetzt und verkleinert; der Screenshot füllt die Fläche von oben.
|
||||||
|
*/
|
||||||
|
export const DesktopFenster = ({ bild, children, className, adresse = "project.webklar.com" }: FensterProps) => (
|
||||||
|
<MacBookFrame url={adresse} className={className}>
|
||||||
|
<div className="wk-geraet-inhalt">{bild ? <RahmenBild bild={bild} /> : children}</div>
|
||||||
|
</MacBookFrame>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handy: iPhone 16 Pro (Inhalt in 402 x 874). Die Portal-Aufnahmen sind genau in diesem Format
|
||||||
|
* mit Safe Area 59 / 34 aufgenommen, Statusleiste und Home-Balken liegen also passend darüber.
|
||||||
|
*/
|
||||||
|
export const HandyRahmen = ({ bild, children, className }: RahmenProps) => (
|
||||||
|
<IPhoneFrame className={className}>
|
||||||
|
<div className="wk-geraet-inhalt wk-geraet-inhalt--handy">{bild ? <RahmenBild bild={bild} /> : children}</div>
|
||||||
|
</IPhoneFrame>
|
||||||
|
);
|
||||||
|
|
||||||
|
type GeraeteRahmenProps = (FensterProps & { art: "desktop" }) | (RahmenProps & { art: "handy" });
|
||||||
|
|
||||||
|
/** Ein Einstieg für beide Rahmen: <GeraeteRahmen art="desktop" .../> oder art="handy". */
|
||||||
|
const GeraeteRahmen = (props: GeraeteRahmenProps) =>
|
||||||
|
props.art === "handy" ? <HandyRahmen {...props} /> : <DesktopFenster {...props} />;
|
||||||
|
|
||||||
|
export default GeraeteRahmen;
|
||||||
@@ -20,7 +20,6 @@ const Header = () => {
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: "Über uns", link: "/ueber-uns" },
|
{ name: "Über uns", link: "/ueber-uns" },
|
||||||
{ name: "Leistungen", link: "#services" },
|
{ name: "Leistungen", link: "#services" },
|
||||||
{ name: "Projekte", link: "#projects" },
|
|
||||||
{ name: "Ablauf", link: "#process" },
|
{ name: "Ablauf", link: "#process" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -33,8 +32,8 @@ const Header = () => {
|
|||||||
<NavBody>
|
<NavBody>
|
||||||
<NavbarLogo href="#">
|
<NavbarLogo href="#">
|
||||||
<Logo width={30} height={30} />
|
<Logo width={30} height={30} />
|
||||||
<span className="font-display text-lg font-medium tracking-tight">
|
<span className="font-display text-lg font-medium tracking-normal">
|
||||||
Webklar
|
WEBklar
|
||||||
</span>
|
</span>
|
||||||
</NavbarLogo>
|
</NavbarLogo>
|
||||||
<NavItems items={navItems} />
|
<NavItems items={navItems} />
|
||||||
@@ -60,8 +59,8 @@ const Header = () => {
|
|||||||
<MobileNavHeader>
|
<MobileNavHeader>
|
||||||
<NavbarLogo href="#">
|
<NavbarLogo href="#">
|
||||||
<Logo width={30} height={30} />
|
<Logo width={30} height={30} />
|
||||||
<span className="font-display text-lg font-medium tracking-tight">
|
<span className="font-display text-lg font-medium tracking-normal">
|
||||||
Webklar
|
WEBklar
|
||||||
</span>
|
</span>
|
||||||
</NavbarLogo>
|
</NavbarLogo>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
@@ -1,25 +1,12 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { ArrowRight } from "lucide-react";
|
import { ArrowRight } from "lucide-react";
|
||||||
import React, { useState, useEffect, useRef, lazy, Suspense } from "react";
|
import { lazy, Suspense } from "react";
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
|
||||||
import CountUp from "@/components/CountUp";
|
|
||||||
import { trackCtaClick } from "@/lib/analytics";
|
import { trackCtaClick } from "@/lib/analytics";
|
||||||
import { fetchProjects } from "@/lib/projects";
|
|
||||||
|
|
||||||
// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss
|
// WebGL-Effekte als eigene Chunks laden, damit der Text nicht auf three.js/ogl warten muss
|
||||||
const Silk = lazy(() => import("@/components/Silk"));
|
const Silk = lazy(() => import("@/components/Silk"));
|
||||||
|
|
||||||
const SPARKLE_SVG = (
|
|
||||||
<svg className="btn-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
d="M9.813 15.904 9 18.75l-.813-2.846a4.5 4.5 0 0 0-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 0 0 3.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 0 0 3.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 0 0-3.09 3.09ZM18.259 8.715 18 9.75l-.259-1.035a3.375 3.375 0 0 0-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 0 0 2.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 0 0 2.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 0 0-2.456 2.456ZM16.894 20.567 16.5 21.75l-.394-1.183a2.25 2.25 0 0 0-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 0 0 1.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 0 0 1.423 1.423l1.183.394-1.183.394a2.25 2.25 0 0 0-1.423 1.423Z"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
|
|
||||||
function DemoButtonLetters({ text }: { text: string }) {
|
function DemoButtonLetters({ text }: { text: string }) {
|
||||||
const chars = text.split("");
|
const chars = text.split("");
|
||||||
return (
|
return (
|
||||||
@@ -33,49 +20,16 @@ function DemoButtonLetters({ text }: { text: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const FOUNDING_DATE = new Date("2026-01-25"); // Samstag, 25. Januar 2026
|
// Drei belegbare Fakten statt Zahlen: Wort-Werte, deshalb kleiner gesetzt als die frühere Zahlenleiste.
|
||||||
|
const FAKTEN = [
|
||||||
|
{ wert: "Falkenstein", label: "Server in Deutschland" },
|
||||||
|
{ wert: "Live-Vorschau", label: "im Kundenportal" },
|
||||||
|
{ wert: "ab 20 €", label: "Hosting im Monat" },
|
||||||
|
];
|
||||||
|
|
||||||
const Hero = () => {
|
const Hero = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { resolvedTheme } = useTheme();
|
const { resolvedTheme } = useTheme();
|
||||||
const [companyAge, setCompanyAge] = useState("");
|
|
||||||
const secondBtnRef = useRef<HTMLButtonElement>(null);
|
|
||||||
|
|
||||||
// Projektzähler kommt live aus Gitea – gleicher queryKey wie im Abschnitt
|
|
||||||
// „Projekte", react-query teilt sich den einen Request.
|
|
||||||
const { data: projectsSnapshot } = useQuery({
|
|
||||||
queryKey: ["projects"],
|
|
||||||
queryFn: ({ signal }) => fetchProjects(signal),
|
|
||||||
staleTime: 5 * 60 * 1000,
|
|
||||||
refetchOnWindowFocus: false,
|
|
||||||
retry: 1,
|
|
||||||
});
|
|
||||||
const projectCount = projectsSnapshot?.total ?? 10;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const calculateAge = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const diff = now.getTime() - FOUNDING_DATE.getTime();
|
|
||||||
|
|
||||||
const totalSeconds = Math.floor(diff / 1000);
|
|
||||||
const days = Math.floor(totalSeconds / (60 * 60 * 24));
|
|
||||||
const hours = Math.floor((totalSeconds % (60 * 60 * 24)) / (60 * 60));
|
|
||||||
|
|
||||||
const years = Math.floor(days / 365);
|
|
||||||
const remainingDays = days % 365;
|
|
||||||
|
|
||||||
if (years > 0) {
|
|
||||||
setCompanyAge(`${years}J ${remainingDays}T ${hours}h`);
|
|
||||||
} else {
|
|
||||||
setCompanyAge(`${days}T ${hours}h`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
calculateAge();
|
|
||||||
const interval = setInterval(calculateAge, 60 * 60 * 1000); // Update every hour (only days/hours shown)
|
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ── Silk-Hintergrund Farben ──
|
// ── Silk-Hintergrund Farben ──
|
||||||
const isDark = resolvedTheme === "dark";
|
const isDark = resolvedTheme === "dark";
|
||||||
@@ -149,32 +103,6 @@ const Hero = () => {
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="btn-wrapper w-full sm:w-auto">
|
|
||||||
<button
|
|
||||||
ref={secondBtnRef}
|
|
||||||
type="button"
|
|
||||||
className="btn w-full sm:w-auto justify-center"
|
|
||||||
onClick={() => {
|
|
||||||
const el = document.getElementById("projects");
|
|
||||||
if (el) el.scrollIntoView({ behavior: "smooth" });
|
|
||||||
else navigate("/#projects");
|
|
||||||
}}
|
|
||||||
aria-label="Projekte ansehen"
|
|
||||||
>
|
|
||||||
{SPARKLE_SVG}
|
|
||||||
<div className="txt-wrapper">
|
|
||||||
<span className="txt-width-helper" aria-hidden="true">
|
|
||||||
<DemoButtonLetters text="Projekte ansehen" />
|
|
||||||
</span>
|
|
||||||
<div className="txt-1">
|
|
||||||
<DemoButtonLetters text="Projekte ansehen" />
|
|
||||||
</div>
|
|
||||||
<div className="txt-2">
|
|
||||||
<DemoButtonLetters text="Wird geladen..." />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trust Line */}
|
{/* Trust Line */}
|
||||||
@@ -184,46 +112,16 @@ const Hero = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom Stats Row */}
|
{/* Bottom Facts Row */}
|
||||||
<div className="container mx-auto px-6 mt-auto pb-12 pt-20 relative z-10">
|
<div className="container mx-auto px-6 mt-auto pb-12 pt-20 relative z-10">
|
||||||
<div className="divider mb-12" />
|
<div className="divider mb-12" />
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 md:gap-12">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 md:gap-12">
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '0.6s' }}>
|
{FAKTEN.map((fakt, i) => (
|
||||||
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2">
|
<div key={fakt.wert} className="animate-fade-in" style={{ animationDelay: `${0.6 + i * 0.1}s` }}>
|
||||||
<CountUp
|
<div className="stat-number text-3xl lg:text-4xl xl:text-5xl text-foreground mb-2">{fakt.wert}</div>
|
||||||
from={0}
|
<div className="label-tag">{fakt.label}</div>
|
||||||
to={projectCount}
|
|
||||||
direction="up"
|
|
||||||
duration={1.2}
|
|
||||||
className="count-up-text"
|
|
||||||
startWhen={true}
|
|
||||||
suffix="+"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="label-tag">Projekte</div>
|
))}
|
||||||
</div>
|
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '0.7s' }}>
|
|
||||||
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2">{companyAge}</div>
|
|
||||||
<div className="label-tag">Am Markt</div>
|
|
||||||
</div>
|
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '0.8s' }}>
|
|
||||||
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2">
|
|
||||||
<CountUp
|
|
||||||
from={0}
|
|
||||||
to={99.9}
|
|
||||||
direction="up"
|
|
||||||
duration={1.5}
|
|
||||||
className="count-up-text"
|
|
||||||
startWhen={true}
|
|
||||||
suffix="%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="label-tag">Systemverfügbarkeit</div>
|
|
||||||
</div>
|
|
||||||
<div className="animate-fade-in" style={{ animationDelay: '0.9s' }}>
|
|
||||||
<div className="stat-number text-4xl md:text-5xl text-foreground mb-2">🇩🇪</div>
|
|
||||||
<div className="label-tag">Serverstandort in Deutschland</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ const ProblemSection = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite */}
|
{/* Hintergrundbild: nur Blitze rechts, auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
|
||||||
<div
|
<div
|
||||||
className="problem-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] z-0"
|
className="problem-section-bg hidden dark:block absolute inset-0 bg-right bg-no-repeat opacity-[0.3] z-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "url(/problem_blitz.jpg)",
|
backgroundImage: "url(/problem_blitz.jpg)",
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const Process = () => {
|
|||||||
{
|
{
|
||||||
number: 4,
|
number: 4,
|
||||||
title: "Launch & Betreuung",
|
title: "Launch & Betreuung",
|
||||||
description: "Go-live mit Tests und Übergabe. Danach Updates, wenn sich Ihr Unternehmen weiterentwickelt.",
|
description: "Go-live mit Tests und Übergabe. Pflege und Updates danach im Wartungs-Abo.",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
import { ArrowUpRight } from "lucide-react";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import BorderGlow from "@/components/BorderGlow";
|
|
||||||
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
|
|
||||||
|
|
||||||
type Project = {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
image: string;
|
|
||||||
url: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const projects: Project[] = [
|
|
||||||
{
|
|
||||||
title: "Email Sorter",
|
|
||||||
description: "Automatisierung · E-Mail-Workflows für Teams",
|
|
||||||
image: "/project%20pics/emailsorter.webp",
|
|
||||||
url: "https://emailsorter.webklar.com/",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Neutral",
|
|
||||||
description: "Website · Markenauftritt & Custom Development",
|
|
||||||
image: "https://images.unsplash.com/photo-1551288049-bebda4e38f71?w=800&h=600&fit=crop",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Verbatim Labs",
|
|
||||||
description: "Website · UI/UX & individuelle Entwicklung",
|
|
||||||
image: "https://images.unsplash.com/photo-1559028012-481c04fa702d?w=800&h=600&fit=crop",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "JMK Engineers",
|
|
||||||
description: "Website · Technische Präsentation & Lead-Generierung",
|
|
||||||
image: "https://images.unsplash.com/photo-1486312338219-ce68d2c6f44d?w=800&h=600&fit=crop",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "GOODZ Club",
|
|
||||||
description: "Website · Mehrsprachig & skalierbare Plattform",
|
|
||||||
image: "https://images.unsplash.com/photo-1542744094-3a31f272c490?w=800&h=600&fit=crop",
|
|
||||||
url: "#",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const ProjectShowcase = () => {
|
|
||||||
const { resolvedTheme } = useTheme();
|
|
||||||
const cardBg = resolvedTheme === "dark" ? "hsl(0 0% 6%)" : "hsl(0 0% 96%)";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<section id="projects" className="pt-8 pb-24 md:pt-12 md:pb-32 bg-background relative">
|
|
||||||
{/* TextHoverEffect */}
|
|
||||||
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
|
|
||||||
<TextHoverEffect text="Projekte" />
|
|
||||||
</div>
|
|
||||||
<div className="container mx-auto px-6">
|
|
||||||
|
|
||||||
{/* Projects Grid */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{projects.map((project, index) => (
|
|
||||||
<BorderGlow
|
|
||||||
key={project.title}
|
|
||||||
edgeSensitivity={30}
|
|
||||||
glowColor="40 80 80"
|
|
||||||
backgroundColor={cardBg}
|
|
||||||
borderRadius={8}
|
|
||||||
glowRadius={30}
|
|
||||||
glowIntensity={0.8}
|
|
||||||
coneSpread={25}
|
|
||||||
colors={['#c084fc', '#f472b6', '#38bdf8']}
|
|
||||||
>
|
|
||||||
<a
|
|
||||||
href={project.url}
|
|
||||||
target={project.url.startsWith("http") ? "_blank" : undefined}
|
|
||||||
rel={project.url.startsWith("http") ? "noopener noreferrer" : undefined}
|
|
||||||
className="group block p-6 md:p-8"
|
|
||||||
style={{ animationDelay: `${index * 0.1}s` }}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<h3 className="text-2xl md:text-3xl font-display font-medium text-foreground mb-2 group-hover:text-muted-foreground transition-colors uppercase tracking-tight">
|
|
||||||
{project.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-muted-foreground text-sm uppercase tracking-wider">
|
|
||||||
{project.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="w-24 h-16 md:w-32 md:h-20 rounded overflow-hidden opacity-0 group-hover:opacity-100 transition-opacity duration-500">
|
|
||||||
<img
|
|
||||||
src={project.image}
|
|
||||||
alt={project.title}
|
|
||||||
loading="lazy"
|
|
||||||
decoding="async"
|
|
||||||
className="w-full h-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<ArrowUpRight className="w-6 h-6 text-muted-foreground group-hover:text-foreground transition-colors" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</a>
|
|
||||||
</BorderGlow>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProjectShowcase;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Lightbulb, Palette, Code, Search } from "lucide-react";
|
import { Lightbulb, Palette, Code, Server } from "lucide-react";
|
||||||
|
|
||||||
const Services = () => {
|
const Services = () => {
|
||||||
const services = [
|
const services = [
|
||||||
@@ -21,9 +21,9 @@ const Services = () => {
|
|||||||
number: "03",
|
number: "03",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Search,
|
icon: Server,
|
||||||
title: "SEO & Betreuung",
|
title: "Hosting & Betreuung",
|
||||||
description: "Sichtbar bei Google, stabil im Betrieb – mit Updates, wenn Ihr Unternehmen wächst.",
|
description: "Hosting auf unserem Server in Deutschland, Pflege und Updates im Wartungs-Abo.",
|
||||||
number: "04",
|
number: "04",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ const SolutionSection = () => {
|
|||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* Hintergrundbild mittig, auf Handy maximale Breite */}
|
{/* Hintergrundbild mittig, auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
|
||||||
<div
|
<div
|
||||||
className="solution-section-bg absolute inset-0 bg-center bg-no-repeat opacity-[0.3] z-0"
|
className="solution-section-bg hidden dark:block absolute inset-0 bg-center bg-no-repeat opacity-[0.3] z-0"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "url(/loesung.jpg)",
|
backgroundImage: "url(/loesung.jpg)",
|
||||||
}}
|
}}
|
||||||
@@ -121,7 +121,7 @@ const SolutionSection = () => {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="text-sm uppercase tracking-wider text-muted-foreground">Das Ergebnis</div>
|
<div className="text-sm uppercase tracking-wider text-muted-foreground">Das Ergebnis</div>
|
||||||
<h3 className="text-2xl md:text-3xl font-display font-medium text-foreground uppercase tracking-tight">
|
<h3 className="text-2xl md:text-3xl font-display font-medium text-foreground uppercase tracking-tight">
|
||||||
Sie sehen, was läuft – und was Umsatz bringt. Mehr Zeit für Kunden statt für Tool-Pflege:
|
Sie sehen, was läuft. Mehr Zeit für Kunden statt für Tool-Pflege:
|
||||||
</h3>
|
</h3>
|
||||||
<div className="text-4xl md:text-5xl font-display font-medium text-primary uppercase tracking-tight">
|
<div className="text-4xl md:text-5xl font-display font-medium text-primary uppercase tracking-tight">
|
||||||
Kontrolle.
|
Kontrolle.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Users, Cog, MessageSquare, Target, BarChart3, Layers } from "lucide-react";
|
import { Users, Cog, MessageSquare, MonitorSmartphone, BarChart3, Layers } from "lucide-react";
|
||||||
import BorderGlow from "@/components/BorderGlow";
|
import BorderGlow from "@/components/BorderGlow";
|
||||||
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
|
import { TextHoverEffect } from "@/components/ui/text-hover-effect";
|
||||||
|
|
||||||
@@ -20,9 +20,9 @@ const Values = () => {
|
|||||||
description: "Jeder Kanal landet im System – mit Status, Zuständigkeit und Historie.",
|
description: "Jeder Kanal landet im System – mit Status, Zuständigkeit und Historie.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: Target,
|
icon: MonitorSmartphone,
|
||||||
title: "Planbare Neukunden",
|
title: "Kundenportal",
|
||||||
description: "Website und Prozesse ziehen an einer Schnur – Sie sehen, was funktioniert.",
|
description: "Fortschritt, Chat und Rechnungen an einem Ort, im Browser oder als App.",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: BarChart3,
|
icon: BarChart3,
|
||||||
@@ -42,9 +42,9 @@ const Values = () => {
|
|||||||
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
|
<div className="h-[14rem] flex items-center justify-center -mb-4 relative z-10">
|
||||||
<TextHoverEffect text="Leistungen" />
|
<TextHoverEffect text="Leistungen" />
|
||||||
</div>
|
</div>
|
||||||
{/* Hintergrundbild: auf Handy maximale Breite */}
|
{/* Hintergrundbild: auf Handy maximale Breite; nur im Dark Mode (dunkles Bild wäre im hellen ein graues Quadrat) */}
|
||||||
<div
|
<div
|
||||||
className="values-section-bg absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
|
className="values-section-bg hidden dark:block absolute inset-0 bg-right bg-no-repeat opacity-[0.3] pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "url(/backgroud_effect.webp)",
|
backgroundImage: "url(/backgroud_effect.webp)",
|
||||||
}}
|
}}
|
||||||
@@ -69,6 +69,7 @@ const Values = () => {
|
|||||||
edgeSensitivity={30}
|
edgeSensitivity={30}
|
||||||
glowColor="40 80 80"
|
glowColor="40 80 80"
|
||||||
backgroundColor="hsl(0 0% 6%)"
|
backgroundColor="hsl(0 0% 6%)"
|
||||||
|
lightBackgroundColor="hsl(0 0% 96%)"
|
||||||
borderRadius={8}
|
borderRadius={8}
|
||||||
glowRadius={30}
|
glowRadius={30}
|
||||||
glowIntensity={0.8}
|
glowIntensity={0.8}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { Children, useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
import "./wisch-reihe.css";
|
||||||
|
|
||||||
|
type WischReiheProps = {
|
||||||
|
/** Beschreibung für Screenreader, z. B. „Vier Leistungen, seitlich wischen“ */
|
||||||
|
ariaLabel: string;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
/** Zähler „2 / 4“ unter der Reihe anzeigen (Standard: ja) */
|
||||||
|
zaehler?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Horizontale Wisch-Reihe für Handys (Spezifikation 9.4): natives Scroll-Snap ohne Bibliothek,
|
||||||
|
* die nächste Karte ist angeschnitten sichtbar, darunter ein Zähler. Keine automatische Bewegung.
|
||||||
|
* Ab 900 px entscheidet der Abschnitt selbst, ob er die Reihe überhaupt zeigt.
|
||||||
|
*/
|
||||||
|
const WischReihe = ({ ariaLabel, children, className, zaehler = true }: WischReiheProps) => {
|
||||||
|
const karten = Children.toArray(children);
|
||||||
|
const reiheRef = useRef<HTMLUListElement>(null);
|
||||||
|
const [aktiv, setAktiv] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const reihe = reiheRef.current;
|
||||||
|
if (!reihe || typeof IntersectionObserver === "undefined") return;
|
||||||
|
const beobachter = new IntersectionObserver(
|
||||||
|
(eintraege) => {
|
||||||
|
for (const e of eintraege) {
|
||||||
|
if (e.isIntersecting) setAktiv(Number((e.target as HTMLElement).dataset.index));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ root: reihe, threshold: 0.6 },
|
||||||
|
);
|
||||||
|
reihe.querySelectorAll<HTMLElement>(":scope > li").forEach((li) => beobachter.observe(li));
|
||||||
|
return () => beobachter.disconnect();
|
||||||
|
}, [karten.length]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`wk-wisch${className ? ` ${className}` : ""}`}>
|
||||||
|
<ul ref={reiheRef} className="wk-wisch__reihe" role="list" tabIndex={0} aria-label={ariaLabel}>
|
||||||
|
{karten.map((karte, i) => (
|
||||||
|
<li key={i} className="wk-wisch__karte" data-index={i}>
|
||||||
|
{karte}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{zaehler && karten.length > 1 && (
|
||||||
|
<p className="wk-wisch__zaehler wk-num" aria-hidden>
|
||||||
|
{aktiv + 1} / {karten.length}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WischReihe;
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/* Deutschland-Karte (Spezifikation 7.1): ruhiger Umriss, roter Punkt auf Falkenstein */
|
||||||
|
|
||||||
|
.wk-karte {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 663 / 892;
|
||||||
|
/* Strichstärke in viewBox-Einheiten, damit die Linie bei jeder Kartengröße etwa 1,25 px stark ist
|
||||||
|
(Desktop: Karte ca. 0,75 px je Einheit, Handy ca. 0,43 px je Einheit) */
|
||||||
|
--karte-strich: 1.7;
|
||||||
|
--karte-punkt: 7px;
|
||||||
|
/* Abstand zwischen Beschriftung und Punkt: Die tschechische Grenze (Ascher Zipfel) liegt knapp links unter dem Punkt */
|
||||||
|
--karte-abstand: 22px;
|
||||||
|
}
|
||||||
|
@media (max-width: 899.98px) {
|
||||||
|
.wk-karte {
|
||||||
|
--karte-strich: 2.9;
|
||||||
|
--karte-punkt: 10px;
|
||||||
|
--karte-abstand: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-karte__svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-karte__umriss path {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--line-strong);
|
||||||
|
stroke-width: var(--karte-strich);
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-karte__kern {
|
||||||
|
fill: var(--accent);
|
||||||
|
r: var(--karte-punkt);
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
/* Büro (optional, O8): hohler Punkt */
|
||||||
|
.wk-karte__punkt--buero .wk-karte__kern {
|
||||||
|
fill: var(--bg);
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 2.4;
|
||||||
|
}
|
||||||
|
/* Der Ring ist im Grundzustand unsichtbar: ohne Bewegung gibt es ihn nicht */
|
||||||
|
.wk-karte__ring {
|
||||||
|
fill: none;
|
||||||
|
stroke: var(--accent);
|
||||||
|
stroke-width: 1.2;
|
||||||
|
opacity: 0;
|
||||||
|
r: var(--karte-punkt);
|
||||||
|
transform-box: fill-box;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beschriftung als echter Text, links neben dem Punkt: dort liegt die leere Fläche innerhalb von
|
||||||
|
Deutschland. Rechts und darunter verläuft die tschechische Grenze dicht am Punkt. */
|
||||||
|
.wk-karte__label {
|
||||||
|
position: absolute;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(58%, 18rem);
|
||||||
|
text-align: right;
|
||||||
|
/* erste Zeile mittig auf Höhe des Punktes, rechter Rand links vom Punkt */
|
||||||
|
transform: translate(calc(-100% - var(--karte-abstand)), -0.72em);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.wk-karte__titel {
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.25;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--fg);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
.wk-karte__zusatz {
|
||||||
|
margin-top: 0.3rem;
|
||||||
|
font-size: var(--fs-small);
|
||||||
|
line-height: 1.4;
|
||||||
|
color: var(--fg-muted);
|
||||||
|
text-wrap: balance;
|
||||||
|
}
|
||||||
|
/* Handy: Die Karte ist klein, links vom Punkt wäre bis zur Westgrenze zu wenig Platz. Dort steht die
|
||||||
|
Beschriftung oberhalb des Punktes, über der leeren Fläche von Thüringen und Sachsen. */
|
||||||
|
@media (max-width: 899.98px) {
|
||||||
|
.wk-karte__label {
|
||||||
|
transform: translate(calc(-100% + 6px), calc(-100% - 14px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (min-width: 900px) {
|
||||||
|
.wk-karte__titel {
|
||||||
|
font-size: 1.1875rem;
|
||||||
|
}
|
||||||
|
.wk-karte__zusatz {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
max-width: 24ch;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Bewegung (nur ohne reduzierte Bewegung) ────────────────────────────────
|
||||||
|
Ablauf: Umriss zeichnet sich in 1,6 s, danach erscheinen Punkt und Beschriftung, dann pulsiert
|
||||||
|
ein Ring dreimal (2,4 s je Durchlauf) und bleibt dann weg. Ausnahmen laut Spezifikation 8.4.
|
||||||
|
Der Umriss wird über stroke-dashoffset gezeichnet (pathLength="1"). Das Muster „1 2“ mit Startversatz
|
||||||
|
1,06 lässt auch bei runden Linienenden nichts übrig, das vor dem Start als Pünktchen sichtbar wäre. */
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.wk-karte[data-lauf="wartet"] .wk-karte__umriss path,
|
||||||
|
.wk-karte[data-lauf="laeuft"] .wk-karte__umriss path {
|
||||||
|
stroke-dasharray: 1 2;
|
||||||
|
stroke-dashoffset: 1.06;
|
||||||
|
}
|
||||||
|
.wk-karte[data-lauf="wartet"] .wk-karte__kern,
|
||||||
|
.wk-karte[data-lauf="wartet"] .wk-karte__label {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wk-karte[data-lauf="laeuft"] .wk-karte__umriss path {
|
||||||
|
animation: wk-karte-zeichnen 1600ms var(--ease) 150ms forwards;
|
||||||
|
}
|
||||||
|
.wk-karte[data-lauf="laeuft"] .wk-karte__kern {
|
||||||
|
animation: wk-karte-punkt 500ms var(--ease) 1750ms backwards;
|
||||||
|
}
|
||||||
|
.wk-karte[data-lauf="laeuft"] .wk-karte__label {
|
||||||
|
animation: wk-karte-label 600ms var(--ease) 1950ms backwards;
|
||||||
|
}
|
||||||
|
.wk-karte[data-lauf="laeuft"] .wk-karte__ring {
|
||||||
|
animation: wk-karte-ring 2400ms var(--ease) 2250ms 3 forwards;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wk-karte-zeichnen {
|
||||||
|
from {
|
||||||
|
stroke-dashoffset: 1.06;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes wk-karte-punkt {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.3);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes wk-karte-label {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes wk-karte-ring {
|
||||||
|
from {
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(2.6);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/*
|
||||||
|
* DeviceFrames.tsx – React-Komponenten für die Geräterahmen
|
||||||
|
* (MacBook Pro 14" und iPhone 16 Pro, Styles in device-frames.css)
|
||||||
|
*
|
||||||
|
* import { MacBookFrame, IPhoneFrame } from './DeviceFrames';
|
||||||
|
*
|
||||||
|
* <MacBookFrame url="project.webklar.com" className="w-[760px]">
|
||||||
|
* <PortalDesktop /> // gesetzt in 1512 x 893 (Safari-Viewport)
|
||||||
|
* </MacBookFrame>
|
||||||
|
* <IPhoneFrame className="w-[220px]">
|
||||||
|
* <PortalMobile /> // gesetzt in 402 x 874, oben 59 / unten 34 frei lassen
|
||||||
|
* </IPhoneFrame>
|
||||||
|
*
|
||||||
|
* Die Breite des Wurzelelements bestimmt die Größe, alles andere skaliert mit
|
||||||
|
* (beim MacBook ist das die Breite des Unterteils, der Deckel ist 87,7 % davon).
|
||||||
|
* device-frames.js wird in React nicht gebraucht: der Hook unten skaliert.
|
||||||
|
* Weitere Attribute (id, aria-hidden, data-*, Events …) gehen an das Wurzelelement.
|
||||||
|
*/
|
||||||
|
import { useEffect, useLayoutEffect, useRef, version } from 'react';
|
||||||
|
import type { CSSProperties, HTMLAttributes, ReactNode, RefObject } from 'react';
|
||||||
|
import './device-frames.css';
|
||||||
|
|
||||||
|
export type MacBookColor = 'silver' | 'space-black';
|
||||||
|
export type IPhoneColor = 'black' | 'natural' | 'white' | 'desert';
|
||||||
|
|
||||||
|
// useLayoutEffect warnt beim Server-Rendering, dort reicht useEffect (läuft nicht)
|
||||||
|
const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
|
||||||
|
|
||||||
|
/** Passt eine Canvas in Designgröße per transform: scale() an ihren Viewport an. */
|
||||||
|
function useCanvasScale(ref: RefObject<HTMLDivElement | null>, width: number, height: number): void {
|
||||||
|
useIsoLayoutEffect(() => {
|
||||||
|
const canvas = ref.current;
|
||||||
|
const host = canvas ? canvas.parentElement : null;
|
||||||
|
if (!canvas || !host) return undefined;
|
||||||
|
const apply = (w: number) => {
|
||||||
|
if (!w) return; // noch nicht sichtbar
|
||||||
|
const k = Math.round((w / width) * 1e6) / 1e6;
|
||||||
|
canvas.style.setProperty('--dm-dw', String(width));
|
||||||
|
canvas.style.setProperty('--dm-dh', String(height));
|
||||||
|
canvas.style.setProperty('--dm-k', String(k));
|
||||||
|
};
|
||||||
|
// Bruchteil-genaue Breite (clientWidth rundet)
|
||||||
|
apply(parseFloat(getComputedStyle(host).width) || host.clientWidth);
|
||||||
|
if (typeof ResizeObserver !== 'function') return undefined;
|
||||||
|
const ro = new ResizeObserver((entries) => {
|
||||||
|
const last = entries[entries.length - 1];
|
||||||
|
if (last) apply(last.contentRect.width);
|
||||||
|
});
|
||||||
|
ro.observe(host);
|
||||||
|
return () => ro.disconnect();
|
||||||
|
}, [ref, width, height]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// inert: React 19 kennt es als boolesches Attribut (dort hieße '' "aus"),
|
||||||
|
// React 18 reicht es als unbekanntes Attribut nur als String durch ('' = gesetzt).
|
||||||
|
const INERT = (parseInt(version, 10) >= 19 ? { inert: true } : { inert: '' }) as unknown as { inert?: boolean };
|
||||||
|
|
||||||
|
/** decorative: Gerät ist ein Bild (role="img"), Inhalt nicht fokussier- oder klickbar */
|
||||||
|
function DeviceCanvas({ width, height, decorative, children }: {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
decorative?: boolean;
|
||||||
|
children?: ReactNode;
|
||||||
|
}) {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
useCanvasScale(ref, width, height);
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="dm-canvas" {...(decorative ? INERT : null)} data-design-width={width} data-design-height={height}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Attribute, die an das Wurzelelement weitergereicht werden (id, aria-hidden, data-*, onClick …) */
|
||||||
|
type RootAttributes = Omit<HTMLAttributes<HTMLDivElement>, 'children' | 'role' | 'color' | 'className' | 'style'>;
|
||||||
|
|
||||||
|
const cx = (...parts: Array<string | undefined | false>) => parts.filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ Symbole
|
||||||
|
Alle dekorativ (aria-hidden), gezeichnet in Design-Punkten. */
|
||||||
|
const G = {
|
||||||
|
// Ampel als eine Grafik: drei exakt gleiche Kreise bei jeder Größe
|
||||||
|
lights: (
|
||||||
|
<svg viewBox="0 0 52 12" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="6" cy="6" r="5.7" fill="#ff5f57" stroke="#000" strokeOpacity=".22" strokeWidth=".6" />
|
||||||
|
<circle cx="26" cy="6" r="5.7" fill="#febc2e" stroke="#000" strokeOpacity=".22" strokeWidth=".6" />
|
||||||
|
<circle cx="46" cy="6" r="5.7" fill="#28c840" stroke="#000" strokeOpacity=".22" strokeWidth=".6" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
apple: (
|
||||||
|
<svg className="dm-macbook__apple" viewBox="0 0 14 17" aria-hidden="true" focusable="false">
|
||||||
|
<path fill="currentColor" d="M11.62 9.03c-.02-1.9 1.55-2.82 1.62-2.86-.88-1.29-2.26-1.47-2.75-1.49-1.17-.12-2.28.69-2.88.69-.59 0-1.5-.67-2.47-.65A3.67 3.67 0 0 0 2.05 6.6C.73 8.88 1.71 12.27 3 14.13c.63.91 1.38 1.93 2.36 1.9.95-.04 1.31-.61 2.45-.61 1.15 0 1.47.61 2.47.59 1.02-.02 1.66-.93 2.29-1.84.72-1.06 1.02-2.08 1.03-2.13-.02-.01-1.97-.76-1.98-3.01ZM9.75 3.46c.52-.63.87-1.51.78-2.38-.75.03-1.66.5-2.2 1.13-.48.56-.9 1.45-.79 2.31.84.07 1.69-.42 2.21-1.06Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
macBattery: (
|
||||||
|
<svg viewBox="0 0 27 13" aria-hidden="true" focusable="false">
|
||||||
|
<rect x=".6" y=".6" width="23.2" height="11.8" rx="3.4" fill="none" stroke="currentColor" strokeOpacity=".45" strokeWidth="1.2" />
|
||||||
|
<rect x="2.3" y="2.3" width="16.4" height="8.4" rx="1.9" fill="currentColor" />
|
||||||
|
<path d="M25 4.4c.9.2 1.5.9 1.5 2.1s-.6 1.9-1.5 2.1z" fill="currentColor" fillOpacity=".45" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
wifi: (
|
||||||
|
<svg viewBox="0 0 17 12.6" aria-hidden="true" focusable="false">
|
||||||
|
<g fill="none" stroke="currentColor" strokeWidth="2.1">
|
||||||
|
<path d="M1.43 4.93a10 10 0 0 1 14.14 0" />
|
||||||
|
<path d="M4.05 7.55a6.3 6.3 0 0 1 8.9 0" />
|
||||||
|
</g>
|
||||||
|
<path fill="currentColor" d="M8.5 12 5.95 9.45a3.6 3.6 0 0 1 5.1 0Z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
search: (
|
||||||
|
<svg viewBox="0 0 15 15" aria-hidden="true" focusable="false">
|
||||||
|
<circle cx="6.2" cy="6.2" r="4.9" fill="none" stroke="currentColor" strokeWidth="1.6" />
|
||||||
|
<path d="m9.8 9.8 4.2 4.2" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
control: (
|
||||||
|
<svg viewBox="0 0 17 14" aria-hidden="true" focusable="false">
|
||||||
|
<rect x=".8" y=".8" width="15.4" height="5.2" rx="2.6" fill="none" stroke="currentColor" strokeWidth="1.3" />
|
||||||
|
<circle cx="4.1" cy="3.4" r="1.7" fill="currentColor" />
|
||||||
|
<rect x=".8" y="8" width="15.4" height="5.2" rx="2.6" fill="none" stroke="currentColor" strokeWidth="1.3" />
|
||||||
|
<circle cx="12.9" cy="10.6" r="1.7" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
sidebar: (
|
||||||
|
<svg viewBox="0 0 22 17" aria-hidden="true" focusable="false">
|
||||||
|
<rect x=".8" y=".8" width="20.4" height="15.4" rx="3.4" fill="none" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M8 1v15" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M3.3 4.6h2.4M3.3 7.1h2.4M3.3 9.6h2.4" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
chevDown: (
|
||||||
|
<svg viewBox="0 0 9 6" aria-hidden="true" focusable="false">
|
||||||
|
<path d="m1 1 3.5 3.5L8 1" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
back: (
|
||||||
|
<svg viewBox="0 0 10 17" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M8.3 1.4 1.7 8.5l6.6 7.1" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
fwd: (
|
||||||
|
<svg viewBox="0 0 10 17" aria-hidden="true" focusable="false">
|
||||||
|
<path d="m1.7 1.4 6.6 7.1-6.6 7.1" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
pageMenu: (
|
||||||
|
<svg viewBox="0 0 18 14" aria-hidden="true" focusable="false">
|
||||||
|
<rect x=".8" y=".8" width="16.4" height="12.4" rx="2.6" fill="none" stroke="currentColor" strokeWidth="1.3" />
|
||||||
|
<path d="M4 4.6h10M4 7h10M4 9.4h6" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
lock: (
|
||||||
|
<svg viewBox="0 0 10 13" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M2.6 5.6V4a2.4 2.4 0 0 1 4.8 0v1.6" fill="none" stroke="currentColor" strokeWidth="1.35" />
|
||||||
|
<rect x=".6" y="5.4" width="8.8" height="7" rx="1.6" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
reload: (
|
||||||
|
<svg viewBox="0 0 14 15" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M12 8.4A5 5 0 1 1 9.7 3.6" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<path d="M8.4 1 11.2 3.5 8.2 5.9" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
share: (
|
||||||
|
<svg viewBox="0 0 16 20" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M5.2 7.2H3.4A1.9 1.9 0 0 0 1.5 9.1v7.5c0 1.05.85 1.9 1.9 1.9h9.2c1.05 0 1.9-.85 1.9-1.9V9.1a1.9 1.9 0 0 0-1.9-1.9h-1.8" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
<path d="M8 12.2V1.4M4.8 4.4 8 1.2l3.2 3.2" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
plus: (
|
||||||
|
<svg viewBox="0 0 15 15" aria-hidden="true" focusable="false">
|
||||||
|
<path d="M7.5 1v13M1 7.5h13" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
tabs: (
|
||||||
|
<svg viewBox="0 0 18 18" aria-hidden="true" focusable="false">
|
||||||
|
<rect x="4.8" y=".9" width="12.3" height="12.3" rx="2.6" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
<path d="M12.9 15.3a2.4 2.4 0 0 1-2.2 1.8H3.4A2.5 2.5 0 0 1 .9 14.6V7.3a2.4 2.4 0 0 1 1.8-2.3" fill="none" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
cellular: (
|
||||||
|
<svg viewBox="0 0 18 12" aria-hidden="true" focusable="false">
|
||||||
|
<rect x="0" y="7.4" width="3.1" height="4.6" rx=".9" fill="currentColor" />
|
||||||
|
<rect x="4.9" y="5" width="3.1" height="7" rx=".9" fill="currentColor" />
|
||||||
|
<rect x="9.8" y="2.5" width="3.1" height="9.5" rx=".9" fill="currentColor" />
|
||||||
|
<rect x="14.7" y="0" width="3.1" height="12" rx=".9" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
iosBattery: (
|
||||||
|
<svg viewBox="0 0 27.5 13" aria-hidden="true" focusable="false">
|
||||||
|
<rect x=".55" y=".55" width="23.9" height="11.9" rx="3.7" fill="none" stroke="currentColor" strokeOpacity=".38" strokeWidth="1.1" />
|
||||||
|
<rect x="2.1" y="2.1" width="18.6" height="8.8" rx="2.2" fill="currentColor" />
|
||||||
|
<path d="M25.6 4.3c.95.25 1.5 1.05 1.5 2.2s-.55 1.95-1.5 2.2z" fill="currentColor" fillOpacity=".4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const MENUS = ['Ablage', 'Bearbeiten', 'Darstellung', 'Verlauf', 'Lesezeichen', 'Fenster', 'Hilfe'];
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ MacBook */
|
||||||
|
export interface MacBookFrameProps extends RootAttributes {
|
||||||
|
/** Adresse in der Safari-Adressleiste */
|
||||||
|
url?: string;
|
||||||
|
color?: MacBookColor;
|
||||||
|
/** false = Vollbild ohne macOS-Menüleiste (Streifen neben der Notch schwarz, keine Fenstertasten) */
|
||||||
|
menuBar?: boolean;
|
||||||
|
/** Datum und Uhrzeit rechts in der Menüleiste */
|
||||||
|
clock?: string;
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** Setzt role="img" + aria-label; der Inhalt ist dann ein Bild (inert, nicht bedienbar) */
|
||||||
|
label?: string;
|
||||||
|
/** Inhalt in 1512 x 893 Design-Pixeln */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MacBookFrame({
|
||||||
|
url = 'project.webklar.com',
|
||||||
|
color = 'silver',
|
||||||
|
menuBar = true,
|
||||||
|
clock = 'Do. 1. Okt. 09:41',
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: MacBookFrameProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...rest}
|
||||||
|
className={cx('dm-macbook', className)}
|
||||||
|
data-color={color}
|
||||||
|
data-menubar={menuBar ? undefined : 'off'}
|
||||||
|
{...(label ? { role: 'img', 'aria-label': label } : null)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<div className="dm-macbook__lid">
|
||||||
|
<div className="dm-macbook__glass">
|
||||||
|
<div className="dm-macbook__screen">
|
||||||
|
{menuBar && (
|
||||||
|
<div className="dm-macbook__menubar" aria-hidden="true">
|
||||||
|
<div className="dm-macbook__menus">
|
||||||
|
{G.apple}
|
||||||
|
<b>Safari</b>
|
||||||
|
{MENUS.map((m) => (
|
||||||
|
<span key={m}>{m}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="dm-macbook__extras">
|
||||||
|
{G.macBattery}
|
||||||
|
{G.wifi}
|
||||||
|
{G.search}
|
||||||
|
{G.control}
|
||||||
|
<span>{clock}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="dm-macbook__window">
|
||||||
|
<div className="dm-macbook__toolbar" aria-hidden="true">
|
||||||
|
<span className="dm-macbook__lights">{G.lights}</span>
|
||||||
|
<span className="dm-macbook__tool dm-macbook__tool--sidebar">
|
||||||
|
{G.sidebar}
|
||||||
|
{G.chevDown}
|
||||||
|
</span>
|
||||||
|
<span className="dm-macbook__tool dm-macbook__tool--nav">
|
||||||
|
{G.back}
|
||||||
|
{G.fwd}
|
||||||
|
</span>
|
||||||
|
<span className="dm-macbook__address">
|
||||||
|
{G.pageMenu}
|
||||||
|
<span className="dm-macbook__url">
|
||||||
|
{G.lock}
|
||||||
|
<span>{url}</span>
|
||||||
|
</span>
|
||||||
|
{G.reload}
|
||||||
|
</span>
|
||||||
|
<span className="dm-macbook__tool dm-macbook__tool--right">
|
||||||
|
{G.share}
|
||||||
|
{G.plus}
|
||||||
|
{G.tabs}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dm-macbook__viewport">
|
||||||
|
<DeviceCanvas width={1512} height={893} decorative={!!label}>
|
||||||
|
{children}
|
||||||
|
</DeviceCanvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dm-macbook__notch" aria-hidden="true">
|
||||||
|
<i className="dm-macbook__camera" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dm-macbook__hinge" aria-hidden="true" />
|
||||||
|
<div className="dm-macbook__base" aria-hidden="true">
|
||||||
|
<i className="dm-macbook__lip" />
|
||||||
|
</div>
|
||||||
|
<div className="dm-macbook__shadow" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ iPhone */
|
||||||
|
export interface IPhoneFrameProps extends RootAttributes {
|
||||||
|
color?: IPhoneColor;
|
||||||
|
/** Uhrzeit in der Statusleiste */
|
||||||
|
time?: string;
|
||||||
|
/** 'dark' für helle App-Inhalte (schwarze Statusleiste und Home-Balken) */
|
||||||
|
statusBar?: 'light' | 'dark';
|
||||||
|
className?: string;
|
||||||
|
style?: CSSProperties;
|
||||||
|
/** Setzt role="img" + aria-label; der Inhalt ist dann ein Bild (inert, nicht bedienbar) */
|
||||||
|
label?: string;
|
||||||
|
/** Inhalt in 402 x 874 Design-Pixeln (oben 59, unten 34 Safe Area) */
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IPhoneFrame({
|
||||||
|
color = 'natural',
|
||||||
|
time = '9:41',
|
||||||
|
statusBar = 'light',
|
||||||
|
className,
|
||||||
|
style,
|
||||||
|
label,
|
||||||
|
children,
|
||||||
|
...rest
|
||||||
|
}: IPhoneFrameProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
{...rest}
|
||||||
|
className={cx('dm-iphone', className)}
|
||||||
|
data-color={color}
|
||||||
|
data-statusbar={statusBar === 'dark' ? 'dark' : undefined}
|
||||||
|
{...(label ? { role: 'img', 'aria-label': label } : null)}
|
||||||
|
style={style}
|
||||||
|
>
|
||||||
|
<span className="dm-iphone__buttons" aria-hidden="true">
|
||||||
|
<i className="dm-iphone__btn--action" />
|
||||||
|
<i className="dm-iphone__btn--vol-up" />
|
||||||
|
<i className="dm-iphone__btn--vol-down" />
|
||||||
|
<i className="dm-iphone__btn--side" />
|
||||||
|
<i className="dm-iphone__btn--camera" />
|
||||||
|
</span>
|
||||||
|
<div className="dm-iphone__body">
|
||||||
|
<div className="dm-iphone__glass">
|
||||||
|
<div className="dm-iphone__screen">
|
||||||
|
<div className="dm-iphone__viewport">
|
||||||
|
<DeviceCanvas width={402} height={874} decorative={!!label}>
|
||||||
|
{children}
|
||||||
|
</DeviceCanvas>
|
||||||
|
</div>
|
||||||
|
<div className="dm-iphone__statusbar" aria-hidden="true">
|
||||||
|
<span className="dm-iphone__time">{time}</span>
|
||||||
|
<span className="dm-iphone__indicators">
|
||||||
|
{G.cellular}
|
||||||
|
{G.wifi}
|
||||||
|
{G.iosBattery}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="dm-iphone__island" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
</div>
|
||||||
|
<div className="dm-iphone__home" aria-hidden="true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,786 @@
|
|||||||
|
/* ==========================================================================
|
||||||
|
device-frames.css – realistische, fluide Geräterahmen
|
||||||
|
MacBook Pro 14" (ab 2021, mit Notch) · iPhone 16 Pro / 17 Pro
|
||||||
|
|
||||||
|
Eine Breite skaliert alles:
|
||||||
|
.dm-macbook { width: 760px } .dm-iphone { width: 220px }
|
||||||
|
Die Rahmengeometrie steht in cqw des Wurzelelements (container-type).
|
||||||
|
Die Bildschirm-Chrome (Menüleiste, Safari, Statusleiste) ist in
|
||||||
|
Design-Punkten gezeichnet: --dm-u = 1 pt des echten Bildschirms.
|
||||||
|
Seiteninhalt gehört in .dm-canvas (feste Designgröße, wird per
|
||||||
|
device-frames.js bzw. React-Hook mit transform: scale() eingepasst):
|
||||||
|
MacBook-Webviewport 1512 x 893, iPhone-Bildschirm 402 x 874.
|
||||||
|
|
||||||
|
Maße (echte Geräte):
|
||||||
|
MacBook Unterteil = Wurzelbreite = 114 % des Deckels (L = 87,72cqw);
|
||||||
|
Alurand 0,35 % L, schwarzes Glas 1,4 % L (Seiten/oben),
|
||||||
|
Kinn 3,1 % L, Deckelecke 2,0 % L, Panelecke 0,7 % S,
|
||||||
|
Display 1512 x 982 pt, Notch 188 x 37 pt.
|
||||||
|
iPhone Gehäuse 71,5 x 149,6 mm, Display 402 x 874 pt,
|
||||||
|
Titanband 1,35 % + schwarzer Rand 2,05 % der Gehäusebreite,
|
||||||
|
konzentrische Radien, Dynamic Island 126 x 37 pt @ 11 pt.
|
||||||
|
========================================================================== */
|
||||||
|
|
||||||
|
/* Wurzeln mit Spezifität 0: Klassen der Seite (Tailwind usw.) gewinnen */
|
||||||
|
:where(.dm-macbook, .dm-iphone) {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
|
container-type: inline-size;
|
||||||
|
isolation: isolate;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
:where(.dm-macbook) {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* box-sizing nur für die eigenen dm-Elemente, nicht für den Inhalt */
|
||||||
|
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"]),
|
||||||
|
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"])::before,
|
||||||
|
:where(.dm-macbook, .dm-iphone) :where([class^="dm-"], [class*=" dm-"])::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Systemschrift nur für die Chrome-Ebenen */
|
||||||
|
.dm-macbook__menubar,
|
||||||
|
.dm-macbook__toolbar,
|
||||||
|
.dm-iphone__statusbar {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", Inter, system-ui, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 1;
|
||||||
|
text-align: left;
|
||||||
|
text-transform: none;
|
||||||
|
text-rendering: geometricPrecision; /* kein Hinting-Zittern bei kleinen Größen */
|
||||||
|
}
|
||||||
|
:where(.dm-macbook__menubar, .dm-macbook__toolbar, .dm-iphone__statusbar) svg {
|
||||||
|
display: block;
|
||||||
|
flex: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
Inhalts-Canvas (gemeinsam)
|
||||||
|
-------------------------------------------------------------------------- */
|
||||||
|
.dm-canvas {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: calc(var(--dm-dw) * 1px);
|
||||||
|
height: calc(var(--dm-dh) * 1px);
|
||||||
|
transform-origin: 0 0;
|
||||||
|
/* JS setzt --dm-k; ohne JS rechnet CSS Breite / Designbreite */
|
||||||
|
transform: scale(var(--dm-k, tan(atan2(100cqw, calc(var(--dm-dw) * 1px)))));
|
||||||
|
overflow: hidden;
|
||||||
|
container-type: inline-size; /* cqw im Inhalt = Design-Pixel */
|
||||||
|
-webkit-user-select: text;
|
||||||
|
user-select: text;
|
||||||
|
}
|
||||||
|
/* Gerät als Bild (role="img"): Inhalt ist Dekoration, nicht bedienbar
|
||||||
|
(zusätzlich zum inert-Attribut der Canvas) */
|
||||||
|
:where(.dm-macbook, .dm-iphone)[role="img"] .dm-canvas {
|
||||||
|
pointer-events: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
MacBook Pro 14"
|
||||||
|
========================================================================== */
|
||||||
|
.dm-macbook {
|
||||||
|
/* Silber (Standard) */
|
||||||
|
--dm-al-hi: #f7f8f9;
|
||||||
|
--dm-al-1: #e0e1e4;
|
||||||
|
--dm-al-2: #cbcdd1;
|
||||||
|
--dm-al-3: #b1b4b9;
|
||||||
|
--dm-al-4: #8f9298;
|
||||||
|
--dm-al-5: #6d7076;
|
||||||
|
--dm-al-6: #4b4d52;
|
||||||
|
--dm-al-spec: rgba(255, 255, 255, .85);
|
||||||
|
--dm-al-sheen: rgba(255, 255, 255, .5);
|
||||||
|
--dm-al-edge: rgba(40, 42, 46, .5);
|
||||||
|
--dm-hinge-1: #08080a;
|
||||||
|
--dm-hinge-2: #2b2c2f;
|
||||||
|
--dm-hinge-3: #46484c;
|
||||||
|
--dm-foot: #2a2b2e;
|
||||||
|
--dm-bezel: #060607;
|
||||||
|
}
|
||||||
|
.dm-macbook[data-color="space-black"] {
|
||||||
|
--dm-al-hi: #8a8c91;
|
||||||
|
--dm-al-1: #56585c;
|
||||||
|
--dm-al-2: #424448;
|
||||||
|
--dm-al-3: #333539;
|
||||||
|
--dm-al-4: #26272a;
|
||||||
|
--dm-al-5: #1b1c1e;
|
||||||
|
--dm-al-6: #111213;
|
||||||
|
--dm-al-spec: rgba(255, 255, 255, .3);
|
||||||
|
--dm-al-sheen: rgba(255, 255, 255, .16);
|
||||||
|
--dm-al-edge: rgba(0, 0, 0, .6);
|
||||||
|
--dm-hinge-1: #050506;
|
||||||
|
--dm-hinge-2: #1e1f21;
|
||||||
|
--dm-hinge-3: #34363a;
|
||||||
|
--dm-foot: #0b0b0c;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Deckel: Aluschale, nur als haarfeiner Rand sichtbar ---- */
|
||||||
|
.dm-macbook__lid {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: 87.72cqw;
|
||||||
|
padding: .307cqw; /* Rand 0,35 % L */
|
||||||
|
border-radius: 1.754cqw 1.754cqw .26cqw .26cqw; /* 2,0 % L oben, unten fast eckig */
|
||||||
|
background:
|
||||||
|
/* Glanzstreifen und Kanten, die vom Licht wegdrehen */
|
||||||
|
linear-gradient(90deg,
|
||||||
|
rgba(0,0,0,.16) 0%, rgba(0,0,0,0) 9%,
|
||||||
|
var(--dm-al-sheen) 30%, rgba(255,255,255,0) 46%,
|
||||||
|
rgba(255,255,255,0) 64%, rgba(255,255,255,.22) 78%,
|
||||||
|
rgba(0,0,0,0) 90%, rgba(0,0,0,.2) 100%),
|
||||||
|
linear-gradient(180deg,
|
||||||
|
var(--dm-al-hi) 0%, var(--dm-al-1) 3%, var(--dm-al-2) 18%, var(--dm-al-1) 34%,
|
||||||
|
var(--dm-al-3) 55%, var(--dm-al-2) 72%, var(--dm-al-3) 90%, var(--dm-al-4) 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 max(.5px, .06cqw) 0 var(--dm-al-spec), /* Glanzkante oben */
|
||||||
|
inset .19cqw 0 .16cqw rgba(0, 0, 0, .12), /* Seiten dunkler */
|
||||||
|
inset -.19cqw 0 .16cqw rgba(0, 0, 0, .2),
|
||||||
|
inset 0 -.26cqw .18cqw rgba(0, 0, 0, .28), /* Unterkante im Schatten */
|
||||||
|
0 0 0 max(.5px, .04cqw) var(--dm-al-edge); /* Außenkante */
|
||||||
|
}
|
||||||
|
/* Glanzlichter, die um den Rand wandern (ringförmig maskiert) */
|
||||||
|
.dm-macbook__lid::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
padding: .307cqw;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background: conic-gradient(from 0deg at 50% 50%,
|
||||||
|
rgba(255,255,255,.35) 0deg, rgba(255,255,255,0) 16deg,
|
||||||
|
rgba(255,255,255,.4) 40deg, rgba(255,255,255,0) 52deg,
|
||||||
|
rgba(0,0,0,.1) 70deg, rgba(255,255,255,.18) 92deg, rgba(0,0,0,.12) 118deg,
|
||||||
|
rgba(0,0,0,.2) 160deg, rgba(0,0,0,.2) 200deg,
|
||||||
|
rgba(0,0,0,.06) 236deg, rgba(255,255,255,.28) 262deg, rgba(255,255,255,0) 286deg,
|
||||||
|
rgba(255,255,255,.55) 318deg, rgba(255,255,255,.08) 336deg,
|
||||||
|
rgba(255,255,255,.35) 360deg);
|
||||||
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- schwarzes Glas mit Rändern ---- */
|
||||||
|
.dm-macbook__glass {
|
||||||
|
position: relative;
|
||||||
|
padding: 1.228cqw 1.228cqw 2.719cqw; /* 1,4 % L Seiten/oben, 3,1 % L Kinn */
|
||||||
|
border-radius: 1.447cqw 1.447cqw .1cqw .1cqw;
|
||||||
|
background: var(--dm-bezel);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 max(.5px, .05cqw) rgba(0, 0, 0, .9),
|
||||||
|
inset 0 0 0 max(1px, .1cqw) rgba(255, 255, 255, .06); /* Glaskante */
|
||||||
|
}
|
||||||
|
/* sehr schwache Spiegelung über dem ganzen Glas (stört nie die Lesbarkeit) */
|
||||||
|
.dm-macbook__glass::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(122deg,
|
||||||
|
rgba(255,255,255,.055) 0%,
|
||||||
|
rgba(255,255,255,.028) 24%,
|
||||||
|
rgba(255,255,255,.008) 46%,
|
||||||
|
rgba(255,255,255,0) 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Display 1512 x 982 pt ---- */
|
||||||
|
.dm-macbook__screen {
|
||||||
|
--dm-u: calc(100cqw / 1512); /* 1 macOS-Punkt */
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1512 / 982;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: .593cqw .593cqw 0 0; /* 0,7 % S, nur oben */
|
||||||
|
container-type: inline-size;
|
||||||
|
background: /* Schreibtisch-Hintergrundbild */
|
||||||
|
radial-gradient(70% 140% at 12% 0%, #4a2f86 0%, rgba(74,47,134,0) 60%),
|
||||||
|
radial-gradient(60% 120% at 88% 0%, #1f3b6e 0%, rgba(31,59,110,0) 65%),
|
||||||
|
linear-gradient(180deg, #1b1430, #0b0a12);
|
||||||
|
}
|
||||||
|
/* Vollbild ohne Menüleiste: Streifen neben der Notch ist schwarz wie der Rand,
|
||||||
|
die Notch verschwindet darin */
|
||||||
|
.dm-macbook[data-menubar="off"] .dm-macbook__screen { background: var(--dm-bezel); }
|
||||||
|
|
||||||
|
/* macOS-Menüleiste (durchscheinend über dem Hintergrund, von der Notch geteilt) */
|
||||||
|
.dm-macbook__menubar {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
height: calc(37 * var(--dm-u));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 0 calc(14 * var(--dm-u)) 0 calc(21 * var(--dm-u));
|
||||||
|
font-size: calc(13 * var(--dm-u));
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .005em;
|
||||||
|
color: rgba(255, 255, 255, .94);
|
||||||
|
background: linear-gradient(180deg, rgba(0,0,0,.28), rgba(0,0,0,.18));
|
||||||
|
text-shadow: 0 0 calc(2 * var(--dm-u)) rgba(0, 0, 0, .25);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Text darf nie in die Notch laufen (auch nicht mit Mindestschriftgröße):
|
||||||
|
was nicht passt, bricht in eine unsichtbare zweite Zeile um */
|
||||||
|
.dm-macbook__menus,
|
||||||
|
.dm-macbook__extras {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.dm-macbook__menus {
|
||||||
|
max-width: calc(633 * var(--dm-u));
|
||||||
|
column-gap: calc(19 * var(--dm-u));
|
||||||
|
}
|
||||||
|
.dm-macbook__extras {
|
||||||
|
max-width: calc(640 * var(--dm-u));
|
||||||
|
justify-content: flex-end;
|
||||||
|
column-gap: calc(17 * var(--dm-u));
|
||||||
|
}
|
||||||
|
.dm-macbook__menus > span,
|
||||||
|
.dm-macbook__menus > b,
|
||||||
|
.dm-macbook__extras > span { line-height: calc(37 * var(--dm-u)); }
|
||||||
|
.dm-macbook__menus b { font-weight: 700; margin-left: calc(4 * var(--dm-u)); }
|
||||||
|
.dm-macbook__apple { width: calc(14 * var(--dm-u)); height: calc(17 * var(--dm-u)); margin-top: calc(-2 * var(--dm-u)); }
|
||||||
|
.dm-macbook__extras svg { height: calc(13 * var(--dm-u)); width: auto; }
|
||||||
|
.dm-macbook__extras svg:nth-child(3) { height: calc(14 * var(--dm-u)); }
|
||||||
|
.dm-macbook__extras span { margin-left: calc(2 * var(--dm-u)); white-space: pre; }
|
||||||
|
|
||||||
|
/* Notch in Rahmenschwarz, mit kleinen Hohlkehlen und Kamera */
|
||||||
|
.dm-macbook__notch {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
z-index: 3;
|
||||||
|
width: calc(188 * var(--dm-u));
|
||||||
|
height: calc(37 * var(--dm-u));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: var(--dm-bezel);
|
||||||
|
border-radius: 0 0 calc(13 * var(--dm-u)) calc(13 * var(--dm-u));
|
||||||
|
}
|
||||||
|
.dm-macbook__notch::before,
|
||||||
|
.dm-macbook__notch::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
width: calc(6 * var(--dm-u));
|
||||||
|
height: calc(6 * var(--dm-u));
|
||||||
|
}
|
||||||
|
.dm-macbook__notch::before {
|
||||||
|
right: 100%;
|
||||||
|
background: radial-gradient(circle at 0 100%, transparent calc(6 * var(--dm-u) - .5px), var(--dm-bezel) calc(6 * var(--dm-u)));
|
||||||
|
}
|
||||||
|
.dm-macbook__notch::after {
|
||||||
|
left: 100%;
|
||||||
|
background: radial-gradient(circle at 100% 100%, transparent calc(6 * var(--dm-u) - .5px), var(--dm-bezel) calc(6 * var(--dm-u)));
|
||||||
|
}
|
||||||
|
.dm-macbook__camera {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
width: calc(8 * var(--dm-u));
|
||||||
|
height: calc(8 * var(--dm-u));
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 36% 34%, rgba(150,140,255,.55) 0, rgba(80,70,170,.25) 22%, rgba(0,0,0,0) 46%),
|
||||||
|
radial-gradient(circle at 50% 50%, #0d0f1c 0, #07080f 55%, #15161c 70%, #050506 100%);
|
||||||
|
box-shadow: 0 0 0 calc(1.2 * var(--dm-u)) rgba(255, 255, 255, .035);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Safari-Fenster (maximiert), Dunkelmodus */
|
||||||
|
.dm-macbook__window {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(37 * var(--dm-u));
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: calc(12 * var(--dm-u)) calc(12 * var(--dm-u)) calc(6 * var(--dm-u)) calc(6 * var(--dm-u));
|
||||||
|
background: #1c1c1e;
|
||||||
|
}
|
||||||
|
.dm-macbook[data-menubar="off"] .dm-macbook__window { border-radius: 0; }
|
||||||
|
.dm-macbook__window::after { /* Fensterkontur im Dunkelmodus */
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: inset 0 0 0 max(.5px, calc(1 * var(--dm-u))) rgba(255, 255, 255, .13);
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
.dm-macbook__toolbar {
|
||||||
|
position: relative;
|
||||||
|
flex: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: calc(52 * var(--dm-u));
|
||||||
|
padding: 0 calc(20 * var(--dm-u));
|
||||||
|
background: linear-gradient(180deg, #333336, #2c2c2f);
|
||||||
|
box-shadow: inset 0 calc(-1 * var(--dm-u)) 0 rgba(0, 0, 0, .75);
|
||||||
|
color: rgba(255, 255, 255, .62);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
/* Ampel als eine Vektorgrafik: drei gleich große Kreise bei jeder Größe */
|
||||||
|
.dm-macbook__lights { display: flex; margin-right: calc(28 * var(--dm-u)); }
|
||||||
|
.dm-macbook__lights svg { width: calc(52 * var(--dm-u)); height: calc(12 * var(--dm-u)); }
|
||||||
|
/* Vollbild: macOS blendet die Fenstertasten aus */
|
||||||
|
.dm-macbook[data-menubar="off"] .dm-macbook__lights { visibility: hidden; }
|
||||||
|
.dm-macbook__tool { display: flex; align-items: center; }
|
||||||
|
.dm-macbook__tool--sidebar { gap: calc(5 * var(--dm-u)); margin-right: calc(30 * var(--dm-u)); }
|
||||||
|
.dm-macbook__tool--sidebar svg:first-child { width: calc(22 * var(--dm-u)); }
|
||||||
|
.dm-macbook__tool--sidebar svg:last-child { width: calc(9 * var(--dm-u)); opacity: .8; }
|
||||||
|
.dm-macbook__tool--nav { gap: calc(26 * var(--dm-u)); }
|
||||||
|
.dm-macbook__tool--nav svg { width: calc(10 * var(--dm-u)); }
|
||||||
|
.dm-macbook__tool--nav svg:last-child { opacity: .35; }
|
||||||
|
.dm-macbook__tool--right { margin-left: auto; gap: calc(28 * var(--dm-u)); }
|
||||||
|
.dm-macbook__tool--right svg:nth-child(1) { width: calc(16 * var(--dm-u)); margin-top: calc(-3 * var(--dm-u)); }
|
||||||
|
.dm-macbook__tool--right svg:nth-child(2) { width: calc(15 * var(--dm-u)); }
|
||||||
|
.dm-macbook__tool--right svg:nth-child(3) { width: calc(18 * var(--dm-u)); }
|
||||||
|
.dm-macbook__address {
|
||||||
|
position: absolute;
|
||||||
|
left: 50%;
|
||||||
|
top: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: calc(10 * var(--dm-u));
|
||||||
|
width: calc(640 * var(--dm-u));
|
||||||
|
height: calc(32 * var(--dm-u));
|
||||||
|
padding: 0 calc(11 * var(--dm-u));
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border-radius: calc(9 * var(--dm-u));
|
||||||
|
background: rgba(255, 255, 255, .085);
|
||||||
|
box-shadow: inset 0 0 0 max(.4px, calc(.5 * var(--dm-u))) rgba(255, 255, 255, .04);
|
||||||
|
}
|
||||||
|
.dm-macbook__address > svg:first-child { width: calc(18 * var(--dm-u)); opacity: .75; }
|
||||||
|
.dm-macbook__address > svg:last-child { width: calc(14 * var(--dm-u)); opacity: .75; }
|
||||||
|
.dm-macbook__url {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
gap: calc(6 * var(--dm-u));
|
||||||
|
font-size: calc(13.5 * var(--dm-u));
|
||||||
|
font-weight: 500;
|
||||||
|
color: rgba(255, 255, 255, .9);
|
||||||
|
letter-spacing: .005em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dm-macbook__url span { overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.dm-macbook__url svg { width: calc(9.5 * var(--dm-u)); color: rgba(255, 255, 255, .6); margin-top: calc(-1 * var(--dm-u)); }
|
||||||
|
|
||||||
|
/* Web-Viewport: 1512 x 893 pt */
|
||||||
|
.dm-macbook__viewport {
|
||||||
|
--dm-dw: 1512;
|
||||||
|
--dm-dh: 893;
|
||||||
|
position: relative;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #000;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Scharnier (dunkel, endet unter dem Alurand des Deckels) ---- */
|
||||||
|
.dm-macbook__hinge {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 87.2cqw; /* 99,4 % L */
|
||||||
|
height: .78cqw; /* 0,9 % L */
|
||||||
|
margin-top: -.05cqw;
|
||||||
|
border-radius: 0 0 .5cqw .5cqw / 0 0 .5cqw .5cqw;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(0,0,0,.55), rgba(0,0,0,0) 4%, rgba(0,0,0,0) 96%, rgba(0,0,0,.55)),
|
||||||
|
linear-gradient(180deg, var(--dm-hinge-1) 0%, var(--dm-hinge-1) 22%, var(--dm-hinge-2) 50%, var(--dm-hinge-3) 66%, var(--dm-hinge-2) 80%, var(--dm-hinge-1) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Unterteil: Aluplatte, fast genau von vorn gesehen ---- */
|
||||||
|
.dm-macbook__base {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
width: 100cqw;
|
||||||
|
height: 2.72cqw; /* 3,1 % L */
|
||||||
|
margin-top: -.15cqw;
|
||||||
|
margin-bottom: .36cqw; /* Platz für die Füße */
|
||||||
|
border-radius: 1.2cqw 1.2cqw 2.2cqw 2.2cqw / .7cqw .7cqw 1.6cqw 1.6cqw;
|
||||||
|
background:
|
||||||
|
/* von oben gerundete Enden: auch die helle Oberkante dreht dort weg */
|
||||||
|
radial-gradient(4.2cqw 1.5cqw at 0 0, rgba(0,0,0,.24), rgba(0,0,0,.08) 55%, rgba(0,0,0,0)),
|
||||||
|
radial-gradient(4.2cqw 1.5cqw at 100% 0, rgba(0,0,0,.24), rgba(0,0,0,.08) 55%, rgba(0,0,0,0)),
|
||||||
|
/* Enden, die vom Licht wegdrehen, und breite Glanzstreifen */
|
||||||
|
linear-gradient(90deg,
|
||||||
|
rgba(0,0,0,.32) 0%, rgba(0,0,0,.08) 2.5%, rgba(0,0,0,0) 6%,
|
||||||
|
rgba(255,255,255,.16) 24%, rgba(255,255,255,0) 40%,
|
||||||
|
rgba(255,255,255,0) 62%, rgba(255,255,255,.1) 76%,
|
||||||
|
rgba(0,0,0,0) 94%, rgba(0,0,0,.08) 97.5%, rgba(0,0,0,.32) 100%),
|
||||||
|
/* Oberseite als schmaler Streifen -> Kante -> Front, die in den Schatten fällt */
|
||||||
|
linear-gradient(180deg,
|
||||||
|
var(--dm-al-hi) 0%, var(--dm-al-1) 4%, var(--dm-al-2) 10%, var(--dm-al-3) 14%,
|
||||||
|
var(--dm-al-5) 16%, var(--dm-al-2) 18.5%, var(--dm-al-hi) 21.5%, var(--dm-al-1) 28%,
|
||||||
|
var(--dm-al-2) 42%, var(--dm-al-3) 58%, var(--dm-al-4) 72%, var(--dm-al-5) 84%,
|
||||||
|
var(--dm-al-6) 93%, var(--dm-al-5) 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 calc(-1 * max(.5px, .044cqw)) 0 rgba(0, 0, 0, .35),
|
||||||
|
0 0 0 max(.5px, .03cqw) rgba(0, 0, 0, .2);
|
||||||
|
}
|
||||||
|
/* Daumenmulde in der Vorderkante */
|
||||||
|
.dm-macbook__lip {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
width: 12.28cqw; /* 14 % L */
|
||||||
|
height: 1.07cqw; /* ca. 40 % der Höhe */
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: 0 0 3.5cqw 3.5cqw / 0 0 1.07cqw 1.07cqw;
|
||||||
|
background:
|
||||||
|
linear-gradient(90deg, rgba(0,0,0,.22) 0%, rgba(0,0,0,0) 22%, rgba(0,0,0,0) 78%, rgba(0,0,0,.22) 100%),
|
||||||
|
linear-gradient(180deg,
|
||||||
|
var(--dm-al-5) 0%, var(--dm-al-4) 30%, var(--dm-al-3) 60%, var(--dm-al-2) 86%, var(--dm-al-1) 100%);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 .1cqw .18cqw rgba(0, 0, 0, .28),
|
||||||
|
0 max(.5px, .044cqw) 0 rgba(255, 255, 255, .5); /* beleuchtete Unterlippe */
|
||||||
|
}
|
||||||
|
/* Gummifüße */
|
||||||
|
.dm-macbook__base::before,
|
||||||
|
.dm-macbook__base::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% - .08cqw);
|
||||||
|
width: 4.2cqw;
|
||||||
|
height: .26cqw;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(closest-side, var(--dm-foot) 30%, rgba(0, 0, 0, 0));
|
||||||
|
opacity: .7; /* von vorn kaum sichtbar */
|
||||||
|
}
|
||||||
|
.dm-macbook__base::before { left: 8.5cqw; }
|
||||||
|
.dm-macbook__base::after { right: 8.5cqw; }
|
||||||
|
|
||||||
|
/* Kontakt- und Umgebungsschatten auf dem "Tisch" */
|
||||||
|
.dm-macbook__shadow {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: -2.3cqw;
|
||||||
|
height: 3.2cqw;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.dm-macbook__shadow::before,
|
||||||
|
.dm-macbook__shadow::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.dm-macbook__shadow::before { /* Kontakt */
|
||||||
|
left: 3%;
|
||||||
|
right: 3%;
|
||||||
|
top: .6cqw;
|
||||||
|
height: .85cqw;
|
||||||
|
background: rgba(0, 0, 0, .8);
|
||||||
|
filter: blur(.27cqw);
|
||||||
|
}
|
||||||
|
.dm-macbook__shadow::after { /* Umgebung */
|
||||||
|
left: 1%;
|
||||||
|
right: 1%;
|
||||||
|
top: .45cqw;
|
||||||
|
height: 2.7cqw;
|
||||||
|
background: radial-gradient(closest-side, rgba(0, 0, 0, .5), rgba(0, 0, 0, .22) 60%, rgba(0, 0, 0, 0));
|
||||||
|
filter: blur(.44cqw);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sehr klein: Haarlinien weglassen, sie würden nur rauschen */
|
||||||
|
@container (max-width: 320px) {
|
||||||
|
.dm-macbook__base::before,
|
||||||
|
.dm-macbook__base::after { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================================================
|
||||||
|
iPhone 16 Pro
|
||||||
|
========================================================================== */
|
||||||
|
.dm-iphone {
|
||||||
|
/* Titan Natur (Standard): warmes, gebürstetes Mittelgrau */
|
||||||
|
--dm-t-hi: #e2ddd5;
|
||||||
|
--dm-t-1: #cac4ba;
|
||||||
|
--dm-t-2: #b3aca1;
|
||||||
|
--dm-t-3: #999287;
|
||||||
|
--dm-t-4: #7f786e;
|
||||||
|
--dm-t-5: #5c564e;
|
||||||
|
--dm-t-ant: rgba(112, 104, 92, .55);
|
||||||
|
--dm-t-glint: rgba(255, 255, 255, .38);
|
||||||
|
--dm-t-band-hl: rgba(255, 255, 255, .10);
|
||||||
|
--dm-status: #fff;
|
||||||
|
}
|
||||||
|
.dm-iphone[data-color="black"] {
|
||||||
|
--dm-t-hi: #6c6c70;
|
||||||
|
--dm-t-1: #46464a;
|
||||||
|
--dm-t-2: #363639;
|
||||||
|
--dm-t-3: #2a2a2d;
|
||||||
|
--dm-t-4: #1e1e21;
|
||||||
|
--dm-t-5: #111113;
|
||||||
|
--dm-t-top-light: rgba(255, 255, 255, .09);
|
||||||
|
--dm-t-ant: rgba(4, 4, 6, .6);
|
||||||
|
--dm-t-glint: rgba(255, 255, 255, .26);
|
||||||
|
--dm-t-band-hl: rgba(255, 255, 255, .06);
|
||||||
|
--dm-t-glint-opacity: .55; /* dunkles Titan spiegelt matter */
|
||||||
|
}
|
||||||
|
.dm-iphone[data-color="white"] {
|
||||||
|
--dm-t-hi: #ffffff;
|
||||||
|
--dm-t-1: #f1f1ef;
|
||||||
|
--dm-t-2: #e1e1de;
|
||||||
|
--dm-t-3: #cbcbc8;
|
||||||
|
--dm-t-4: #aeaeab;
|
||||||
|
--dm-t-5: #8a8a87;
|
||||||
|
--dm-t-ant: rgba(150, 150, 146, .55);
|
||||||
|
--dm-t-glint: rgba(255, 255, 255, .6);
|
||||||
|
}
|
||||||
|
.dm-iphone[data-color="desert"] {
|
||||||
|
--dm-t-hi: #f0e2d2;
|
||||||
|
--dm-t-1: #dcc4ab;
|
||||||
|
--dm-t-2: #c4a98f;
|
||||||
|
--dm-t-3: #ad9178;
|
||||||
|
--dm-t-4: #8f7560;
|
||||||
|
--dm-t-5: #6b5645;
|
||||||
|
--dm-t-ant: rgba(130, 104, 82, .55);
|
||||||
|
--dm-t-glint: rgba(255, 246, 236, .55);
|
||||||
|
}
|
||||||
|
/* heller App-Inhalt: dunkle Statusleiste und Home-Balken */
|
||||||
|
.dm-iphone[data-statusbar="dark"] { --dm-status: #000; }
|
||||||
|
|
||||||
|
/* ---- Titanband (von vorn sichtbar) ---- */
|
||||||
|
.dm-iphone__body {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 1.35cqw; /* Titanband */
|
||||||
|
border-radius: 17.66cqw; /* 14,26 + 2,05 + 1,35 */
|
||||||
|
background:
|
||||||
|
/* Antennenlinien (nur auf dem Band sichtbar, Glas deckt den Rest) */
|
||||||
|
linear-gradient(180deg, transparent 10.9%, var(--dm-t-ant) 10.9%, var(--dm-t-ant) 11.15%, transparent 11.15%,
|
||||||
|
transparent 88.85%, var(--dm-t-ant) 88.85%, var(--dm-t-ant) 89.1%, transparent 89.1%),
|
||||||
|
linear-gradient(90deg, transparent 23.6%, var(--dm-t-ant) 23.6%, var(--dm-t-ant) 24.1%, transparent 24.1%,
|
||||||
|
transparent 75.9%, var(--dm-t-ant) 75.9%, var(--dm-t-ant) 76.4%, transparent 76.4%),
|
||||||
|
/* Licht von oben */
|
||||||
|
linear-gradient(180deg, var(--dm-t-top-light, rgba(255,255,255,.16)), rgba(255,255,255,0) 22%, rgba(0,0,0,0) 74%, rgba(0,0,0,.14)),
|
||||||
|
/* Umgebungsspiegelungen rundherum (0deg = oben) */
|
||||||
|
conic-gradient(from 0deg at 50% 50%,
|
||||||
|
var(--dm-t-1) 0deg, var(--dm-t-hi) 13deg, var(--dm-t-2) 26deg, var(--dm-t-3) 40deg,
|
||||||
|
var(--dm-t-hi) 58deg, var(--dm-t-2) 72deg, var(--dm-t-3) 100deg, var(--dm-t-4) 126deg,
|
||||||
|
var(--dm-t-2) 148deg, var(--dm-t-3) 166deg, var(--dm-t-4) 182deg, var(--dm-t-3) 196deg,
|
||||||
|
var(--dm-t-1) 211deg, var(--dm-t-3) 236deg, var(--dm-t-4) 258deg, var(--dm-t-2) 282deg,
|
||||||
|
var(--dm-t-hi) 302deg, var(--dm-t-2) 318deg, var(--dm-t-1) 336deg, var(--dm-t-hi) 347deg, var(--dm-t-1) 360deg);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 max(.5px, .16cqw) rgba(0, 0, 0, .30), /* Außenkante rollt weg */
|
||||||
|
inset 0 0 0 .42cqw rgba(0, 0, 0, .10),
|
||||||
|
inset 0 0 0 .82cqw var(--dm-t-band-hl, rgba(255, 255, 255, .16)), /* gerundetes Band fängt Licht */
|
||||||
|
0 0 0 max(.5px, .12cqw) rgba(0, 0, 0, .28), /* Silhouette auf hellem Grund */
|
||||||
|
0 .8cqw 1.6cqw rgba(0, 0, 0, .18),
|
||||||
|
0 5cqw 12cqw -1cqw rgba(0, 0, 0, .42);
|
||||||
|
}
|
||||||
|
/* Glanzpunkte auf dem Band (ringförmig maskiert, das Glas bleibt frei) */
|
||||||
|
.dm-iphone__body::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: .16cqw;
|
||||||
|
z-index: 1;
|
||||||
|
padding: 1.1cqw;
|
||||||
|
border-radius: 17.5cqw;
|
||||||
|
pointer-events: none;
|
||||||
|
background: conic-gradient(from 0deg at 50% 50%,
|
||||||
|
rgba(255,255,255,.1) 0deg, rgba(255,255,255,0) 14deg,
|
||||||
|
var(--dm-t-glint) 26deg, rgba(255,255,255,0) 38deg,
|
||||||
|
rgba(255,255,255,0) 80deg, rgba(255,255,255,.14) 100deg, rgba(255,255,255,0) 116deg,
|
||||||
|
rgba(255,255,255,0) 146deg, rgba(255,255,255,.24) 156deg, rgba(255,255,255,0) 166deg,
|
||||||
|
rgba(0,0,0,.1) 190deg, rgba(255,255,255,0) 214deg,
|
||||||
|
rgba(255,255,255,.04) 250deg, rgba(255,255,255,.2) 284deg, rgba(255,255,255,.04) 306deg,
|
||||||
|
var(--dm-t-glint) 326deg, rgba(255,255,255,.7) 333deg, var(--dm-t-glint) 340deg,
|
||||||
|
rgba(255,255,255,.06) 350deg, rgba(255,255,255,.1) 360deg);
|
||||||
|
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
|
||||||
|
-webkit-mask-composite: xor;
|
||||||
|
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||||
|
opacity: var(--dm-t-glint-opacity, .8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- schwarzes Glas / Rand ---- */
|
||||||
|
.dm-iphone__glass {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
padding: 2.05cqw;
|
||||||
|
border-radius: 16.31cqw;
|
||||||
|
background: #020203;
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 max(.5px, .12cqw) var(--dm-t-1), /* polierte Fase */
|
||||||
|
0 0 0 max(.6px, .3cqw) rgba(0, 0, 0, .18),
|
||||||
|
inset 0 0 0 max(.5px, .14cqw) rgba(255, 255, 255, .07); /* Glaskante */
|
||||||
|
}
|
||||||
|
.dm-iphone__glass::after { /* Spiegelung */
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(118deg,
|
||||||
|
rgba(255,255,255,.07) 0%,
|
||||||
|
rgba(255,255,255,.03) 22%,
|
||||||
|
rgba(255,255,255,.01) 40%,
|
||||||
|
rgba(255,255,255,0) 52%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Display 402 x 874 pt ---- */
|
||||||
|
.dm-iphone__screen {
|
||||||
|
--dm-u: calc(100cqw / 402); /* 1 iOS-Punkt */
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 402 / 874;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 14.26cqw; /* 15,3 % der Displaybreite */
|
||||||
|
background: #000;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
.dm-iphone__viewport {
|
||||||
|
--dm-dw: 402;
|
||||||
|
--dm-dh: 874;
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
container-type: inline-size;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Statusleiste: Uhrzeit mittig im linken "Ohr", Symbole im rechten */
|
||||||
|
.dm-iphone__statusbar {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 0 auto 0;
|
||||||
|
z-index: 3;
|
||||||
|
height: calc(59 * var(--dm-u));
|
||||||
|
color: var(--dm-status);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.dm-iphone__time,
|
||||||
|
.dm-iphone__indicators {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(30 * var(--dm-u));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
}
|
||||||
|
.dm-iphone__time {
|
||||||
|
left: calc(16 * var(--dm-u));
|
||||||
|
width: calc(110 * var(--dm-u));
|
||||||
|
font-size: calc(17 * var(--dm-u));
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -.01em;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dm-iphone__indicators {
|
||||||
|
right: calc(16 * var(--dm-u));
|
||||||
|
width: calc(110 * var(--dm-u));
|
||||||
|
gap: calc(5.5 * var(--dm-u));
|
||||||
|
}
|
||||||
|
.dm-iphone__indicators svg:nth-child(1) { width: calc(18 * var(--dm-u)); }
|
||||||
|
.dm-iphone__indicators svg:nth-child(2) { width: calc(16.5 * var(--dm-u)); }
|
||||||
|
.dm-iphone__indicators svg:nth-child(3) { width: calc(27.5 * var(--dm-u)); }
|
||||||
|
|
||||||
|
/* Dynamic Island */
|
||||||
|
.dm-iphone__island {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 4;
|
||||||
|
top: calc(11 * var(--dm-u));
|
||||||
|
left: 50%;
|
||||||
|
width: calc(126 * var(--dm-u));
|
||||||
|
height: calc(37 * var(--dm-u));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: calc(18.5 * var(--dm-u));
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
.dm-iphone__island i { /* Frontkamera unter dem Glas */
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: calc(12.5 * var(--dm-u));
|
||||||
|
width: calc(12 * var(--dm-u));
|
||||||
|
height: calc(12 * var(--dm-u));
|
||||||
|
transform: translateY(-50%);
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 38% 34%, rgba(130,140,255,.42) 0, rgba(60,60,140,.18) 24%, rgba(0,0,0,0) 48%),
|
||||||
|
radial-gradient(circle, #0b0d18 0, #06070c 58%, #101116 74%, #000 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Home-Balken 140 x 5 pt, 8 pt vom unteren Rand */
|
||||||
|
.dm-iphone__home {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
left: 50%;
|
||||||
|
bottom: calc(8 * var(--dm-u));
|
||||||
|
width: calc(140 * var(--dm-u));
|
||||||
|
height: calc(5 * var(--dm-u));
|
||||||
|
transform: translateX(-50%);
|
||||||
|
border-radius: calc(3 * var(--dm-u));
|
||||||
|
background: var(--dm-status);
|
||||||
|
opacity: .92;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Seitentasten (stehen ca. 0,75 % der Gehäusebreite über) ---- */
|
||||||
|
.dm-iphone__buttons i {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 0;
|
||||||
|
width: 2.2cqw;
|
||||||
|
border-radius: .9cqw / 1.6cqw;
|
||||||
|
}
|
||||||
|
.dm-iphone__btn--action,
|
||||||
|
.dm-iphone__btn--vol-up,
|
||||||
|
.dm-iphone__btn--vol-down {
|
||||||
|
left: -.78cqw;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.35), rgba(255,255,255,0) 10%, rgba(255,255,255,0) 90%, rgba(0,0,0,.25)),
|
||||||
|
linear-gradient(90deg, var(--dm-t-4) 0%, var(--dm-t-hi) 22%, var(--dm-t-2) 48%, var(--dm-t-3) 100%);
|
||||||
|
box-shadow: inset 0 0 0 max(.4px, .1cqw) rgba(0, 0, 0, .22);
|
||||||
|
}
|
||||||
|
.dm-iphone__btn--side,
|
||||||
|
.dm-iphone__btn--camera {
|
||||||
|
right: -.78cqw;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.35), rgba(255,255,255,0) 10%, rgba(255,255,255,0) 90%, rgba(0,0,0,.25)),
|
||||||
|
linear-gradient(270deg, var(--dm-t-4) 0%, var(--dm-t-hi) 22%, var(--dm-t-2) 48%, var(--dm-t-3) 100%);
|
||||||
|
box-shadow: inset 0 0 0 max(.4px, .1cqw) rgba(0, 0, 0, .22);
|
||||||
|
}
|
||||||
|
.dm-iphone__btn--action { top: 18.2%; height: 4.3%; }
|
||||||
|
.dm-iphone__btn--vol-up { top: 28.6%; height: 7.3%; }
|
||||||
|
.dm-iphone__btn--vol-down { top: 38.6%; height: 7.3%; }
|
||||||
|
.dm-iphone__btn--side { top: 30%; height: 12%; }
|
||||||
|
/* Kamerasteuerung: fast bündig, Saphirglas in Titaneinfassung */
|
||||||
|
.dm-iphone__buttons .dm-iphone__btn--camera {
|
||||||
|
top: 60%;
|
||||||
|
height: 8%;
|
||||||
|
right: -.42cqw;
|
||||||
|
border-radius: .8cqw / 1.2cqw;
|
||||||
|
background: /* Saphirglanz über farblich passendem Teil */
|
||||||
|
linear-gradient(180deg, rgba(255,255,255,.28), rgba(255,255,255,0) 22%, rgba(255,255,255,0) 78%, rgba(255,255,255,.14)),
|
||||||
|
linear-gradient(270deg, var(--dm-t-5) 0%, var(--dm-t-2) 28%, var(--dm-t-4) 58%, var(--dm-t-3) 100%);
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 max(.5px, .14cqw) var(--dm-t-3),
|
||||||
|
inset 0 0 0 max(.4px, .08cqw) rgba(255, 255, 255, .12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* sehr klein: Antennenlinien weglassen */
|
||||||
|
@container (max-width: 140px) {
|
||||||
|
.dm-iphone__body { --dm-t-ant: transparent; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/* Inhalt der Geräterahmen (MacBook/iPhone aus device-frames): füllt die Bildschirmfläche */
|
||||||
|
.wk-geraet-inhalt {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: clip;
|
||||||
|
background: #0b0712;
|
||||||
|
}
|
||||||
|
.wk-geraet-inhalt > img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
object-position: top;
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
import { useEffect, useRef, useState, useSyncExternalStore, type MouseEvent } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Pause } from "lucide-react";
|
||||||
|
import DeutschlandKarte from "@/components/DeutschlandKarte";
|
||||||
|
import "./hero.css";
|
||||||
|
|
||||||
|
/** Wechselnder Teil der Überschrift (Spezifikation 7.1). Es wechselt der ganze Teil nach „Wir bauen“ (Ihre/Ihren). */
|
||||||
|
const VARIANTEN = ["Ihre Website.", "Ihren Online-Shop.", "Ihre Web-App.", "Ihre Automatisierung."];
|
||||||
|
/** Ein Durchlauf: Website → Online-Shop → Web-App → Automatisierung → zurück zu Website, dann Stillstand. */
|
||||||
|
const ABLAUF = [0, 1, 2, 3, 0];
|
||||||
|
/** Alle 2,4 s ein Wechsel (die Überblendung selbst dauert 400 ms, siehe hero.css) */
|
||||||
|
const TAKT_MS = 2400;
|
||||||
|
|
||||||
|
const FESTER_SATZ = "Wir bauen Websites, Online-Shops, Web-Apps und Automatisierungen.";
|
||||||
|
const REDUZIERT = "(prefers-reduced-motion: reduce)";
|
||||||
|
|
||||||
|
/** Reagiert auch auf eine Änderung der Systemeinstellung, solange die Seite offen ist. */
|
||||||
|
const useReduzierteBewegung = () =>
|
||||||
|
useSyncExternalStore(
|
||||||
|
(melde) => {
|
||||||
|
const abfrage = window.matchMedia?.(REDUZIERT);
|
||||||
|
abfrage?.addEventListener?.("change", melde);
|
||||||
|
return () => abfrage?.removeEventListener?.("change", melde);
|
||||||
|
},
|
||||||
|
() => window.matchMedia?.(REDUZIERT).matches ?? false,
|
||||||
|
() => false,
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Ein zweiter Klick auf denselben Anker ändert die URL nicht; dann selbst hinscrollen (wie in der Kopfleiste). */
|
||||||
|
const zumKundenportal = (e: MouseEvent<HTMLAnchorElement>) => {
|
||||||
|
if (window.location.pathname === "/" && window.location.hash === "#kundenportal") {
|
||||||
|
e.preventDefault();
|
||||||
|
document.getElementById("kundenportal")?.scrollIntoView();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const HeroKarte = () => {
|
||||||
|
const reduziert = useReduzierteBewegung();
|
||||||
|
const [schritt, setSchritt] = useState(0);
|
||||||
|
const [knopfGedrueckt, setKnopfGedrueckt] = useState(false);
|
||||||
|
const [hover, setHover] = useState(false);
|
||||||
|
const [fokus, setFokus] = useState(false);
|
||||||
|
const [verborgen, setVerborgen] = useState(
|
||||||
|
() => typeof document !== "undefined" && document.visibilityState === "hidden",
|
||||||
|
);
|
||||||
|
const ueberschrift = useRef<HTMLHeadingElement>(null);
|
||||||
|
|
||||||
|
// Solange der Durchlauf nicht zu Ende ist und niemand angehalten hat, ist die Überschrift „in Bewegung“
|
||||||
|
const inBewegung = !reduziert && !knopfGedrueckt && schritt < ABLAUF.length - 1;
|
||||||
|
// Hover oder Fokus auf der Überschrift und ein ausgeblendeter Tab halten den Wechsel nur an
|
||||||
|
const pausiert = hover || fokus || verborgen;
|
||||||
|
const aktiv = reduziert ? 0 : ABLAUF[schritt];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const aendern = () => setVerborgen(document.visibilityState === "hidden");
|
||||||
|
document.addEventListener("visibilitychange", aendern);
|
||||||
|
return () => document.removeEventListener("visibilitychange", aendern);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inBewegung || pausiert) return;
|
||||||
|
const timer = window.setTimeout(() => setSchritt((s) => s + 1), TAKT_MS);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [inBewegung, pausiert, schritt]);
|
||||||
|
|
||||||
|
const anhalten = () => {
|
||||||
|
setKnopfGedrueckt(true);
|
||||||
|
// Der Knopf verschwindet: Fokus auf die Überschrift, damit er nicht ins Leere fällt
|
||||||
|
ueberschrift.current?.focus({ preventScroll: true });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ohne Bewegung steht nur die erste Variante da; sonst liegen alle übereinander (die breiteste bestimmt die Breite)
|
||||||
|
const sichtbareVarianten = reduziert ? VARIANTEN.slice(0, 1) : VARIANTEN;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="wk-hero">
|
||||||
|
<div className="wk-container wk-hero__raster">
|
||||||
|
<div className="wk-hero__text">
|
||||||
|
<p className="wk-eyebrow wk-hero__zeile">Websites, Shops und Apps für kleine Betriebe</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="wk-hero__kopf"
|
||||||
|
onMouseEnter={() => setHover(true)}
|
||||||
|
onMouseLeave={() => setHover(false)}
|
||||||
|
onFocus={() => setFokus(true)}
|
||||||
|
onBlur={() => setFokus(false)}
|
||||||
|
>
|
||||||
|
<h1 ref={ueberschrift} className="wk-h1 wk-hero__h1" tabIndex={-1}>
|
||||||
|
<span className="wk-visually-hidden">{FESTER_SATZ}</span>
|
||||||
|
<span className="wk-hero__sichtbar" aria-hidden="true">
|
||||||
|
<span className="wk-hero__fest">Wir bauen</span>
|
||||||
|
<span className="wk-hero__wechsel">
|
||||||
|
{sichtbareVarianten.map((text, i) => (
|
||||||
|
<span key={text} className="wk-hero__variante" data-aktiv={i === aktiv}>
|
||||||
|
{text}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</h1>
|
||||||
|
{inBewegung && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="wk-hero__pause"
|
||||||
|
aria-label="Wechselnde Überschrift anhalten"
|
||||||
|
onClick={anhalten}
|
||||||
|
>
|
||||||
|
<span className="wk-hero__pause-symbol" aria-hidden="true">
|
||||||
|
<Pause size={14} strokeWidth={2.4} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="wk-lead wk-hero__lead">
|
||||||
|
In unserem Kundenportal sehen Sie jederzeit, wie weit Ihr Projekt ist – mit Live-Vorschau Ihrer Website.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="wk-hero__aktionen">
|
||||||
|
<Link to="/kontakt" className="wk-btn wk-btn--primary">
|
||||||
|
Projekt besprechen
|
||||||
|
</Link>
|
||||||
|
<Link to="/#kundenportal" className="wk-btn wk-btn--secondary" onClick={zumKundenportal}>
|
||||||
|
Kundenportal ansehen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="wk-hero__karte">
|
||||||
|
<DeutschlandKarte />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeroKarte;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
|
/** Spezifikation 7.7: ein Satz, ein Button, E-Mail und Telefon. */
|
||||||
|
const KontaktBuehne = () => (
|
||||||
|
<section id="kontakt" className="wk-section wk-section--alt wk-kontakt" aria-labelledby="kontakt-titel">
|
||||||
|
<div className="wk-container wk-kontakt__inhalt">
|
||||||
|
<h2 id="kontakt-titel" className="wk-h2 wk-reveal">
|
||||||
|
Erzählen Sie uns, was Sie vorhaben.
|
||||||
|
</h2>
|
||||||
|
<p className="wk-lead wk-reveal">
|
||||||
|
Zwei Sätze genügen. Wir melden uns spätestens am nächsten Werktag. Im ersten Gespräch klären wir in
|
||||||
|
15 Minuten, ob und wie wir helfen können – kostenlos und unverbindlich.
|
||||||
|
</p>
|
||||||
|
<div className="wk-kontakt__aktion wk-reveal">
|
||||||
|
<Link to="/kontakt" className="wk-btn wk-btn--primary">
|
||||||
|
Projekt besprechen
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<p className="wk-kontakt__wege">
|
||||||
|
<a href="mailto:support@webklar.com" className="wk-link">
|
||||||
|
support@webklar.com
|
||||||
|
</a>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<a href="tel:+4917623726355" className="wk-link wk-num">
|
||||||
|
0176 23726355
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
|
||||||
|
export default KontaktBuehne;
|
||||||
@@ -0,0 +1,895 @@
|
|||||||
|
import {
|
||||||
|
lazy,
|
||||||
|
Suspense,
|
||||||
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
useSyncExternalStore,
|
||||||
|
version,
|
||||||
|
type ComponentType,
|
||||||
|
type ReactNode,
|
||||||
|
type RefObject,
|
||||||
|
} from "react";
|
||||||
|
import WischReihe from "@/components/WischReihe";
|
||||||
|
import { DesktopFenster, HandyRahmen, RahmenBild, type RahmenBildDaten } from "@/components/GeraeteRahmen";
|
||||||
|
import { useBewegungErlaubt, useDemoLauf, useFeinerZeiger, useImBild } from "./demos/gemeinsam";
|
||||||
|
import type { TerminZustand } from "./demos/termin/ablauf";
|
||||||
|
import type { HeatmapZustand } from "./demos/heatmap/ablauf";
|
||||||
|
import type { PinnwandZustand } from "./demos/pinnwand/ablauf";
|
||||||
|
import type { ChatZustand } from "./demos/chat/ablauf";
|
||||||
|
import "./kundenportal.css";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abschnitt Kundenportal (Spezifikation 7.2).
|
||||||
|
* Ab 900 px: festgepinnte Bühne mit Funktionsliste links und Desktop-Fenster plus Handy-Rahmen rechts.
|
||||||
|
* Darunter (unter 900 px): keine Bühne, sondern eine Wisch-Reihe mit je einem Handy-Screenshot.
|
||||||
|
* Danach die Schrittzeile (ersetzt "So arbeiten wir").
|
||||||
|
* Die Stationen 5 bis 7 (Terminplaner, Website-Analyse, News-Pinnwand) zeigen statt eines Bildes eine
|
||||||
|
* Live-Demo: auf dem MacBook die erfundene Kunden-Website, auf dem iPhone das Portal (Spezifikation
|
||||||
|
* 2026-10-04-funktionen-demos). Die Demos werden per React.lazy erst geladen, wenn die Bühne ihre Station fast erreicht.
|
||||||
|
* Live sind außerdem (Wunsch des Inhabers 2026-10-04, Spezifikation 2026-10-04-vorschau-chat):
|
||||||
|
* - Live-Vorschau: im MacBook die Beispiel-Website der Bäckerei Sonnenschein, die beim Scrollen durch die Station mitläuft;
|
||||||
|
* - Chat: im MacBook der Portal-Chat, die Kamera zoomt hinein, Nachrichten ploppen auf (im iPhone im Gleichtakt).
|
||||||
|
* Der Überpunkt „Funktionen“ klappt weich auf und zu (Unterpunkte falten sich ein, „Als App“ gleitet nach, FLIP).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const EINLEITUNG =
|
||||||
|
"Sie sehen, was wir zuletzt erledigt haben, öffnen die Live-Vorschau Ihrer Website und schreiben uns im Chat – mit Fotos, Dateien oder Sprachnachricht. Ihre Rechnungen liegen ebenfalls dort, und aufs Handy installieren Sie das Portal wie eine App.";
|
||||||
|
|
||||||
|
// ── Bilder (erzeugt von scripts/relaunch-bilder.py nach public/relaunch/portal/) ──────────────────────────
|
||||||
|
|
||||||
|
const BILDER = "/relaunch/portal";
|
||||||
|
const DESKTOP_BREITEN = [800, 1200, 1600];
|
||||||
|
const HANDY_BREITEN = [390, 780];
|
||||||
|
|
||||||
|
const srcSet = (name: string, breiten: number[]) =>
|
||||||
|
breiten.map((b) => `${BILDER}/${name}-${b}.webp ${b}w`).join(", ");
|
||||||
|
|
||||||
|
/** Desktop-Bild: Rohformat 3024 x 1786 (MacBook-Safari-Fläche 1512 x 893 @2x), ausgeliefert in 800 / 1200 / 1600 px. */
|
||||||
|
const desktopBild = (name: string, alt: string): RahmenBildDaten => ({
|
||||||
|
src: `${BILDER}/${name}-1200.webp`,
|
||||||
|
srcSet: srcSet(name, DESKTOP_BREITEN),
|
||||||
|
sizes: "(min-width: 1200px) 692px, 50vw",
|
||||||
|
width: 1600,
|
||||||
|
height: 945,
|
||||||
|
alt,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Handy-Bild: Rohformat 804 x 1748 = iPhone 16 Pro @2x (das lange Bild 804 x 5131), ausgeliefert in 390 / 780 px;
|
||||||
|
* width/height geben das Seitenverhältnis bei 780 px an. */
|
||||||
|
const handyBild = (name: string, alt: string, sizes: string, hoehe = 1696): RahmenBildDaten => ({
|
||||||
|
src: `${BILDER}/${name}-780.webp`,
|
||||||
|
srcSet: srcSet(name, HANDY_BREITEN),
|
||||||
|
sizes,
|
||||||
|
width: 780,
|
||||||
|
height: hoehe,
|
||||||
|
alt,
|
||||||
|
});
|
||||||
|
|
||||||
|
type DemoArt = "termin" | "heatmap" | "pinnwand";
|
||||||
|
/** Live-Stationen außerhalb der Gruppe „Funktionen“ */
|
||||||
|
type LiveArt = "vorschau" | "chat";
|
||||||
|
type LadeArt = DemoArt | LiveArt;
|
||||||
|
|
||||||
|
type Funktion = {
|
||||||
|
id: string;
|
||||||
|
titel: string;
|
||||||
|
/** Ein Satz unter dem Titel; die drei Funktionen zeigen nur ihren Titel (Wunsch des Inhabers 2026-10-04). */
|
||||||
|
unterschrift?: string;
|
||||||
|
/** Bedienhinweis zur Live-Demo, je nachdem, ob es eine Maus gibt (nur Website-Analyse). */
|
||||||
|
bedienhinweis?: { maus: string; tippen: string };
|
||||||
|
/** Desktop-Bild; Demo-Stationen zeigen stattdessen die Website, "Als App" hat keines (dort blendet das Fenster aus). */
|
||||||
|
desktop?: { name: string; alt: string };
|
||||||
|
handy: { name: string; alt: string };
|
||||||
|
/** Der lange Screenshot, der sich im Handy-Rahmen nach oben schiebt. */
|
||||||
|
lang?: boolean;
|
||||||
|
/** Live-Demo statt Desktop-Bild: welche erfundene Kunden-Website im MacBook läuft (gehört zur Gruppe „Funktionen“). */
|
||||||
|
demo?: DemoArt;
|
||||||
|
/** Live-Station außerhalb der Gruppe: Beispiel-Website (Live-Vorschau) oder Portal-Chat mit Zoom (Chat). */
|
||||||
|
live?: LiveArt;
|
||||||
|
/** Text der Zeile „Erfundenes Beispiel · …“ unter dem MacBook, wenn nicht die Adresse dort stehen soll. */
|
||||||
|
beispiel?: string;
|
||||||
|
/** Adresse in der Safari-Leiste (reservierte Endung .example, es ist kein echter Betrieb gemeint). */
|
||||||
|
adresse?: string;
|
||||||
|
/** Beschreibung der Beispiel-Website für Screenreader (die Website selbst ist aria-hidden). */
|
||||||
|
beschreibung?: string;
|
||||||
|
/** Hinweis unter dem MacBook (Live-Vorschau, Chat, Terminplaner, Pinnwand; bei der Website-Analyse steht dort die Diagnose). */
|
||||||
|
hinweis?: string;
|
||||||
|
/** Noch nicht für Kunden buchbar: Marke „Bald verfügbar“ am Titel (Terminplaner läuft bisher nur im Pilot, Stand 04.10.2026). */
|
||||||
|
bald?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BaldMarke = ({ f }: { f: Funktion }) => (f.bald ? <span className="kp-bald">Bald verfügbar</span> : null);
|
||||||
|
|
||||||
|
/** Mitte des Knopfes "Aufhängen" im Portal-Screenshot der News-Pinnwand (Designpunkte der iPhone-Fläche 402 x 874). */
|
||||||
|
const AUFHAENGEN_PUNKT = { x: 201, y: 613 };
|
||||||
|
|
||||||
|
// Reihenfolge und Texte laut Spezifikation 7.2. Platzhalter des Portals ("kommt bald") werden nicht beworben,
|
||||||
|
// weitere Ansichten nur nach Freigabe des Inhabers (O9 = nein).
|
||||||
|
const FUNKTIONEN: Funktion[] = [
|
||||||
|
{
|
||||||
|
id: "live-vorschau",
|
||||||
|
titel: "Live-Vorschau",
|
||||||
|
unterschrift: "Ihre Website, noch während wir daran bauen.",
|
||||||
|
live: "vorschau",
|
||||||
|
adresse: "baeckerei-sonnenschein.project.webklar.com",
|
||||||
|
beispiel: "Bäckerei Sonnenschein",
|
||||||
|
beschreibung:
|
||||||
|
"Beispiel-Website einer Bäckerei in der Live-Vorschau: Startseite mit frischem Roggenbrot, Sortiment, Backstube, Torten und Öffnungszeiten",
|
||||||
|
hinweis: "Geschützt: Ihre Vorschau öffnet sich nur nach der Anmeldung im Kundenportal – Fremde sehen nichts.",
|
||||||
|
handy: { name: "phone-1-projekte", alt: "Kundenportal auf dem Handy: Projektübersicht mit Live-Vorschau der Website" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "fortschritt",
|
||||||
|
titel: "Fortschritt",
|
||||||
|
unterschrift: "Was wir zuletzt erledigt haben – in einfachen Sätzen.",
|
||||||
|
desktop: {
|
||||||
|
name: "desktop-2-fortschritt",
|
||||||
|
alt: "Kundenportal: zuletzt erledigte Arbeiten am Auftrag, in einfachen Sätzen beschrieben",
|
||||||
|
},
|
||||||
|
handy: {
|
||||||
|
name: "phone-3-projektdetails",
|
||||||
|
alt: "Kundenportal auf dem Handy: Projektdetails mit den letzten Änderungen",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "chat",
|
||||||
|
titel: "Chat",
|
||||||
|
unterschrift: "Frage, Foto oder Sprachnachricht direkt ans Team.",
|
||||||
|
live: "chat",
|
||||||
|
beispiel: "Bäckerei Sonnenschein im Kundenportal",
|
||||||
|
beschreibung:
|
||||||
|
"Chat im Kundenportal: Die Bäckerei schickt ein Foto vom neuen Roggenbrot und eine Sprachnachricht, Kenso vom WEBklar-Team antwortet",
|
||||||
|
hinweis: "Antwortet das Team, kommt zusätzlich eine E-Mail – so verpassen Sie keine Nachricht.",
|
||||||
|
handy: { name: "phone-4c-chat-leer", alt: "Kundenportal auf dem Handy: Chat mit dem WEBklar-Team" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "rechnungen",
|
||||||
|
titel: "Rechnungen",
|
||||||
|
unterschrift: "Ansehen, bezahlen, herunterladen.",
|
||||||
|
desktop: { name: "desktop-4-rechnungen", alt: "Kundenportal: Rechnungen mit Betrag und Status" },
|
||||||
|
handy: { name: "phone-5-rechnungen", alt: "Kundenportal auf dem Handy: Rechnungen zum Ansehen, Bezahlen und Herunterladen" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "terminplaner",
|
||||||
|
titel: "Terminplaner",
|
||||||
|
demo: "termin",
|
||||||
|
bald: true,
|
||||||
|
adresse: "salon-schnittstelle.example",
|
||||||
|
beschreibung:
|
||||||
|
"Beispiel-Website eines Friseursalons mit Buchungsformular und einem Kalender, in dem der neue Termin erscheint",
|
||||||
|
hinweis:
|
||||||
|
"Kalender-Abo: Den Link einmal in Google Kalender, Apple Kalender oder Outlook einfügen. Neue Termine erscheinen dort mit etwas Verzögerung.",
|
||||||
|
handy: {
|
||||||
|
name: "phone-9-termine",
|
||||||
|
alt: "Kundenportal von WEBklar auf dem Handy: Der Terminplaner zeigt die Termine des Donnerstags, darunter eine bestätigte Buchung, eine neue Anfrage, die Sie annehmen, ablehnen oder verschieben können, und einen weiteren bestätigten Termin.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "website-analyse",
|
||||||
|
titel: "Website-Analyse",
|
||||||
|
bedienhinweis: { maus: "Zeigen Sie mit der Maus auf die Seite.", tippen: "Tippen Sie auf eine Stelle der Seite." },
|
||||||
|
demo: "heatmap",
|
||||||
|
adresse: "boulderhalle-ueberhang.example",
|
||||||
|
beschreibung:
|
||||||
|
"Beispiel-Website einer Boulderhalle, darüber eine Heatmap: Je häufiger eine Stelle angeklickt wird, desto wärmer ihre Farbe",
|
||||||
|
handy: {
|
||||||
|
name: "phone-10-analyse",
|
||||||
|
alt: "Kundenportal von WEBklar auf dem Handy: Die Website-Analyse zeigt für die letzten 30 Tage Seitenaufrufe, Besucher, Kontakt-Klicks und die Zustimmungsquote, darunter ein Diagramm mit den Seitenaufrufen pro Tag.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "news-pinnwand",
|
||||||
|
titel: "News-Pinnwand",
|
||||||
|
demo: "pinnwand",
|
||||||
|
adresse: "musterbach.example",
|
||||||
|
beschreibung: "Beispiel-Website einer Ortsgemeinde mit einer Pinnwand, an der ein neuer Zettel aufgehängt wird",
|
||||||
|
hinweis: "Neuer Zettel: Er erscheint nach dem Aufhängen auf der Website.",
|
||||||
|
handy: {
|
||||||
|
name: "phone-11-news-musterbach",
|
||||||
|
alt: "Kundenportal von WEBklar auf dem Handy: Ein neuer Zettel für die News-Pinnwand mit Überschrift und Text ist ausgefüllt, darunter der Knopf zum Aufhängen auf der Website.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "als-app",
|
||||||
|
titel: "Als App",
|
||||||
|
unterschrift: "Aufs Handy installieren, fertig.",
|
||||||
|
handy: {
|
||||||
|
name: "phone-2-projekte-lang",
|
||||||
|
alt: "Kundenportal als App auf dem Handy: lange Projektseite mit Vorschau, Status und letzten Änderungen",
|
||||||
|
},
|
||||||
|
lang: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const LETZTER = FUNKTIONEN.length - 1;
|
||||||
|
/** Art der Station: bestimmt, was nachgeladen wird und welche Hinweise unter dem MacBook stehen. */
|
||||||
|
const ladeArt = (f: Funktion): LadeArt | undefined => f.demo ?? f.live;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Länge jedes Schritts auf der Bühne (CSS-Variable aus kundenportal.css): normale Stationen kurz, die Demos länger
|
||||||
|
* (der Ablauf soll zu sehen sein), die Live-Vorschau am längsten, weil die Website beim Scrollen durchläuft.
|
||||||
|
* Die Summe ergibt mit der Bühnenhöhe und dem letzten Schritt die Scrollstrecke der Hülle.
|
||||||
|
*/
|
||||||
|
const strecke = (f: Funktion) => (f.live ? `var(--kp-l-${f.live})` : f.demo ? "var(--kp-l-demo)" : "var(--kp-l-normal)");
|
||||||
|
const HUELLE_HOEHE = `calc(var(--kp-buehne-h) + ${FUNKTIONEN.slice(0, LETZTER).map(strecke).join(" + ")} + var(--kp-ende))`;
|
||||||
|
/** Überpunkt der drei Funktionen in der Liste der Bühne (Wunsch des Inhabers 2026-10-04): klappt auf, sobald eine
|
||||||
|
* der drei erreicht ist; die Unterpunkte zeigen nur ihren Titel. */
|
||||||
|
const GRUPPE = { titel: "Funktionen", text: "Bausteine für Ihre Website." };
|
||||||
|
const DEMO_INDIZES = FUNKTIONEN.flatMap((f, i) => (f.demo ? [i] : []));
|
||||||
|
|
||||||
|
// ── Demos (eigener Chunk, erst geladen, wenn die Bühne die Station fast erreicht) ──────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React.lazy mit `vorladen`: Der Chunk wird beim Einhängen der Station angestoßen, nicht erst, wenn die Komponente
|
||||||
|
* zum ersten Mal gerendert wird. So laden Website, Handy-Ebene und Taktgeber gleichzeitig (kein Wasserfall).
|
||||||
|
*/
|
||||||
|
const lazyMitVorladen = <P,>(laden: () => Promise<{ default: ComponentType<P> }>) => {
|
||||||
|
let versprechen: ReturnType<typeof laden> | undefined;
|
||||||
|
const lade = () => (versprechen ??= laden());
|
||||||
|
const Komponente = lazy(lade);
|
||||||
|
return Object.assign(Komponente, { vorladen: () => void lade().catch(() => {}) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const BaeckereiSeite = lazyMitVorladen(() => import("./demos/baeckerei/BaeckereiSeite"));
|
||||||
|
const ChatSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.ChatSteuerung })));
|
||||||
|
const ChatSeite = lazyMitVorladen(() => import("./demos/chat/ChatSeite"));
|
||||||
|
const ChatHandy = lazyMitVorladen(() => import("./demos/chat/ChatHandy"));
|
||||||
|
const TerminSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.TerminSteuerung })));
|
||||||
|
const HeatmapSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.HeatmapSteuerung })));
|
||||||
|
const PinnwandSteuerung = lazyMitVorladen(() => import("./demos/steuerung").then((m) => ({ default: m.PinnwandSteuerung })));
|
||||||
|
const TerminSeite = lazyMitVorladen(() => import("./demos/termin/TerminSeite"));
|
||||||
|
const TerminHandy = lazyMitVorladen(() => import("./demos/termin/TerminHandy"));
|
||||||
|
const HeatmapSeite = lazyMitVorladen(() => import("./demos/heatmap/HeatmapSeite"));
|
||||||
|
const HeatmapDiagnose = lazyMitVorladen(() => import("./demos/heatmap/HeatmapDiagnose"));
|
||||||
|
const PinnwandSeite = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandSeite"));
|
||||||
|
const PinnwandHandy = lazyMitVorladen(() => import("./demos/pinnwand/PinnwandHandy"));
|
||||||
|
|
||||||
|
/** Chunks je Demo; die Website-Chunks (Seite, Diagnose) und der Taktgeber laden gleichzeitig. */
|
||||||
|
const VORLADEN: Record<LadeArt, (() => void)[]> = {
|
||||||
|
vorschau: [BaeckereiSeite.vorladen],
|
||||||
|
// ChatHandy braucht nur die Wisch-Reihe (auf der Bühne gleitet das iPhone in der Station Chat weg), ist aber winzig
|
||||||
|
chat: [ChatSteuerung.vorladen, ChatSeite.vorladen, ChatHandy.vorladen],
|
||||||
|
termin: [TerminSteuerung.vorladen, TerminSeite.vorladen, TerminHandy.vorladen],
|
||||||
|
heatmap: [HeatmapSteuerung.vorladen, HeatmapSeite.vorladen, HeatmapDiagnose.vorladen],
|
||||||
|
pinnwand: [PinnwandSteuerung.vorladen, PinnwandSeite.vorladen, PinnwandHandy.vorladen],
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Hintergrund der Beispiel-Websites, solange ihr Chunk lädt (statt einer schwarzen Fläche im MacBook). */
|
||||||
|
const PLATZHALTER: Record<LadeArt, string> = {
|
||||||
|
vorschau: "#f7f0e4",
|
||||||
|
chat: "#0b0712",
|
||||||
|
termin: "#f6f1ea",
|
||||||
|
heatmap: "#141414",
|
||||||
|
pinnwand: "#f7f2e6",
|
||||||
|
};
|
||||||
|
const Platzhalter = ({ art }: { art: LadeArt }) => (
|
||||||
|
<div className="kp-platzhalter" style={{ background: PLATZHALTER[art] }} aria-hidden="true" />
|
||||||
|
);
|
||||||
|
|
||||||
|
// inert: React 19 kennt es als boolesches Attribut, React 18 reicht nur den String durch ('' = gesetzt)
|
||||||
|
const INERT = (parseInt(version, 10) >= 19 ? { inert: true } : { inert: "" }) as unknown as { inert?: boolean };
|
||||||
|
|
||||||
|
const beispielText = (f: Funktion) => `Erfundenes Beispiel · ${f.beispiel ?? f.adresse}`;
|
||||||
|
|
||||||
|
/** Satz unter dem Titel einer Karte der Wisch-Reihe; die drei Funktionen haben keinen (nur der Titel). */
|
||||||
|
const KartenUnterschrift = ({ f }: { f: Funktion }) =>
|
||||||
|
f.unterschrift ? <p className="wk-muted kp-karte__text">{f.unterschrift}</p> : null;
|
||||||
|
|
||||||
|
const SCHRITTE = [
|
||||||
|
{ nr: 1, text: "Kennenlernen" },
|
||||||
|
{ nr: 2, text: "Angebot mit Preis" },
|
||||||
|
{ nr: 3, text: "Bauen mit Vorschau und Ihrer Freigabe" },
|
||||||
|
{ nr: 4, text: "Online – Hosting und Pflege auf Wunsch im Abo" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// ── Hilfsfunktionen ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Medienabfrage als Wert. Ohne matchMedia (SSR) gilt `standard`. */
|
||||||
|
const useMedia = (abfrage: string, standard = false) =>
|
||||||
|
useSyncExternalStore(
|
||||||
|
(melden) => {
|
||||||
|
if (typeof window === "undefined" || !window.matchMedia) return () => {};
|
||||||
|
const mq = window.matchMedia(abfrage);
|
||||||
|
mq.addEventListener?.("change", melden);
|
||||||
|
return () => mq.removeEventListener?.("change", melden);
|
||||||
|
},
|
||||||
|
() => (typeof window !== "undefined" && window.matchMedia ? window.matchMedia(abfrage).matches : standard),
|
||||||
|
() => standard,
|
||||||
|
);
|
||||||
|
|
||||||
|
const begrenze = (wert: number, min: number, max: number) => Math.min(max, Math.max(min, wert));
|
||||||
|
|
||||||
|
// ── Desktop: festgepinnte Bühne ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Hülle ist hoch (Scrollstrecke), darin klebt die Bühne (position: sticky).
|
||||||
|
* Hinter der Bühne liegt eine unsichtbare Spur aus Markern, eine Marke je Funktion. Der IntersectionObserver
|
||||||
|
* meldet, welche Marke gerade die Bildschirmmitte kreuzt: das ist die aktive Funktion, das Bild blendet über.
|
||||||
|
* Beim letzten Schritt wird der Scrollfortschritt als CSS-Variable --kp-q (0 bis 1) an die Bühne geschrieben,
|
||||||
|
* und das CSS schiebt den langen Screenshot per translateY nach oben (nur transform).
|
||||||
|
*/
|
||||||
|
const Buehne = () => {
|
||||||
|
const [aktiv, setAktiv] = useState(0);
|
||||||
|
// Bilder werden erst eingehängt, wenn ihr Schritt (oder der davor) erreicht ist: spart Daten beim ersten Erreichen
|
||||||
|
const [hoechster, setHoechster] = useState(0);
|
||||||
|
const huelle = useRef<HTMLDivElement>(null);
|
||||||
|
const buehne = useRef<HTMLDivElement>(null);
|
||||||
|
const marker = useRef<(HTMLDivElement | null)[]>([]);
|
||||||
|
|
||||||
|
// Demos: laeuft = Station aktiv, Bühne im Bild, Tab sichtbar, Bewegung erlaubt (Spezifikation 4).
|
||||||
|
// Je Demo gibt es einen Taktgeber (eigener Chunk); sein Zustand geht an Website, Handy-Ebene und Diagnose.
|
||||||
|
const imBild = useImBild(buehne, 0.35);
|
||||||
|
const lauf = useDemoLauf(true, imBild);
|
||||||
|
const fein = useFeinerZeiger();
|
||||||
|
const aktivDemo = FUNKTIONEN[aktiv].demo;
|
||||||
|
const aktivLive = FUNKTIONEN[aktiv].live;
|
||||||
|
const aktivArt = ladeArt(FUNKTIONEN[aktiv]);
|
||||||
|
const bewegung = useBewegungErlaubt();
|
||||||
|
const [chat, setChat] = useState<ChatZustand | null>(null);
|
||||||
|
const [termin, setTermin] = useState<TerminZustand | null>(null);
|
||||||
|
const [heat, setHeat] = useState<HeatmapZustand | null>(null);
|
||||||
|
const [pinnwand, setPinnwand] = useState<PinnwandZustand | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof IntersectionObserver === "undefined") return;
|
||||||
|
const beobachter = new IntersectionObserver(
|
||||||
|
(eintraege) => {
|
||||||
|
for (const e of eintraege) {
|
||||||
|
if (!e.isIntersecting) continue;
|
||||||
|
const i = Number((e.target as HTMLElement).dataset.index);
|
||||||
|
setAktiv(i);
|
||||||
|
setHoechster((h) => Math.max(h, i));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
// Messlinie in der Bildschirmmitte: die Marke, die sie kreuzt, ist die aktive
|
||||||
|
{ rootMargin: "-50% 0px -50% 0px", threshold: 0 },
|
||||||
|
);
|
||||||
|
marker.current.forEach((m) => m && beobachter.observe(m));
|
||||||
|
return () => beobachter.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Scrollfortschritt des letzten Schritts (0 bis 1) für den langen Screenshot
|
||||||
|
useEffect(() => {
|
||||||
|
const el = buehne.current;
|
||||||
|
const huellenEl = huelle.current;
|
||||||
|
const letzterMarker = marker.current[LETZTER];
|
||||||
|
if (!el || !huellenEl || !letzterMarker || !window.matchMedia) return;
|
||||||
|
const ruhig = window.matchMedia("(prefers-reduced-motion: no-preference)");
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
const berechne = () => {
|
||||||
|
raf = 0;
|
||||||
|
if (!ruhig.matches) {
|
||||||
|
el.style.setProperty("--kp-q", "0");
|
||||||
|
el.style.setProperty("--kp-v", "0");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
const huelleBox = huellenEl.getBoundingClientRect();
|
||||||
|
if (huelleBox.bottom < -vh || huelleBox.top > 2 * vh) return;
|
||||||
|
const oben = parseFloat(getComputedStyle(el).top) || 0;
|
||||||
|
const gescrollt = oben - huelleBox.top; // Strecke seit dem Anheften
|
||||||
|
const strecke = huelleBox.height - el.offsetHeight; // gesamte Pin-Strecke
|
||||||
|
const seitSchritt6 = vh / 2 - letzterMarker.getBoundingClientRect().top;
|
||||||
|
const laenge = strecke - (gescrollt - seitSchritt6); // Länge des letzten Schritts
|
||||||
|
const vorlauf = 0.12 * vh; // erst bewegt sich der Rahmen, dann der Inhalt
|
||||||
|
const halten = 0.2 * vh; // am Ende bleibt der lange Screenshot kurz stehen
|
||||||
|
const q = begrenze((seitSchritt6 - vorlauf) / Math.max(1, laenge - vorlauf - halten), 0, 1);
|
||||||
|
el.style.setProperty("--kp-q", q.toFixed(4));
|
||||||
|
|
||||||
|
// Live-Vorschau (erster Schritt): die Beispiel-Website läuft vom Anheften der Bühne bis zum nächsten Schritt
|
||||||
|
// einmal von oben nach unten durch; am Anfang und am Ende bleibt sie kurz stehen
|
||||||
|
const ersterMarker = marker.current[0];
|
||||||
|
if (ersterMarker) {
|
||||||
|
const laenge1 = ersterMarker.offsetHeight - (vh / 2 - oben);
|
||||||
|
const v = begrenze((gescrollt - 0.06 * vh) / Math.max(1, laenge1 - 0.2 * vh), 0, 1);
|
||||||
|
el.style.setProperty("--kp-v", v.toFixed(4));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const planen = () => {
|
||||||
|
if (!raf) raf = requestAnimationFrame(berechne);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("scroll", planen, { passive: true });
|
||||||
|
window.addEventListener("resize", planen);
|
||||||
|
ruhig.addEventListener?.("change", planen);
|
||||||
|
berechne();
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", planen);
|
||||||
|
window.removeEventListener("resize", planen);
|
||||||
|
ruhig.removeEventListener?.("change", planen);
|
||||||
|
if (raf) cancelAnimationFrame(raf);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/** Klick in der Liste: zur Marke dieser Funktion scrollen (weich oder sofort entscheidet das CSS der Seite). */
|
||||||
|
const springe = (i: number) => {
|
||||||
|
const huellenEl = huelle.current;
|
||||||
|
const buehnenEl = buehne.current;
|
||||||
|
const m = marker.current[i];
|
||||||
|
if (!huellenEl || !buehnenEl || !m) return;
|
||||||
|
const oben = parseFloat(getComputedStyle(buehnenEl).top) || 0;
|
||||||
|
const huelleOben = huellenEl.getBoundingClientRect().top;
|
||||||
|
// Marke knapp über die Messlinie legen, mindestens aber so weit scrollen, dass die Bühne angeheftet ist
|
||||||
|
const zurMarke = huelleOben + m.offsetTop - (window.innerHeight / 2 - 1);
|
||||||
|
const zurBuehne = huelleOben - oben;
|
||||||
|
window.scrollTo({ top: window.scrollY + Math.max(zurMarke, zurBuehne) });
|
||||||
|
};
|
||||||
|
|
||||||
|
const app = aktiv === LETZTER;
|
||||||
|
const eingehaengt = (i: number) => i <= hoechster + 1;
|
||||||
|
const demoEingehaengt = (art: LadeArt) => FUNKTIONEN.some((f, i) => ladeArt(f) === art && eingehaengt(i));
|
||||||
|
|
||||||
|
// Sobald die Station eingehängt ist, laden alle Chunks der Demo gleichzeitig (Website, Handy-Ebene, Taktgeber)
|
||||||
|
useEffect(() => {
|
||||||
|
for (const art of Object.keys(VORLADEN) as LadeArt[]) {
|
||||||
|
if (FUNKTIONEN.some((f, i) => ladeArt(f) === art && i <= hoechster + 1)) VORLADEN[art].forEach((vorladen) => vorladen());
|
||||||
|
}
|
||||||
|
}, [hoechster]);
|
||||||
|
|
||||||
|
// Überpunkt „Funktionen“: offen, solange eine der drei Funktionen aktiv ist. Beim Verlassen falten sich die Unterpunkte
|
||||||
|
// erst ein („schliesst“), dann verschwindet die Unterliste und „Als App“ gleitet nach oben (ohne Bewegung: sofort zu).
|
||||||
|
const [gruppe, setGruppe] = useState<"zu" | "offen" | "schliesst">("zu");
|
||||||
|
const gruppeGewuenscht = aktivDemo !== undefined;
|
||||||
|
useEffect(() => {
|
||||||
|
if (gruppeGewuenscht) setGruppe("offen");
|
||||||
|
else setGruppe((g) => (g === "offen" && bewegung ? "schliesst" : "zu"));
|
||||||
|
}, [gruppeGewuenscht, bewegung]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (gruppe !== "schliesst") return;
|
||||||
|
const t = window.setTimeout(() => setGruppe("zu"), 260);
|
||||||
|
return () => window.clearTimeout(t);
|
||||||
|
}, [gruppe]);
|
||||||
|
|
||||||
|
// FLIP: Ändert sich die Höhe der Gruppe, gleiten die Einträge darunter von ihrer alten Lage an die neue (nur transform).
|
||||||
|
// Die Lagen werden bei jedem Stationswechsel gemerkt, bewegt wird nur, wenn die Gruppe auf- oder zugeht.
|
||||||
|
const liste = useRef<HTMLOListElement>(null);
|
||||||
|
const lagen = useRef(new Map<Element, number>());
|
||||||
|
const letzteGruppe = useRef(gruppe);
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const ol = liste.current;
|
||||||
|
if (!ol) return;
|
||||||
|
const neu = new Map<Element, number>();
|
||||||
|
for (const li of Array.from(ol.children)) neu.set(li, (li as HTMLElement).offsetTop);
|
||||||
|
if (bewegung && letzteGruppe.current !== gruppe) {
|
||||||
|
for (const [li, oben] of neu) {
|
||||||
|
const alt = lagen.current.get(li);
|
||||||
|
if (alt === undefined || alt === oben || typeof li.animate !== "function") continue;
|
||||||
|
li.animate([{ transform: `translateY(${alt - oben}px)` }, { transform: "translateY(0)" }], {
|
||||||
|
duration: 480,
|
||||||
|
easing: "cubic-bezier(0.2, 0.8, 0.2, 1)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
letzteGruppe.current = gruppe;
|
||||||
|
lagen.current = neu;
|
||||||
|
}, [gruppe, aktiv, bewegung]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kp-huelle" ref={huelle} style={{ height: HUELLE_HOEHE }}>
|
||||||
|
{/* Taktgeber der Demos (rendern nichts): erst geladen, wenn die Bühne ihre Station fast erreicht */}
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{demoEingehaengt("chat") ? (
|
||||||
|
<ChatSteuerung laeuft={lauf.laeuft && aktivLive === "chat"} statisch={lauf.statisch} onZustand={setChat} />
|
||||||
|
) : null}
|
||||||
|
{demoEingehaengt("termin") ? (
|
||||||
|
<TerminSteuerung laeuft={lauf.laeuft && aktivDemo === "termin"} statisch={lauf.statisch} onZustand={setTermin} />
|
||||||
|
) : null}
|
||||||
|
{demoEingehaengt("heatmap") ? (
|
||||||
|
<HeatmapSteuerung
|
||||||
|
laeuft={lauf.laeuft && aktivDemo === "heatmap"}
|
||||||
|
statisch={lauf.statisch}
|
||||||
|
aktiv={aktivDemo === "heatmap"}
|
||||||
|
onZustand={setHeat}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{demoEingehaengt("pinnwand") ? (
|
||||||
|
<PinnwandSteuerung laeuft={lauf.laeuft && aktivDemo === "pinnwand"} statisch={lauf.statisch} onZustand={setPinnwand} />
|
||||||
|
) : null}
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
|
<div className="kp-spur" aria-hidden="true">
|
||||||
|
{FUNKTIONEN.map((f, i) => (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className="kp-marker"
|
||||||
|
data-index={i}
|
||||||
|
style={{ ["--kp-l" as string]: strecke(f) }}
|
||||||
|
ref={(el) => (marker.current[i] = el)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="kp-buehne" ref={buehne} data-app={app ? "ja" : "nein"} data-station={FUNKTIONEN[aktiv].id}>
|
||||||
|
<div className="wk-container kp-buehne__innen">
|
||||||
|
<ol className="kp-liste" aria-label="Funktionen des Kundenportals" ref={liste}>
|
||||||
|
{FUNKTIONEN.map((f, i) => {
|
||||||
|
if (!f.demo) {
|
||||||
|
return (
|
||||||
|
<li key={f.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kp-eintrag"
|
||||||
|
aria-current={i === aktiv ? "step" : undefined}
|
||||||
|
onClick={() => springe(i)}
|
||||||
|
>
|
||||||
|
<span className="kp-eintrag__punkt" aria-hidden="true" />
|
||||||
|
<span className="kp-eintrag__titel">{f.titel}</span>
|
||||||
|
<span className="kp-eintrag__text">{f.unterschrift}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// die drei Funktionen: ein Überpunkt, der aufklappt, sobald eine von ihnen erreicht ist
|
||||||
|
if (i !== DEMO_INDIZES[0]) return null;
|
||||||
|
const offen = gruppe === "offen";
|
||||||
|
return (
|
||||||
|
<li key="funktionen" className="kp-gruppe" data-offen={offen ? "ja" : "nein"} data-zustand={gruppe}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kp-eintrag kp-eintrag--gruppe"
|
||||||
|
aria-expanded={offen}
|
||||||
|
aria-controls="kp-unterliste"
|
||||||
|
onClick={() => springe(DEMO_INDIZES[0])}
|
||||||
|
>
|
||||||
|
<span className="kp-eintrag__titel">
|
||||||
|
{GRUPPE.titel}
|
||||||
|
<svg className="kp-eintrag__pfeil" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
|
||||||
|
<path d="m4 6 4 4 4-4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span className="kp-eintrag__text">{GRUPPE.text}</span>
|
||||||
|
</button>
|
||||||
|
<ol
|
||||||
|
id="kp-unterliste"
|
||||||
|
className="kp-unterliste"
|
||||||
|
aria-label={GRUPPE.titel}
|
||||||
|
hidden={gruppe === "zu"}
|
||||||
|
aria-hidden={gruppe === "schliesst" || undefined}
|
||||||
|
>
|
||||||
|
{DEMO_INDIZES.map((d) => (
|
||||||
|
<li key={FUNKTIONEN[d].id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="kp-untereintrag"
|
||||||
|
aria-current={d === aktiv ? "step" : undefined}
|
||||||
|
onClick={() => springe(d)}
|
||||||
|
>
|
||||||
|
<span className="kp-eintrag__punkt" aria-hidden="true" />
|
||||||
|
<span className="kp-untereintrag__titel">
|
||||||
|
{FUNKTIONEN[d].titel}
|
||||||
|
<BaldMarke f={FUNKTIONEN[d]} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<div className="kp-geraete">
|
||||||
|
<DesktopFenster className="kp-fenster" adresse={FUNKTIONEN[aktiv].adresse}>
|
||||||
|
<div className="kp-ebenen" aria-hidden={app || undefined}>
|
||||||
|
{FUNKTIONEN.map((f, i) => {
|
||||||
|
const art = ladeArt(f);
|
||||||
|
if (!eingehaengt(i) || !(f.desktop || art)) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
|
||||||
|
aria-hidden={i !== aktiv || undefined}
|
||||||
|
// die Website selbst ist aria-hidden; die aktive Ebene trägt eine kurze Beschreibung
|
||||||
|
role={f.beschreibung && i === aktiv ? "img" : undefined}
|
||||||
|
aria-label={f.beschreibung && i === aktiv ? f.beschreibung : undefined}
|
||||||
|
>
|
||||||
|
{f.desktop ? (
|
||||||
|
<RahmenBild bild={desktopBild(f.desktop.name, f.desktop.alt)} />
|
||||||
|
) : art ? (
|
||||||
|
// ohne gemeldeten Zustand steht die Website im Anfangszustand da (Chunk lädt parallel zum Taktgeber)
|
||||||
|
<Suspense fallback={<Platzhalter art={art} />}>
|
||||||
|
{f.live === "vorschau" ? <BaeckereiSeite /> : null}
|
||||||
|
{f.live === "chat" ? <ChatSeite zustand={chat ?? undefined} /> : null}
|
||||||
|
{f.demo === "termin" ? <TerminSeite zustand={termin ?? undefined} variante="desktop" /> : null}
|
||||||
|
{f.demo === "heatmap" ? <HeatmapSeite zustand={heat ?? undefined} variante="desktop" /> : null}
|
||||||
|
{f.demo === "pinnwand" ? <PinnwandSeite zustand={pinnwand ?? undefined} variante="desktop" /> : null}
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</DesktopFenster>
|
||||||
|
|
||||||
|
<HandyRahmen className="kp-handy">
|
||||||
|
{FUNKTIONEN.map((f, i) =>
|
||||||
|
eingehaengt(i) ? (
|
||||||
|
<div
|
||||||
|
key={f.id}
|
||||||
|
className={`kp-lage${i === aktiv ? " is-aktiv" : ""}`}
|
||||||
|
aria-hidden={i !== aktiv || undefined}
|
||||||
|
>
|
||||||
|
{f.lang ? (
|
||||||
|
<div className="kp-lang">
|
||||||
|
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 340px, 30vw", 4978)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "(min-width: 1200px) 230px, 20vw")} />
|
||||||
|
)}
|
||||||
|
{f.demo ? (
|
||||||
|
// Live-Ebene über dem Portal-Bild: Mitteilung bzw. Tipp-Finger, synchron zur Website
|
||||||
|
// (in der Station Chat gleitet das iPhone weg, dort gibt es keine Ebene)
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{f.demo === "termin" && termin ? <TerminHandy zustand={termin} /> : null}
|
||||||
|
{f.demo === "pinnwand" && pinnwand ? <PinnwandHandy zustand={pinnwand} punkt={AUFHAENGEN_PUNKT} /> : null}
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</HandyRahmen>
|
||||||
|
|
||||||
|
{/* unter dem MacBook, links neben dem iPhone: Hinweis auf das erfundene Beispiel und je Live-Station eine kleine Karte
|
||||||
|
(Website-Analyse: die Diagnose, sonst ein Hinweis zur Funktion) */}
|
||||||
|
<div className="kp-unter">
|
||||||
|
<div className="kp-beispiele">
|
||||||
|
{FUNKTIONEN.filter((f) => ladeArt(f)).map((f) => (
|
||||||
|
<p
|
||||||
|
key={f.id}
|
||||||
|
className="kp-beispiel"
|
||||||
|
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
|
||||||
|
aria-hidden={ladeArt(f) !== aktivArt || undefined}
|
||||||
|
>
|
||||||
|
{/* Website-Analyse: statt der Adresse (steht in der Safari-Leiste) der Bedienhinweis, damit es eine Zeile bleibt;
|
||||||
|
ohne Maus (Tablet quer) heißt es „Tippen Sie“ statt „Zeigen Sie mit der Maus“ */}
|
||||||
|
{f.bedienhinweis
|
||||||
|
? `Erfundenes Beispiel · ${fein ? f.bedienhinweis.maus : f.bedienhinweis.tippen}`
|
||||||
|
: beispielText(f)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="kp-karten">
|
||||||
|
<div
|
||||||
|
className="kp-diagnose"
|
||||||
|
data-an={aktivDemo === "heatmap" ? "ja" : "nein"}
|
||||||
|
aria-hidden={aktivDemo !== "heatmap" || undefined}
|
||||||
|
{...(aktivDemo !== "heatmap" ? INERT : null)}
|
||||||
|
>
|
||||||
|
{demoEingehaengt("heatmap") ? (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<HeatmapDiagnose zustand={heat ?? undefined} />
|
||||||
|
</Suspense>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{FUNKTIONEN.filter((f) => ladeArt(f) && f.hinweis).map((f) => (
|
||||||
|
<p
|
||||||
|
key={f.id}
|
||||||
|
className="kp-hinweis"
|
||||||
|
data-an={ladeArt(f) === aktivArt ? "ja" : "nein"}
|
||||||
|
aria-hidden={ladeArt(f) !== aktivArt || undefined}
|
||||||
|
>
|
||||||
|
{f.hinweis}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Handy: Wisch-Reihe ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Karte "Als App": der lange Screenshot schiebt sich langsam nach oben, solange die Karte sichtbar ist. */
|
||||||
|
const LangeKarte = ({ f }: { f: Funktion }) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [sichtbar, setSichtbar] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const el = ref.current;
|
||||||
|
if (!el || typeof IntersectionObserver === "undefined") return;
|
||||||
|
const beobachter = new IntersectionObserver(([e]) => setSichtbar(e.isIntersecting), { threshold: 0.6 });
|
||||||
|
beobachter.observe(el);
|
||||||
|
return () => beobachter.disconnect();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kp-karte" ref={ref} data-sichtbar={sichtbar ? "ja" : "nein"}>
|
||||||
|
<HandyRahmen className="kp-karte__handy">
|
||||||
|
<div className="kp-lang">
|
||||||
|
<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "250px", 4978)} />
|
||||||
|
</div>
|
||||||
|
</HandyRahmen>
|
||||||
|
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
|
||||||
|
<KartenUnterschrift f={f} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Kartenrahmen einer Demo-Karte: Handy mit der Website, Titel, Unterschrift und der Hinweis auf das erfundene Beispiel. */
|
||||||
|
const DemoKarteRahmen = ({
|
||||||
|
f,
|
||||||
|
geraetRef,
|
||||||
|
nachher,
|
||||||
|
kulisse,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
f: Funktion;
|
||||||
|
/** Steht sofort im Handy (vor dem Nachladen der Demo), z. B. die Portal-Aufnahme unter der Chat-Ebene */
|
||||||
|
kulisse?: ReactNode;
|
||||||
|
/** Das Handy der Karte: nach seiner Sichtbarkeit richtet sich der Ablauf (die Karte selbst kann höher sein als der Bildschirm) */
|
||||||
|
geraetRef: RefObject<HTMLDivElement>;
|
||||||
|
/** Unter dem Text (Heatmap: die Diagnose) */
|
||||||
|
nachher?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
}) => (
|
||||||
|
<div className="kp-karte kp-karte--demo" data-demo={f.demo}>
|
||||||
|
<div className="kp-karte__geraet" ref={geraetRef}>
|
||||||
|
<HandyRahmen className="kp-karte__handy">
|
||||||
|
{kulisse}
|
||||||
|
<Suspense fallback={f.demo ? <Platzhalter art={f.demo} /> : null}>{children}</Suspense>
|
||||||
|
</HandyRahmen>
|
||||||
|
</div>
|
||||||
|
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
|
||||||
|
<BaldMarke f={f} />
|
||||||
|
<KartenUnterschrift f={f} />
|
||||||
|
<p className="kp-karte__beispiel">{beispielText(f)}</p>
|
||||||
|
{f.bedienhinweis ? <p className="kp-karte__bedienen">{f.bedienhinweis.tippen}</p> : null}
|
||||||
|
{nachher}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eigener Ablauf je Karte: Die Website wird geladen, sobald das Handy der Karte (angeschnitten) in Sicht kommt,
|
||||||
|
* und läuft, solange es zu mindestens 60 % im Bild ist. Maßgeblich ist das Handy, nicht die ganze Karte:
|
||||||
|
* Die Heatmap-Karte ist mit der Diagnose höher als kleine Bildschirme, 60 % der Karte würden dort nie erreicht.
|
||||||
|
*/
|
||||||
|
const useKarte = (art: LadeArt) => {
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const nah = useImBild(ref, 0);
|
||||||
|
const imBild = useImBild(ref, 0.6);
|
||||||
|
const [gesehen, setGesehen] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (nah) setGesehen(true);
|
||||||
|
}, [nah]);
|
||||||
|
// alle Chunks der Demo gleichzeitig laden, sobald die Karte angeschnitten in Sicht kommt
|
||||||
|
useEffect(() => {
|
||||||
|
if (gesehen) VORLADEN[art].forEach((vorladen) => vorladen());
|
||||||
|
}, [gesehen, art]);
|
||||||
|
const lauf = useDemoLauf(true, imBild);
|
||||||
|
return { ref, gesehen, ...lauf };
|
||||||
|
};
|
||||||
|
|
||||||
|
const TerminKarte = ({ f }: { f: Funktion }) => {
|
||||||
|
const k = useKarte("termin");
|
||||||
|
const [z, setZ] = useState<TerminZustand | null>(null);
|
||||||
|
return (
|
||||||
|
<DemoKarteRahmen f={f} geraetRef={k.ref}>
|
||||||
|
{k.gesehen ? <TerminSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
|
||||||
|
{k.gesehen ? <TerminSeite zustand={z ?? undefined} variante="handy" /> : null}
|
||||||
|
</DemoKarteRahmen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Chat-Karte: die Portal-Aufnahme des leeren Chats, darüber das Gespräch, sobald die Karte in Sicht kommt. */
|
||||||
|
const ChatKarte = ({ f }: { f: Funktion }) => {
|
||||||
|
const k = useKarte("chat");
|
||||||
|
const [z, setZ] = useState<ChatZustand | null>(null);
|
||||||
|
return (
|
||||||
|
<DemoKarteRahmen
|
||||||
|
f={f}
|
||||||
|
geraetRef={k.ref}
|
||||||
|
kulisse={<RahmenBild bild={handyBild(f.handy.name, f.handy.alt, "250px")} />}
|
||||||
|
>
|
||||||
|
{k.gesehen ? <ChatSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
|
||||||
|
{k.gesehen && z ? <ChatHandy zustand={z} /> : null}
|
||||||
|
</DemoKarteRahmen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const PinnwandKarte = ({ f }: { f: Funktion }) => {
|
||||||
|
const k = useKarte("pinnwand");
|
||||||
|
const [z, setZ] = useState<PinnwandZustand | null>(null);
|
||||||
|
return (
|
||||||
|
<DemoKarteRahmen f={f} geraetRef={k.ref}>
|
||||||
|
{k.gesehen ? <PinnwandSteuerung laeuft={k.laeuft} statisch={k.statisch} onZustand={setZ} /> : null}
|
||||||
|
{k.gesehen ? <PinnwandSeite zustand={z ?? undefined} variante="handy" /> : null}
|
||||||
|
</DemoKarteRahmen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Heatmap-Karte: Tippen auf eine Zone wählt sie, darunter erscheint die kompakte Diagnose. */
|
||||||
|
const HeatmapKarte = ({ f }: { f: Funktion }) => {
|
||||||
|
const k = useKarte("heatmap");
|
||||||
|
const [z, setZ] = useState<HeatmapZustand | null>(null);
|
||||||
|
return (
|
||||||
|
<DemoKarteRahmen
|
||||||
|
f={f}
|
||||||
|
geraetRef={k.ref}
|
||||||
|
nachher={
|
||||||
|
<div className="kp-karte__diagnose">
|
||||||
|
<Suspense fallback={null}>{k.gesehen ? <HeatmapDiagnose zustand={z ?? undefined} kompakt /> : null}</Suspense>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{k.gesehen ? <HeatmapSteuerung laeuft={k.laeuft} statisch={k.statisch} aktiv onZustand={setZ} /> : null}
|
||||||
|
{k.gesehen ? <HeatmapSeite zustand={z ?? undefined} variante="handy" /> : null}
|
||||||
|
</DemoKarteRahmen>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const HandyReihe = () => (
|
||||||
|
<div className="kp-reihe">
|
||||||
|
<WischReihe ariaLabel="Acht Funktionen des Kundenportals, seitlich wischen">
|
||||||
|
{FUNKTIONEN.map((f) => {
|
||||||
|
if (f.lang) return <LangeKarte key={f.id} f={f} />;
|
||||||
|
if (f.live === "chat") return <ChatKarte key={f.id} f={f} />;
|
||||||
|
if (f.demo === "termin") return <TerminKarte key={f.id} f={f} />;
|
||||||
|
if (f.demo === "heatmap") return <HeatmapKarte key={f.id} f={f} />;
|
||||||
|
if (f.demo === "pinnwand") return <PinnwandKarte key={f.id} f={f} />;
|
||||||
|
return (
|
||||||
|
<div key={f.id} className="kp-karte">
|
||||||
|
<HandyRahmen className="kp-karte__handy" bild={handyBild(f.handy.name, f.handy.alt, "250px")} />
|
||||||
|
<h3 className="wk-h3 kp-karte__titel">{f.titel}</h3>
|
||||||
|
<KartenUnterschrift f={f} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</WischReihe>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Schrittzeile ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Schrittzeile = ({ desktop }: { desktop: boolean }) =>
|
||||||
|
desktop ? (
|
||||||
|
<div className="wk-container">
|
||||||
|
<ol className="kp-schritte wk-reveal" aria-label="Ablauf in vier Schritten">
|
||||||
|
{SCHRITTE.map((s) => (
|
||||||
|
<li key={s.nr} className="kp-schritt">
|
||||||
|
<span className="kp-schritt__nr wk-num">{s.nr}</span>
|
||||||
|
<span className="kp-schritt__text">{s.text}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="kp-schritte-handy">
|
||||||
|
<WischReihe ariaLabel="Ablauf in vier Schritten, seitlich wischen" className="kp-schritte-reihe">
|
||||||
|
{SCHRITTE.map((s) => (
|
||||||
|
<div key={s.nr} className="kp-schritt-karte">
|
||||||
|
<span className="kp-schritt__nr wk-num">{s.nr}</span>
|
||||||
|
<span className="kp-schritt__text">{s.text}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</WischReihe>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// ── Abschnitt ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const Kundenportal = () => {
|
||||||
|
const desktop = useMedia("(min-width: 900px)");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section id="kundenportal" className="wk-section wk-section--alt kp" aria-labelledby="kundenportal-titel">
|
||||||
|
<div className="wk-container kp__kopf wk-reveal">
|
||||||
|
<h2 id="kundenportal-titel" className="wk-h2">
|
||||||
|
{/* zwei Sätze, zwei Zeilen: der Umbruch soll nach "Projekt." fallen, nicht mitten im zweiten Satz */}
|
||||||
|
<span className="kp-satz">Ihr Projekt.</span> <span className="kp-satz">Live im Kundenportal.</span>
|
||||||
|
</h2>
|
||||||
|
<p className="wk-lead">{EINLEITUNG}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{desktop ? <Buehne /> : <HandyReihe />}
|
||||||
|
<Schrittzeile desktop={desktop} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Kundenportal;
|
||||||