Initialisieren
This commit is contained in:
2
.audit-next.err.log
Normal file
2
.audit-next.err.log
Normal file
@@ -0,0 +1,2 @@
|
||||
⚠ "next start" does not work with "output: standalone" configuration. Use "node .next/standalone/server.js" instead.
|
||||
^C
|
||||
8
.audit-next.out.log
Normal file
8
.audit-next.out.log
Normal file
@@ -0,0 +1,8 @@
|
||||
|
||||
> webseite@0.1.0 start
|
||||
> next start -p 3100
|
||||
|
||||
▲ Next.js 16.2.11
|
||||
- Local: http://localhost:3100
|
||||
- Network: http://10.5.0.2:3100
|
||||
✓ Ready in 249ms
|
||||
12
.claude/launch.json
Normal file
12
.claude/launch.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": "0.0.1",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "dev",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": ["run", "dev"],
|
||||
"port": 3000,
|
||||
"autoPort": true
|
||||
}
|
||||
]
|
||||
}
|
||||
32
.dockerignore
Normal file
32
.dockerignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.*
|
||||
|
||||
# next.js build output
|
||||
.next
|
||||
out
|
||||
build
|
||||
|
||||
# git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# env files (pass secrets via docker-compose env_file / environment instead)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# docs and planning files (not needed inside the image)
|
||||
*.md
|
||||
*.csv
|
||||
|
||||
# editor/OS
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# docker itself
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
docker-compose.yml
|
||||
41
.gitignore
vendored
Normal file
41
.gitignore
vendored
Normal file
@@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
5
AGENTS.md
Normal file
5
AGENTS.md
Normal file
@@ -0,0 +1,5 @@
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
# This is NOT the Next.js you know
|
||||
|
||||
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
32
Dockerfile
Normal file
32
Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:20-alpine AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
NEXT_TELEMETRY_DISABLED=1 \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
BIN
Keyword Stats 2026-07-22 at 23_08_06.csv
Normal file
BIN
Keyword Stats 2026-07-22 at 23_08_06.csv
Normal file
Binary file not shown.
|
BIN
Keyword Stats 2026-07-22 at 23_15_06.csv
Normal file
BIN
Keyword Stats 2026-07-22 at 23_15_06.csv
Normal file
Binary file not shown.
|
384
LIVE-PLAN-entscheidomat.md
Normal file
384
LIVE-PLAN-entscheidomat.md
Normal file
@@ -0,0 +1,384 @@
|
||||
# Entscheidomat – Allgemeiner Go-Live-Plan
|
||||
|
||||
**Projekt:** Entscheidomat-Webseite
|
||||
**Projektordner:** `C:\Users\timo\Documents\Elena\webseite`
|
||||
**Zieldomain:** `https://entscheidomat.com`
|
||||
**Ziel:** Die Website sicher, rechtlich sauber, technisch stabil und öffentlich nutzbar live nehmen.
|
||||
|
||||
---
|
||||
|
||||
## Go-Live-Status heute
|
||||
|
||||
### Bereits vorhanden
|
||||
|
||||
- [x] Next.js-Projekt funktioniert lokal
|
||||
- [x] Produktions-Build läuft erfolgreich
|
||||
- [x] Haupt-Tool-Oberfläche vorhanden
|
||||
- [x] Münze, Glücksrad, Würfel, Zufallszahl, Magic 8-Ball und Listen-Tool vorhanden
|
||||
- [x] responsive Grundstruktur vorhanden
|
||||
- [x] deutsche Seitensprache vorhanden
|
||||
- [x] grundlegende Title- und Description-Metadaten vorhanden
|
||||
|
||||
### Noch offen
|
||||
|
||||
- [ ] echte Domain verbinden und HTTPS prüfen
|
||||
- [ ] Impressum
|
||||
- [ ] Datenschutz
|
||||
- [ ] Kontaktmöglichkeit
|
||||
- [ ] Canonical URL
|
||||
- [ ] Open-Graph- und Twitter-Metadaten
|
||||
- [ ] Sitemap
|
||||
- [ ] Robots-Datei
|
||||
- [ ] Structured Data / JSON-LD
|
||||
- [ ] SEO-Unterseiten für die wichtigsten Tools
|
||||
- [ ] Lint-Fehler beheben
|
||||
- [ ] mobile und Accessibility-QA
|
||||
- [ ] Analytics und Consent-Lösung
|
||||
- [ ] Search Console
|
||||
- [ ] finale Produktionsprüfung
|
||||
|
||||
---
|
||||
|
||||
# Phase 1 – Entscheidungen vor dem Launch
|
||||
|
||||
## 1. Domain und Marke festlegen
|
||||
|
||||
- [ ] Ist `entscheidomat.com` definitiv die Hauptdomain?
|
||||
- [ ] Soll `www.entscheidomat.com` weiterleiten oder die Hauptdomain sein?
|
||||
- [ ] finalen Markennamen und Logo festlegen
|
||||
- [ ] Kontakt-E-Mail unter der Domain festlegen
|
||||
- [ ] Betreiber/Verantwortlichen für Impressum und Datenschutz bestimmen
|
||||
|
||||
**Ergebnis:** Eine einzige kanonische Domainvariante.
|
||||
|
||||
## 2. Zielgruppe und Produktversprechen festlegen
|
||||
|
||||
Finale Kurzbeschreibung:
|
||||
|
||||
> Entscheidomat ist ein kostenloser Zufallsgenerator und eine digitale Entscheidungshilfe mit Münzwurf, Glücksrad, Würfel, Zufallszahl, Magic 8-Ball und Listen-Auswahl.
|
||||
|
||||
Diese Beschreibung muss konsistent erscheinen in:
|
||||
|
||||
- Homepage
|
||||
- Meta Description
|
||||
- Open Graph
|
||||
- JSON-LD
|
||||
- Social Profiles
|
||||
- Impressum/Datenschutz, soweit relevant
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 – Rechtliche Grundlage
|
||||
|
||||
Diese Punkte müssen vor dem öffentlichen Launch erledigt werden:
|
||||
|
||||
- [ ] Impressum erstellen
|
||||
- [ ] Datenschutzerklärung erstellen
|
||||
- [ ] Kontaktmöglichkeit ergänzen
|
||||
- [ ] Erklärung zu lokalen Speicherfunktionen ergänzen
|
||||
- [ ] Erklärung zu Analytics ergänzen, falls Analytics eingesetzt wird
|
||||
- [ ] Erklärung zu Werbung/AdSense ergänzen, sobald Werbung aktiv ist
|
||||
- [ ] Cookie-/Consent-Management für nicht notwendige Cookies und personalisierte Werbung prüfen
|
||||
- [ ] EEA-, UK- und Schweiz-Anforderungen für Google-Werbung prüfen
|
||||
- [ ] Footer dauerhaft mit Impressum und Datenschutz verlinken
|
||||
|
||||
**Go-Live-Blocker:** Ohne Impressum und Datenschutz nicht öffentlich starten.
|
||||
|
||||
---
|
||||
|
||||
# Phase 3 – Produkt- und Funktionstest
|
||||
|
||||
## 3.1 Alle Tools testen
|
||||
|
||||
Für jedes Tool prüfen:
|
||||
|
||||
- [ ] Tool öffnet ohne Fehler
|
||||
- [ ] Startzustand ist verständlich
|
||||
- [ ] Button funktioniert mehrfach hintereinander
|
||||
- [ ] Ergebnis wird sichtbar aktualisiert
|
||||
- [ ] Ergebnis ist für Screenreader angekündigt
|
||||
- [ ] Reset/Neustart funktioniert
|
||||
- [ ] keine falschen oder unmöglichen Ergebnisse
|
||||
- [ ] keine Endlosschleife
|
||||
- [ ] keine fehlerhaften Zufallsbereiche
|
||||
- [ ] keine Daten gehen beim normalen Seitenwechsel unerwartet verloren
|
||||
|
||||
## 3.2 Spezifische Tests
|
||||
|
||||
### Münzwurf
|
||||
|
||||
- [ ] Kopf und Zahl erscheinen korrekt
|
||||
- [ ] Animation endet immer
|
||||
- [ ] Ergebnis bleibt nach Animation sichtbar
|
||||
- [ ] Animation funktioniert auch bei Reduced Motion
|
||||
|
||||
### Glücksrad
|
||||
|
||||
- [ ] Standardoptionen funktionieren
|
||||
- [ ] eigene Optionen können hinzugefügt werden
|
||||
- [ ] leere Eingaben werden abgefangen
|
||||
- [ ] lange Optionen werden sauber dargestellt
|
||||
- [ ] Rad bleibt innerhalb des Bildschirms
|
||||
|
||||
### Würfel
|
||||
|
||||
- [ ] Würfelanimation endet zuverlässig
|
||||
- [ ] Ergebnis stimmt mit der gewählten Würfelart überein
|
||||
- [ ] mehrfaches Würfeln funktioniert
|
||||
|
||||
### Zufallszahl
|
||||
|
||||
- [ ] Minimum und Maximum werden validiert
|
||||
- [ ] Minimum größer als Maximum wird abgefangen
|
||||
- [ ] Dezimalwerte werden sauber behandelt
|
||||
- [ ] sehr große Werte verursachen keinen Layoutfehler
|
||||
|
||||
### Magic 8-Ball
|
||||
|
||||
- [ ] Frage kann eingegeben werden
|
||||
- [ ] leere Frage wird sinnvoll behandelt
|
||||
- [ ] Antwort wird deutlich angezeigt
|
||||
- [ ] mehrfaches Fragen funktioniert
|
||||
|
||||
### Listen-Auswahl
|
||||
|
||||
- [ ] leere Liste wird abgefangen
|
||||
- [ ] mehrere Einträge werden korrekt getrennt
|
||||
- [ ] Sonderzeichen funktionieren
|
||||
- [ ] zufällige Auswahl wird angezeigt
|
||||
|
||||
---
|
||||
|
||||
# Phase 4 – Mobile, Browser und Accessibility
|
||||
|
||||
## 4.1 Browsermatrix
|
||||
|
||||
- [ ] Chrome Desktop
|
||||
- [ ] Chrome Android
|
||||
- [ ] Safari iPhone
|
||||
- [ ] Safari macOS
|
||||
- [ ] Firefox Desktop
|
||||
- [ ] Edge Desktop
|
||||
|
||||
## 4.2 Bildschirmgrößen
|
||||
|
||||
- [ ] 320 px Breite
|
||||
- [ ] 375 px Breite
|
||||
- [ ] 390 px Breite
|
||||
- [ ] 768 px Breite
|
||||
- [ ] 1024 px Breite
|
||||
- [ ] 1440 px Breite
|
||||
|
||||
## 4.3 Accessibility
|
||||
|
||||
- [ ] alle interaktiven Elemente per Tab erreichbar
|
||||
- [ ] sichtbarer Focus-Ring vorhanden
|
||||
- [ ] Buttons haben verständliche Namen
|
||||
- [ ] Tabs haben korrekte ARIA-Zuordnung
|
||||
- [ ] Ergebnisse werden per `aria-live` angekündigt
|
||||
- [ ] keine wichtigen Inhalte nur über Farbe verständlich
|
||||
- [ ] ausreichender Farbkontrast
|
||||
- [ ] Reduced Motion funktioniert
|
||||
- [ ] Sound wird nicht ungefragt abgespielt
|
||||
- [ ] Escape/Back-Verhalten bei Overlays prüfen
|
||||
|
||||
---
|
||||
|
||||
# Phase 5 – Technischer Produktions-Check
|
||||
|
||||
## 5.1 Codequalität
|
||||
|
||||
- [ ] `npm run build` erfolgreich
|
||||
- [ ] `npm run lint` erfolgreich
|
||||
- [ ] keine TypeScript-Fehler
|
||||
- [ ] keine Browser-Console-Errors
|
||||
- [ ] keine Hydration-Warnings
|
||||
- [ ] keine unnötigen Debug-Logs
|
||||
- [ ] keine Testdaten oder Platzhalter im Produktionsbuild
|
||||
- [ ] keine privaten API-Keys im Clientcode
|
||||
|
||||
Aktuell besteht der Build, aber der Lint-Lauf hat noch sechs Fehler in Preloader, Sound-Provider, Dice-Roller und zwei Hooks. Diese müssen vor dem finalen Go-Live behoben werden.
|
||||
|
||||
## 5.2 Performance
|
||||
|
||||
- [ ] Lighthouse Mobile prüfen
|
||||
- [ ] Largest Contentful Paint prüfen
|
||||
- [ ] Cumulative Layout Shift prüfen
|
||||
- [ ] Interaction to Next Paint prüfen
|
||||
- [ ] JavaScript-Bundle prüfen
|
||||
- [ ] Animationen auf schwachen Geräten testen
|
||||
- [ ] Fonts und externe Ressourcen prüfen
|
||||
- [ ] keine unnötigen Audio-/Videoressourcen laden
|
||||
|
||||
Zielwerte als praktische Launch-Grenze:
|
||||
|
||||
- Performance mindestens 80 auf Mobile
|
||||
- keine kritischen Accessibility-Fehler
|
||||
- keine großen Layout Shifts
|
||||
- sichtbarer Hauptinhalt ohne lange Wartezeit
|
||||
|
||||
---
|
||||
|
||||
# Phase 6 – SEO-Basis vor dem Launch
|
||||
|
||||
- [ ] finale Startseiten-H1 festlegen
|
||||
- [ ] Title auf Produktkategorie und Nutzen ausrichten
|
||||
- [ ] Description finalisieren
|
||||
- [ ] Canonical setzen
|
||||
- [ ] Open Graph setzen
|
||||
- [ ] Twitter Card setzen
|
||||
- [ ] Social Preview Image erstellen
|
||||
- [ ] `app/robots.ts` erstellen
|
||||
- [ ] `app/sitemap.ts` erstellen
|
||||
- [ ] JSON-LD für WebApplication/WebSite ergänzen
|
||||
- [ ] Haupt-Tools intern verlinken
|
||||
- [ ] keine Testseiten indexierbar machen
|
||||
- [ ] URLs für Tool-Unterseiten festlegen
|
||||
|
||||
## Minimale empfehlenswerte Unterseiten
|
||||
|
||||
- `/ja-nein-generator`
|
||||
- `/gluecksrad`
|
||||
- `/wuerfel-online`
|
||||
- `/namen-auslosen`
|
||||
- `/muenze-werfen`
|
||||
- `/zufallszahl-generator`
|
||||
- `/magic-8-ball`
|
||||
- `/listen-generator`
|
||||
- `/impressum`
|
||||
- `/datenschutz`
|
||||
|
||||
---
|
||||
|
||||
# Phase 7 – Analytics und Monitoring
|
||||
|
||||
Vor dem Launch entscheiden:
|
||||
|
||||
- [ ] Google Analytics 4 oder datenschutzfreundliche Alternative
|
||||
- [ ] Consent-Management
|
||||
- [ ] Search Console
|
||||
- [ ] Bing Webmaster Tools
|
||||
- [ ] Fehler-Monitoring
|
||||
|
||||
Sinnvolle Events:
|
||||
|
||||
- [ ] `tool_open`
|
||||
- [ ] `tool_result`
|
||||
- [ ] `tool_share`
|
||||
- [ ] `tool_customized`
|
||||
- [ ] `faq_open`
|
||||
|
||||
Keine personenbezogenen Daten in Event-Parametern speichern.
|
||||
|
||||
Nach dem Launch beobachten:
|
||||
|
||||
- Indexierung
|
||||
- Impressions
|
||||
- Klicks
|
||||
- CTR
|
||||
- Tool-Nutzung
|
||||
- Absprungrate
|
||||
- Core Web Vitals
|
||||
- Browserfehler
|
||||
- mobile Fehler
|
||||
|
||||
---
|
||||
|
||||
# Phase 8 – AdSense erst nach dem Basis-Launch
|
||||
|
||||
AdSense nicht als allerersten Schritt einbauen. Erst muss die Seite einen klaren eigenständigen Nutzerwert und ausreichenden öffentlichen Content haben.
|
||||
|
||||
Vor dem AdSense-Antrag:
|
||||
|
||||
- [ ] Impressum und Datenschutz live
|
||||
- [ ] Consent Management eingerichtet
|
||||
- [ ] mehrere hochwertige Tool-Seiten live
|
||||
- [ ] keine leeren oder dünnen Unterseiten
|
||||
- [ ] keine Platzhaltertexte
|
||||
- [ ] Kontaktmöglichkeit vorhanden
|
||||
- [ ] Search Console eingerichtet
|
||||
- [ ] Anzeigen nicht in interaktive Buttons integrieren
|
||||
- [ ] Anzeigen klar als Werbung erkennbar
|
||||
- [ ] keine Klickanreize
|
||||
- [ ] keine Werbung vor dem eigentlichen Tool, wenn dadurch die Nutzung gestört wird
|
||||
|
||||
Das Ziel ist nicht möglichst viele Anzeigen, sondern maximaler regelkonformer Umsatz bei guter UX.
|
||||
|
||||
---
|
||||
|
||||
# Phase 9 – Go-Live-Ablauf
|
||||
|
||||
## Einen Tag vor dem Launch
|
||||
|
||||
- [ ] finalen Git-Commit/Backup erstellen
|
||||
- [ ] Produktionsvariablen prüfen
|
||||
- [ ] Domain-DNS prüfen
|
||||
- [ ] HTTPS prüfen
|
||||
- [ ] Build und Lint erneut ausführen
|
||||
- [ ] mobile Smoke-Test durchführen
|
||||
- [ ] rechtliche Seiten prüfen
|
||||
- [ ] Social Preview testen
|
||||
- [ ] robots.txt testen
|
||||
- [ ] sitemap.xml testen
|
||||
|
||||
## Zum Launch
|
||||
|
||||
- [ ] Domain auf Produktionsversion zeigen lassen
|
||||
- [ ] Startseite öffnen
|
||||
- [ ] alle Haupt-URLs mit 200 testen
|
||||
- [ ] HTTPS-Redirect testen
|
||||
- [ ] Canonical im HTML prüfen
|
||||
- [ ] robots.txt aufrufen
|
||||
- [ ] sitemap.xml aufrufen
|
||||
- [ ] Search Console bestätigen
|
||||
- [ ] Sitemap einreichen
|
||||
- [ ] erste URL-Inspektion starten
|
||||
|
||||
## Direkt nach dem Launch
|
||||
|
||||
- [ ] Server-/Browserfehler beobachten
|
||||
- [ ] Search Console Coverage prüfen
|
||||
- [ ] Mobile Usability prüfen
|
||||
- [ ] Social Share testen
|
||||
- [ ] mindestens ein Tool auf iPhone und Android testen
|
||||
- [ ] Nutzerfeedback sammeln
|
||||
- [ ] keine großen Content- oder URL-Änderungen ohne Redirect-Plan
|
||||
|
||||
---
|
||||
|
||||
# Go/No-Go-Checkliste
|
||||
|
||||
Die Website darf live gehen, wenn alle Punkte erfüllt sind:
|
||||
|
||||
- [ ] Domain und HTTPS funktionieren
|
||||
- [ ] Impressum vorhanden
|
||||
- [ ] Datenschutz vorhanden
|
||||
- [ ] Kontaktmöglichkeit vorhanden
|
||||
- [ ] Build erfolgreich
|
||||
- [ ] Lint erfolgreich
|
||||
- [ ] keine Console Errors
|
||||
- [ ] alle Kern-Tools funktionieren
|
||||
- [ ] mobile Smoke-Tests bestanden
|
||||
- [ ] Canonical korrekt
|
||||
- [ ] robots.txt vorhanden
|
||||
- [ ] sitemap.xml vorhanden
|
||||
- [ ] keine versehentliche Noindex-Sperre
|
||||
- [ ] Analytics/Consent-Entscheidung umgesetzt
|
||||
- [ ] Backup vorhanden
|
||||
|
||||
**Wenn ein rechtlicher Punkt, HTTPS, Build, Kernfunktion oder schwerer Mobile-Fehler offen ist: NO-GO.**
|
||||
|
||||
---
|
||||
|
||||
# Empfohlene Reihenfolge für die nächsten konkreten Arbeiten
|
||||
|
||||
1. Impressum, Datenschutz und Kontakt
|
||||
2. Domain-/Canonical-Konfiguration
|
||||
3. Lint-Fehler beheben
|
||||
4. Mobile- und Accessibility-QA
|
||||
5. Sitemap, Robots, Open Graph und JSON-LD
|
||||
6. die fünf wichtigsten SEO-Unterseiten erstellen
|
||||
7. Search Console und Monitoring einrichten
|
||||
8. Website live nehmen
|
||||
9. zwei bis vier Wochen Daten sammeln
|
||||
10. erst danach AdSense beantragen und vorsichtig einbauen
|
||||
254
README.md
Normal file
254
README.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Entscheidomat
|
||||
|
||||
Entscheidomat ist eine deutschsprachige Next.js-Web-App mit sieben kostenlosen
|
||||
Zufalls-Tools: Ja/Nein-Generator, Münze, Glücksrad, Würfel,
|
||||
Zufallszahl-Generator, Magic 8-Ball und Namen-Auslosung.
|
||||
|
||||
Die Anwendung wird als Docker-Container gebaut und hinter einem Reverse Proxy
|
||||
wie Caddy betrieben. Diese Anleitung beschreibt den kompletten Weg vom
|
||||
Produktionsserver bis zum sicheren Go-live von `entscheidomat.com`.
|
||||
|
||||
## Überblick
|
||||
|
||||
```text
|
||||
Browser
|
||||
-> Cloudflare DNS / CDN (optional)
|
||||
-> Caddy (TLS, HTTPS, Redirects)
|
||||
-> Docker-Container auf Port 3000
|
||||
-> Next.js-App
|
||||
```
|
||||
|
||||
Die App selbst enthält keine Datenbank. Eigene Einstellungen der Tools werden im
|
||||
Browser der Nutzenden gespeichert. Umami Cloud wird für anonyme,
|
||||
aggregierte Nutzungsstatistiken geladen.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Auf dem Produktionsserver werden benötigt:
|
||||
|
||||
- Linux-Server mit aktuellem Docker und Docker Compose Plugin
|
||||
- eine Domain, z. B. `entscheidomat.com`
|
||||
- Zugriff auf die DNS-Verwaltung, ggf. Cloudflare
|
||||
- Caddy als Reverse Proxy (oder ein gleichwertiger Proxy)
|
||||
- offene Ports `80` und `443` für Caddy
|
||||
|
||||
Prüfen:
|
||||
|
||||
```bash
|
||||
docker --version
|
||||
docker compose version
|
||||
caddy version
|
||||
```
|
||||
|
||||
## 1. Projekt auf den Server übertragen
|
||||
|
||||
Lege auf dem Server ein Verzeichnis für die Anwendung an und übertrage den
|
||||
Projektordner dorthin. Bei einem Git-Repository ist ein Clone am einfachsten:
|
||||
|
||||
```bash
|
||||
git clone <DEIN-REPOSITORY-URL> /opt/entscheidomat
|
||||
cd /opt/entscheidomat
|
||||
```
|
||||
|
||||
Ohne Git kannst du den Projektordner per `rsync` oder SFTP übertragen. Nicht
|
||||
mit übertragen werden müssen `node_modules` und `.next`; Docker erzeugt beides
|
||||
beim Build neu.
|
||||
|
||||
## 2. Produktionsumgebung konfigurieren
|
||||
|
||||
Erstelle im Projektordner eine `.env`-Datei:
|
||||
|
||||
```bash
|
||||
APP_PORT=127.0.0.1:3000
|
||||
```
|
||||
|
||||
Damit ist der App-Port nur lokal auf dem Server erreichbar. Caddy leitet
|
||||
Anfragen intern an die App weiter; Port 3000 muss nicht öffentlich offen sein.
|
||||
|
||||
> Die `.env`-Datei gehört nicht in ein öffentliches Repository. Aktuell werden
|
||||
> für die Anwendung keine weiteren Server-Geheimnisse benötigt.
|
||||
|
||||
## 3. App bauen und starten
|
||||
|
||||
```bash
|
||||
cd /opt/entscheidomat
|
||||
docker compose up -d --build
|
||||
docker compose ps
|
||||
docker compose logs -f app
|
||||
```
|
||||
|
||||
Lokaler Gesundheitscheck auf dem Server:
|
||||
|
||||
```bash
|
||||
curl -I http://127.0.0.1:3000/
|
||||
```
|
||||
|
||||
Erwartet wird `HTTP/1.1 200 OK`.
|
||||
|
||||
## 4. Caddy konfigurieren
|
||||
|
||||
Lege eine Caddy-Konfiguration an oder ergänze die bestehende Caddyfile. Der
|
||||
folgende Aufbau leitet `www` permanent auf die Hauptdomain und nutzt Caddy für
|
||||
TLS und Reverse Proxying:
|
||||
|
||||
```caddyfile
|
||||
www.entscheidomat.com {
|
||||
redir https://entscheidomat.com{uri} permanent
|
||||
}
|
||||
|
||||
entscheidomat.com {
|
||||
encode zstd gzip
|
||||
reverse_proxy 127.0.0.1:3000
|
||||
}
|
||||
```
|
||||
|
||||
Nach dem Ändern die Konfiguration prüfen und neu laden:
|
||||
|
||||
```bash
|
||||
caddy validate --config /etc/caddy/Caddyfile
|
||||
sudo systemctl reload caddy
|
||||
```
|
||||
|
||||
Der genaue Pfad kann je nach Caddy-Installation abweichen.
|
||||
|
||||
## 5. DNS und Cloudflare
|
||||
|
||||
Lege für die Domain folgende DNS-Einträge an:
|
||||
|
||||
| Typ | Name | Ziel |
|
||||
| --- | --- | --- |
|
||||
| A | `@` | Öffentliche IPv4-Adresse des Servers |
|
||||
| CNAME | `www` | `entscheidomat.com` |
|
||||
|
||||
Wenn Cloudflare verwendet wird:
|
||||
|
||||
1. Stelle SSL/TLS auf **Full (strict)**.
|
||||
2. Lass Caddy ein gültiges Zertifikat verwalten oder nutze ein korrekt
|
||||
eingerichtetes Cloudflare Origin Certificate.
|
||||
3. Aktiviere erst nach erfolgreichem Origin-Test den Proxy-Modus (orange Wolke).
|
||||
4. Lege keine Cache-Regel an, die HTML-Seiten oder `robots.txt`/`sitemap.xml`
|
||||
dauerhaft mit einem 404-Status cached.
|
||||
|
||||
## 6. Go-live-Prüfung
|
||||
|
||||
Nach DNS-Propagation alle folgenden Befehle ausführen:
|
||||
|
||||
```bash
|
||||
curl -I https://entscheidomat.com/
|
||||
curl -I https://entscheidomat.com/ja-nein-generator
|
||||
curl -I https://entscheidomat.com/robots.txt
|
||||
curl -I https://entscheidomat.com/sitemap.xml
|
||||
curl -I http://entscheidomat.com/
|
||||
curl -I https://www.entscheidomat.com/
|
||||
```
|
||||
|
||||
Erwartungen:
|
||||
|
||||
- Startseite, Toolseiten, `robots.txt` und `sitemap.xml`: `200`
|
||||
- HTTP und `www`: ein permanenter Redirect auf `https://entscheidomat.com`
|
||||
- keine Cloudflare- oder Proxy-404-Seiten
|
||||
|
||||
Danach im Browser prüfen:
|
||||
|
||||
- Canonical URLs
|
||||
- Titel und Meta-Beschreibungen
|
||||
- Open-Graph-Vorschau
|
||||
- alle sieben Toolseiten
|
||||
- Impressum und Datenschutzerklärung
|
||||
|
||||
## 7. SEO nach dem Launch
|
||||
|
||||
1. Property in Google Search Console und Bing Webmaster Tools bestätigen.
|
||||
2. `https://entscheidomat.com/sitemap.xml` einreichen.
|
||||
3. Nach einigen Tagen Coverage, Indexierung und Core Web Vitals prüfen.
|
||||
4. Regelmäßig die Umami-Daten und Suchanfragen aus der Search Console auswerten.
|
||||
|
||||
Die App erzeugt statisch unter anderem:
|
||||
|
||||
- `/robots.txt`
|
||||
- `/sitemap.xml`
|
||||
- `/opengraph-image`
|
||||
- `/twitter-image`
|
||||
|
||||
## Aktualisierungen deployen
|
||||
|
||||
Bei jeder Änderung:
|
||||
|
||||
```bash
|
||||
cd /opt/entscheidomat
|
||||
git pull
|
||||
docker compose up -d --build
|
||||
docker compose logs --tail=100 app
|
||||
```
|
||||
|
||||
Ohne Git zuerst die neuen Dateien übertragen und anschließend nur die letzten
|
||||
zwei Befehle ausführen.
|
||||
|
||||
Bei einem Fehler lässt sich der vorherige, funktionierende Stand am einfachsten
|
||||
über den vorherigen Git-Commit und einen erneuten Build wiederherstellen.
|
||||
|
||||
## Betrieb und Diagnose
|
||||
|
||||
```bash
|
||||
# Container-Status
|
||||
docker compose ps
|
||||
|
||||
# Live-Logs
|
||||
docker compose logs -f app
|
||||
|
||||
# Neustart ohne Neubau
|
||||
docker compose restart app
|
||||
|
||||
# Lokalen Container-Check ausführen
|
||||
docker compose exec app wget --spider -q http://localhost:3000/
|
||||
```
|
||||
|
||||
Für Caddy:
|
||||
|
||||
```bash
|
||||
sudo systemctl status caddy
|
||||
sudo journalctl -u caddy -f
|
||||
```
|
||||
|
||||
Richte zusätzlich einen externen Uptime-Monitor für mindestens diese URLs ein:
|
||||
|
||||
- `https://entscheidomat.com/`
|
||||
- `https://entscheidomat.com/robots.txt`
|
||||
- `https://entscheidomat.com/sitemap.xml`
|
||||
|
||||
## Datenschutz und rechtliche Launch-Checkliste
|
||||
|
||||
Vor der öffentlichen Veröffentlichung müssen die Angaben in Impressum und
|
||||
Datenschutzerklärung auf die tatsächliche Konfiguration abgestimmt werden:
|
||||
|
||||
- vollständige ladungsfähige Geschäftsanschrift des Einzelunternehmens
|
||||
- tatsächlicher Name des Hostinganbieters
|
||||
- Serverstandort Texas, USA, sowie Speicherdauer der Server-Logfiles
|
||||
- dokumentierte Grundlage für den Datenübertragungsmechanismus in die USA
|
||||
- Umami Cloud mit gewählter Datenregion EU und die passende
|
||||
Auftragsverarbeitungs-/Datenschutzdokumentation
|
||||
- Umsatzsteuer-ID oder Wirtschafts-ID nur, falls tatsächlich vorhanden
|
||||
|
||||
Die vorhandenen Texte sind ein Pre-Launch-Stand und keine individuelle
|
||||
Rechtsberatung. Vor dem Livegang sollte die finale Fassung rechtlich geprüft
|
||||
werden.
|
||||
|
||||
## Lokale Entwicklung
|
||||
|
||||
```bash
|
||||
npm ci
|
||||
npm run dev
|
||||
npm run lint
|
||||
npm run build
|
||||
```
|
||||
|
||||
Die lokale App ist anschließend unter `http://localhost:3000` erreichbar.
|
||||
|
||||
## Tech-Stack
|
||||
|
||||
- Next.js 16, React 19 und TypeScript
|
||||
- Tailwind CSS v4
|
||||
- Docker mit Multi-Stage-Build
|
||||
- Caddy als Reverse Proxy
|
||||
- Cloudflare optional für DNS/CDN
|
||||
- Umami Cloud, Datenregion EU
|
||||
730
SEO-LIVE-PLAN-entscheidomat.md
Normal file
730
SEO-LIVE-PLAN-entscheidomat.md
Normal file
@@ -0,0 +1,730 @@
|
||||
# Entscheidomat.com – Masterplan für Livegang, SEO, AEO, GEO und AdSense
|
||||
|
||||
**Erstellt:** 23. Juli 2026
|
||||
**Projekt:** `C:\Users\timo\Documents\Elena\webseite`
|
||||
**Zieldomain:** `https://entscheidomat.com`
|
||||
**Ziel:** Ein technisch sauberes, indexierbares, zitierfähiges und AdSense-taugliches Web-Tool-Portal auf Basis einer Exact-Match-Domain.
|
||||
|
||||
> Dieser Plan ist ein Umsetzungsplan, keine Garantie für Rankings, AI-Zitate, AdSense-Freischaltung oder einen bestimmten Umsatz. Die Domain, Suchvolumina und CPCs sind Chancen-Signale, keine Traffic- oder Erlösprognosen.
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### Aktueller Stand
|
||||
|
||||
Die bestehende Website ist ein visuell starkes, funktionierendes Next.js-Tool mit sechs interaktiven Zufalls-Tools:
|
||||
|
||||
- Münze
|
||||
- Glücksrad
|
||||
- Würfel
|
||||
- Zufallszahl
|
||||
- Magic 8-Ball
|
||||
- Listen-Auswahl
|
||||
|
||||
Der Produktions-Build wurde erfolgreich ausgeführt:
|
||||
|
||||
```text
|
||||
✓ Compiled successfully
|
||||
✓ Finished TypeScript
|
||||
✓ Generating static pages
|
||||
○ / Static prerendered
|
||||
```
|
||||
|
||||
Der Lint-Lauf ist dagegen noch nicht sauber:
|
||||
|
||||
```text
|
||||
6 errors in:
|
||||
- components/preloader.tsx
|
||||
- components/providers/sound-provider.tsx
|
||||
- components/tools/dice-roller.tsx
|
||||
- hooks/use-local-storage.ts
|
||||
- hooks/use-prefers-reduced-motion.ts
|
||||
```
|
||||
|
||||
### Gesamturteil
|
||||
|
||||
| Bereich | Aktueller Stand | Live-Urteil |
|
||||
|---|---:|---|
|
||||
| Design und Produktidee | stark | bereit nach QA |
|
||||
| Interaktive Funktionalität | gut | bereit nach QA |
|
||||
| Produktions-Build | bestanden | gut |
|
||||
| SEO-Technik | lückenhaft | noch nicht bereit |
|
||||
| AEO/GEO/AI-Zitierfähigkeit | schwach | noch nicht bereit |
|
||||
| AdSense-Voraussetzungen | unvollständig | noch nicht beantragen |
|
||||
| Rechtliches | nicht erkennbar | kritischer Blocker |
|
||||
|
||||
**Empfehlung:** Erst P0/P1 abschließen, dann live stellen und indexieren. Nicht direkt mit einer reinen Tool-Oberfläche in AdSense gehen.
|
||||
|
||||
---
|
||||
|
||||
## 2. Quellen und Audit-Methode
|
||||
|
||||
Verwendet wurden:
|
||||
|
||||
1. aktueller Code im Projektordner
|
||||
2. echter `npm run build`-Lauf
|
||||
3. echter `npm run lint`-Lauf
|
||||
4. gerenderte lokale Seite im Browser
|
||||
5. Keyword-Planner-Export `Keyword Stats 2026-07-22 at 23_15_06.csv`
|
||||
6. großer Keyword-Planner-Export `Keyword Stats 2026-07-22 at 23_08_06.csv`
|
||||
7. SEO-Audit- und AI-SEO-Skills
|
||||
8. öffentlich gefundene Corey-Haines-Referenzen zu Programmatic SEO: Template-Design, Datenqualität, Quality Controls und technischem SEO
|
||||
9. Google Search Central und Google AdSense-Hilfedokumentation
|
||||
|
||||
Eine lokale Skill-Datei mit dem Namen „Corey Haynes“ wurde nicht gefunden. Deshalb wird hier kein nicht vorhandener Skill behauptet. Der Plan übernimmt den erkennbaren Corey-Haines-Ansatz: **erst Suchintention und Template-Architektur, dann skalieren; jede programmatische Seite braucht einzigartige Substanz, Qualitätskontrollen und einen echten Nutzerwert.**
|
||||
|
||||
---
|
||||
|
||||
## 3. Keyword-Planner-Befund
|
||||
|
||||
### 3.1 Entscheidomat-spezifischer Export
|
||||
|
||||
Datei:
|
||||
|
||||
`Keyword Stats 2026-07-22 at 23_15_06.csv`
|
||||
|
||||
Auswertung:
|
||||
|
||||
- 30 Keywords
|
||||
- zusammen ausgewiesenes Volumen: **419.050 monatliche Suchen**
|
||||
- 28 Keywords mit Wettbewerb „Niedrig“
|
||||
- die Summe darf **nicht** als erwartbarer Traffic addiert werden, weil Varianten und Suchintentionen sich überschneiden
|
||||
|
||||
Wichtige Gruppen:
|
||||
|
||||
| Cluster | Beispielkeywords | Planner-Volumen | Wettbewerb / CPC-Signal |
|
||||
|---|---|---:|---|
|
||||
| Ja/Nein | `ja nein generator`, `generator ja nein`, `ja oder nein generator` | je 50.000 | niedrig, Top-of-page ca. 0,80–1,08 € |
|
||||
| Würfel | `wuerfel online` | 50.000 | niedrig, ca. 0,39–1,37 € |
|
||||
| Namen | `zufallsgenerator namen`, `namen zufallsgenerator`, `namens zufallsgenerator` | je 50.000 | niedrig, CPC nicht durchgehend vorhanden |
|
||||
| Glücksrad | `gluecksrad online` | 50.000 | niedrig, ca. 0,55–2,96 € |
|
||||
| Entscheidungsrad | `entscheidungsrad` | 5.000 | niedrig, ca. 0,35–3,21 € |
|
||||
| Glücksrad drehen | `glücksrad drehen` | 5.000 | niedrig, ca. 0,36–1,64 € |
|
||||
| Münze | `münze werfen online` | 5.000 | niedrig, kein CPC-Wert im Export |
|
||||
| Entscheidung | `entscheidungsfinder` | 500 | niedrig |
|
||||
|
||||
### 3.2 Großer gemischter Export
|
||||
|
||||
Datei:
|
||||
|
||||
`Keyword Stats 2026-07-22 at 23_08_06.csv`
|
||||
|
||||
Auswertung:
|
||||
|
||||
- 869 Keywords
|
||||
- zusammen ausgewiesenes Volumen: **2.903.900 monatliche Suchen**
|
||||
- 773 Keywords mit Wettbewerb „Niedrig“
|
||||
- enthält mehrere Projekte und Themen, insbesondere Prozentrechner und Schriftgeneratoren
|
||||
|
||||
Die größten Begriffe des großen Exports sind **nicht** Entscheidomat-Ziele:
|
||||
|
||||
- `prozentrechner`: 500.000
|
||||
- `prozent rechner`: 500.000
|
||||
- zahlreiche Prozentrechnungsvarianten: 50.000
|
||||
- Schriftgenerator-Varianten: 50.000
|
||||
|
||||
**Konsequenz:** Die 2,9 Millionen dürfen nicht Entscheidomat zugerechnet werden. Prozentomat und Schriftgenerator gehören in separate Projekte bzw. Domains. Entscheidomat erhält ausschließlich die Zufalls-, Entscheidungs-, Würfel-, Rad-, Namen- und Münz-Cluster.
|
||||
|
||||
### 3.3 Keyword-Strategie für die Exact-Match-Domain
|
||||
|
||||
`entscheidomat.com` ist ein gutes Brand-/Category-Signal, aber eine Exact-Match-Domain ist **kein Ranking-Shortcut**. Sie ersetzt nicht:
|
||||
|
||||
- hilfreiche Inhalte
|
||||
- gute Seitenarchitektur
|
||||
- interne Links
|
||||
- externe Autorität
|
||||
- technische Indexierbarkeit
|
||||
- E-E-A-T
|
||||
|
||||
Die Domain sollte als merkbare Marke für ein **Entscheidungs- und Zufallstool-Portal** positioniert werden.
|
||||
|
||||
---
|
||||
|
||||
## 4. Empfohlene Seitenarchitektur
|
||||
|
||||
### Startseite `/`
|
||||
|
||||
**Primäre Funktion:** Marke plus Überblick über das Tool-Portfolio.
|
||||
|
||||
Empfohlener Title:
|
||||
|
||||
> Entscheidomat – Kostenloser Zufallsgenerator und Entscheidungshilfe
|
||||
|
||||
Empfohlener H1:
|
||||
|
||||
> Kostenloser Zufallsgenerator für schnelle Entscheidungen
|
||||
|
||||
Markenclaim als Unterzeile:
|
||||
|
||||
> Lass den Zufall entscheiden.
|
||||
|
||||
Die Startseite soll nicht alle Keywords unnatürlich wiederholen. Sie erklärt das Produkt, verlinkt auf die Tool-Seiten und beantwortet die wichtigste Definition direkt.
|
||||
|
||||
### Priorität-1-Seiten
|
||||
|
||||
| URL | Primärintention | Primärkeywords |
|
||||
|---|---|---|
|
||||
| `/ja-nein-generator` | direkte Ja-/Nein-Entscheidung | `ja nein generator`, `ja oder nein generator`, `generator ja nein` |
|
||||
| `/gluecksrad` | Rad drehen / Entscheidungsrad | `gluecksrad online`, `glücksrad drehen`, `entscheidungsrad` |
|
||||
| `/wuerfel-online` | virtueller Würfel | `wuerfel online`, `online virtueller würfel` |
|
||||
| `/namen-auslosen` | Namen zufällig ziehen | `zufallsgenerator namen`, `namen zufallsgenerator`, `namen auslosen` |
|
||||
| `/muenze-werfen` | Kopf oder Zahl | `münze werfen online`, `online münze werfen`, `münze online werfen` |
|
||||
|
||||
### Priorität-2-Seiten
|
||||
|
||||
| URL | Zweck |
|
||||
|---|---|
|
||||
| `/zufallszahl-generator` | Zufallszahlen mit frei wählbarem Bereich |
|
||||
| `/magic-8-ball` | Fragen und zufällige Antworten |
|
||||
| `/listen-generator` | Liste mischen, Einträge ziehen, Gruppen bilden |
|
||||
| `/entscheidungsrad` | Alias-/Intentseite, nur wenn eigenständig nützlich |
|
||||
| `/zufallsgenerator` | zentrale Kategorie-Seite, falls die Suchintention separat belegt wird |
|
||||
|
||||
**Wichtig:** Keine dünnen Doorway-Seiten erstellen. Jede Seite braucht ein eigenes Tool, eine eigene Erklärung, eigene Beispiele, eigene FAQs und eigene interne Linkbeziehungen.
|
||||
|
||||
---
|
||||
|
||||
## 5. P0 – Kritische Live-Blocker
|
||||
|
||||
### P0.1 Rechtliche Seiten
|
||||
|
||||
Vor dem öffentlichen Launch ergänzen:
|
||||
|
||||
- `/impressum`
|
||||
- `/datenschutz`
|
||||
- optional `/nutzungsbedingungen`
|
||||
- Kontaktmöglichkeit
|
||||
|
||||
Im Footer müssen diese Seiten dauerhaft verlinkt sein. Das ist insbesondere für eine deutsche Website und für AdSense wichtig.
|
||||
|
||||
### P0.2 Domain- und URL-Konfiguration
|
||||
|
||||
Nach Festlegung der echten Domain:
|
||||
|
||||
- HTTPS erzwingen
|
||||
- eine kanonische Variante festlegen: `https://entscheidomat.com`
|
||||
- `www` entweder sauber weiterleiten oder als kanonische Variante festlegen
|
||||
- HTTP auf HTTPS weiterleiten
|
||||
- lokale/Preview-Domain niemals als Canonical ausliefern
|
||||
- `metadataBase` auf `https://entscheidomat.com` setzen
|
||||
|
||||
### P0.3 Canonical und Social Metadata
|
||||
|
||||
In `app/layout.tsx` ergänzen:
|
||||
|
||||
- `metadataBase`
|
||||
- self-referencing canonical
|
||||
- Open Graph title/description/url/image
|
||||
- Twitter card
|
||||
- locale `de_DE`
|
||||
- Site name
|
||||
- passende Social-Share-Grafik 1200×630
|
||||
|
||||
### P0.4 Robots und Sitemap
|
||||
|
||||
Anlegen:
|
||||
|
||||
- `app/robots.ts`
|
||||
- `app/sitemap.ts`
|
||||
|
||||
Die Sitemap darf nur 200er, kanonische und indexierbare URLs enthalten. Keine Test-, Query- oder internen URLs.
|
||||
|
||||
Robots darf Suchcrawler nicht blockieren. Wenn AI-Zitate gewünscht sind, keine Sperren gegen GPTBot, ChatGPT-User, PerplexityBot, ClaudeBot, Google-Extended oder Bingbot einbauen, sofern keine bewusste andere Geschäftsentscheidung getroffen wird.
|
||||
|
||||
### P0.5 Lint-Fehler beseitigen
|
||||
|
||||
Vor Launch beheben:
|
||||
|
||||
- `preloader.tsx`: Reduced-Motion-Initialisierung ohne problematisches synchrones State-Setzen im Effect
|
||||
- `sound-provider.tsx`: Local-Storage-Hydration sauber behandeln
|
||||
- `dice-roller.tsx`: Transformationszustand aus einem renderbaren State ableiten, nicht `ref.current` direkt rendern
|
||||
- `use-local-storage.ts`: Hydration-Strategie prüfen
|
||||
- `use-prefers-reduced-motion.ts`: Media-Query-Initialisierung sauber lösen
|
||||
|
||||
Akzeptanzkriterium:
|
||||
|
||||
```text
|
||||
npm run build -> exit 0
|
||||
npm run lint -> exit 0
|
||||
```
|
||||
|
||||
### P0.6 Browser- und Accessibility-QA
|
||||
|
||||
Prüfen:
|
||||
|
||||
- Chrome Desktop
|
||||
- Chrome Android / iPhone-Breite
|
||||
- Tastaturbedienung
|
||||
- Screenreader-Landmarks
|
||||
- sichtbare Focus-Zustände
|
||||
- `role=tab` mit passendem `tabpanel`
|
||||
- `aria-controls` und `id` für Tabs
|
||||
- keine Inhalte, die nur nach Animation sichtbar werden
|
||||
- reduzierte Bewegung
|
||||
- Sound standardmäßig nicht ungefragt abspielen
|
||||
|
||||
---
|
||||
|
||||
## 6. P1 – Onpage-SEO und Informationsarchitektur
|
||||
|
||||
### 6.1 Direct-Answer-Block auf der Startseite
|
||||
|
||||
Direkt unter dem Hero, als sichtbarer HTML-Text:
|
||||
|
||||
> **Was ist Entscheidomat?**
|
||||
> Entscheidomat ist ein kostenloser Zufallsgenerator und eine digitale Entscheidungshilfe. Du kannst online eine Münze werfen, ein Glücksrad drehen, würfeln, eine Zufallszahl erzeugen, den Magic 8-Ball fragen oder eine eigene Liste auslosen – ohne Anmeldung und direkt im Browser.
|
||||
|
||||
Der Absatz ist für Menschen, klassische Snippets und AI-Extraktion geschrieben. Kein Keyword-Stuffing.
|
||||
|
||||
### 6.2 Tool-Abschnitte auf der Startseite
|
||||
|
||||
Unter dem interaktiven Bereich je ein kurzer, echter Informationsabschnitt:
|
||||
|
||||
- Was macht das Tool?
|
||||
- Wann ist es hilfreich?
|
||||
- Welche Optionen kann man anpassen?
|
||||
- Link zur vertiefenden Tool-Seite
|
||||
|
||||
### 6.3 Inhalte auf jeder Tool-Seite
|
||||
|
||||
Jede Tool-Seite braucht mindestens:
|
||||
|
||||
1. H1 mit natürlicher Suchintention
|
||||
2. 40–60-Wörter-Direktantwort
|
||||
3. das funktionierende Tool möglichst früh
|
||||
4. kurze Bedienungsanleitung
|
||||
5. konkrete Beispiele
|
||||
6. Vorteile und Grenzen
|
||||
7. verwandte Tools
|
||||
8. 5–8 echte FAQs
|
||||
9. Aktualisierungsdatum
|
||||
10. interne Links zur Startseite und zu verwandten Seiten
|
||||
|
||||
### 6.4 Beispiel für `/ja-nein-generator`
|
||||
|
||||
H1:
|
||||
|
||||
> Ja-Nein-Generator – schnelle Entscheidungen online treffen
|
||||
|
||||
Direktantwort:
|
||||
|
||||
> Der Ja-Nein-Generator beantwortet eine Frage zufällig mit Ja oder Nein. Formuliere deine Frage, starte den Generator und erhalte sofort eine zufällige Entscheidung – kostenlos, ohne Anmeldung und direkt im Browser.
|
||||
|
||||
FAQs:
|
||||
|
||||
- Wie funktioniert ein Ja-Nein-Generator?
|
||||
- Ist das Ergebnis wirklich zufällig?
|
||||
- Kann ich den Generator mehrfach verwenden?
|
||||
- Eignet sich der Generator für Gruppenentscheidungen?
|
||||
- Ersetzt ein Zufallsergebnis eine wichtige persönliche Entscheidung?
|
||||
|
||||
### 6.5 Title-/H1-Mapping
|
||||
|
||||
Jede URL bekommt einen eigenen Title, eine eigene H1 und eine eigene Description. Keine Serienseiten mit nur ausgetauschtem Keyword.
|
||||
|
||||
---
|
||||
|
||||
## 7. P1 – Structured Data
|
||||
|
||||
### Startseite
|
||||
|
||||
- `WebSite`
|
||||
- `Organization` oder `SoftwareApplication`
|
||||
- `WebApplication` mit Name, URL, Beschreibung, Browser-Anforderung und kostenloser Nutzung
|
||||
|
||||
### Tool-Seiten
|
||||
|
||||
- `WebApplication` oder `SoftwareApplication`
|
||||
- `BreadcrumbList`
|
||||
- `FAQPage` nur für Fragen, die sichtbar auf der Seite stehen
|
||||
|
||||
Schema muss exakt mit sichtbarem Inhalt übereinstimmen. Keine erfundenen Bewertungen, Preise, Nutzerzahlen oder Auszeichnungen.
|
||||
|
||||
Google weist ausdrücklich darauf hin, dass Structured Data keine Garantie für Rich Results oder AI-Zitate ist. Es verbessert aber die maschinenlesbare Interpretation und die allgemeine SEO-Grundlage.
|
||||
|
||||
---
|
||||
|
||||
## 8. P1 – AEO und GEO für AI-Sichtbarkeit
|
||||
|
||||
### Grundprinzip
|
||||
|
||||
Nicht separate „AI-Texte“ produzieren. Ein klarer, hilfreicher Inhalt soll gleichzeitig:
|
||||
|
||||
- Menschen helfen
|
||||
- Google ranken können
|
||||
- von AI-Systemen extrahiert werden können
|
||||
- als Quelle zitiert werden können
|
||||
|
||||
### Content-Muster
|
||||
|
||||
Für jede wichtige Seite:
|
||||
|
||||
- Definition am Anfang
|
||||
- direkte Antwort auf die Hauptfrage
|
||||
- kurze Absätze
|
||||
- eindeutige H2/H3-Fragen
|
||||
- nummerierte Bedienungsanleitung
|
||||
- Vergleichstabelle, wenn sinnvoll
|
||||
- FAQ
|
||||
- sichtbarer Stand/Aktualisierungszeitpunkt
|
||||
- klare Verweise auf verwandte Tools
|
||||
|
||||
### AI-Readiness-Checkliste
|
||||
|
||||
- [ ] Hauptdefinition in den ersten 100 Wörtern
|
||||
- [ ] jeder zentrale Absatz ohne Kontext verständlich
|
||||
- [ ] keine wichtigen Informationen nur in Canvas/SVG/JavaScript
|
||||
- [ ] semantisches HTML
|
||||
- [ ] echte H1/H2/H3-Hierarchie
|
||||
- [ ] Tool-Name und Zweck klar genannt
|
||||
- [ ] FAQs sichtbar und nicht nur im Schema versteckt
|
||||
- [ ] robots erlaubt gewünschte AI-Suchcrawler
|
||||
- [ ] `llms.txt` optional als Zusatz, nicht als Ersatz für SEO
|
||||
- [ ] keine KI-generierten Massenvarianten ohne eigenen Nutzwert
|
||||
|
||||
### `/llms.txt`
|
||||
|
||||
Optional anlegen, nachdem die echten Seiten existieren. Inhalt:
|
||||
|
||||
- kurze Produktdefinition
|
||||
- Zielgruppe
|
||||
- Liste der Tools
|
||||
- kanonische URLs
|
||||
- Kontakt und rechtliche Seiten
|
||||
- kurze Hinweise zu kostenloser Nutzung
|
||||
|
||||
`llms.txt` ist ein Zusatzsignal für einige AI-Ökosysteme, aber kein offizieller Google-Ranking-Hebel und kein Ersatz für Sitemap, HTML, interne Links oder E-E-A-T.
|
||||
|
||||
---
|
||||
|
||||
## 9. P1 – Corey-Haines-inspirierte Programmatic-SEO-Regeln
|
||||
|
||||
Die geplanten Tool-Seiten dürfen über Templates erzeugt werden, aber nur mit Qualitätskontrollen.
|
||||
|
||||
### Jede Seite muss eigene Daten enthalten
|
||||
|
||||
- eigenes Keyword-Cluster
|
||||
- eigene Suchintention
|
||||
- eigenes Tool-Verhalten
|
||||
- eigene Beispiele
|
||||
- eigene FAQs
|
||||
- eigener Direct-Answer-Text
|
||||
- eigene interne Linkempfehlungen
|
||||
- eigene Title/Description
|
||||
|
||||
### Nicht skalieren, bevor eine Musterseite gut ist
|
||||
|
||||
Reihenfolge:
|
||||
|
||||
1. `/ja-nein-generator` vollständig bauen
|
||||
2. als SEO-/UX-/Schema-/AdSense-Musterseite prüfen
|
||||
3. `/gluecksrad` als zweite Musterseite bauen
|
||||
4. erst danach Datenmodell und Seiten-Template auf alle Tools ausrollen
|
||||
|
||||
### Quality Gate pro programmatischer Seite
|
||||
|
||||
Eine Seite wird nur veröffentlicht, wenn:
|
||||
|
||||
- sie ein echtes funktionierendes Tool besitzt
|
||||
- mindestens 500–900 Wörter echter, nützlicher Kontext vorhanden sind, sofern die Intention das rechtfertigt
|
||||
- der Text nicht künstlich verlängert ist
|
||||
- mindestens 5 einzigartige FAQs vorhanden sind
|
||||
- keine Copy-Paste-Blöcke von anderen Tool-Seiten bestehen
|
||||
- Title, H1, URL und Suchintention zusammenpassen
|
||||
- Links und Schema validiert sind
|
||||
- mobile Darstellung geprüft wurde
|
||||
|
||||
Die Wortzahl ist kein Selbstzweck. Nutzerwert und Vollständigkeit gehen vor künstlicher Länge.
|
||||
|
||||
---
|
||||
|
||||
## 10. P2 – AdSense-Plan
|
||||
|
||||
### Wichtige Realität
|
||||
|
||||
AdSense wird nicht durch Keyword-Volumen allein freigeschaltet. Ein Tool-Portal braucht:
|
||||
|
||||
- originellen, hilfreichen Content
|
||||
- klare Navigation
|
||||
- erreichbare rechtliche Seiten
|
||||
- Datenschutz und Einwilligungsmanagement
|
||||
- keine irreführenden Anzeigen
|
||||
- keine ungültigen Klicks
|
||||
- gute Nutzererfahrung
|
||||
- ausreichende öffentliche Substanz
|
||||
|
||||
Es gibt keinen seriösen Weg, „maximales AdSense“ zu garantieren. Das Ziel ist **maximale regelkonforme Ertragschance bei guter Nutzererfahrung**.
|
||||
|
||||
### Vor AdSense-Antrag
|
||||
|
||||
- [ ] Domain live und stabil
|
||||
- [ ] HTTPS aktiv
|
||||
- [ ] Impressum live
|
||||
- [ ] Datenschutz live
|
||||
- [ ] Cookie-/Consent-Lösung für EEA, UK und Schweiz eingerichtet
|
||||
- [ ] Google-zertifizierte CMP bzw. Google Privacy & Messaging geprüft
|
||||
- [ ] Sitemap und Search Console eingerichtet
|
||||
- [ ] Seiten indexierbar
|
||||
- [ ] mehrere echte Tool-Seiten live
|
||||
- [ ] informative Inhalte pro Tool vorhanden
|
||||
- [ ] Kontaktmöglichkeit vorhanden
|
||||
- [ ] keine Platzhaltertexte
|
||||
- [ ] keine leeren Kategorien
|
||||
- [ ] keine übermäßigen Popups oder Interstitials
|
||||
|
||||
### Ad-Platzierung
|
||||
|
||||
Nicht direkt neben einem Button, der ein Ergebnis erzeugt. Keine Platzierung, die versehentliche Klicks provoziert.
|
||||
|
||||
Empfohlene Reihenfolge:
|
||||
|
||||
1. oberhalb/unterhalb des redaktionellen Direct-Answer-Blocks, nur wenn genug Inhalt vorhanden ist
|
||||
2. zwischen Inhalt und FAQ, nicht innerhalb der eigentlichen Tool-Steuerung
|
||||
3. ein responsives Ad im unteren Content-Bereich
|
||||
4. optional ein dezentes Banner auf langen Erklärseiten
|
||||
|
||||
Nicht empfehlen:
|
||||
|
||||
- Ads als erste sichtbare Fläche vor dem Tool
|
||||
- Ads zwischen Ja-/Nein-Buttons
|
||||
- Ads neben Würfel-/Rad-Steuerungen
|
||||
- Anzeigen in die interaktive Oberfläche hinein
|
||||
- mehrere Ads auf einer kurzen, inhaltsarmen Seite
|
||||
- „Klicke hier“-Formulierungen
|
||||
|
||||
Google AdSense empfiehlt, Anzeigen als Anzeigen erkennbar zu halten, den eigenen Content hervorzuheben, responsive Formate zu verwenden und Bereiche mit wenig einzigartigem Content nicht mit Werbung zu überladen.
|
||||
|
||||
### Ertragsoptimierung nach ausreichendem Traffic
|
||||
|
||||
Erst nach stabilen Daten testen:
|
||||
|
||||
- Ad-Viewability
|
||||
- Page RPM
|
||||
- Session RPM
|
||||
- Engagement Rate
|
||||
- Tool-Nutzung vor und nach Ad-Placement
|
||||
- Core Web Vitals
|
||||
- mobile vs. desktop
|
||||
- Consent Rate
|
||||
|
||||
Nicht nur CPC optimieren. Ein höherer CPC mit schlechter UX kann Umsatz und SEO verschlechtern.
|
||||
|
||||
---
|
||||
|
||||
## 11. P2 – Analytics und Messung
|
||||
|
||||
### Technische Einrichtung
|
||||
|
||||
- Google Search Console
|
||||
- GA4 oder datenschutzkonforme Alternative
|
||||
- Consent Mode korrekt konfigurieren
|
||||
- `sitemap.xml` einreichen
|
||||
- Bing Webmaster Tools
|
||||
- Ereignisse für Tool-Nutzung
|
||||
|
||||
### Events
|
||||
|
||||
Empfohlene Events:
|
||||
|
||||
- `tool_open`
|
||||
- `tool_result`
|
||||
- `tool_customized`
|
||||
- `tool_share`
|
||||
- `faq_expand`
|
||||
- `outbound_click`
|
||||
|
||||
Keine personenbezogenen Daten in Event-Parametern speichern.
|
||||
|
||||
### SEO-Monitoring
|
||||
|
||||
Monatlich prüfen:
|
||||
|
||||
- Klicks und Impressionen je URL
|
||||
- CTR je Keyword
|
||||
- Indexierungsstatus
|
||||
- Core Web Vitals
|
||||
- Suchanfragen mit Impressionen, aber niedriger CTR
|
||||
- neue Suchintentionen
|
||||
- Seiten ohne Impressionen
|
||||
- interne Links und Crawlbarkeit
|
||||
|
||||
### AI-Monitoring
|
||||
|
||||
Monatlich 10–20 Queries testen:
|
||||
|
||||
- „kostenloser Zufallsgenerator online"
|
||||
- „bester Ja Nein Generator"
|
||||
- „Glücksrad online drehen"
|
||||
- „Würfel online"
|
||||
- „Namen zufällig auslosen"
|
||||
- „Münze werfen online"
|
||||
- „Entscheidungshilfe ohne Anmeldung"
|
||||
|
||||
In Google AI Overviews, ChatGPT, Perplexity und Bing/Copilot dokumentieren:
|
||||
|
||||
- wird Entscheidomat genannt?
|
||||
- wird eine URL zitiert?
|
||||
- welche Wettbewerber werden zitiert?
|
||||
- welche Formulierung verwendet die AI?
|
||||
- welche Seite muss inhaltlich verbessert werden?
|
||||
|
||||
---
|
||||
|
||||
## 12. Konkrete Umsetzungsreihenfolge
|
||||
|
||||
### Phase A – Launch-Fundament
|
||||
|
||||
- [ ] Domain `entscheidomat.com` verbinden
|
||||
- [ ] HTTPS und Redirect-Strategie prüfen
|
||||
- [ ] `metadataBase` und Canonical setzen
|
||||
- [ ] OG-/Twitter-Card erstellen
|
||||
- [ ] Social Image erzeugen
|
||||
- [ ] Impressum erstellen
|
||||
- [ ] Datenschutz erstellen
|
||||
- [ ] Footer erweitern
|
||||
- [ ] `app/robots.ts` erstellen
|
||||
- [ ] `app/sitemap.ts` erstellen
|
||||
- [ ] gewünschte AI-Crawler nicht blockieren
|
||||
- [ ] Lint-Fehler beheben
|
||||
- [ ] mobile QA durchführen
|
||||
|
||||
### Phase B – Startseite SEO-/AI-fähig machen
|
||||
|
||||
- [ ] Title auf Kategorie + Nutzen ausrichten
|
||||
- [ ] H1 um „kostenloser Zufallsgenerator“ ergänzen
|
||||
- [ ] Direct-Answer-Block hinzufügen
|
||||
- [ ] sechs Tool-Erklärungen hinzufügen
|
||||
- [ ] 8–10 FAQs hinzufügen
|
||||
- [ ] WebApplication-/Organization-Schema ergänzen
|
||||
- [ ] Breadcrumb-Schema für Unterseiten vorbereiten
|
||||
|
||||
### Phase C – erste fünf Suchseiten
|
||||
|
||||
- [ ] `/ja-nein-generator`
|
||||
- [ ] `/gluecksrad`
|
||||
- [ ] `/wuerfel-online`
|
||||
- [ ] `/namen-auslosen`
|
||||
- [ ] `/muenze-werfen`
|
||||
|
||||
Für jede Seite:
|
||||
|
||||
- [ ] eigenes Tool
|
||||
- [ ] eigener Title/H1/Description
|
||||
- [ ] Direct Answer
|
||||
- [ ] Bedienungsanleitung
|
||||
- [ ] Beispiele
|
||||
- [ ] FAQ
|
||||
- [ ] interne Links
|
||||
- [ ] Schema
|
||||
- [ ] Mobile-/Accessibility-QA
|
||||
|
||||
### Phase D – Search Console und AdSense
|
||||
|
||||
- [ ] Search Console bestätigen
|
||||
- [ ] Sitemap einreichen
|
||||
- [ ] Indexierung prüfen
|
||||
- [ ] Analytics und Consent prüfen
|
||||
- [ ] mindestens mehrere wertvolle Seiten stabil live haben
|
||||
- [ ] AdSense-Policies prüfen
|
||||
- [ ] AdSense beantragen
|
||||
- [ ] Ads erst nach Freischaltung und in klar getrennten Content-Bereichen einbauen
|
||||
- [ ] Viewability und UX messen
|
||||
|
||||
### Phase E – Autorität und GEO
|
||||
|
||||
- [ ] About-/Produktseite
|
||||
- [ ] glaubwürdige Projekt-/Betreiberinformationen
|
||||
- [ ] echte Erwähnungen und Reviews aufbauen
|
||||
- [ ] Tool-Vergleiche und How-to-Inhalte
|
||||
- [ ] keine künstlichen Backlinks
|
||||
- [ ] keine massenhaft generierten Thin Pages
|
||||
- [ ] monatliches AI-Citation-Tracking
|
||||
|
||||
---
|
||||
|
||||
## 13. Definition of Done – perfekte Live-Version
|
||||
|
||||
### Technik
|
||||
|
||||
- [ ] `npm run build` exit 0
|
||||
- [ ] `npm run lint` exit 0
|
||||
- [ ] HTTPS und Canonical korrekt
|
||||
- [ ] keine Console Errors
|
||||
- [ ] mobile Layouts geprüft
|
||||
- [ ] Tastatur und Screenreader geprüft
|
||||
- [ ] Core Web Vitals gemessen
|
||||
|
||||
### Crawlability
|
||||
|
||||
- [ ] `/robots.txt` 200
|
||||
- [ ] `/sitemap.xml` 200
|
||||
- [ ] nur kanonische URLs in Sitemap
|
||||
- [ ] keine wichtigen Inhalte hinter blockierendem Client-JS
|
||||
- [ ] keine versehentlichen `noindex`-Signale
|
||||
|
||||
### SEO
|
||||
|
||||
- [ ] jede indexierbare Seite hat eigenen Title
|
||||
- [ ] jede Seite hat eigene Description
|
||||
- [ ] genau ein sinnvoller H1
|
||||
- [ ] Keywords werden natürlich eingesetzt
|
||||
- [ ] interne Links sind beschreibend
|
||||
- [ ] keine dünnen Doorway-Seiten
|
||||
|
||||
### AEO/GEO
|
||||
|
||||
- [ ] Definition am Seitenanfang
|
||||
- [ ] direkte Antworten auf Nutzerfragen
|
||||
- [ ] sichtbare FAQ
|
||||
- [ ] semantische Überschriften
|
||||
- [ ] Schema entspricht sichtbarem Content
|
||||
- [ ] AI-Crawler-Entscheidung dokumentiert
|
||||
- [ ] AI-Visibility-Baseline erstellt
|
||||
|
||||
### AdSense
|
||||
|
||||
- [ ] Impressum und Datenschutz
|
||||
- [ ] Consent Management für EEA/UK/Schweiz
|
||||
- [ ] keine Ads in Tool-Controls
|
||||
- [ ] Ads klar erkennbar
|
||||
- [ ] keine Klickanreize
|
||||
- [ ] keine ungültigen Klicks
|
||||
- [ ] ausreichender einzigartiger Content
|
||||
- [ ] Ads nur dort, wo sie UX nicht zerstören
|
||||
|
||||
---
|
||||
|
||||
## 14. Quellen
|
||||
|
||||
### Google Search Central
|
||||
|
||||
- [AI features and your website](https://developers.google.com/search/docs/appearance/ai-features)
|
||||
- [Optimizing for generative AI features](https://developers.google.com/search/docs/fundamentals/ai-optimization-guide)
|
||||
- [SEO Starter Guide](https://developers.google.com/search/docs/fundamentals/seo-starter-guide)
|
||||
- [Structured Data introduction](https://developers.google.com/search/docs/appearance/structured-data/intro-structured-data)
|
||||
- [Canonical URLs](https://developers.google.com/search/docs/crawling-indexing/consolidate-duplicate-urls)
|
||||
- [Sitemaps](https://developers.google.com/search/docs/crawling-indexing/sitemaps/overview)
|
||||
|
||||
### Google AdSense
|
||||
|
||||
- [Best practices for ad placement](https://support.google.com/adsense/answer/1282097)
|
||||
- [Viewability best practices](https://support.google.com/adsense/answer/6219980)
|
||||
- [EU user consent policy / CMP](https://support.google.com/adsense/answer/7670013)
|
||||
- [Consent optimization](https://support.google.com/adsense/answer/16878447)
|
||||
|
||||
### Keyword-Daten
|
||||
|
||||
- `Keyword Stats 2026-07-22 at 23_15_06.csv` – entscheidomat-spezifischer Export
|
||||
- `Keyword Stats 2026-07-22 at 23_08_06.csv` – gemischter Portfolio-Export; nicht vollständig Entscheidomat zurechnen
|
||||
|
||||
---
|
||||
|
||||
## Schlussentscheidung
|
||||
|
||||
**Nicht als finale SEO-/AdSense-Version live nehmen.**
|
||||
|
||||
**Als funktionierender Produktprototyp:** ja.
|
||||
**Als sauberer öffentlicher Launch:** nach Phase A.
|
||||
**Als SEO-/AI-/AdSense-optimierte Version:** nach Phase B bis D.
|
||||
**Als skalierbares Portfolio:** erst nach einer vollständig geprüften Musterseite und einem stabilen Quality Gate für programmatische Seiten.
|
||||
121
ZUKUNFTSPLAN.md
Normal file
121
ZUKUNFTSPLAN.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# 🚀 Zukunftsplan: SEO & EMD-Portfolio (Web-Tools)
|
||||
|
||||
Dieses Dokument dient als strategischer Zukunftsplan und Fahrplan für das Web-Tool-Portfolio auf Basis der Google Keyword Planner Analysen.
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Strategische Grundausrichtung: Multi-Domain-Ansatz
|
||||
|
||||
Anstatt alle unterschiedlichen Tools auf einer gemischten Website zu bündeln, setzen wir auf **fokussierte Exact-Match-Domains (EMDs)**.
|
||||
|
||||
### Vorteile dieses Ansatzes:
|
||||
- **100 % SEO-Relevanz:** Jede Domain bedient exakt ein Themenfeld. Google belohnt diese thematische Reinheit mit besseren organischen Rankings.
|
||||
- **Hohe Click-Through-Rate (CTR):** Nutzer in den Suchergebnissen vertrauen prägnanten Domainnamen wie `entscheidomat.com` oder `prozentomat.de`.
|
||||
- **Einfache Skalierbarkeit:** Neue Nischen können als eigenständige Marken gelaucht werden.
|
||||
|
||||
---
|
||||
|
||||
## 🏆 Die Hauptprojekte & Roadmap
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Web-Tool Portfolio] --> B[Projekt 1: entscheidomat.com]
|
||||
A --> C[Projekt 2: prozentomat.de]
|
||||
A --> D[Projekt 3: schriftomat.de]
|
||||
A --> E[Projekt 4: nameomat.de]
|
||||
|
||||
B --> B1[Glücksrad / 50k]
|
||||
B --> B2[Ja/Nein Generator / 50k]
|
||||
B --> B3[Würfel Online / 50k]
|
||||
B --> B4[Namen-Auslosung / 50k]
|
||||
B --> B5[Münzwurf / 5k]
|
||||
|
||||
C --> C1[Prozentrechner / 500k]
|
||||
C --> C2[Prozentuale Zu/Abnahme]
|
||||
C --> C3[MwSt.-Rechner]
|
||||
C --> C4[Dreisatz-Rechner]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 🎲 Projekt 1: `entscheidomat.com` (Sofort-Launch)
|
||||
|
||||
* **Fokus:** Zufallsgeneratoren, Entscheidungs-Tools & Unterhaltung.
|
||||
* **Suchvolumen-Pool:** **> 250.000 Suchen / Monat** (bei geringem Wettbewerb).
|
||||
* **Entwicklungsstatus:** Tools sind bereits vollständig programmiert.
|
||||
|
||||
### SEO-Titel & URL-Struktur:
|
||||
- **Startseite (`/`):** *Entscheidomat – Der Online Entscheidungsfinder & Zufallsgenerator*
|
||||
- **`/gluecksrad`:** *Online Glücksrad & Entscheidungsrad drehen*
|
||||
- **Keywords:** `gluecksrad online` (50.000 Suchen) | `entscheidungsrad` (5.000 Suchen, **3,21 € Max CPC**)
|
||||
- **`/ja-nein-generator`:** *Ja Nein Generator – Schnelle Antworten finden*
|
||||
- **Keywords:** `ja nein generator` (50.000 Suchen, 1,08 € CPC) | `ja oder nein generator` (50.000 Suchen)
|
||||
- **`/wuerfel-online`:** *Virtueller Würfel Online – 6er & 10er Würfel rollen*
|
||||
- **Keywords:** `wuerfel online` (50.000 Suchen, 1,37 € CPC)
|
||||
- **`/namen-auslosen`:** *Namen Zufallsgenerator – Gewinner & Teams ziehen*
|
||||
- **Keywords:** `zufallsgenerator namen` (50.000 Suchen)
|
||||
- **`/muenze-werfen`:** *Münze werfen online – Kopf oder Zahl*
|
||||
- **Keywords:** `münze werfen online` (5.000 Suchen)
|
||||
|
||||
---
|
||||
|
||||
## 2. 🧮 Projekt 2: `prozentomat.de` (Traffic- & Ertrags-Gigant)
|
||||
|
||||
* **Fokus:** Prozentrechnung, Rabatt-, Finanz- & Schul-Rechner.
|
||||
* **Suchvolumen-Pool:** **> 500.000 Suchen / Monat**
|
||||
* **Monetarisierung:** **1,75 € Max CPC** (Finanz-Segment mit sehr hohem Werbewert).
|
||||
|
||||
### Geplante Tools auf `prozentomat.de`:
|
||||
1. **Klassischer Prozentrechner:** „Wie viel sind X % von Y?“ / „Wie viel % sind X von Y?“
|
||||
2. **Prozentuale Zu- & Abnahme (Rabattrechner):** „Betrag um X % erhöhen / verringern“.
|
||||
3. **Mehrwertsteuer-Rechner:** 19 % & 7 % MwSt. hinzurechnen oder herausrechnen.
|
||||
4. **Dreisatz-Prozentrechner:** Berechnung von Grundwert, Prozentwert und Prozentsatz.
|
||||
5. **Prozentuale Veränderung:** prozentuale Differenz zwischen zwei Werten.
|
||||
|
||||
---
|
||||
|
||||
## 3. 📦 Pipeline-Projekte (Zukunft)
|
||||
|
||||
### ✍️ Projekt 3: `schriftomat.de` / `fontomat.de`
|
||||
- **Fokus:** Unicode-Textgenerator & Instagram-Schriftarten zum Kopieren.
|
||||
- **Traffic-Potenzial:** **> 340.000 Suchen / Monat** | CPC: **1,69 €**
|
||||
- **Aufwand:** Sehr gering (rein clientseitiges JavaScript).
|
||||
|
||||
### 💡 Projekt 4: `nameomat.de`
|
||||
- **Fokus:** Namensgenerator für Marken, Firmen, Gaming & Haustiere.
|
||||
- **Traffic-Potenzial:** **> 21.000 Suchen / Monat** | CPC: **1,90 €** (Höchster CPC im Generator-Segment).
|
||||
|
||||
---
|
||||
|
||||
## 💰 Finanz- & Monetarisierungs-Prognose (AdSense)
|
||||
|
||||
Basierend auf einer konservativen Klickrate (CTR) von 1,5 % – 2,0 % und den Klickpreisen (CPC) der Google Keyword Planner Daten:
|
||||
|
||||
| Projekt | Monatliche Seitenaufrufe (geschätzt) | AdSense Umsatzerwartung / Monat |
|
||||
| :--- | :---: | :---: |
|
||||
| **`entscheidomat.com`** | ~120.000 Pageviews | **800 € – 2.200 €** |
|
||||
| **`prozentomat.de`** | ~150.000 Pageviews | **1.800 € – 4.500 €** |
|
||||
| **`schriftomat.de`** | ~100.000 Pageviews | **600 € – 1.600 €** |
|
||||
| **GESAMT-PORTFOLIO** | **~370.000 Pageviews** | **3.200 € – 8.300 € / Monat** |
|
||||
|
||||
---
|
||||
|
||||
## 📋 Schritt-für-Schritt Fahrplan zur Umsetzung
|
||||
|
||||
### Phase 1: Launch von `entscheidomat.com`
|
||||
- [ ] Domain `entscheidomat.com` registrieren.
|
||||
- [ ] Bisheriges Tool-Set auf der Domain einrichten.
|
||||
- [ ] Einzelseiten (`/gluecksrad`, `/ja-nein-generator`, `/wuerfel-online`, `/muenze-werfen`, `/namen-auslosen`) anlegen.
|
||||
- [ ] SEO-Optimierung: Meta-Title, H1-Tags, Meta-Description, OpenGraph-Tags & Schema.org Markup (`SoftwareApplication` / `WebApplication`).
|
||||
- [ ] In der Google Search Console anmelden & Sitemap einreichen.
|
||||
|
||||
### Phase 2: Aufbau von `prozentomat.de`
|
||||
- [ ] Domain `prozentomat.de` registrieren.
|
||||
- [ ] Schnelles React/Next.js oder HTML/JS Frontend für die 5 Prozentrechner-Formulartools bauen.
|
||||
- [ ] Erklärungstexte & Formeln unter die Rechner setzen (wichtig für Google SEO Content-Relevanz).
|
||||
- [ ] Google Search Console Anmeldung & Indexierung anstoßen.
|
||||
|
||||
### Phase 3: Monetarisierung & Ausbau
|
||||
- [ ] Google AdSense Konto beantragen und Anzeigenplätze (Header, Sidebar, In-Content) optimieren.
|
||||
- [ ] Affiliate-Partnerschaften einbinden (z. B. Buchhaltung / Finanzen bei Prozentomat).
|
||||
- [ ] Pipeline-Projekte (`schriftomat.de`, `nameomat.de`) umsetzen.
|
||||
163
app/datenschutz/page.tsx
Normal file
163
app/datenschutz/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import LegalLayout from "@/components/legal-layout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Datenschutzerklärung",
|
||||
description: "Datenschutzerklärung von Entscheidomat gemäß DSGVO.",
|
||||
alternates: { canonical: "/datenschutz" },
|
||||
};
|
||||
|
||||
export default function DatenschutzPage() {
|
||||
return (
|
||||
<LegalLayout title="Datenschutzerklärung" updated="23. Juli 2026">
|
||||
<section>
|
||||
<h2>1. Verantwortlicher</h2>
|
||||
<p className="mt-3">
|
||||
Verantwortlich für die Datenverarbeitung auf dieser Website im
|
||||
Sinne der Datenschutz-Grundverordnung (DSGVO) ist:
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Timo Knuth
|
||||
<br />
|
||||
E-Mail: <a href="mailto:info@entscheidomat.com">info@entscheidomat.com</a>
|
||||
</p>
|
||||
<p className="mt-3 font-medium text-clay">
|
||||
Die vollständige ladungsfähige Geschäftsanschrift wird vor dem
|
||||
öffentlichen Launch ergänzt.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Weitere Angaben finden Sie im{" "}
|
||||
<Link href="/impressum">Impressum</Link>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Grundsatz der Datenverarbeitung</h2>
|
||||
<p className="mt-3">
|
||||
Entscheidomat ist bewusst datensparsam aufgebaut: Die
|
||||
Zufalls-Tools (Münze, Glücksrad, Würfel, Zufallszahl, Magic
|
||||
8-Ball, Listen-Auswahl, Ja/Nein-Generator) laufen vollständig im
|
||||
Browser. Eingaben wie eigene Beschriftungen, Listen oder
|
||||
Fragen werden nicht an unseren Server übertragen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Hosting und Server-Logfiles</h2>
|
||||
<p className="mt-3">
|
||||
Beim Aufruf dieser Website verarbeitet der Hostinganbieter
|
||||
automatisch technische Zugriffsdaten (z. B. IP-Adresse,
|
||||
Datum und Uhrzeit des Zugriffs, aufgerufene Seite, verwendeter
|
||||
Browser) in Server-Logfiles. Dies ist technisch notwendig, um die
|
||||
Website sicher und stabil auszuliefern (Rechtsgrundlage: Art. 6
|
||||
Abs. 1 lit. f DSGVO, berechtigtes Interesse an einem
|
||||
funktionsfähigen Betrieb).
|
||||
</p>
|
||||
<p className="mt-3 font-medium text-clay">
|
||||
Der Webserver befindet sich in Texas, USA. Die Bezeichnung des
|
||||
Hostinganbieters und die Speicherdauer der Logfiles werden vor dem
|
||||
öffentlichen Launch ergänzt.
|
||||
</p>
|
||||
|
||||
<h2 className="mt-6">3.1 Datenübermittlung in die USA (Drittlandtransfer)</h2>
|
||||
<p className="mt-3">
|
||||
Diese Website wird auf einem Server in Texas, USA gehostet. Dabei werden
|
||||
personenbezogene Daten (insbesondere die technischen
|
||||
Zugriffsdaten aus Ziffer 3) in ein Land außerhalb der
|
||||
Europäischen Union und des Europäischen Wirtschaftsraums
|
||||
(sogenanntes Drittland) übertragen.
|
||||
</p>
|
||||
<p className="mt-3 font-medium text-clay">
|
||||
Vor dem öffentlichen Launch wird geprüft und dokumentiert, auf
|
||||
welcher konkreten Grundlage die Datenübermittlung erfolgt und ob
|
||||
der gewählte Hostinganbieter die erforderlichen Garantien für den
|
||||
Drittlandtransfer bietet.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Lokale Speicherung (localStorage)</h2>
|
||||
<p className="mt-3">
|
||||
Eigene Einstellungen – z. B. Münz-Beschriftungen, eigene
|
||||
Würfel-Texte, Listen-Einträge, Magic-8-Ball-Antworten oder die
|
||||
Sound-Einstellung – werden ausschließlich im{" "}
|
||||
<code>localStorage</code> Ihres Browsers auf Ihrem Gerät
|
||||
gespeichert, damit sie bei erneutem Besuch erhalten bleiben.
|
||||
Diese Daten verlassen Ihr Gerät nicht und werden nicht an uns
|
||||
übertragen. Sie können diese Daten jederzeit über die
|
||||
Browsereinstellungen (Website-Daten löschen) entfernen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Cookies und Werbung</h2>
|
||||
<p className="mt-3">
|
||||
Entscheidomat setzt aktuell keine Cookies und keine Werbedienste
|
||||
ein. Sollte künftig Werbung (z. B. Google AdSense)
|
||||
hinzukommen, wird diese Datenschutzerklärung vorher aktualisiert
|
||||
und eine Einwilligungslösung (Consent-Banner) für Nutzerinnen und
|
||||
Nutzer aus der EU, dem UK und der Schweiz eingebaut, bevor der
|
||||
Dienst aktiv geschaltet wird.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Webanalyse mit Umami</h2>
|
||||
<p className="mt-3">
|
||||
Wir nutzen Umami Cloud (Umami Software, Inc.) mit Datenregion EU,
|
||||
um anonyme, aggregierte Nutzungsstatistiken zu erheben (z. B.
|
||||
Seitenaufrufe, Referrer, ungefähre geografische Region, verwendetes
|
||||
Gerät). Rechtsgrundlage ist unser berechtigtes Interesse an der
|
||||
Analyse und Verbesserung des Angebots (Art. 6 Abs. 1 lit. f DSGVO).
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Umami setzt <strong>keine Cookies</strong> und verwendet keine
|
||||
persistente, geräteübergreifende Kennung zur Wiedererkennung
|
||||
einzelner Personen. IP-Adressen werden nicht gespeichert; es
|
||||
erfolgt lediglich eine tagesaktuelle, nicht rückverfolgbare
|
||||
Kennzeichnung zur Unterscheidung wiederkehrender Besuche
|
||||
(Hashing). Eine Zusammenführung mit anderen Diensten oder eine
|
||||
Profilbildung findet nicht statt. Die Daten werden auf Servern in
|
||||
der EU verarbeitet.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Da keine Cookies gesetzt und keine personenbezogenen Kennungen
|
||||
gespeichert werden, ist nach unserer Einschätzung keine
|
||||
Einwilligung nach § 25 TDDDG erforderlich.
|
||||
Weitere Informationen:{" "}
|
||||
<a
|
||||
href="https://umami.is/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
umami.is/privacy
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>7. Ihre Rechte</h2>
|
||||
<p className="mt-3">Sie haben im Rahmen der geltenden Datenschutzgesetze jederzeit das Recht auf:</p>
|
||||
<ul className="mt-3">
|
||||
<li>Auskunft über Ihre bei uns gespeicherten personenbezogenen Daten (Art. 15 DSGVO)</li>
|
||||
<li>Berichtigung unrichtiger Daten (Art. 16 DSGVO)</li>
|
||||
<li>Löschung Ihrer bei uns gespeicherten Daten (Art. 17 DSGVO)</li>
|
||||
<li>Einschränkung der Datenverarbeitung (Art. 18 DSGVO)</li>
|
||||
<li>Widerspruch gegen die Verarbeitung (Art. 21 DSGVO)</li>
|
||||
<li>Datenübertragbarkeit (Art. 20 DSGVO)</li>
|
||||
<li>Beschwerde bei einer Datenschutz-Aufsichtsbehörde</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>8. Kontakt für Datenschutzanfragen</h2>
|
||||
<p className="mt-3">
|
||||
Bei Fragen zum Datenschutz wenden Sie sich bitte an:{" "}
|
||||
info@entscheidomat.com.
|
||||
</p>
|
||||
</section>
|
||||
</LegalLayout>
|
||||
);
|
||||
}
|
||||
374
app/globals.css
Normal file
374
app/globals.css
Normal file
@@ -0,0 +1,374 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
/* Typografie */
|
||||
--font-sans: var(--font-inter), ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-display: var(--font-cormorant), Georgia, serif;
|
||||
|
||||
/* Farben – Warmes Porzellan-Papier mit sattem Gelb-Grün / Smaragd */
|
||||
--color-paper: #faf8f3;
|
||||
--color-surface: #ffffff;
|
||||
--color-ink: #111412;
|
||||
--color-ink-soft: #45504a;
|
||||
--color-line: #e0eae4;
|
||||
|
||||
--color-primary: #111412;
|
||||
--color-primary-strong: #000000;
|
||||
--color-primary-soft: #e6f7f0;
|
||||
|
||||
/* Glücksspiel & Zufall – Gelb-Grün / Smaragdgrün */
|
||||
--color-emerald-accent: #0d7a57;
|
||||
--color-emerald-strong: #095c41;
|
||||
--color-emerald-soft: #e6f7f0;
|
||||
--color-emerald-line: #b2e5d1;
|
||||
|
||||
--color-clay: #b4633f;
|
||||
--color-clay-soft: #f6eae2;
|
||||
|
||||
/* Schatten */
|
||||
--shadow-soft: 0 2px 4px rgb(17 20 18 / 0.03), 0 12px 36px rgb(17 20 18 / 0.06);
|
||||
--shadow-lift: 0 4px 12px rgb(17 20 18 / 0.06), 0 24px 52px rgb(17 20 18 / 0.10);
|
||||
--shadow-cta: 0 12px 28px rgb(17 20 18 / 0.22);
|
||||
|
||||
/* Animationen */
|
||||
--animate-float: float 7s ease-in-out infinite;
|
||||
--animate-coin-spin: coin-spin 10s linear infinite;
|
||||
--animate-bob: bob 2.4s ease-in-out infinite;
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-16px);
|
||||
}
|
||||
}
|
||||
@keyframes coin-spin {
|
||||
from {
|
||||
transform: rotateY(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotateY(360deg);
|
||||
}
|
||||
}
|
||||
@keyframes bob {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: translateY(6px);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@keyframes stream {
|
||||
from {
|
||||
stroke-dashoffset: 1000;
|
||||
}
|
||||
to {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
}
|
||||
@keyframes shootTL {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
80% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: translate(520px, 180px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes shootTR {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
80% {
|
||||
opacity: 0.7;
|
||||
}
|
||||
100% {
|
||||
transform: translate(-520px, 190px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes shootBL {
|
||||
0% {
|
||||
transform: translate(0, 0);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
80% {
|
||||
opacity: 0.65;
|
||||
}
|
||||
100% {
|
||||
transform: translate(480px, -170px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------- Basis ---------- */
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-paper);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
/* Der Inline-Check im Root-Layout läuft vor der Hydration. Dadurch blitzt der
|
||||
Preloader bei einem Reload innerhalb derselben Browser-Session nicht auf. */
|
||||
html[data-preloader="cached"] [data-preloader-overlay] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Der Preloader würde auf einem gedrosselten Mobilgerät das LCP-Element
|
||||
verdecken. Die Seite bleibt dort sofort lesbar; Desktop behält den Effekt. */
|
||||
@media (max-width: 767px) {
|
||||
[data-preloader-overlay] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Die Hero-Inhalte sind bereits vor dem Öffnen des Vorhangs im Startzustand.
|
||||
So kann GSAP sie nicht erst sichtbar machen und anschließend zurücksetzen. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
html[data-preloader="active"] .hero-el {
|
||||
opacity: 0;
|
||||
transform: translateY(34px);
|
||||
}
|
||||
|
||||
.reveal {
|
||||
opacity: 0;
|
||||
transform: translateY(var(--reveal-y, 30px));
|
||||
transition:
|
||||
opacity 0.7s ease var(--reveal-delay, 0s),
|
||||
transform 0.7s cubic-bezier(0.22, 1, 0.36, 1) var(--reveal-delay, 0s);
|
||||
}
|
||||
|
||||
.reveal--visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgb(22 21 19 / 0.12);
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
/* Dezente Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: var(--color-paper);
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #d5d0c2;
|
||||
border-radius: 8px;
|
||||
border: 2px solid var(--color-paper);
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #c1bbab;
|
||||
}
|
||||
|
||||
/* ---------- Hintergrund-Deko ---------- */
|
||||
|
||||
.bg-grid {
|
||||
background-image:
|
||||
linear-gradient(to right, rgb(32 30 25 / 0.045) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, rgb(32 30 25 / 0.045) 1px, transparent 1px);
|
||||
background-size: 46px 46px;
|
||||
mask-image: radial-gradient(ellipse 90% 65% at 50% 0%, black 25%, transparent 78%);
|
||||
-webkit-mask-image: radial-gradient(ellipse 90% 65% at 50% 0%, black 25%, transparent 78%);
|
||||
}
|
||||
|
||||
.bg-noise {
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
opacity: 0.03;
|
||||
}
|
||||
|
||||
/* ---------- 3D-Münze (gebürstetes Messing, flach) ---------- */
|
||||
|
||||
.coin-scene {
|
||||
perspective: 1100px;
|
||||
}
|
||||
|
||||
.coin {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.coin-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.coin-face--front {
|
||||
transform: translateZ(4px);
|
||||
background: #dec998;
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgb(255 250 235 / 0.5),
|
||||
inset 0 -2px 4px rgb(120 90 40 / 0.12);
|
||||
}
|
||||
|
||||
.coin-face--back {
|
||||
background: #d6be87;
|
||||
box-shadow:
|
||||
inset 0 1px 2px rgb(255 248 228 / 0.5),
|
||||
inset 0 -2px 4px rgb(110 82 36 / 0.12);
|
||||
}
|
||||
|
||||
.coin-edge {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
border-radius: 9999px;
|
||||
background: #8a6f44;
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.coin-engraving {
|
||||
position: absolute;
|
||||
inset: 8%;
|
||||
border-radius: 9999px;
|
||||
border: 2px solid rgb(122 94 51 / 0.38);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.coin-engraving--inner {
|
||||
inset: 16%;
|
||||
border-width: 1px;
|
||||
border-color: rgb(122 94 51 / 0.26);
|
||||
}
|
||||
|
||||
.coin-label {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.16em;
|
||||
color: #6e5533;
|
||||
text-shadow: 0 1px 0 rgb(255 246 224 / 0.8);
|
||||
max-width: 78%;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ---------- 3D-Würfel ---------- */
|
||||
|
||||
.dice-scene {
|
||||
perspective: 900px;
|
||||
}
|
||||
|
||||
.dice {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transform-style: preserve-3d;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.dice-face {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #fdfcf9;
|
||||
border: 1px solid var(--color-line);
|
||||
border-radius: 18%;
|
||||
box-shadow: inset 0 0 14px rgb(32 30 25 / 0.06);
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
overflow: hidden;
|
||||
padding: 8%;
|
||||
}
|
||||
|
||||
.dice-face-label {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: clamp(9px, 1.6vw, 13px);
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
color: var(--color-ink);
|
||||
overflow-wrap: anywhere;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- Magic 8-Ball (warmes Anthrazit, flach) ---------- */
|
||||
|
||||
.ball-body {
|
||||
background: #1d1b17;
|
||||
box-shadow:
|
||||
inset 0 -18px 42px rgb(0 0 0 / 0.6),
|
||||
inset 0 10px 26px rgb(255 255 255 / 0.07),
|
||||
0 30px 60px rgb(32 30 25 / 0.28);
|
||||
}
|
||||
|
||||
.ball-window {
|
||||
background: #12130f;
|
||||
box-shadow:
|
||||
inset 0 4px 18px rgb(0 0 0 / 0.75),
|
||||
0 0 0 6px rgb(255 255 255 / 0.05);
|
||||
}
|
||||
|
||||
/* ---------- Diverse ---------- */
|
||||
|
||||
.tabular {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
input[type="number"]::-webkit-outer-spin-button,
|
||||
input[type="number"]::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input[type="number"] {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
163
app/gluecksrad/page.tsx
Normal file
163
app/gluecksrad/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import type { Metadata } from "next";
|
||||
import WheelOfFortune from "@/components/tools/wheel-of-fortune";
|
||||
import SubpageLayout from "@/components/subpage-layout";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
const PAGE_URL = `${SITE_URL}/gluecksrad`;
|
||||
const PAGE_TITLE = "Glücksrad online drehen – kostenloses Entscheidungsrad";
|
||||
const PAGE_DESCRIPTION =
|
||||
"Glücksrad online mit bis zu 12 Optionen: eintragen, drehen und zufällig entscheiden lassen. Kostenlos, ohne Anmeldung und direkt im Browser.";
|
||||
const UPDATED = "23. Juli 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
alternates: { canonical: "/gluecksrad" },
|
||||
openGraph: { type: "website", locale: "de_DE", siteName: "Entscheidomat", title: PAGE_TITLE, description: PAGE_DESCRIPTION, url: PAGE_URL, images: ["/opengraph-image"] },
|
||||
twitter: { card: "summary_large_image", title: PAGE_TITLE, description: PAGE_DESCRIPTION, images: ["/twitter-image"] },
|
||||
};
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Wie viele Optionen kann ich im Glücksrad eintragen?",
|
||||
a: "Zwischen 2 und 12 Optionen. Du kannst sie jederzeit ändern, hinzufügen oder entfernen – deine Liste bleibt automatisch im Browser gespeichert.",
|
||||
},
|
||||
{
|
||||
q: "Wie wird die Option ausgewählt?",
|
||||
a: "Beim Drehen erzeugt das Tool im Browser einen neuen Zufallswert und ordnet das Ergebnis einer sichtbaren Option zu. Das Glücksrad ist für spielerische Alltagsentscheidungen gedacht, nicht für verbindliche oder sicherheitskritische Zwecke.",
|
||||
},
|
||||
{
|
||||
q: "Werden meine Optionen gespeichert oder übertragen?",
|
||||
a: "Deine Optionen werden ausschließlich lokal im localStorage deines Browsers gespeichert und nicht an einen Server übertragen.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich das Rad auf die Standardoptionen zurücksetzen?",
|
||||
a: "Ja, über den Button „Zurücksetzen“ im Optionen-Editor werden die Standardoptionen 1–6 wiederhergestellt.",
|
||||
},
|
||||
{
|
||||
q: "Eignet sich das Glücksrad für Gruppen und Präsentationen?",
|
||||
a: "Ja, es ist groß und deutlich lesbar. Für wichtige Entscheidungen sollte es aber nicht der einzige Entscheidungsweg sein.",
|
||||
},
|
||||
{
|
||||
q: "Wie nutze ich das Glücksrad für eine faire Auswahl?",
|
||||
a: "Trage jede Option genau einmal ein. Das Rad teilt alle Einträge in gleich große Segmente; doppelte Einträge erhöhen deshalb die Chance dieser Option. Prüfe die Liste vor dem Drehen, wenn alle dieselbe Gewinnchance haben sollen.",
|
||||
},
|
||||
{
|
||||
q: "Was ist der Unterschied zwischen Glücksrad und Namen auslosen?",
|
||||
a: "Das Glücksrad zeigt die Optionen visuell und eignet sich besonders für kleine Gruppen oder Präsentationen mit bis zu 12 Einträgen. Bei langen Listen oder mehreren Ziehungen ohne Wiederholung ist die Namen-Auslosung übersichtlicher.",
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Entscheidomat", item: SITE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Glücksrad", item: PAGE_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
name: "Glücksrad",
|
||||
url: PAGE_URL,
|
||||
description: PAGE_DESCRIPTION,
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "Any (Web-Browser)",
|
||||
isPartOf: { "@id": `${SITE_URL}/#website` },
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: FAQS.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Trage 2 bis 12 Optionen im Editor ein – z. B. Namen, Aktivitäten oder Restaurants.",
|
||||
"Passe die Beschriftungen jederzeit an.",
|
||||
"Tippe auf „Drehen!“ – das Rad dreht sich und bremst zufällig ab.",
|
||||
"Der Zeiger markiert die gewonnene Option, inklusive Konfetti.",
|
||||
];
|
||||
|
||||
const EXAMPLES = [
|
||||
"„Welches Restaurant heute Abend?“",
|
||||
"„Wer räumt diese Woche auf?“",
|
||||
"„Welcher Film wird geschaut?“",
|
||||
];
|
||||
|
||||
const VORTEILE = (
|
||||
<p>
|
||||
Das Glücksrad eignet sich besonders für Gruppenentscheidungen mit mehreren
|
||||
gleichwertigen Optionen, die visuell gut dargestellt werden können. Pro Option
|
||||
sind bis zu 24 Zeichen und maximal 12 Optionen möglich – für sehr viele
|
||||
Alternativen eignet sich eher die Listen-Auswahl.
|
||||
</p>
|
||||
);
|
||||
|
||||
const ADDITIONAL_CONTENT = (
|
||||
<article className="space-y-4 text-base leading-relaxed text-ink-soft">
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Glücksrad online: Entscheidungen sichtbar und fair auslosen
|
||||
</h2>
|
||||
<p>
|
||||
Ein Online-Glücksrad ist eine Entscheidungshilfe für mehrere gleichwertige
|
||||
Optionen. Du trägst zwischen zwei und zwölf Begriffe ein, das Tool verteilt sie
|
||||
auf gleich große Segmente und der Zeiger bestimmt beim Drehen ein Ergebnis.
|
||||
Dadurch hat jede einmal eingetragene Option dieselbe Chance. Typische Beispiele
|
||||
sind Restaurants, Filme, Spiele, Aufgaben, Themen für einen Vortrag oder die
|
||||
Reihenfolge in einer Gruppe.
|
||||
</p>
|
||||
<p>
|
||||
Damit die Auswahl fair bleibt, sollte jede Möglichkeit genau einmal in der Liste
|
||||
stehen. Ein doppelter Eintrag nimmt zwei Segmente ein und wird entsprechend
|
||||
wahrscheinlicher ausgewählt. Das ist nützlich, wenn du bewusst gewichten willst,
|
||||
aber ungeeignet für eine faire Verlosung. Prüfe deshalb die Beschriftungen vor
|
||||
dem Start und erkläre der Gruppe kurz, dass alle Segmente gleich groß sind. Die
|
||||
gespeicherten Optionen bleiben nur lokal in deinem Browser und werden nicht an
|
||||
einen Server übertragen.
|
||||
</p>
|
||||
<p>
|
||||
Für kleine Gruppen ist das drehende Rad oft besser als eine Liste, weil alle
|
||||
Optionen gleichzeitig sichtbar sind und das Ergebnis einen gemeinsamen Moment
|
||||
schafft. Bei vielen Namen oder wenn nach jeder Ziehung ein Eintrag verschwinden
|
||||
soll, ist die Namen-Auslosung die bessere Wahl. Und wenn es nur um zwei
|
||||
Möglichkeiten geht, sind Ja-Nein-Generator oder Münzwurf schneller. Für wichtige
|
||||
Entscheidungen bleibt Zufall ein Impuls – Verantwortung und Folgen sollten alle
|
||||
Beteiligten vorher besprechen.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default function GluecksradPage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<SubpageLayout
|
||||
toolSlug="gluecksrad"
|
||||
toolName="Glücksrad"
|
||||
h1Title="Glücksrad online drehen – dein kostenloses Entscheidungsrad"
|
||||
introText="Das Online-Glücksrad ist ein digitales Entscheidungsrad mit bis zu 12 frei einstellbaren Optionen. Trage deine Optionen ein, drehe das Rad und lass den Zeiger die Entscheidung treffen – kostenlos, ohne Anmeldung und direkt im Browser."
|
||||
steps={STEPS}
|
||||
examples={EXAMPLES}
|
||||
vorteileText={VORTEILE}
|
||||
additionalContent={ADDITIONAL_CONTENT}
|
||||
faqs={FAQS}
|
||||
updatedDate={UPDATED}
|
||||
>
|
||||
<WheelOfFortune />
|
||||
</SubpageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
9
app/icon.svg
Normal file
9
app/icon.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#0d7a57"/>
|
||||
<path d="M143 168h226c18 0 33 15 33 33v110c0 18-15 33-33 33H143c-18 0-33-15-33-33V201c0-18 15-33 33-33Z" fill="#faf8f3"/>
|
||||
<rect x="142" y="200" width="94" height="112" rx="20" fill="#dcefe6"/>
|
||||
<rect x="276" y="200" width="94" height="112" rx="20" fill="#0d7a57"/>
|
||||
<path d="m300 255 17 17 31-37" fill="none" stroke="#faf8f3" stroke-linecap="round" stroke-linejoin="round" stroke-width="14"/>
|
||||
<path d="M256 344v36" fill="none" stroke="#faf8f3" stroke-linecap="round" stroke-width="16"/>
|
||||
<circle cx="256" cy="408" r="25" fill="#8bb79d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 676 B |
88
app/impressum/page.tsx
Normal file
88
app/impressum/page.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { Metadata } from "next";
|
||||
import LegalLayout from "@/components/legal-layout";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Impressum",
|
||||
description: "Impressum und Anbieterkennzeichnung von Entscheidomat.",
|
||||
alternates: { canonical: "/impressum" },
|
||||
};
|
||||
|
||||
export default function ImpressumPage() {
|
||||
return (
|
||||
<LegalLayout title="Impressum" updated="23. Juli 2026">
|
||||
<section>
|
||||
<h2>Angaben gemäß § 5 Digitale-Dienste-Gesetz (DDG)</h2>
|
||||
<p className="mt-3">
|
||||
Timo Knuth
|
||||
<br />
|
||||
Einzelunternehmer
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Kontakt</h2>
|
||||
<p className="mt-3">
|
||||
E-Mail: <a href="mailto:info@entscheidomat.com">info@entscheidomat.com</a>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</h2>
|
||||
<p className="mt-3">Timo Knuth</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Nutzungshinweis und Haftungsrahmen</h2>
|
||||
<p className="mt-3">
|
||||
Entscheidomat ist eine spielerische Entscheidungshilfe für
|
||||
alltägliche, folgenarme Situationen. Die Tools ersetzen keine
|
||||
persönliche, medizinische, rechtliche, finanzielle oder sonstige
|
||||
fachliche Beratung und sind nicht für verbindliche Auslosungen,
|
||||
Gewinnspiele oder sicherheitskritische Zwecke bestimmt.
|
||||
</p>
|
||||
<p className="mt-3">
|
||||
Die Ergebnisse werden mit browserbasierten Zufallswerten erzeugt.
|
||||
Es wird keine Zusage für kryptografische Zufälligkeit, rechtliche
|
||||
Verbindlichkeit oder eine bestimmte Eignung für einen konkreten
|
||||
Zweck gemacht. Gesetzliche Haftung, insbesondere bei Vorsatz,
|
||||
grober Fahrlässigkeit sowie bei Schäden an Leben, Körper oder
|
||||
Gesundheit, bleibt unberührt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Inhalte</h2>
|
||||
<p className="mt-3">
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 DDG für eigene
|
||||
Inhalte auf diesen Seiten nach den allgemeinen Gesetzen
|
||||
verantwortlich. Nach §§ 8 bis 10 DDG sind wir als Diensteanbieter
|
||||
jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde
|
||||
Informationen zu überwachen oder nach Umständen zu forschen, die
|
||||
auf eine rechtswidrige Tätigkeit hinweisen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Haftung für Links</h2>
|
||||
<p className="mt-3">
|
||||
Entscheidomat enthält aktuell keine Links zu externen Webseiten
|
||||
Dritter, auf deren Inhalte wir Einfluss haben. Sollten künftig
|
||||
externe Links ergänzt werden, gilt: Für die Inhalte der
|
||||
verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber
|
||||
der Seiten verantwortlich.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Urheberrecht</h2>
|
||||
<p className="mt-3">
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf
|
||||
diesen Seiten unterliegen dem deutschen Urheberrecht.
|
||||
Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der
|
||||
schriftlichen Zustimmung des jeweiligen Autors bzw. Erstellers.
|
||||
</p>
|
||||
</section>
|
||||
</LegalLayout>
|
||||
);
|
||||
}
|
||||
175
app/ja-nein-generator/page.tsx
Normal file
175
app/ja-nein-generator/page.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import type { Metadata } from "next";
|
||||
import JaNeinGenerator from "@/components/tools/ja-nein-generator";
|
||||
import SubpageLayout from "@/components/subpage-layout";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
const PAGE_URL = `${SITE_URL}/ja-nein-generator`;
|
||||
const PAGE_TITLE = "Ja-Nein-Generator: Ja oder Nein online entscheiden";
|
||||
const PAGE_DESCRIPTION =
|
||||
"Mit dem Ja-Nein-Generator erhältst du sofort eine zufällige Antwort auf deine Frage: Ja oder Nein. Kostenlos, ohne Anmeldung, direkt im Browser.";
|
||||
const UPDATED = "23. Juli 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
alternates: { canonical: "/ja-nein-generator" },
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "de_DE",
|
||||
siteName: "Entscheidomat",
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
url: PAGE_URL,
|
||||
images: ["/opengraph-image"],
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
images: ["/twitter-image"],
|
||||
},
|
||||
};
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Wie funktioniert ein Ja-Nein-Generator?",
|
||||
a: "Du gibst optional eine Frage ein und startest den Generator per Klick oder Tipp. Das Tool erzeugt im Browser einen neuen Zufallswert und zeigt danach „Ja“ oder „Nein“ an.",
|
||||
},
|
||||
{
|
||||
q: "Wie entsteht das Ergebnis?",
|
||||
a: "Bei jeder Anfrage erzeugt das Tool im Browser einen neuen Zufallswert. Der Ja-Nein-Generator ist für spielerische Alltagsentscheidungen gedacht, nicht für sicherheitskritische oder rechtlich verbindliche Zwecke.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich den Generator mehrfach verwenden?",
|
||||
a: "Ja, beliebig oft. Der Verlauf zeigt dir die letzten zehn Ergebnisse, damit du siehst, wie oft Ja bzw. Nein gefallen ist.",
|
||||
},
|
||||
{
|
||||
q: "Eignet sich der Generator für Gruppenentscheidungen?",
|
||||
a: "Für lockere Gruppenentscheidungen (z. B. „Bestellen wir Pizza?“) eignet er sich gut. Für Entscheidungen, die alle Beteiligten mittragen müssen, ersetzt er kein gemeinsames Gespräch.",
|
||||
},
|
||||
{
|
||||
q: "Ersetzt ein Zufallsergebnis eine wichtige persönliche Entscheidung?",
|
||||
a: "Nein. Der Generator ist als spielerischer Denkanstoß für Alltagsfragen gedacht – bei wichtigen persönlichen, finanziellen, rechtlichen oder gesundheitlichen Entscheidungen solltest du dich nicht auf einen Zufallsgenerator verlassen.",
|
||||
},
|
||||
{
|
||||
q: "Wann ist ein Ja-Nein-Generator besser als ein Münzwurf?",
|
||||
a: "Beide Methoden helfen bei einer Entscheidung zwischen zwei Optionen. Der Ja-Nein-Generator passt besser, wenn du eine konkrete Frage formulierst und direkt „Ja“ oder „Nein“ sehen möchtest; ein Münzwurf eignet sich eher für Kopf oder Zahl oder frei beschriftete Seiten.",
|
||||
},
|
||||
{
|
||||
q: "Ist der Ja-Nein-Generator für wichtige Entscheidungen geeignet?",
|
||||
a: "Nein. Er ist als spielerischer Denkanstoß für Alltagsfragen gedacht. Für gesundheitliche, rechtliche, finanzielle oder andere folgenreiche Entscheidungen solltest du Informationen sammeln und gegebenenfalls fachlichen Rat einholen.",
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Entscheidomat", item: SITE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Ja-Nein-Generator", item: PAGE_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
name: "Ja-Nein-Generator",
|
||||
url: PAGE_URL,
|
||||
description: PAGE_DESCRIPTION,
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "Any (Web-Browser)",
|
||||
isPartOf: { "@id": `${SITE_URL}/#website` },
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: FAQS.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Trage optional deine Frage in das Eingabefeld ein.",
|
||||
"Tippe auf die Karte oder den Button „Ja oder Nein?“.",
|
||||
"Der Generator wählt zufällig zwischen Ja und Nein und zeigt dir sofort das Ergebnis.",
|
||||
"Frag so oft du willst – der Verlauf zeigt dir die letzten zehn Ergebnisse.",
|
||||
];
|
||||
|
||||
const EXAMPLES = [
|
||||
"„Sollen wir heute Abend bestellen statt kochen?“",
|
||||
"„Soll ich den Film jetzt zu Ende schauen?“",
|
||||
"„Starte ich heute noch mit dem Sport?“",
|
||||
];
|
||||
|
||||
const VORTEILE = (
|
||||
<p>
|
||||
Der Generator ist ideal für schnelle Alltagsfragen ohne großes Abwägen – er ist
|
||||
kostenlos, sofort einsatzbereit und komplett anonym, da nichts an einen Server
|
||||
übertragen wird. Für wichtige persönliche, finanzielle, rechtliche oder gesundheitliche
|
||||
Entscheidungen ist ein reiner Zufallsmechanismus dagegen nicht geeignet.
|
||||
</p>
|
||||
);
|
||||
|
||||
const ADDITIONAL_CONTENT = (
|
||||
<article className="space-y-4 text-base leading-relaxed text-ink-soft">
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Ja-Nein-Generator oder Münzwurf: Was passt wann?
|
||||
</h2>
|
||||
<p>
|
||||
Ein Ja-Nein-Generator ist eine digitale Entscheidungshilfe für Fragen mit
|
||||
genau zwei möglichen Antworten. Er eignet sich, wenn die Optionen bereits klar
|
||||
sind und ihr eine kleine Pattsituation ohne langes Diskutieren auflösen möchtet:
|
||||
„Bestellen wir heute?“, „Gehen wir noch spazieren?“ oder „Starte ich jetzt mit
|
||||
der Aufgabe?“ Die Frage ist dabei nur ein hilfreicher Rahmen; sie verändert die
|
||||
Wahrscheinlichkeit nicht.
|
||||
</p>
|
||||
<p>
|
||||
Im Vergleich zum Münzwurf ist das Ergebnis sprachlich direkter. Statt Kopf oder
|
||||
Zahl erscheint sofort Ja oder Nein – praktisch auf dem Smartphone, in einer
|
||||
Gruppe oder wenn ihr das Ergebnis später noch einmal im Verlauf sehen wollt.
|
||||
Für zwei frei benannte Optionen wie „Kino oder Couch“ kannst du entweder die
|
||||
Frage entsprechend formulieren oder den Münzwurf mit eigenen Seiten nutzen.
|
||||
Bei drei oder mehr gleichwertigen Möglichkeiten ist ein Glücksrad meist die
|
||||
passendere Entscheidungshilfe.
|
||||
</p>
|
||||
<p>
|
||||
Zufall ersetzt keine Abwägung, kann aber sichtbar machen, was du selbst willst:
|
||||
Achte auf deine erste Reaktion auf das Ergebnis. Fühlt sich ein „Nein“ unerwartet
|
||||
enttäuschend an, steckt in dieser Reaktion oft schon eine Präferenz. Für
|
||||
finanzielle, rechtliche, gesundheitliche oder andere folgenreiche Entscheidungen
|
||||
solltest du Informationen sammeln, Alternativen vergleichen und gegebenenfalls
|
||||
fachlichen Rat einholen – nicht würfeln.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default function JaNeinGeneratorPage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<SubpageLayout
|
||||
toolSlug="ja-nein-generator"
|
||||
toolName="Ja-Nein-Generator"
|
||||
h1Title="Ja-Nein-Generator – Ja oder Nein online entscheiden"
|
||||
introText="Der Ja-Nein-Generator beantwortet eine Frage zufällig mit Ja oder Nein. Formuliere deine Frage, starte den Generator und erhalte sofort eine zufällige Entscheidung – kostenlos, ohne Anmeldung und direkt im Browser."
|
||||
steps={STEPS}
|
||||
examples={EXAMPLES}
|
||||
vorteileText={VORTEILE}
|
||||
additionalContent={ADDITIONAL_CONTENT}
|
||||
faqs={FAQS}
|
||||
updatedDate={UPDATED}
|
||||
>
|
||||
<JaNeinGenerator />
|
||||
</SubpageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
121
app/layout.tsx
Normal file
121
app/layout.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Inter, Cormorant_Garamond } from "next/font/google";
|
||||
import Script from "next/script";
|
||||
import "./globals.css";
|
||||
import { SoundProvider } from "@/components/providers/sound-provider";
|
||||
import Preloader from "@/components/preloader";
|
||||
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "optional",
|
||||
});
|
||||
|
||||
const cormorant = Cormorant_Garamond({
|
||||
variable: "--font-cormorant",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
display: "optional",
|
||||
});
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
const SITE_NAME = "Entscheidomat";
|
||||
const SITE_TITLE = "Entscheidomat – Lass den Zufall entscheiden";
|
||||
const SITE_DESCRIPTION =
|
||||
"Münze werfen, Glücksrad drehen, würfeln, eine Zufallszahl erzeugen, den Magic 8-Ball fragen, eine Liste auslosen oder den Ja-Nein-Generator befragen – wunderschön animiert, komplett anpassbar und kostenlos.";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: SITE_TITLE,
|
||||
template: "%s – Entscheidomat",
|
||||
},
|
||||
description: SITE_DESCRIPTION,
|
||||
keywords: [
|
||||
"Münzwurf",
|
||||
"Glücksrad",
|
||||
"Würfel",
|
||||
"Zufallsgenerator",
|
||||
"Entscheidung",
|
||||
"Kopf oder Zahl",
|
||||
"Ja Nein Generator",
|
||||
],
|
||||
alternates: {
|
||||
canonical: "/",
|
||||
},
|
||||
verification: {
|
||||
google: "8AohGR6zT7xA_1au64S-bh1i0GDPx7d-cbMdS518NkE",
|
||||
},
|
||||
openGraph: {
|
||||
type: "website",
|
||||
locale: "de_DE",
|
||||
url: SITE_URL,
|
||||
siteName: SITE_NAME,
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: SITE_TITLE,
|
||||
description: SITE_DESCRIPTION,
|
||||
},
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${SITE_URL}/#website`,
|
||||
url: SITE_URL,
|
||||
name: SITE_NAME,
|
||||
description: SITE_DESCRIPTION,
|
||||
inLanguage: "de-DE",
|
||||
},
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}/#organization`,
|
||||
name: SITE_NAME,
|
||||
url: SITE_URL,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html
|
||||
lang="de"
|
||||
className={`${inter.variable} ${cormorant.variable} h-full antialiased`}
|
||||
>
|
||||
<body className="flex min-h-full flex-col">
|
||||
<Script id="preloader-session" strategy="beforeInteractive">
|
||||
{`try {
|
||||
const hasSeenPreloader = sessionStorage.getItem("entscheidomat:preloader-seen") === "1";
|
||||
const isMobile = window.matchMedia("(max-width: 767px)").matches;
|
||||
document.documentElement.dataset.preloader = hasSeenPreloader || isMobile ? "cached" : "active";
|
||||
} catch {
|
||||
document.documentElement.dataset.preloader = window.matchMedia("(max-width: 767px)").matches ? "cached" : "active";
|
||||
}`}
|
||||
</Script>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<Script
|
||||
src="https://cloud.umami.is/script.js"
|
||||
data-website-id="15bab46b-b1a9-4d2f-9c69-deaef60a0cfc"
|
||||
strategy="afterInteractive"
|
||||
/>
|
||||
<Preloader />
|
||||
<SoundProvider>{children}</SoundProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
146
app/magic-8-ball/page.tsx
Normal file
146
app/magic-8-ball/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import Magic8Ball from "@/components/tools/magic-8-ball";
|
||||
import SubpageLayout from "@/components/subpage-layout";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
const PAGE_URL = `${SITE_URL}/magic-8-ball`;
|
||||
const PAGE_TITLE = "Magic 8-Ball online fragen – zufällige Antworten";
|
||||
const PAGE_DESCRIPTION =
|
||||
"Der Magic 8-Ball beantwortet deine Frage mit einer zufälligen, anpassbaren Antwort. Fragen stellen, schütteln, Antwort erhalten – kostenlos, ohne Anmeldung.";
|
||||
const UPDATED = "23. Juli 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
alternates: { canonical: "/magic-8-ball" },
|
||||
openGraph: { type: "website", locale: "de_DE", siteName: "Entscheidomat", title: PAGE_TITLE, description: PAGE_DESCRIPTION, url: PAGE_URL, images: ["/opengraph-image"] },
|
||||
twitter: { card: "summary_large_image", title: PAGE_TITLE, description: PAGE_DESCRIPTION, images: ["/twitter-image"] },
|
||||
};
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Wie funktioniert der Magic 8-Ball?",
|
||||
a: "Stelle optional deine Frage, tippe auf den Ball oder den Button – der Ball schüttelt sich kurz und zeigt eine zufällig ausgewählte Antwort aus deiner Antwortliste.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich die Antworten selbst anpassen?",
|
||||
a: "Ja, du kannst 2 bis 20 eigene Antworten im Editor eintragen. Sie werden automatisch lokal gespeichert.",
|
||||
},
|
||||
{
|
||||
q: "Sind die Antworten immer eindeutig Ja oder Nein?",
|
||||
a: "Nein, die klassischen 8-Ball-Antworten sind oft bewusst vage formuliert (z. B. „Frag später nochmal“). Für eine eindeutige Ja/Nein-Antwort eignet sich der Ja-Nein-Generator besser.",
|
||||
},
|
||||
{
|
||||
q: "Werden meine Antworten gespeichert oder an einen Server übertragen?",
|
||||
a: "Deine Antwortliste wird nur lokal im localStorage deines Browsers gespeichert, nicht an uns übertragen.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich den Ball mehrfach fragen?",
|
||||
a: "Ja, beliebig oft hintereinander.",
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Entscheidomat", item: SITE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Magic 8-Ball", item: PAGE_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
name: "Magic 8-Ball",
|
||||
url: PAGE_URL,
|
||||
description: PAGE_DESCRIPTION,
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "Any (Web-Browser)",
|
||||
isPartOf: { "@id": `${SITE_URL}/#website` },
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: FAQS.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Trage optional deine Frage ein.",
|
||||
"Tippe auf den Ball oder den Button „8-Ball schütteln“.",
|
||||
"Der Ball schüttelt sich kurz und zeigt eine zufällige Antwort im Fenster.",
|
||||
"Passe die möglichen Antworten im Editor an (2–20 Antworten).",
|
||||
];
|
||||
|
||||
const EXAMPLES = [
|
||||
"„Wird das Projekt heute fertig?“",
|
||||
"„Soll ich noch mal anrufen?“",
|
||||
"Spielerische Fragen in der Gruppe stellen",
|
||||
];
|
||||
|
||||
const VORTEILE = (
|
||||
<p>
|
||||
Eigene Antwortliste möglich (2–20 Antworten, automatisch gespeichert). Die klassischen
|
||||
Antworten sind oft bewusst mehrdeutig formuliert – für eine eindeutige Ja- oder
|
||||
Nein-Entscheidung eignet sich der{" "}
|
||||
<Link href="/ja-nein-generator" className="text-[#c97d60] font-medium hover:underline underline-offset-2">
|
||||
Ja-Nein-Generator
|
||||
</Link>{" "}
|
||||
besser.
|
||||
</p>
|
||||
);
|
||||
|
||||
const ADDITIONAL_CONTENT = (
|
||||
<article className="space-y-4 text-base leading-relaxed text-ink-soft">
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Magic 8-Ball online: spielerische Antworten auf deine Frage
|
||||
</h2>
|
||||
<p>
|
||||
Der Magic 8-Ball ist eine spielerische Entscheidungshilfe mit anpassbaren Antworten.
|
||||
Frage den Ball, schüttle ihn und erhalte eine zufällig ausgewählte Antwort. Mit einer
|
||||
eigenen Antwortliste kannst du ihn für Spiele, Gruppenfragen oder kleine Impulse
|
||||
personalisieren.
|
||||
</p>
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Magic 8-Ball, Ja-Nein-Generator oder Münzwurf?
|
||||
</h2>
|
||||
<p>
|
||||
Der Magic 8-Ball passt zu offenen und spielerischen Fragen, weil seine Antworten auch
|
||||
mehrdeutig sein können. Für eine eindeutige Antwort nutze den <Link href="/ja-nein-generator" className="text-[#c97d60] font-medium hover:underline underline-offset-2">Ja-Nein-Generator</Link>; für Kopf oder Zahl oder zwei frei benannte Optionen eignet sich die <Link href="/muenze-werfen" className="text-[#c97d60] font-medium hover:underline underline-offset-2">Online-Münze</Link>.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default function Magic8BallPage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<SubpageLayout
|
||||
toolSlug="magic-8-ball"
|
||||
toolName="Magic 8-Ball"
|
||||
h1Title="Magic 8-Ball online fragen – zufällige Antworten erhalten"
|
||||
introText="Der Magic 8-Ball beantwortet deine Frage mit einer zufälligen Antwort aus einer anpassbaren Liste. Stelle deine Frage, schüttle den Ball per Klick und erhalte sofort eine Antwort – kostenlos, ohne Anmeldung, direkt im Browser."
|
||||
steps={STEPS}
|
||||
examples={EXAMPLES}
|
||||
vorteileText={VORTEILE}
|
||||
additionalContent={ADDITIONAL_CONTENT}
|
||||
faqs={FAQS}
|
||||
updatedDate={UPDATED}
|
||||
>
|
||||
<Magic8Ball />
|
||||
</SubpageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
app/muenze-werfen/page.tsx
Normal file
150
app/muenze-werfen/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import CoinFlip from "@/components/tools/coin-flip";
|
||||
import SubpageLayout from "@/components/subpage-layout";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
const PAGE_URL = `${SITE_URL}/muenze-werfen`;
|
||||
const PAGE_TITLE = "Münze werfen online – Kopf oder Zahl entscheiden";
|
||||
const PAGE_DESCRIPTION =
|
||||
"Münze online werfen: Die Online-Münze simuliert Kopf oder Zahl mit 3D-Animation und frei beschriftbaren Seiten. Kostenlos und ohne Anmeldung.";
|
||||
const UPDATED = "23. Juli 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
alternates: { canonical: "/muenze-werfen" },
|
||||
openGraph: { type: "website", locale: "de_DE", siteName: "Entscheidomat", title: PAGE_TITLE, description: PAGE_DESCRIPTION, url: PAGE_URL, images: ["/opengraph-image"] },
|
||||
twitter: { card: "summary_large_image", title: PAGE_TITLE, description: PAGE_DESCRIPTION, images: ["/twitter-image"] },
|
||||
};
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Wie entsteht das Ergebnis beim Online-Münzwurf?",
|
||||
a: "Bei jedem Wurf erzeugt das Tool im Browser einen neuen Zufallswert und zeigt eine der beiden Seiten an. Der Münzwurf ist für spielerische Alltagsentscheidungen gedacht, nicht für verbindliche oder sicherheitskritische Zwecke.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich die Seiten der Münze umbenennen?",
|
||||
a: "Ja, du kannst beliebige Texte statt „Kopf“ und „Zahl“ verwenden, z. B. „Ja“/„Nein“ oder „Ich“/„Du“ (bis 14 Zeichen pro Seite).",
|
||||
},
|
||||
{
|
||||
q: "Wird mein Wurfverlauf gespeichert?",
|
||||
a: "Der Verlauf der letzten 10 Würfe wird nur während der aktuellen Sitzung angezeigt. Deine Beschriftungen selbst bleiben dauerhaft lokal im Browser gespeichert.",
|
||||
},
|
||||
{
|
||||
q: "Funktioniert der Münzwurf auch ohne Ton?",
|
||||
a: "Ja, Sound ist optional und über den Lautsprecher-Button in der Navigation jederzeit abschaltbar.",
|
||||
},
|
||||
{
|
||||
q: "Ersetzt der Münzwurf eine wichtige Entscheidung?",
|
||||
a: "Nein. Er ist für spielerische Alltagsentscheidungen gedacht – nicht für wichtige persönliche, finanzielle oder rechtliche Entscheidungen.",
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Entscheidomat", item: SITE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Münze werfen", item: PAGE_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
name: "Münze werfen",
|
||||
url: PAGE_URL,
|
||||
description: PAGE_DESCRIPTION,
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "Any (Web-Browser)",
|
||||
isPartOf: { "@id": `${SITE_URL}/#website` },
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: FAQS.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Passe optional die Beschriftung von Vorder- und Rückseite an (bis 14 Zeichen).",
|
||||
"Tippe auf die Münze oder den Button „Münze werfen“.",
|
||||
"Die Münze dreht sich in 3D und landet zufällig auf einer Seite.",
|
||||
"Das Ergebnis erscheint direkt darunter, der Verlauf zeigt die letzten 10 Würfe.",
|
||||
];
|
||||
|
||||
const EXAMPLES = [
|
||||
"„Wer beginnt das Spiel?“",
|
||||
"„Kino oder Couch?“",
|
||||
"Klassisches Kopf-oder-Zahl bei Alltagsentscheidungen",
|
||||
];
|
||||
|
||||
const VORTEILE = (
|
||||
<p>
|
||||
Schnell und unkompliziert für binäre Entscheidungen, die freie Beschriftung macht
|
||||
die Münze vielseitig einsetzbar. Für Entscheidungen mit mehr als zwei Optionen eignet sich eher das{" "}
|
||||
<Link href="/gluecksrad" className="text-[#c97d60] font-medium hover:underline underline-offset-2">
|
||||
Glücksrad
|
||||
</Link>{" "}
|
||||
oder der{" "}
|
||||
<Link href="/wuerfel-online" className="text-[#c97d60] font-medium hover:underline underline-offset-2">
|
||||
Würfel
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
|
||||
const ADDITIONAL_CONTENT = (
|
||||
<article className="space-y-4 text-base leading-relaxed text-ink-soft">
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Münze online werfen oder Ja-Nein-Generator?
|
||||
</h2>
|
||||
<p>
|
||||
Beide Tools helfen bei einer Entscheidung zwischen zwei gleichwertigen Optionen. Wenn
|
||||
du klassisch Kopf oder Zahl wählen oder die beiden Seiten selbst beschriften möchtest,
|
||||
passt die Online-Münze. Für eine formulierte Frage mit einer direkten Antwort eignet
|
||||
sich der <Link href="/ja-nein-generator" className="text-[#c97d60] font-medium hover:underline underline-offset-2">Ja-Nein-Generator</Link> besser.
|
||||
</p>
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Für welche Entscheidungen eignet sich ein Münzwurf?
|
||||
</h2>
|
||||
<p>
|
||||
Einen Münzwurf kannst du nutzen, um eine kleine Pattsituation aufzulösen: Wer beginnt,
|
||||
Kino oder Couch, bestellen oder kochen. Bei mehr als zwei Optionen ist ein
|
||||
<Link href="/gluecksrad" className="text-[#c97d60] font-medium hover:underline underline-offset-2"> Glücksrad</Link> übersichtlicher. Für wichtige persönliche, finanzielle, rechtliche oder gesundheitliche Entscheidungen sollte Zufall keine Grundlage sein.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default function MuenzeWerfenPage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<SubpageLayout
|
||||
toolSlug="muenze-werfen"
|
||||
toolName="Münze werfen"
|
||||
h1Title="Münze werfen online – Kopf oder Zahl entscheiden"
|
||||
introText="Wirf eine Münze online und entscheide zwischen Kopf oder Zahl. Beschrifte beide Seiten frei, starte den Münzwurf per Klick und erhalte sofort ein zufälliges Ergebnis – kostenlos und ohne Anmeldung."
|
||||
steps={STEPS}
|
||||
examples={EXAMPLES}
|
||||
vorteileText={VORTEILE}
|
||||
additionalContent={ADDITIONAL_CONTENT}
|
||||
faqs={FAQS}
|
||||
updatedDate={UPDATED}
|
||||
>
|
||||
<CoinFlip />
|
||||
</SubpageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
143
app/namen-auslosen/page.tsx
Normal file
143
app/namen-auslosen/page.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import ListPicker from "@/components/tools/list-picker";
|
||||
import SubpageLayout from "@/components/subpage-layout";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
const PAGE_URL = `${SITE_URL}/namen-auslosen`;
|
||||
const PAGE_TITLE = "Zufallsgenerator Namen – Namen online auslosen";
|
||||
const PAGE_DESCRIPTION =
|
||||
"Mit dem Zufallsgenerator für Namen ziehst du Namen oder Optionen zufällig aus deiner Liste – für Gewinner, Teams und Aufgaben. Kostenlos und ohne Anmeldung.";
|
||||
const UPDATED = "23. Juli 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
alternates: { canonical: "/namen-auslosen" },
|
||||
openGraph: { type: "website", locale: "de_DE", siteName: "Entscheidomat", title: PAGE_TITLE, description: PAGE_DESCRIPTION, url: PAGE_URL, images: ["/opengraph-image"] },
|
||||
twitter: { card: "summary_large_image", title: PAGE_TITLE, description: PAGE_DESCRIPTION, images: ["/twitter-image"] },
|
||||
};
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Wie viele Namen kann ich eintragen?",
|
||||
a: "Beliebig viele – mindestens 2 sind nötig. Ein Name bzw. Eintrag pro Zeile in das Textfeld eintragen.",
|
||||
},
|
||||
{
|
||||
q: "Was bedeutet „Gezogene Einträge entfernen“?",
|
||||
a: "Ist die Option aktiviert, wird ein gezogener Eintrag automatisch aus der Liste entfernt (Ziehung ohne Zurücklegen) – praktisch für Teameinteilungen oder mehrere Gewinner nacheinander.",
|
||||
},
|
||||
{
|
||||
q: "Werden meine Namen gespeichert oder an einen Server übertragen?",
|
||||
a: "Deine Liste wird ausschließlich lokal im localStorage deines Browsers gespeichert, nicht an uns übertragen.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich Duplikate in der Liste verwenden?",
|
||||
a: "Technisch ja – ein doppelt eingetragener Name kann dadurch häufiger berücksichtigt werden. Wenn alle Namen gleich behandelt werden sollen, trage jeden Namen nur einmal ein.",
|
||||
},
|
||||
{
|
||||
q: "Eignet sich das Tool für offizielle Gewinnspiele?",
|
||||
a: "Für lockere, private Ziehungen ist es gut geeignet. Bei rechtlich bindenden Gewinnspielen solltest du eigene Teilnahmebedingungen und mögliche Dokumentationspflichten separat prüfen.",
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Entscheidomat", item: SITE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Namen-Auslosung", item: PAGE_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
name: "Namen-Auslosung",
|
||||
url: PAGE_URL,
|
||||
description: PAGE_DESCRIPTION,
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "Any (Web-Browser)",
|
||||
isPartOf: { "@id": `${SITE_URL}/#website` },
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: FAQS.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Trage mindestens 2 Namen oder Einträge ein, einen pro Zeile.",
|
||||
"Aktiviere optional „Gezogene Einträge entfernen“, um ohne Zurücklegen zu ziehen (z. B. für Teameinteilung).",
|
||||
"Tippe auf „Zufällig ziehen“.",
|
||||
"Der gezogene Name erscheint groß im Ergebnisfeld, inklusive Konfetti.",
|
||||
];
|
||||
|
||||
const EXAMPLES = [
|
||||
"Gewinner unter Teilnehmenden auslosen",
|
||||
"Reihenfolge für eine Präsentation festlegen",
|
||||
"Teams zufällig zusammenstellen (mehrfach ziehen „ohne Zurücklegen“)",
|
||||
];
|
||||
|
||||
const VORTEILE = (
|
||||
<p>
|
||||
Beliebig viele Einträge möglich, flexibel für Namen oder andere Texte. Die Option
|
||||
„ohne Zurücklegen“ macht wiederholtes Ziehen für Gruppenaufteilungen praktisch. Die
|
||||
Ziehung nutzt einen browserbasierten Zufallswert – eine Gewichtung einzelner Einträge ist nicht möglich.
|
||||
</p>
|
||||
);
|
||||
|
||||
const ADDITIONAL_CONTENT = (
|
||||
<article className="space-y-4 text-base leading-relaxed text-ink-soft">
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Namen zufällig ziehen – für Gewinner, Teams und Reihenfolgen
|
||||
</h2>
|
||||
<p>
|
||||
Der Zufallsgenerator für Namen zieht einen Eintrag aus deiner eigenen Liste. Damit
|
||||
kannst du einen Gewinner bestimmen, eine Reihenfolge für Präsentationen festlegen oder
|
||||
Personen fair nacheinander auswählen. Für mehrere Ziehungen aktiviere „Gezogene
|
||||
Einträge entfernen“: Dann wird jeder Name nur einmal gezogen.
|
||||
</p>
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
So bleibt eine Namen-Auslosung fair
|
||||
</h2>
|
||||
<p>
|
||||
Trage jeden Namen genau einmal ein, wenn alle die gleiche Chance erhalten sollen.
|
||||
Doppelte Einträge erhöhen die Chance dieses Namens. Für eine Entscheidung zwischen
|
||||
festen Optionen ist das <Link href="/gluecksrad" className="text-[#c97d60] font-medium hover:underline underline-offset-2">Glücksrad</Link> eine gute Alternative; für eine zufällige Zahl nutze den <Link href="/zufallszahl-generator" className="text-[#c97d60] font-medium hover:underline underline-offset-2">Zufallszahl-Generator</Link>.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default function NamenAuslosenPage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<SubpageLayout
|
||||
toolSlug="namen-auslosen"
|
||||
toolName="Namen-Auslosung"
|
||||
h1Title="Zufallsgenerator für Namen – Namen zufällig auslosen"
|
||||
introText="Der Zufallsgenerator für Namen zieht einen Eintrag aus deiner eigenen Liste. Trage Namen oder Optionen ein, starte die Ziehung und erhalte sofort ein zufälliges Ergebnis – ideal für Gewinner, Teameinteilungen oder Aufgabenverteilung."
|
||||
steps={STEPS}
|
||||
examples={EXAMPLES}
|
||||
vorteileText={VORTEILE}
|
||||
additionalContent={ADDITIONAL_CONTENT}
|
||||
faqs={FAQS}
|
||||
updatedDate={UPDATED}
|
||||
>
|
||||
<ListPicker />
|
||||
</SubpageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
17
app/opengraph-image.tsx
Normal file
17
app/opengraph-image.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { OgImageContent, ogImageSize } from "@/lib/og-image";
|
||||
|
||||
export const alt = "Entscheidomat – Lass den Zufall entscheiden";
|
||||
export const size = ogImageSize;
|
||||
export const contentType = "image/png";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function Image() {
|
||||
const image = await readFile(join(process.cwd(), "public/images/og-decision-tools.jpg"));
|
||||
const visualSrc = `data:image/jpeg;base64,${image.toString("base64")}`;
|
||||
|
||||
return new ImageResponse(<OgImageContent visualSrc={visualSrc} />, size);
|
||||
}
|
||||
68
app/page.tsx
Normal file
68
app/page.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { Metadata } from "next";
|
||||
import Nav from "@/components/nav";
|
||||
import Hero from "@/components/hero";
|
||||
import DirectAnswer from "@/components/direct-answer";
|
||||
import Playground from "@/components/playground";
|
||||
import Features from "@/components/features";
|
||||
import HomepageFaq, { HOMEPAGE_FAQS } from "@/components/homepage-faq";
|
||||
import Footer from "@/components/footer";
|
||||
import DeferredScrollFlourishes from "@/components/deferred-scroll-flourishes";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
absolute: "Entscheidomat – Kostenloser Zufallsgenerator & Entscheidungshilfe",
|
||||
},
|
||||
description:
|
||||
"Kostenloser Zufallsgenerator und Entscheidungshilfe für schnelle Alltagsentscheidungen: Ja-Nein-Generator, Glücksrad, Würfel, Münzwurf und mehr – direkt im Browser.",
|
||||
alternates: { canonical: "/" },
|
||||
};
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
name: "Entscheidomat",
|
||||
url: SITE_URL,
|
||||
description:
|
||||
"Kostenloser Zufallsgenerator und digitale Entscheidungshilfe mit sieben Tools: Ja-Nein-Generator, Münze, Glücksrad, Würfel, Zufallszahl, Magic 8-Ball und Listen-Auslosung.",
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "Any (Web-Browser)",
|
||||
isPartOf: { "@id": `${SITE_URL}/#website` },
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: HOMEPAGE_FAQS.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<Nav />
|
||||
<main className="flex-1">
|
||||
<Hero />
|
||||
<DirectAnswer />
|
||||
<Playground />
|
||||
<Features />
|
||||
<HomepageFaq />
|
||||
</main>
|
||||
<Footer />
|
||||
<DeferredScrollFlourishes />
|
||||
</>
|
||||
);
|
||||
}
|
||||
13
app/robots.ts
Normal file
13
app/robots.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
},
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
};
|
||||
}
|
||||
33
app/sitemap.ts
Normal file
33
app/sitemap.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
import { TOOL_PAGES } from "@/lib/tool-pages";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: SITE_URL,
|
||||
lastModified: "2026-07-23",
|
||||
changeFrequency: "weekly",
|
||||
priority: 1,
|
||||
},
|
||||
...TOOL_PAGES.map((t) => ({
|
||||
url: `${SITE_URL}/${t.slug}`,
|
||||
lastModified: t.lastModified,
|
||||
changeFrequency: "monthly" as const,
|
||||
priority: 0.8,
|
||||
})),
|
||||
{
|
||||
url: `${SITE_URL}/impressum`,
|
||||
lastModified: "2026-07-23",
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.2,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/datenschutz`,
|
||||
lastModified: "2026-07-23",
|
||||
changeFrequency: "yearly",
|
||||
priority: 0.2,
|
||||
},
|
||||
];
|
||||
}
|
||||
17
app/twitter-image.tsx
Normal file
17
app/twitter-image.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { OgImageContent, ogImageSize } from "@/lib/og-image";
|
||||
|
||||
export const alt = "Entscheidomat – Lass den Zufall entscheiden";
|
||||
export const size = ogImageSize;
|
||||
export const contentType = "image/png";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export default async function Image() {
|
||||
const image = await readFile(join(process.cwd(), "public/images/og-decision-tools.jpg"));
|
||||
const visualSrc = `data:image/jpeg;base64,${image.toString("base64")}`;
|
||||
|
||||
return new ImageResponse(<OgImageContent visualSrc={visualSrc} />, size);
|
||||
}
|
||||
165
app/wuerfel-online/page.tsx
Normal file
165
app/wuerfel-online/page.tsx
Normal file
@@ -0,0 +1,165 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import DiceRoller from "@/components/tools/dice-roller";
|
||||
import SubpageLayout from "@/components/subpage-layout";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
const PAGE_URL = `${SITE_URL}/wuerfel-online`;
|
||||
const PAGE_TITLE = "Würfel online – virtuellen 6-seitigen Würfel werfen";
|
||||
const PAGE_DESCRIPTION =
|
||||
"Würfel online direkt im Browser: mit klassischen Augen oder eigenen Texten auf sechs Seiten würfeln. Kostenlos, ohne Anmeldung und sofort nutzbar.";
|
||||
const UPDATED = "23. Juli 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
alternates: { canonical: "/wuerfel-online" },
|
||||
openGraph: { type: "website", locale: "de_DE", siteName: "Entscheidomat", title: PAGE_TITLE, description: PAGE_DESCRIPTION, url: PAGE_URL, images: ["/opengraph-image"] },
|
||||
twitter: { card: "summary_large_image", title: PAGE_TITLE, description: PAGE_DESCRIPTION, images: ["/twitter-image"] },
|
||||
};
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Wie entsteht das Ergebnis beim Online-Würfel?",
|
||||
a: "Bei jedem Wurf erzeugt das Tool im Browser einen neuen Zufallswert und zeigt eine Würfelseite an. Der Online-Würfel ist für Spiele und spielerische Alltagsentscheidungen gedacht, nicht für verbindliche oder sicherheitskritische Zwecke.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich eigene Texte statt Zahlen verwenden?",
|
||||
a: "Ja, im Modus „Eigene Texte“ kannst du bis zu 18 Zeichen pro Würfelseite eintragen – z. B. für Aufgaben oder Optionen.",
|
||||
},
|
||||
{
|
||||
q: "Werden meine eigenen Würfeltexte gespeichert?",
|
||||
a: "Ja, lokal im Browser (localStorage). Sie werden nicht an einen Server übertragen und bleiben beim nächsten Besuch erhalten.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich mehrere Würfel gleichzeitig werfen?",
|
||||
a: "Aktuell simuliert das Tool einen Würfel pro Wurf. Für mehrere Würfe einfach mehrfach hintereinander würfeln.",
|
||||
},
|
||||
{
|
||||
q: "Funktioniert der Würfel auch auf dem Smartphone?",
|
||||
a: "Ja, das Tool ist vollständig responsiv und funktioniert auf Smartphone, Tablet und Desktop.",
|
||||
},
|
||||
{
|
||||
q: "Wie wahrscheinlich ist jede Seite beim Online-Würfel?",
|
||||
a: "Bei einem klassischen sechsseitigen Würfel hat jede Zahl von 1 bis 6 bei jedem einzelnen Wurf die gleiche Chance von einem Sechstel. Ein Wurf beeinflusst den nächsten nicht.",
|
||||
},
|
||||
{
|
||||
q: "Wann nutze ich einen Würfel statt Glücksrad oder Ja-Nein-Generator?",
|
||||
a: "Der Würfel passt für bis zu sechs gleichwertige Optionen, für Brettspiele oder für eigene Aufgaben auf sechs Seiten. Bei zwei Optionen ist der Ja-Nein-Generator schneller; bei mehr als sechs Optionen bietet das Glücksrad mehr Platz und Übersicht.",
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Entscheidomat", item: SITE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Würfel Online", item: PAGE_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
name: "Würfel Online",
|
||||
url: PAGE_URL,
|
||||
description: PAGE_DESCRIPTION,
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "Any (Web-Browser)",
|
||||
isPartOf: { "@id": `${SITE_URL}/#website` },
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: FAQS.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Wähle zwischen „Zahlen 1–6“ oder „Eigene Texte“.",
|
||||
"Bei eigenen Texten: trage bis zu 18 Zeichen pro Seite ein.",
|
||||
"Tippe auf „Würfeln!“ – der Würfel dreht sich in 3D und landet auf einer Seite.",
|
||||
"Das Ergebnis erscheint direkt darunter.",
|
||||
];
|
||||
|
||||
const EXAMPLES = [
|
||||
"„Welche Aufgabe mache ich zuerst?“ (6 Aufgaben als eigene Texte)",
|
||||
"Ersatzwürfel für Brett- und Gesellschaftsspiele",
|
||||
"Zufallsentscheidung zwischen 6 gleichwertigen Optionen",
|
||||
];
|
||||
|
||||
const VORTEILE = (
|
||||
<p>
|
||||
Praktisch als Ersatz für einen echten Würfel, jederzeit griffbereit im Browser.
|
||||
Für mehr als 6 Optionen eignet sich eher das{" "}
|
||||
<Link href="/gluecksrad" className="text-[#c97d60] font-medium hover:underline underline-offset-2">
|
||||
Glücksrad
|
||||
</Link>{" "}
|
||||
oder die Listen-Auswahl.
|
||||
</p>
|
||||
);
|
||||
|
||||
const ADDITIONAL_CONTENT = (
|
||||
<article className="space-y-4 text-base leading-relaxed text-ink-soft">
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Online würfeln: Zufallszahl, Spielhilfe oder sechs Optionen wählen
|
||||
</h2>
|
||||
<p>
|
||||
Ein Online-Würfel simuliert einen klassischen sechsseitigen Würfel direkt im
|
||||
Browser. Bei jedem Wurf erzeugt das Tool einen neuen browserbasierten Zufallswert und
|
||||
wählt damit eine Zahl von 1 bis 6 aus. Das macht den virtuellen
|
||||
Würfel zu einem praktischen Ersatz, wenn kein echter Würfel zur Hand ist – etwa
|
||||
bei Brettspielen, unterwegs oder beim spontanen Entscheiden in einer Gruppe.
|
||||
</p>
|
||||
<p>
|
||||
Besonders flexibel wird der Würfel mit eigenen Texten. Statt Zahlen kannst du
|
||||
sechs Aufgaben, Aktivitäten oder Namen eintragen und eine Option auslosen:
|
||||
„kochen“, „aufräumen“, „spazieren“, „spielen“, „lesen“ oder „Pause“. Für eine
|
||||
gleich behandelte Auswahl sollte jeder Eintrag nur auf einer Seite stehen. Sind die Optionen
|
||||
unterschiedlich wichtig oder sollen manche öfter vorkommen, bildet ein einfacher
|
||||
Würfel diese Gewichtung nicht ab. Dann solltest du die Auswahl vorher bewusst
|
||||
festlegen statt ein zufälliges Ergebnis als objektiv zu behandeln.
|
||||
</p>
|
||||
<p>
|
||||
Der Würfel eignet sich am besten für bis zu sechs gleichwertige Möglichkeiten.
|
||||
Für genau zwei Antworten liefern der Ja-Nein-Generator oder ein Münzwurf ein
|
||||
klareres Ergebnis. Für sieben bis zwölf Optionen ist ein Glücksrad besser
|
||||
lesbar, und für lange Namenslisten hilft die Namen-Auslosung. Wie bei allen
|
||||
Zufallstools gilt: Für Spiele und kleine Alltagsfragen ist der Würfel ideal;
|
||||
Entscheidungen mit gesundheitlichen, rechtlichen oder finanziellen Folgen
|
||||
solltest du nicht an einen Zufallswert delegieren.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default function WuerfelOnlinePage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<SubpageLayout
|
||||
toolSlug="wuerfel-online"
|
||||
toolName="Würfel Online"
|
||||
h1Title="Würfel online werfen – dein virtueller 6-seitiger Würfel"
|
||||
introText="Der virtuelle Würfel simuliert einen echten 6-seitigen Würfel direkt im Browser. Wähle zwischen klassischen Würfelaugen oder eigenen Texten auf den sechs Seiten und würfle mit einem Klick – kostenlos und ohne Anmeldung."
|
||||
steps={STEPS}
|
||||
examples={EXAMPLES}
|
||||
vorteileText={VORTEILE}
|
||||
additionalContent={ADDITIONAL_CONTENT}
|
||||
faqs={FAQS}
|
||||
updatedDate={UPDATED}
|
||||
>
|
||||
<DiceRoller />
|
||||
</SubpageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
149
app/zufallszahl-generator/page.tsx
Normal file
149
app/zufallszahl-generator/page.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Metadata } from "next";
|
||||
import Link from "next/link";
|
||||
import RandomNumber from "@/components/tools/random-number";
|
||||
import SubpageLayout from "@/components/subpage-layout";
|
||||
|
||||
const SITE_URL = "https://entscheidomat.com";
|
||||
const PAGE_URL = `${SITE_URL}/zufallszahl-generator`;
|
||||
const PAGE_TITLE = "Zufallszahl-Generator – Zahl im Wunschbereich erzeugen";
|
||||
const PAGE_DESCRIPTION =
|
||||
"Zufallszahl-Generator mit frei wählbarem Zahlenbereich. Minimum und Maximum eintragen, generieren, Ergebnis erhalten – kostenlos, ohne Anmeldung.";
|
||||
const UPDATED = "23. Juli 2026";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: PAGE_TITLE,
|
||||
description: PAGE_DESCRIPTION,
|
||||
alternates: { canonical: "/zufallszahl-generator" },
|
||||
openGraph: { type: "website", locale: "de_DE", siteName: "Entscheidomat", title: PAGE_TITLE, description: PAGE_DESCRIPTION, url: PAGE_URL, images: ["/opengraph-image"] },
|
||||
twitter: { card: "summary_large_image", title: PAGE_TITLE, description: PAGE_DESCRIPTION, images: ["/twitter-image"] },
|
||||
};
|
||||
|
||||
const FAQS = [
|
||||
{
|
||||
q: "Kann ich auch negative Zahlen oder sehr große Bereiche eingeben?",
|
||||
a: "Ja, Minimum und Maximum sind frei wählbare Zahlen. Die Reihenfolge der Eingabe spielt keine Rolle, das Tool sortiert automatisch.",
|
||||
},
|
||||
{
|
||||
q: "Werden auch Dezimalzahlen unterstützt?",
|
||||
a: "Nein, aktuell erzeugt der Generator ausschließlich ganze Zahlen innerhalb des gewählten Bereichs.",
|
||||
},
|
||||
{
|
||||
q: "Wie entsteht die Zufallszahl?",
|
||||
a: "Bei jeder Anfrage erzeugt das Tool im Browser einen neuen Zufallswert und leitet daraus eine ganze Zahl im gewählten Bereich ab. Der Generator ist für spielerische und alltägliche Zwecke gedacht, nicht für sicherheitskritische oder verbindliche Anwendungen.",
|
||||
},
|
||||
{
|
||||
q: "Werden meine Einstellungen für Minimum und Maximum gespeichert?",
|
||||
a: "Ja, lokal im localStorage deines Browsers. Sie werden nicht an einen Server übertragen.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich mehrere Zahlen hintereinander generieren?",
|
||||
a: "Ja, beliebig oft – einfach erneut auf „Zahl generieren“ tippen.",
|
||||
},
|
||||
];
|
||||
|
||||
const jsonLd = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "BreadcrumbList",
|
||||
itemListElement: [
|
||||
{ "@type": "ListItem", position: 1, name: "Entscheidomat", item: SITE_URL },
|
||||
{ "@type": "ListItem", position: 2, name: "Zufallszahl-Generator", item: PAGE_URL },
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebApplication",
|
||||
name: "Zufallszahl-Generator",
|
||||
url: PAGE_URL,
|
||||
description: PAGE_DESCRIPTION,
|
||||
applicationCategory: "UtilitiesApplication",
|
||||
operatingSystem: "Any (Web-Browser)",
|
||||
isPartOf: { "@id": `${SITE_URL}/#website` },
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "EUR" },
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: FAQS.map((f) => ({
|
||||
"@type": "Question",
|
||||
name: f.q,
|
||||
acceptedAnswer: { "@type": "Answer", text: f.a },
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const STEPS = [
|
||||
"Trage Minimum und Maximum ein – die Reihenfolge ist egal.",
|
||||
"Tippe auf „Zahl generieren“.",
|
||||
"Eine Zufallszahl im gewählten Bereich erscheint mit kurzer Animation.",
|
||||
"Passe den Zahlenraum jederzeit an und generiere erneut.",
|
||||
];
|
||||
|
||||
const EXAMPLES = [
|
||||
"Losnummern für eine Verlosung ziehen",
|
||||
"„Wie viele Liegestütze mache ich?“",
|
||||
"Zufällige Teamnummer oder Startreihenfolge vergeben",
|
||||
];
|
||||
|
||||
const VORTEILE = (
|
||||
<p>
|
||||
Beliebiger Zahlenbereich möglich, auch mit negativen Zahlen. Der Generator erzeugt
|
||||
ausschließlich ganze Zahlen – für Entscheidungen zwischen benannten Optionen eignen sich eher das{" "}
|
||||
<Link href="/gluecksrad" className="text-[#c97d60] font-medium hover:underline underline-offset-2">
|
||||
Glücksrad
|
||||
</Link>{" "}
|
||||
oder die{" "}
|
||||
<Link href="/namen-auslosen" className="text-[#c97d60] font-medium hover:underline underline-offset-2">
|
||||
Namen-Auslosung
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
);
|
||||
|
||||
const ADDITIONAL_CONTENT = (
|
||||
<article className="space-y-4 text-base leading-relaxed text-ink-soft">
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Zufallszahl im gewünschten Bereich erzeugen
|
||||
</h2>
|
||||
<p>
|
||||
Lege Minimum und Maximum fest, um eine ganze Zufallszahl aus genau diesem Bereich zu
|
||||
erhalten. Das ist praktisch für Startnummern, kleine Spielaufgaben oder eine zufällige
|
||||
Reihenfolge. Negative Zahlen sind ebenfalls möglich; die Reihenfolge deiner Eingaben
|
||||
spielt keine Rolle.
|
||||
</p>
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink">
|
||||
Wann passt eine Zufallszahl – und wann ein anderes Tool?
|
||||
</h2>
|
||||
<p>
|
||||
Nutze diesen Generator, wenn das Ergebnis eine Zahl sein soll. Möchtest du stattdessen
|
||||
Namen oder Texte ziehen, ist die <Link href="/namen-auslosen" className="text-[#c97d60] font-medium hover:underline underline-offset-2">Namen-Auslosung</Link> passender. Bei mehreren sichtbaren Optionen hilft ein <Link href="/gluecksrad" className="text-[#c97d60] font-medium hover:underline underline-offset-2">Glücksrad</Link> dabei, die Auswahl gemeinsam nachzuvollziehen.
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
|
||||
export default function ZufallszahlGeneratorPage() {
|
||||
return (
|
||||
<>
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify(jsonLd).replace(/</g, "\\u003c"),
|
||||
}}
|
||||
/>
|
||||
<SubpageLayout
|
||||
toolSlug="zufallszahl-generator"
|
||||
toolName="Zufallszahl-Generator"
|
||||
h1Title="Zufallszahl-Generator – Zahl im Wunschbereich erzeugen"
|
||||
introText="Der Zufallszahl-Generator erzeugt eine zufällige Ganzzahl in einem selbst gewählten Bereich. Trage Minimum und Maximum ein, generiere die Zahl per Klick und erhalte sofort ein zufälliges Ergebnis – kostenlos, ohne Anmeldung, direkt im Browser."
|
||||
steps={STEPS}
|
||||
examples={EXAMPLES}
|
||||
vorteileText={VORTEILE}
|
||||
additionalContent={ADDITIONAL_CONTENT}
|
||||
faqs={FAQS}
|
||||
updatedDate={UPDATED}
|
||||
>
|
||||
<RandomNumber />
|
||||
</SubpageLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
components/coin-3d.tsx
Normal file
74
components/coin-3d.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
"use client";
|
||||
|
||||
import { forwardRef } from "react";
|
||||
|
||||
type Coin3DProps = {
|
||||
frontLabel: string;
|
||||
backLabel: string;
|
||||
/** Kantenlänge in px */
|
||||
size?: number;
|
||||
/** Endlos-Idle-Drehung (Hero) */
|
||||
spin?: boolean;
|
||||
/**
|
||||
* Achse, um die die Münze rotiert. Bestimmt, wie die Rückseite
|
||||
* vorgedreht wird, damit ihr Text lesbar bleibt.
|
||||
* "x" = Flip um die X-Achse (Tool), "y" = Drehung um die Y-Achse (Hero).
|
||||
*/
|
||||
spinAxis?: "x" | "y";
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/** Gestapelte Kreisscheiben erzeugen die Münzdicke. */
|
||||
const EDGE_LAYERS = [-3, -2, -1, 0, 1, 2, 3];
|
||||
|
||||
/**
|
||||
* Echte 3D-CSS-Münze. Der forwarded ref zeigt auf das rotierende
|
||||
* Innen-Element, damit GSAP `rotationX` flippen kann.
|
||||
*/
|
||||
const Coin3D = forwardRef<HTMLDivElement, Coin3DProps>(function Coin3D(
|
||||
{ frontLabel, backLabel, size = 168, spin = false, spinAxis = "x", className = "" },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`coin ${spin ? "animate-coin-spin" : ""} ${className}`}
|
||||
style={{ width: size, height: size }}
|
||||
>
|
||||
{EDGE_LAYERS.map((z) => (
|
||||
<div
|
||||
key={z}
|
||||
aria-hidden
|
||||
className="coin-edge"
|
||||
style={{ transform: `translateZ(${z}px)` }}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="coin-face coin-face--front">
|
||||
<div className="coin-engraving" aria-hidden />
|
||||
<div className="coin-engraving coin-engraving--inner" aria-hidden />
|
||||
<span className="coin-label" style={{ fontSize: size * 0.1 }}>
|
||||
{frontLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="coin-face coin-face--back"
|
||||
style={{
|
||||
transform:
|
||||
spinAxis === "y"
|
||||
? "rotateY(180deg) translateZ(4px)"
|
||||
: "rotateX(180deg) translateZ(4px)",
|
||||
}}
|
||||
>
|
||||
<div className="coin-engraving" aria-hidden />
|
||||
<div className="coin-engraving coin-engraving--inner" aria-hidden />
|
||||
<span className="coin-label" style={{ fontSize: size * 0.1 }}>
|
||||
{backLabel}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default Coin3D;
|
||||
26
components/deferred-scroll-flourishes.tsx
Normal file
26
components/deferred-scroll-flourishes.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const ScrollFlourishes = dynamic(() => import("@/components/scroll-flourishes"), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
/** Die dekorativen, aufwändigen Ranken sind nur auf größeren Displays sinnvoll. */
|
||||
export default function DeferredScrollFlourishes() {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const query = window.matchMedia(
|
||||
"(min-width: 768px) and (prefers-reduced-motion: no-preference)",
|
||||
);
|
||||
const update = () => setEnabled(query.matches);
|
||||
|
||||
update();
|
||||
query.addEventListener("change", update);
|
||||
return () => query.removeEventListener("change", update);
|
||||
}, []);
|
||||
|
||||
return enabled ? <ScrollFlourishes /> : null;
|
||||
}
|
||||
21
components/direct-answer.tsx
Normal file
21
components/direct-answer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
export default function DirectAnswer() {
|
||||
return (
|
||||
<section className="relative py-4 sm:py-8">
|
||||
<div className="mx-auto w-full max-w-3xl px-5 sm:px-8">
|
||||
<div className="rounded-3xl border border-line bg-surface/80 p-7 text-center shadow-soft sm:p-9">
|
||||
<h2 className="font-display text-xl font-semibold tracking-tight text-ink">
|
||||
Was ist Entscheidomat?
|
||||
</h2>
|
||||
<p className="mt-3 text-base leading-relaxed text-ink-soft">
|
||||
Entscheidomat ist ein kostenloser Zufallsgenerator und eine
|
||||
digitale Entscheidungshilfe. Du kannst online eine Münze werfen,
|
||||
ein Glücksrad drehen, würfeln, eine Zufallszahl erzeugen, den
|
||||
Magic 8-Ball fragen, eine eigene Liste auslosen oder den
|
||||
Ja-Nein-Generator befragen – ganz ohne Anmeldung und direkt im
|
||||
Browser.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
76
components/features.tsx
Normal file
76
components/features.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
ShieldCheck,
|
||||
SlidersHorizontal,
|
||||
Smartphone,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
import Reveal from "@/components/reveal";
|
||||
|
||||
const FEATURES = [
|
||||
{
|
||||
icon: SlidersHorizontal,
|
||||
title: "Komplett anpassbar",
|
||||
text: "Beschriftungen, Optionen und Antworten – alles passt sich deiner Entscheidung an und bleibt automatisch gespeichert.",
|
||||
tint: "bg-[#e6f7f0] text-[#0d7a57]",
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: "Kein Account nötig",
|
||||
text: "Kein Login, keine Werbung, kein Schnickschnack. Öffnen, werfen, entscheiden – fertig.",
|
||||
tint: "bg-[#e6f7f0] text-[#0d7a57]",
|
||||
},
|
||||
{
|
||||
icon: Smartphone,
|
||||
title: "Überall dabei",
|
||||
text: "Ob Handy, Tablet oder Desktop: Der Entscheidomat fühlt sich auf jedem Gerät butterweich an.",
|
||||
tint: "bg-[#e6f7f0] text-[#0d7a57]",
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: "Liebe zum Detail",
|
||||
text: "Physikalische Spins, feine 3D-Animationen und Soundeffekte machen jede Entscheidung zum kleinen Erlebnis.",
|
||||
tint: "bg-[#e6f7f0] text-[#0d7a57]",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Features() {
|
||||
return (
|
||||
<section id="features" className="relative scroll-mt-20 py-24 sm:py-32">
|
||||
<div className="mx-auto w-full max-w-6xl px-5 sm:px-8">
|
||||
<Reveal className="mx-auto max-w-2xl text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-ink-soft">
|
||||
Warum Entscheidomat?
|
||||
</p>
|
||||
<h2 className="mt-3 text-4xl font-semibold tracking-[-0.02em] sm:text-5xl">
|
||||
Weil Entscheiden auch{" "}
|
||||
<em className="font-display italic text-[#0d7a57]">schön</em> sein kann.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-relaxed text-ink-soft">
|
||||
Keine langweiligen Standard-Tools mehr. Hier bekommst du Zufall mit
|
||||
Stil – und trotzdem blitzschnell und ohne Hürden.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{FEATURES.map((feature, i) => (
|
||||
<Reveal key={feature.title} delay={i * 0.08}>
|
||||
<div className="group h-full rounded-3xl border border-line bg-surface p-7 shadow-soft transition-all duration-300 hover:-translate-y-1.5 hover:shadow-lift">
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-2xl ${feature.tint} transition-transform duration-300 group-hover:scale-110 group-hover:-rotate-6`}
|
||||
>
|
||||
<feature.icon className="h-6 w-6" strokeWidth={2.1} />
|
||||
</div>
|
||||
<h3 className="mt-5 font-display text-lg font-semibold tracking-tight">
|
||||
{feature.title}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-soft">
|
||||
{feature.text}
|
||||
</p>
|
||||
</div>
|
||||
</Reveal>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
30
components/footer.tsx
Normal file
30
components/footer.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link";
|
||||
import { Coins } from "lucide-react";
|
||||
|
||||
export default function Footer() {
|
||||
return (
|
||||
<footer className="border-t border-line bg-surface/60">
|
||||
<div className="mx-auto flex w-full max-w-6xl flex-col items-center justify-between gap-4 px-5 py-8 text-sm text-ink-soft sm:flex-row sm:px-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Coins className="h-4 w-4 text-[#0d7a57]" />
|
||||
<span className="font-display font-semibold text-ink">
|
||||
Entscheid<span className="italic text-[#0d7a57]">omat</span>
|
||||
</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>© {new Date().getFullYear()}</span>
|
||||
</div>
|
||||
<p>
|
||||
Gebaut für alle, die sich (manchmal) nicht entscheiden können.
|
||||
</p>
|
||||
<nav aria-label="Rechtliches" className="flex items-center gap-4">
|
||||
<Link href="/impressum" className="transition-colors hover:text-[#0d7a57]">
|
||||
Impressum
|
||||
</Link>
|
||||
<Link href="/datenschutz" className="transition-colors hover:text-[#0d7a57]">
|
||||
Datenschutz
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
327
components/hero.tsx
Normal file
327
components/hero.tsx
Normal file
@@ -0,0 +1,327 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
motion,
|
||||
useMotionValue,
|
||||
useScroll,
|
||||
useSpring,
|
||||
useTransform,
|
||||
} from "framer-motion";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
|
||||
export default function Hero() {
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
const { scrollY } = useScroll();
|
||||
|
||||
// Scroll-Transformationen (Reversibel)
|
||||
const ringY = useTransform(scrollY, [0, 1000], [0, 180]);
|
||||
const ringScale = useTransform(scrollY, [0, 1000], [1, 1.18]);
|
||||
const ringOpacity = useTransform(scrollY, [0, 400, 1000, 1500], [1, 1, 0.5, 0]);
|
||||
const ringRotate = useTransform(scrollY, [0, 1000], [0, 10]);
|
||||
|
||||
// Interaktive Cursor-Bewegung (Subtile Spring-Physik für elegantes Hovering)
|
||||
const rawMouseX = useMotionValue(0);
|
||||
const rawMouseY = useMotionValue(0);
|
||||
|
||||
const smoothMouseX = useSpring(rawMouseX, { stiffness: 60, damping: 28 });
|
||||
const smoothMouseY = useSpring(rawMouseY, { stiffness: 60, damping: 28 });
|
||||
|
||||
// Subtile magnetische Linienverschiebung
|
||||
const magnetX = useTransform(smoothMouseX, [-0.5, 0.5], [-10, 10]);
|
||||
const magnetY = useTransform(smoothMouseY, [-0.5, 0.5], [-8, 8]);
|
||||
|
||||
// SVG-Glow-Position (in viewBox 1550 x 950 Koordinaten)
|
||||
const [svgGlow, setSvgGlow] = useState({ x: 775, y: 475 });
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent<HTMLElement>) => {
|
||||
const rect = sectionRef.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
|
||||
// Normalisierte Mauskoordinaten (-0.5 bis 0.5)
|
||||
const normX = (e.clientX - rect.left) / rect.width - 0.5;
|
||||
const normY = (e.clientY - rect.top) / rect.height - 0.5;
|
||||
rawMouseX.set(normX);
|
||||
rawMouseY.set(normY);
|
||||
|
||||
// Umrechnung auf SVG-ViewBox-Koordinaten für das sanfte Aufleuchten
|
||||
const svgX = Math.round(((e.clientX - rect.left) / rect.width) * 1550);
|
||||
const svgY = Math.round(((e.clientY - rect.top) / rect.height) * 950);
|
||||
setSvgGlow({ x: svgX, y: svgY });
|
||||
};
|
||||
|
||||
// Text-Intro startet erst, wenn der Preloader das "app:reveal"-Event feuert
|
||||
useEffect(() => {
|
||||
// Der Hero ist das LCP-Element. Auf Mobilgeräten bleibt er deshalb sofort
|
||||
// sichtbar, statt seine endgültige Darstellung durch eine Timeline zu verzögern.
|
||||
if (window.matchMedia("(max-width: 767px)").matches) return;
|
||||
|
||||
let ctx: gsap.Context | undefined;
|
||||
let fallback: ReturnType<typeof setTimeout> | undefined;
|
||||
|
||||
const start = () => {
|
||||
if (ctx) return;
|
||||
ctx = gsap.context(() => {
|
||||
gsap.fromTo(
|
||||
".hero-el",
|
||||
{ y: 34, opacity: 0 },
|
||||
{
|
||||
y: 0,
|
||||
opacity: 1,
|
||||
duration: 1,
|
||||
stagger: 0.1,
|
||||
ease: "power3.out",
|
||||
},
|
||||
);
|
||||
}, sectionRef);
|
||||
};
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
||||
window.addEventListener("app:reveal", start, { once: true });
|
||||
|
||||
// Beim gecachten bzw. mobilen Preloader ist die Seite schon sichtbar,
|
||||
// bevor dieser Effect registriert wird. Dann sofort animieren statt auf
|
||||
// den Fallback-Timer zu warten.
|
||||
if (document.documentElement.dataset.preloader !== "active") {
|
||||
start();
|
||||
} else {
|
||||
fallback = setTimeout(start, 1200);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mm.revert();
|
||||
window.removeEventListener("app:reveal", start);
|
||||
if (fallback) clearTimeout(fallback);
|
||||
ctx?.revert();
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<section
|
||||
id="top"
|
||||
ref={sectionRef}
|
||||
onMouseMove={handleMouseMove}
|
||||
className="relative flex min-h-[100svh] flex-col justify-center pt-16 cursor-default"
|
||||
>
|
||||
{/* Weicher Spot in der Mitte: Fadet am Rand und unten zu 100% transparent in den Seitenhintergrund aus */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-0 bg-[radial-gradient(ellipse_75%_55%_at_50%_35%,#ffffff_0%,transparent_100%)]"
|
||||
/>
|
||||
|
||||
{/* Multischichtiges Ring-Design: Kreise fließen flüssig über die Sektionsgrenze nach unten */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 1.5, ease: "easeOut" }}
|
||||
style={{
|
||||
y: ringY,
|
||||
x: magnetX,
|
||||
scale: ringScale,
|
||||
opacity: ringOpacity,
|
||||
rotate: ringRotate,
|
||||
}}
|
||||
className="pointer-events-none absolute left-1/2 top-1/2 w-[min(100vw,1550px)] h-[min(115vh,1100px)] -translate-x-1/2 -translate-y-1/2"
|
||||
>
|
||||
<motion.div style={{ y: magnetY }} className="w-full h-full">
|
||||
<svg
|
||||
aria-hidden
|
||||
className="w-full h-full"
|
||||
viewBox="0 0 1550 1100"
|
||||
fill="none"
|
||||
>
|
||||
<defs>
|
||||
{/* Subtiler radialer Glow-Verlauf unter dem Cursor */}
|
||||
<radialGradient
|
||||
id="cursorLineGlow"
|
||||
cx={svgGlow.x}
|
||||
cy={svgGlow.y}
|
||||
r="280"
|
||||
gradientUnits="userSpaceOnUse"
|
||||
>
|
||||
<stop offset="0%" stopColor="#0d7a57" stopOpacity="0.32" />
|
||||
<stop offset="50%" stopColor="#0d7a57" stopOpacity="0.12" />
|
||||
<stop offset="100%" stopColor="#0d7a57" stopOpacity="0" />
|
||||
</radialGradient>
|
||||
</defs>
|
||||
|
||||
{/* 1. Basis-Ellipsen mit dezentem Ruhe-Opacity */}
|
||||
<ellipse
|
||||
cx="775"
|
||||
cy="490"
|
||||
rx="290"
|
||||
ry="180"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.13"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<ellipse
|
||||
cx="775"
|
||||
cy="490"
|
||||
rx="440"
|
||||
ry="275"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.10"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<ellipse
|
||||
cx="775"
|
||||
cy="490"
|
||||
rx="610"
|
||||
ry="370"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.11"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<ellipse
|
||||
cx="775"
|
||||
cy="490"
|
||||
rx="770"
|
||||
ry="460"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.08"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="5 5"
|
||||
/>
|
||||
<ellipse
|
||||
cx="775"
|
||||
cy="490"
|
||||
rx="920"
|
||||
ry="550"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.06"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<ellipse
|
||||
cx="775"
|
||||
cy="490"
|
||||
rx="1080"
|
||||
ry="640"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.05"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Dynamisch kreuzende Orbit-Bögen – reichen flüssig bis in die nächste Sektion */}
|
||||
<circle
|
||||
cx="690"
|
||||
cy="440"
|
||||
r="620"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.07"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<circle
|
||||
cx="860"
|
||||
cy="570"
|
||||
r="680"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.06"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
<ellipse
|
||||
cx="775"
|
||||
cy="490"
|
||||
rx="1040"
|
||||
ry="440"
|
||||
transform="rotate(-11 775 490)"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.06"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* Feine Horizon-Linie hinter den Buttons */}
|
||||
<line
|
||||
x1="390"
|
||||
y1="690"
|
||||
x2="1160"
|
||||
y2="690"
|
||||
stroke="#111412"
|
||||
strokeOpacity="0.09"
|
||||
strokeWidth="1"
|
||||
/>
|
||||
|
||||
{/* 2. Subtile Aufleuchten-Ebene unter dem Cursor */}
|
||||
<g stroke="url(#cursorLineGlow)" strokeWidth="1.3">
|
||||
<ellipse cx="775" cy="490" rx="290" ry="180" />
|
||||
<ellipse cx="775" cy="490" rx="440" ry="275" />
|
||||
<ellipse cx="775" cy="490" rx="610" ry="370" />
|
||||
<ellipse cx="775" cy="490" rx="770" ry="460" strokeDasharray="5 5" />
|
||||
<ellipse cx="775" cy="490" rx="920" ry="550" />
|
||||
<ellipse cx="775" cy="490" rx="1080" ry="640" />
|
||||
<circle cx="690" cy="440" r="620" />
|
||||
<circle cx="860" cy="570" r="680" />
|
||||
<ellipse
|
||||
cx="775"
|
||||
cy="490"
|
||||
rx="1040"
|
||||
ry="440"
|
||||
transform="rotate(-11 775 490)"
|
||||
/>
|
||||
<line x1="390" y1="690" x2="1160" y2="690" />
|
||||
</g>
|
||||
</svg>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
<div className="bg-noise pointer-events-none absolute inset-0" aria-hidden />
|
||||
|
||||
<div className="relative mx-auto flex w-full max-w-5xl flex-col items-center px-5 py-16 text-center sm:px-8">
|
||||
<h1 className="font-sans text-[11vw] font-medium leading-[1.02] tracking-[-0.03em] sm:text-6xl lg:text-7xl">
|
||||
<span className="hero-el block">Kostenloser</span>
|
||||
<span className="hero-el block font-display text-[1.06em] font-normal italic tracking-normal text-[#0d7a57]">
|
||||
Zufallsgenerator
|
||||
</span>
|
||||
<span className="hero-el block">für schnelle Entscheidungen</span>
|
||||
</h1>
|
||||
|
||||
<p className="hero-el mt-8 max-w-md text-base leading-relaxed text-ink-soft sm:text-lg">
|
||||
Lass den Zufall entscheiden – mit Münze, Glücksrad, Würfel & mehr für
|
||||
alle, die sich mal wieder nicht entscheiden können.
|
||||
</p>
|
||||
|
||||
<div className="hero-el mt-10 flex flex-wrap items-center justify-center gap-3">
|
||||
<motion.a
|
||||
href="#tools"
|
||||
whileHover={{ y: -2, scale: 1.02 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
className="inline-flex items-center rounded-full bg-[#0d7a57] px-8 py-3.5 text-sm font-semibold text-white shadow-[0_12px_28px_rgba(13,122,87,0.30)] transition-colors duration-200 hover:bg-[#095c41]"
|
||||
>
|
||||
Jetzt entscheiden
|
||||
</motion.a>
|
||||
<motion.a
|
||||
href="#features"
|
||||
whileHover={{ y: -2 }}
|
||||
whileTap={{ scale: 0.97 }}
|
||||
className="inline-flex items-center rounded-full border border-ink/20 bg-surface/80 px-8 py-3.5 text-sm font-semibold text-ink transition-colors duration-200 hover:border-[#0d7a57] hover:text-[#0d7a57]"
|
||||
>
|
||||
Mehr erfahren
|
||||
</motion.a>
|
||||
</div>
|
||||
|
||||
<p className="hero-el mt-8 inline-flex items-center rounded-full border border-[#0d7a57]/20 bg-[#e6f7f0] px-4 py-1.5 text-xs font-semibold uppercase tracking-[0.2em] text-[#0d7a57]">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-[#0d7a57] mr-2.5" />
|
||||
7 Tools · Kostenlos · Ohne Anmeldung
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Scroll-Hinweis */}
|
||||
<a
|
||||
href="#tools"
|
||||
aria-label="Zu den Tools scrollen"
|
||||
className="absolute bottom-6 left-1/2 z-10 hidden -translate-x-1/2 flex-col items-center gap-1 text-ink-soft transition-colors hover:text-ink md:flex"
|
||||
>
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em]">
|
||||
Scroll
|
||||
</span>
|
||||
<ArrowDown className="animate-bob h-4 w-4" />
|
||||
</a>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
74
components/homepage-faq.tsx
Normal file
74
components/homepage-faq.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import Reveal from "@/components/reveal";
|
||||
|
||||
export const HOMEPAGE_FAQS = [
|
||||
{
|
||||
q: "Was ist Entscheidomat?",
|
||||
a: "Entscheidomat ist ein kostenloses Portal mit sieben Zufalls- und Entscheidungs-Tools: Ja-Nein-Generator, Münze, Glücksrad, Würfel, Zufallszahl, Magic 8-Ball und Listen-Auslosung – alle direkt im Browser nutzbar.",
|
||||
},
|
||||
{
|
||||
q: "Ist Entscheidomat wirklich kostenlos?",
|
||||
a: "Ja, vollständig kostenlos. Es gibt keine versteckten Kosten und keine Pflicht-Anmeldung, um die Tools zu nutzen.",
|
||||
},
|
||||
{
|
||||
q: "Muss ich mich registrieren?",
|
||||
a: "Nein. Es ist keine Anmeldung, keine E-Mail-Adresse und kein Account nötig – einfach öffnen und loslegen.",
|
||||
},
|
||||
{
|
||||
q: "Werden meine Daten gespeichert oder weitergegeben?",
|
||||
a: "Eigene Einstellungen (z. B. Beschriftungen oder Listen) werden nur lokal im localStorage deines Browsers gespeichert und nicht an einen Server übertragen. Details findest du in der Datenschutzerklärung.",
|
||||
},
|
||||
{
|
||||
q: "Funktioniert Entscheidomat auch auf dem Handy?",
|
||||
a: "Ja, alle Tools sind vollständig responsiv und funktionieren auf Smartphone, Tablet und Desktop.",
|
||||
},
|
||||
{
|
||||
q: "Wie zufällig sind die Ergebnisse?",
|
||||
a: "Jedes Tool erzeugt bei jeder Anfrage einen neuen Zufallswert, unabhängig von vorherigen Ergebnissen.",
|
||||
},
|
||||
{
|
||||
q: "Kann ich die Tools für wichtige Entscheidungen nutzen?",
|
||||
a: "Die Tools sind für spielerische Alltagsentscheidungen gedacht. Bei wichtigen persönlichen, finanziellen, rechtlichen oder gesundheitlichen Entscheidungen solltest du dich nicht auf einen Zufallsgenerator verlassen.",
|
||||
},
|
||||
{
|
||||
q: "Gibt es Werbung auf Entscheidomat?",
|
||||
a: "Aktuell zeigt Entscheidomat keine Werbung. Sollte sich das künftig ändern, wird das vorher transparent in der Datenschutzerklärung ergänzt.",
|
||||
},
|
||||
{
|
||||
q: "Wie kann ich Feedback geben oder Kontakt aufnehmen?",
|
||||
a: "Am einfachsten per E-Mail an info@entscheidomat.com – die Kontaktdaten findest du auch im Impressum.",
|
||||
},
|
||||
];
|
||||
|
||||
export default function HomepageFaq() {
|
||||
return (
|
||||
<section id="faq" className="relative scroll-mt-20 py-20 sm:py-28">
|
||||
<div className="mx-auto w-full max-w-3xl px-5 sm:px-8">
|
||||
<Reveal className="text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-ink-soft">
|
||||
Häufige Fragen
|
||||
</p>
|
||||
<h2 className="mt-3 text-4xl font-medium tracking-[-0.02em] sm:text-5xl">
|
||||
Noch Fragen zu{" "}
|
||||
<em className="font-display italic text-[#0d7a57]">Entscheidomat</em>?
|
||||
</h2>
|
||||
</Reveal>
|
||||
|
||||
<Reveal delay={0.1} className="mt-10 space-y-3">
|
||||
{HOMEPAGE_FAQS.map((faq) => (
|
||||
<details
|
||||
key={faq.q}
|
||||
className="group rounded-2xl border border-line bg-cream/70 px-5 py-4"
|
||||
>
|
||||
<summary className="cursor-pointer list-none font-semibold text-ink marker:content-none">
|
||||
{faq.q}
|
||||
</summary>
|
||||
<p className="mt-2 text-sm leading-relaxed text-ink-soft">
|
||||
{faq.a}
|
||||
</p>
|
||||
</details>
|
||||
))}
|
||||
</Reveal>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
38
components/legal-layout.tsx
Normal file
38
components/legal-layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Nav from "@/components/nav";
|
||||
import Footer from "@/components/footer";
|
||||
|
||||
export default function LegalLayout({
|
||||
title,
|
||||
updated,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
updated: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="flex-1 pt-16">
|
||||
<div className="mx-auto w-full max-w-3xl px-5 py-16 sm:px-8 sm:py-24">
|
||||
<h1 className="font-display text-4xl font-medium tracking-[-0.02em] sm:text-5xl">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-ink-soft">Stand: {updated}</p>
|
||||
|
||||
<div className="mt-8 rounded-2xl border border-clay/30 bg-clay-soft px-5 py-4 text-sm font-medium leading-relaxed text-clay">
|
||||
<strong>Hinweis für den Pre-Launch:</strong> Vor der öffentlichen
|
||||
Veröffentlichung müssen die Angaben zum Diensteanbieter und eine
|
||||
ladungsfähige Geschäftsanschrift vollständig ergänzt werden.
|
||||
</div>
|
||||
|
||||
<div className="mt-10 space-y-8 text-[15px] leading-relaxed text-ink-soft [&_h2]:font-display [&_h2]:text-xl [&_h2]:font-semibold [&_h2]:text-ink [&_a]:text-[#0d7a57] [&_a]:underline [&_a]:underline-offset-2 [&_ul]:list-disc [&_ul]:space-y-1 [&_ul]:pl-5">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
134
components/nav.tsx
Normal file
134
components/nav.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { motion } from "framer-motion";
|
||||
import { ChevronDown, Menu, Volume2, VolumeX, X } from "lucide-react";
|
||||
import { useSound } from "@/components/providers/sound-provider";
|
||||
|
||||
const TOOLS = [
|
||||
{ href: "/ja-nein-generator", label: "Ja-Nein-Generator", hint: "Schnelle Entscheidungen" },
|
||||
{ href: "/muenze-werfen", label: "Münze werfen", hint: "Kopf oder Zahl" },
|
||||
{ href: "/gluecksrad", label: "Glücksrad", hint: "Drehen & auswählen" },
|
||||
{ href: "/wuerfel-online", label: "Würfel online", hint: "Für Spiel & Zufall" },
|
||||
{ href: "/zufallszahl-generator", label: "Zufallszahl", hint: "Dein Zahlenbereich" },
|
||||
{ href: "/magic-8-ball", label: "Magic 8-Ball", hint: "Das kleine Orakel" },
|
||||
{ href: "/namen-auslosen", label: "Namen auslosen", hint: "Listen & Gewinner" },
|
||||
];
|
||||
|
||||
export default function Nav() {
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
const [toolsOpen, setToolsOpen] = useState(false);
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const { enabled, toggle, play } = useSound();
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 10);
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
setToolsOpen(false);
|
||||
setMobileOpen(false);
|
||||
}
|
||||
};
|
||||
const onPointerDown = (event: MouseEvent) => {
|
||||
if (!dropdownRef.current?.contains(event.target as Node)) setToolsOpen(false);
|
||||
};
|
||||
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
window.addEventListener("mousedown", onPointerDown);
|
||||
return () => {
|
||||
window.removeEventListener("scroll", onScroll);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
window.removeEventListener("mousedown", onPointerDown);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const closeMenus = () => {
|
||||
setToolsOpen(false);
|
||||
setMobileOpen(false);
|
||||
};
|
||||
|
||||
const soundButton = (
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
toggle();
|
||||
if (!enabled) play("pop");
|
||||
}}
|
||||
whileTap={{ scale: 0.88 }}
|
||||
aria-label={enabled ? "Sound ausschalten" : "Sound einschalten"}
|
||||
title={enabled ? "Sound ausschalten" : "Sound einschalten"}
|
||||
className="flex h-10 w-10 shrink-0 cursor-pointer items-center justify-center rounded-full border border-line bg-surface text-ink-soft shadow-soft transition-colors duration-200 hover:border-[#0d7a57]/50 hover:text-[#0d7a57]"
|
||||
>
|
||||
{enabled ? <Volume2 className="h-[18px] w-[18px]" /> : <VolumeX className="h-[18px] w-[18px]" />}
|
||||
</motion.button>
|
||||
);
|
||||
|
||||
return (
|
||||
<header className={`fixed inset-x-0 top-0 z-50 transition-all duration-300 ${scrolled || mobileOpen ? "border-b border-line/70 bg-paper/95 shadow-[0_4px_24px_rgb(23_23_28/0.05)] backdrop-blur-xl" : "border-b border-transparent bg-transparent"}`}>
|
||||
<nav className="mx-auto flex h-16 w-full max-w-6xl items-center justify-between px-5 sm:px-8" aria-label="Hauptnavigation">
|
||||
<Link href="/#top" className="group flex items-center gap-2.5" onClick={closeMenus}>
|
||||
<img
|
||||
src="/icon.svg"
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
className="h-9 w-9 shrink-0 transition-transform duration-200 group-hover:scale-105"
|
||||
/>
|
||||
<span className="flex items-baseline">
|
||||
<span className="text-lg font-semibold tracking-tight">Entscheid</span>
|
||||
<span className="font-display text-lg italic text-[#0d7a57] transition-colors group-hover:text-[#095c41]">omat</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="hidden items-center gap-1 sm:flex">
|
||||
<div ref={dropdownRef} className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setToolsOpen((open) => !open)}
|
||||
aria-expanded={toolsOpen}
|
||||
aria-haspopup="menu"
|
||||
className="inline-flex items-center gap-1.5 rounded-full px-4 py-2 text-sm font-medium text-ink-soft transition-colors duration-200 hover:bg-[#e6f7f0] hover:text-[#0d7a57]"
|
||||
>
|
||||
Tools <ChevronDown className={`h-4 w-4 transition-transform duration-200 ${toolsOpen ? "rotate-180" : ""}`} />
|
||||
</button>
|
||||
{toolsOpen && (
|
||||
<div role="menu" className="absolute right-0 top-[calc(100%+0.7rem)] w-80 overflow-hidden rounded-3xl border border-line bg-surface p-2 shadow-[0_22px_55px_rgba(17,20,18,0.16)]">
|
||||
<p className="px-3 pb-2 pt-1 text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-soft">Alle Zufalls-Tools</p>
|
||||
{TOOLS.map((tool) => (
|
||||
<Link key={tool.href} href={tool.href} role="menuitem" onClick={closeMenus} className="group flex items-center justify-between rounded-2xl px-3 py-2.5 transition-colors hover:bg-[#e6f7f0]">
|
||||
<span><span className="block text-sm font-semibold text-ink group-hover:text-[#0d7a57]">{tool.label}</span><span className="block text-xs text-ink-soft">{tool.hint}</span></span>
|
||||
<span aria-hidden className="text-[#0d7a57] opacity-0 transition-opacity group-hover:opacity-100">→</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Link href="/#features" className="rounded-full px-4 py-2 text-sm font-medium text-ink-soft transition-colors duration-200 hover:bg-[#e6f7f0] hover:text-[#0d7a57]">Features</Link>
|
||||
{soundButton}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 sm:hidden">
|
||||
{soundButton}
|
||||
<button type="button" onClick={() => setMobileOpen((open) => !open)} aria-expanded={mobileOpen} aria-controls="mobile-menu" aria-label={mobileOpen ? "Menü schließen" : "Menü öffnen"} className="flex h-10 w-10 items-center justify-center rounded-full border border-line bg-surface text-ink shadow-soft">
|
||||
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{mobileOpen && (
|
||||
<div id="mobile-menu" className="border-t border-line/70 bg-paper px-5 pb-6 pt-4 shadow-xl sm:hidden">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<p className="mb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-ink-soft">Tools auswählen</p>
|
||||
<div className="grid gap-1">
|
||||
{TOOLS.map((tool) => <Link key={tool.href} href={tool.href} onClick={closeMenus} className="flex items-center justify-between rounded-2xl px-4 py-3 hover:bg-[#e6f7f0]"><span><span className="block font-semibold text-ink">{tool.label}</span><span className="text-xs text-ink-soft">{tool.hint}</span></span><span className="text-[#0d7a57]">→</span></Link>)}
|
||||
</div>
|
||||
<Link href="/#features" onClick={closeMenus} className="mt-3 block rounded-2xl border border-line px-4 py-3 font-semibold text-ink">Features</Link>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
156
components/playground.tsx
Normal file
156
components/playground.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import {
|
||||
ArrowRight,
|
||||
CircleDashed,
|
||||
Coins,
|
||||
Dices,
|
||||
Disc3,
|
||||
Hash,
|
||||
HelpCircle,
|
||||
ListChecks,
|
||||
} from "lucide-react";
|
||||
import Reveal from "@/components/reveal";
|
||||
|
||||
const GENERATORS = [
|
||||
{
|
||||
id: "janein",
|
||||
slug: "ja-nein-generator",
|
||||
title: "Ja-Nein-Generator",
|
||||
badge: "Zwei Optionen",
|
||||
description:
|
||||
"Stelle eine Frage und erhalte sofort eine unparteiische Antwort mit Ja oder Nein. Ideal für schnelle Alltagsentscheidungen.",
|
||||
icon: HelpCircle,
|
||||
},
|
||||
{
|
||||
id: "coin",
|
||||
slug: "muenze-werfen",
|
||||
title: "Münze werfen",
|
||||
badge: "3D Münzwurf",
|
||||
description:
|
||||
"Wirf eine goldene 3D-Münze in die Luft und überlasse knifflige Wahlentscheidungen dem klassischen Kopf oder Zahl.",
|
||||
icon: Coins,
|
||||
},
|
||||
{
|
||||
id: "wheel",
|
||||
slug: "gluecksrad",
|
||||
title: "Glücksrad online",
|
||||
badge: "Entscheidungsrad",
|
||||
description:
|
||||
"Erstelle dein eigenes Glücksrad mit individuellen Feldern und drehe für eine transparente, zufällige Auswahl.",
|
||||
icon: Disc3,
|
||||
},
|
||||
{
|
||||
id: "dice",
|
||||
slug: "wuerfel-online",
|
||||
title: "Würfel online",
|
||||
badge: "Würfel-Simulator",
|
||||
description:
|
||||
"Rolle einen oder mehrere 6er, 10er oder benutzerdefinierte 3D-Würfel für Spiele & Entscheidungen.",
|
||||
icon: Dices,
|
||||
},
|
||||
{
|
||||
id: "number",
|
||||
slug: "zufallszahl-generator",
|
||||
title: "Zufallszahl-Generator",
|
||||
badge: "Zahlenbereich",
|
||||
description:
|
||||
"Generiere eine oder mehrere echte Zufallszahlen in einem frei definierten Bereich zwischen Minimum und Maximum.",
|
||||
icon: Hash,
|
||||
},
|
||||
{
|
||||
id: "ball",
|
||||
slug: "magic-8-ball",
|
||||
title: "Magic 8-Ball",
|
||||
badge: "Mystisches Orakel",
|
||||
description:
|
||||
"Befrage die magische Billardkugel und erhalte geheimnisvolle Antworten auf deine Lebensfragen.",
|
||||
icon: CircleDashed,
|
||||
},
|
||||
{
|
||||
id: "list",
|
||||
slug: "namen-auslosen",
|
||||
title: "Namen & Liste auslosen",
|
||||
badge: "Gewinnspiel & Teams",
|
||||
description:
|
||||
"Mische Namen oder Optionen in einer Liste, lose Gewinner aus oder erstelle automatisch zufällige Gruppen.",
|
||||
icon: ListChecks,
|
||||
},
|
||||
] as const;
|
||||
|
||||
export default function Playground() {
|
||||
return (
|
||||
<section id="tools" className="relative scroll-mt-20 py-20 sm:py-28">
|
||||
<div className="relative mx-auto w-full max-w-6xl px-5 sm:px-8">
|
||||
<Reveal className="mx-auto max-w-2xl text-center">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-ink-soft">
|
||||
Die 7 Zufalls-Tools
|
||||
</p>
|
||||
<h2 className="mt-3 text-4xl font-semibold tracking-[-0.02em] sm:text-5xl">
|
||||
Wähle deinen{" "}
|
||||
<em className="font-display italic text-[#0d7a57]">Generator</em>.
|
||||
</h2>
|
||||
<p className="mt-4 text-lg leading-relaxed text-ink-soft">
|
||||
Klicke auf ein Tool, um direkt zur interaktiven Vollversion mit
|
||||
allen Einstellungen zu gelangen.
|
||||
</p>
|
||||
</Reveal>
|
||||
|
||||
<div className="mt-14 grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{GENERATORS.map((tool, i) => {
|
||||
const Icon = tool.icon;
|
||||
const isFeatured = i === 6;
|
||||
|
||||
return (
|
||||
<Reveal
|
||||
key={tool.id}
|
||||
delay={i * 0.06}
|
||||
className={isFeatured ? "sm:col-span-2 lg:col-span-1" : ""}
|
||||
>
|
||||
<Link
|
||||
href={`/${tool.slug}`}
|
||||
className="group relative flex h-full flex-col justify-between overflow-hidden rounded-[2rem] border border-line bg-surface p-7 sm:p-8 shadow-lift transition-all duration-300 hover:-translate-y-1.5 hover:border-[#0d7a57]/40 hover:shadow-[0_20px_45px_rgba(13,122,87,0.14)]"
|
||||
>
|
||||
{/* Subtiler Glow-Effekt im Hintergrund der Karte */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute -right-12 -top-12 h-36 w-36 rounded-full bg-[#0d7a57]/5 blur-2xl transition-all duration-500 group-hover:scale-150 group-hover:bg-[#0d7a57]/15"
|
||||
/>
|
||||
|
||||
<div>
|
||||
{/* Icon & Badge Header */}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-[#0d7a57]/20 bg-[#e6f7f0] text-[#0d7a57] shadow-sm transition-all duration-300 group-hover:scale-110 group-hover:border-[#0d7a57] group-hover:bg-[#0d7a57] group-hover:text-white">
|
||||
<Icon className="h-6 w-6" strokeWidth={2.1} />
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full border border-line bg-paper px-3 py-1 text-[11px] font-semibold uppercase tracking-wider text-ink-soft">
|
||||
{tool.badge}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Titel & Beschreibung */}
|
||||
<h3 className="mt-6 font-display text-2xl font-semibold tracking-tight text-ink transition-colors duration-200 group-hover:text-[#0d7a57]">
|
||||
{tool.title}
|
||||
</h3>
|
||||
<p className="mt-2.5 text-sm leading-relaxed text-ink-soft">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Action Link Footer */}
|
||||
<div className="mt-8 flex items-center justify-between border-t border-line/60 pt-4 text-xs font-semibold uppercase tracking-widest text-[#0d7a57]">
|
||||
<span>Generator öffnen</span>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-[#e6f7f0] text-[#0d7a57] transition-all duration-300 group-hover:translate-x-1 group-hover:bg-[#0d7a57] group-hover:text-white">
|
||||
<ArrowRight className="h-3.5 w-3.5" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</Reveal>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
148
components/preloader.tsx
Normal file
148
components/preloader.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
|
||||
|
||||
const CURL_PATH =
|
||||
"M 100 30 C 88 30, 76 26, 66 22 C 56 18, 46 18, 40 24 C 34 30, 38 40, 48 40 C 56 40, 60 32, 54 28 C 49 25, 43 29, 46 34";
|
||||
const SESSION_KEY = "entscheidomat:preloader-seen";
|
||||
|
||||
/**
|
||||
* Minimalistische Ladeanimation: ein Kalligraphie-Ornament,
|
||||
* das sich selbst zeichnet, danach hebt sich der Vorhang.
|
||||
* Feuert "app:reveal", sobald die Seite sichtbar wird.
|
||||
*/
|
||||
export default function Preloader() {
|
||||
const reduced = usePrefersReducedMotion();
|
||||
const [animationDone, setAnimationDone] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const leftCurlRef = useRef<SVGPathElement>(null);
|
||||
const rightCurlRef = useRef<SVGPathElement>(null);
|
||||
const dotRef = useRef<SVGCircleElement>(null);
|
||||
const wordRef = useRef<HTMLParagraphElement>(null);
|
||||
const hasStartedRef = useRef(false);
|
||||
|
||||
const reveal = () => {
|
||||
document.documentElement.dataset.preloader = "complete";
|
||||
window.dispatchEvent(new Event("app:reveal"));
|
||||
};
|
||||
|
||||
// Reduced Motion: keine Animation, aber Reveal-Event trotzdem dispatchen –
|
||||
// rein imperativ, kein setState, deshalb kein separater Effekt-Zweig nötig.
|
||||
useEffect(() => {
|
||||
let hasSeenPreloader = false;
|
||||
|
||||
try {
|
||||
hasSeenPreloader = window.sessionStorage.getItem(SESSION_KEY) === "1";
|
||||
} catch {
|
||||
// Falls Browser-Speicher blockiert ist, bleibt der Loader funktional.
|
||||
}
|
||||
|
||||
const shouldSkip = document.documentElement.dataset.preloader === "cached";
|
||||
if (reduced || shouldSkip || (hasSeenPreloader && !hasStartedRef.current)) {
|
||||
setAnimationDone(true);
|
||||
reveal();
|
||||
return;
|
||||
}
|
||||
|
||||
hasStartedRef.current = true;
|
||||
try {
|
||||
window.sessionStorage.setItem(SESSION_KEY, "1");
|
||||
} catch {
|
||||
// Die Animation darf nicht von verfügbarem Browser-Speicher abhängen.
|
||||
}
|
||||
|
||||
document.documentElement.style.overflow = "hidden";
|
||||
const duration = window.matchMedia("(max-width: 767px)").matches ? 0.45 : 1;
|
||||
|
||||
const curls = [leftCurlRef.current, rightCurlRef.current].filter(
|
||||
Boolean,
|
||||
) as SVGPathElement[];
|
||||
const lengths = curls.map((p) => p.getTotalLength());
|
||||
curls.forEach((p, i) => {
|
||||
p.style.strokeDasharray = `${lengths[i]}`;
|
||||
p.style.strokeDashoffset = `${lengths[i]}`;
|
||||
});
|
||||
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => {
|
||||
document.documentElement.style.overflow = "";
|
||||
setAnimationDone(true);
|
||||
reveal();
|
||||
},
|
||||
});
|
||||
|
||||
tl.to(curls, {
|
||||
strokeDashoffset: 0,
|
||||
duration: 0.35 * duration,
|
||||
ease: "power2.inOut",
|
||||
stagger: 0.05 * duration,
|
||||
})
|
||||
.fromTo(
|
||||
dotRef.current,
|
||||
{ scale: 0, transformOrigin: "50% 50%" },
|
||||
{ scale: 1, duration: 0.15 * duration, ease: "back.out(3)" },
|
||||
`-=${0.12 * duration}`,
|
||||
)
|
||||
.fromTo(
|
||||
wordRef.current,
|
||||
{ opacity: 0, y: 10, letterSpacing: "0.5em" },
|
||||
{ opacity: 1, y: 0, letterSpacing: "0.28em", duration: 0.22 * duration, ease: "power2.out" },
|
||||
`-=${0.1 * duration}`,
|
||||
)
|
||||
.to(overlayRef.current, {
|
||||
yPercent: -100,
|
||||
duration: 0.4 * duration,
|
||||
ease: "power4.inOut",
|
||||
delay: 0.08 * duration,
|
||||
});
|
||||
|
||||
return () => {
|
||||
document.documentElement.style.overflow = "";
|
||||
tl.kill();
|
||||
};
|
||||
}, [reduced]);
|
||||
|
||||
if (reduced || animationDone) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={overlayRef}
|
||||
aria-hidden
|
||||
data-preloader-overlay
|
||||
className="fixed inset-0 z-[100] flex flex-col items-center justify-center bg-paper"
|
||||
>
|
||||
<svg
|
||||
width="220"
|
||||
height="66"
|
||||
viewBox="0 0 200 60"
|
||||
fill="none"
|
||||
stroke="#161513"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
>
|
||||
<path ref={leftCurlRef} d={CURL_PATH} />
|
||||
<path
|
||||
ref={rightCurlRef}
|
||||
d={CURL_PATH}
|
||||
transform="rotate(180 100 30)"
|
||||
/>
|
||||
<circle
|
||||
ref={dotRef}
|
||||
cx="100"
|
||||
cy="30"
|
||||
r="1.8"
|
||||
fill="#161513"
|
||||
stroke="none"
|
||||
/>
|
||||
</svg>
|
||||
<p
|
||||
ref={wordRef}
|
||||
className="mt-6 text-[11px] font-semibold uppercase tracking-[0.28em] text-ink-soft opacity-0"
|
||||
>
|
||||
Entscheidomat
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
53
components/providers/sound-provider.tsx
Normal file
53
components/providers/sound-provider.tsx
Normal file
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { playSound, type SoundName } from "@/lib/sounds";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
|
||||
type SoundContextValue = {
|
||||
enabled: boolean;
|
||||
toggle: () => void;
|
||||
play: (name: SoundName) => void;
|
||||
};
|
||||
|
||||
const SoundContext = createContext<SoundContextValue>({
|
||||
enabled: false,
|
||||
toggle: () => {},
|
||||
play: () => {},
|
||||
});
|
||||
|
||||
const STORAGE_KEY = "entscheidomat-sound";
|
||||
|
||||
export function SoundProvider({ children }: { children: ReactNode }) {
|
||||
const [enabled, setEnabled] = useLocalStorage(STORAGE_KEY, true);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setEnabled((prev) => !prev);
|
||||
}, [setEnabled]);
|
||||
|
||||
const play = useCallback(
|
||||
(name: SoundName) => {
|
||||
if (enabled) playSound(name);
|
||||
},
|
||||
[enabled],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({ enabled, toggle, play }),
|
||||
[enabled, toggle, play],
|
||||
);
|
||||
|
||||
return (
|
||||
<SoundContext.Provider value={value}>{children}</SoundContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSound() {
|
||||
return useContext(SoundContext);
|
||||
}
|
||||
28
components/related-tools.tsx
Normal file
28
components/related-tools.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link";
|
||||
import { TOOL_PAGES } from "@/lib/tool-pages";
|
||||
|
||||
export default function RelatedTools({ current }: { current: string }) {
|
||||
const others = TOOL_PAGES.filter((t) => t.slug !== current);
|
||||
|
||||
return (
|
||||
<p className="text-base text-ink-soft leading-relaxed">
|
||||
Weitere Zufalls-Tools:{" "}
|
||||
{others.map((t, i) => (
|
||||
<span key={t.slug}>
|
||||
<Link
|
||||
href={`/${t.slug}`}
|
||||
className="text-[#c97d60] font-medium hover:underline underline-offset-2 transition-colors"
|
||||
>
|
||||
{t.label}
|
||||
</Link>
|
||||
{i < others.length - 2
|
||||
? ", "
|
||||
: i === others.length - 2
|
||||
? " sowie "
|
||||
: "."}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
64
components/reveal.tsx
Normal file
64
components/reveal.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
|
||||
|
||||
type RevealProps = {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
/** Verzögerung in Sekunden (für Stagger) */
|
||||
delay?: number;
|
||||
y?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Leichte Scroll-Reveal-Variante ohne ScrollTrigger. IntersectionObserver
|
||||
* verursacht keine fortlaufende Scroll-Arbeit und lädt keine GSAP-Plugins.
|
||||
*/
|
||||
export default function Reveal({
|
||||
children,
|
||||
className,
|
||||
delay = 0,
|
||||
y = 30,
|
||||
}: RevealProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
if (reducedMotion.matches || !window.IntersectionObserver) {
|
||||
const frame = window.requestAnimationFrame(() => setVisible(true));
|
||||
return () => window.cancelAnimationFrame(frame);
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
setVisible(true);
|
||||
observer.disconnect();
|
||||
}
|
||||
},
|
||||
{ rootMargin: "0px 0px -12%" },
|
||||
);
|
||||
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`reveal${visible ? " reveal--visible" : ""}${className ? ` ${className}` : ""}`}
|
||||
style={
|
||||
{
|
||||
"--reveal-delay": `${delay}s`,
|
||||
"--reveal-y": `${y}px`,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
390
components/scroll-flourishes.tsx
Normal file
390
components/scroll-flourishes.tsx
Normal file
@@ -0,0 +1,390 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
|
||||
/**
|
||||
* Botanisch präzise verschnörkelte Ranken:
|
||||
* Das Wachstum ist exakt auf den gesamten Scrollverlauf abgestimmt (kein zu schnelles Wachsen).
|
||||
* Die Ranken entfalten sich genau synchron zur Scroll-Position und erreichen den
|
||||
* untersten Rand erst, wenn man auch ganz unten angekommen ist.
|
||||
*/
|
||||
|
||||
// Botanisch geformtes Blatt mit spitzer Silhouetten-Form, Blattader & dezentem Hover-Wiegeln
|
||||
function Leaf({
|
||||
x,
|
||||
y,
|
||||
rotate = 0,
|
||||
scale = 1,
|
||||
className = "",
|
||||
}: {
|
||||
x: number;
|
||||
y: number;
|
||||
rotate?: number;
|
||||
scale?: number;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<motion.g
|
||||
transform={`translate(${x}, ${y}) rotate(${rotate}) scale(${scale})`}
|
||||
whileHover={{ scale: scale * 1.18, rotate: rotate + 6 }}
|
||||
transition={{ type: "spring", stiffness: 160, damping: 16 }}
|
||||
className={`vine-leaf pointer-events-auto cursor-pointer ${className}`}
|
||||
>
|
||||
{/* Äußere Blattsilhouette – edles Gelb-Grün / Smaragd */}
|
||||
<path
|
||||
d="M 0 0 C 14 -20 36 -12 42 0 C 36 12 14 20 0 0 Z"
|
||||
fill="#0d7a57"
|
||||
fillOpacity="0.92"
|
||||
className="transition-all duration-300 hover:fill-[#109b6f]"
|
||||
/>
|
||||
{/* Feine Blattader */}
|
||||
<path
|
||||
d="M 0 0 C 15 -3 28 -2 39 0"
|
||||
stroke="#ffffff"
|
||||
strokeOpacity="0.75"
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</motion.g>
|
||||
);
|
||||
}
|
||||
|
||||
// Offene Spiral-Tendril (Ranken-Kringel)
|
||||
function Tendril({ d, className = "" }: { d: string; className?: string }) {
|
||||
return (
|
||||
<path
|
||||
className={`vine-tendril ${className}`}
|
||||
d={d}
|
||||
fill="none"
|
||||
stroke="#161513"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Ranke 1 (Links-Oben nach Mitte-Rechts - Hero Bereich)
|
||||
const STEM_1 =
|
||||
"M -40 20 C 120 70, 240 160, 320 300 C 400 440, 280 600, 420 740 C 520 860, 680 940, 820 1060";
|
||||
|
||||
const TENDRILS_1 = [
|
||||
"M 240 160 C 275 135, 295 160, 275 185 C 255 195, 240 178, 252 165",
|
||||
"M 420 740 C 465 710, 485 745, 460 770 C 435 785, 415 755, 428 738",
|
||||
];
|
||||
|
||||
const LEAF_NODES_1 = [
|
||||
{ x: 120, y: 70, rotate: -25, scale: 0.9 },
|
||||
{ x: 240, y: 160, rotate: 42, scale: 1.1 },
|
||||
{ x: 320, y: 300, rotate: -45, scale: 1.0 },
|
||||
{ x: 400, y: 440, rotate: 32, scale: 0.95 },
|
||||
{ x: 420, y: 740, rotate: -35, scale: 1.05 },
|
||||
{ x: 680, y: 940, rotate: 50, scale: 0.9 },
|
||||
];
|
||||
|
||||
// Ranke 2 (Rechts-Oben edel schwebend - Mittlerer Bereich)
|
||||
const STEM_2 =
|
||||
"M 920 30 C 760 150, 620 310, 710 490 C 800 680, 620 830, 740 1020 C 860 1180, 720 1340, 840 1500";
|
||||
|
||||
const TENDRILS_2 = [
|
||||
"M 710 490 C 665 460, 640 495, 665 520 C 690 535, 710 505, 695 488",
|
||||
"M 740 1020 C 695 990, 670 1025, 695 1050 C 720 1065, 740 1035, 725 1018",
|
||||
];
|
||||
|
||||
const LEAF_NODES_2 = [
|
||||
{ x: 760, y: 150, rotate: 155, scale: 1.0 },
|
||||
{ x: 710, y: 490, rotate: -130, scale: 1.15 },
|
||||
{ x: 620, y: 830, rotate: 140, scale: 0.9 },
|
||||
{ x: 740, y: 1020, rotate: -150, scale: 1.05 },
|
||||
{ x: 840, y: 1500, rotate: 135, scale: 1.1 },
|
||||
];
|
||||
|
||||
// Ranke 3 (Untere Ranke Links – Wächst erst in der 2. Seitenhälfte bis in den Footer)
|
||||
const STEM_3 =
|
||||
"M -30 50 C 210 180, 440 240, 520 430 C 600 620, 420 810, 610 1000 C 780 1170, 690 1340, 840 1550 C 920 1720, 780 1880, 910 2050";
|
||||
|
||||
const TENDRILS_3 = [
|
||||
"M 520 430 C 565 400, 590 435, 565 460 C 540 475, 518 445, 532 428",
|
||||
"M 610 1000 C 655 970, 680 1005, 655 1030 C 630 1045, 608 1015, 622 998",
|
||||
"M 840 1550 C 885 1520, 910 1555, 885 1580 C 860 1595, 838 1565, 852 1548",
|
||||
];
|
||||
|
||||
const LEAF_NODES_3 = [
|
||||
{ x: 210, y: 180, rotate: -30, scale: 1.05 },
|
||||
{ x: 440, y: 240, rotate: 35, scale: 0.9 },
|
||||
{ x: 520, y: 430, rotate: -40, scale: 1.1 },
|
||||
{ x: 420, y: 810, rotate: 45, scale: 1.0 },
|
||||
{ x: 610, y: 1000, rotate: -35, scale: 1.15 },
|
||||
{ x: 780, y: 1170, rotate: 30, scale: 0.95 },
|
||||
{ x: 840, y: 1550, rotate: -25, scale: 1.1 },
|
||||
{ x: 880, y: 1860, rotate: 40, scale: 0.95 },
|
||||
];
|
||||
|
||||
// Ranke 4 (Untere Ranke Rechts – Wächst erst in der 2. Seitenhälfte bis in den Footer)
|
||||
const STEM_4 =
|
||||
"M 900 40 C 740 220, 620 400, 700 600 C 780 800, 600 1000, 720 1220 C 840 1440, 680 1680, 780 1920 C 880 2080, 750 2200, 850 2320";
|
||||
|
||||
const TENDRILS_4 = [
|
||||
"M 700 600 C 655 570, 630 605, 655 630 C 680 645, 700 615, 685 598",
|
||||
"M 720 1220 C 675 1190, 650 1225, 675 1250 C 700 1265, 720 1235, 705 1218",
|
||||
];
|
||||
|
||||
const LEAF_NODES_4 = [
|
||||
{ x: 740, y: 220, rotate: 145, scale: 1.0 },
|
||||
{ x: 700, y: 600, rotate: 125, scale: 0.95 },
|
||||
{ x: 600, y: 1000, rotate: -140, scale: 1.1 },
|
||||
{ x: 720, y: 1220, rotate: 135, scale: 1.05 },
|
||||
{ x: 780, y: 1920, rotate: -130, scale: 0.95 },
|
||||
];
|
||||
|
||||
export default function ScrollFlourishes() {
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const root = rootRef.current;
|
||||
if (!root) return;
|
||||
|
||||
const mm = gsap.matchMedia();
|
||||
|
||||
mm.add("(prefers-reduced-motion: no-preference)", () => {
|
||||
// 1. Dasharray & Offset für alle Linienpfade vorbereiten
|
||||
const strokePaths = root.querySelectorAll<SVGPathElement>(
|
||||
".vine-stem, .vine-tendril",
|
||||
);
|
||||
strokePaths.forEach((p) => {
|
||||
const len = p.getTotalLength();
|
||||
p.style.strokeDasharray = `${len}`;
|
||||
p.style.strokeDashoffset = `${len}`;
|
||||
});
|
||||
|
||||
const leaves = root.querySelectorAll<SVGGElement>(".vine-leaf");
|
||||
gsap.set(leaves, { opacity: 0, scale: 0, transformOrigin: "0px 0px" });
|
||||
|
||||
// 2. Perfekt getaktete ScrollTrigger Timeline (Spannweite von 0 bis 1.0 des gesamten Dokument-Scrolls)
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: document.body,
|
||||
start: "top top",
|
||||
end: "bottom bottom",
|
||||
scrub: 0.7,
|
||||
},
|
||||
});
|
||||
|
||||
// Ranke 1 (Hero): Wächst im oberen Drittel (0.0 bis 0.38)
|
||||
tl.to(
|
||||
root.querySelectorAll(".vine-stem-1"),
|
||||
{ strokeDashoffset: 0, ease: "none", duration: 0.38 },
|
||||
0,
|
||||
);
|
||||
tl.to(
|
||||
root.querySelectorAll(".vine-tendril-1"),
|
||||
{ strokeDashoffset: 0, ease: "none", duration: 0.08 },
|
||||
0.25,
|
||||
);
|
||||
|
||||
// Ranke 2 (Mitte-Rechts): Wächst im mittleren Bereich (0.22 bis 0.68)
|
||||
tl.to(
|
||||
root.querySelectorAll(".vine-stem-2"),
|
||||
{ strokeDashoffset: 0, ease: "none", duration: 0.46 },
|
||||
0.22,
|
||||
);
|
||||
tl.to(
|
||||
root.querySelectorAll(".vine-tendril-2"),
|
||||
{ strokeDashoffset: 0, ease: "none", duration: 0.08 },
|
||||
0.48,
|
||||
);
|
||||
|
||||
// Ranke 3 (Untere Ranke Links): Wächst in der 2. Hälfte bis ganz nach unten (0.45 bis 1.0)
|
||||
tl.to(
|
||||
root.querySelectorAll(".vine-stem-3"),
|
||||
{ strokeDashoffset: 0, ease: "none", duration: 0.55 },
|
||||
0.45,
|
||||
);
|
||||
tl.to(
|
||||
root.querySelectorAll(".vine-tendril-3"),
|
||||
{ strokeDashoffset: 0, ease: "none", duration: 0.08 },
|
||||
0.72,
|
||||
);
|
||||
|
||||
// Ranke 4 (Untere Ranke Rechts): Wächst in der 2. Hälfte bis ganz nach unten (0.50 bis 1.0)
|
||||
tl.to(
|
||||
root.querySelectorAll(".vine-stem-4"),
|
||||
{ strokeDashoffset: 0, ease: "none", duration: 0.5 },
|
||||
0.5,
|
||||
);
|
||||
tl.to(
|
||||
root.querySelectorAll(".vine-tendril-4"),
|
||||
{ strokeDashoffset: 0, ease: "none", duration: 0.08 },
|
||||
0.78,
|
||||
);
|
||||
|
||||
// Blätter entfalten sich organisch zur jeweiligen Wachstumszeit
|
||||
tl.to(
|
||||
leaves,
|
||||
{
|
||||
opacity: 1,
|
||||
scale: 1,
|
||||
ease: "back.out(1.5)",
|
||||
duration: 0.1,
|
||||
stagger: 0.02,
|
||||
},
|
||||
0.1,
|
||||
);
|
||||
|
||||
// Subtiler, ruhiger Parallax-Versatz (Ohne hektische Maus-Swayings)
|
||||
tl.to(
|
||||
".vine-container-1",
|
||||
{ yPercent: 6, scale: 1.06, ease: "none", duration: 1 },
|
||||
0,
|
||||
);
|
||||
tl.to(
|
||||
".vine-container-2",
|
||||
{ yPercent: 8, scale: 0.95, ease: "none", duration: 1 },
|
||||
0,
|
||||
);
|
||||
tl.to(
|
||||
".vine-container-3",
|
||||
{ yPercent: -4, scale: 1.05, ease: "none", duration: 1 },
|
||||
0,
|
||||
);
|
||||
tl.to(
|
||||
".vine-container-4",
|
||||
{ yPercent: 5, scale: 1.04, ease: "none", duration: 1 },
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
return () => mm.revert();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 -z-10 overflow-hidden select-none"
|
||||
>
|
||||
{/* Ranke 1: Links-Oben nach Mitte-Rechts (Hero) */}
|
||||
<div className="vine-container-1 absolute left-[-4vw] top-[-2vh] w-[50vw] max-w-[750px] min-w-[320px] opacity-35 transition-opacity duration-500">
|
||||
<svg
|
||||
viewBox="0 0 850 1100"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
className="w-full h-auto pointer-events-auto"
|
||||
>
|
||||
<path
|
||||
className="vine-stem vine-stem-1"
|
||||
d={STEM_1}
|
||||
stroke="#161513"
|
||||
strokeWidth="1.6"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{TENDRILS_1.map((d, i) => (
|
||||
<Tendril key={i} className="vine-tendril-1" d={d} />
|
||||
))}
|
||||
{LEAF_NODES_1.map((node, i) => (
|
||||
<Leaf
|
||||
key={i}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
rotate={node.rotate}
|
||||
scale={node.scale}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Ranke 2: Rechts-Oben (Mittlerer Bereich) */}
|
||||
<div className="vine-container-2 absolute right-[-4vw] top-[18vh] w-[54vw] max-w-[820px] min-w-[340px] opacity-30 transition-opacity duration-500">
|
||||
<svg
|
||||
viewBox="0 0 950 1550"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
className="w-full h-auto pointer-events-auto"
|
||||
>
|
||||
<path
|
||||
className="vine-stem vine-stem-2"
|
||||
d={STEM_2}
|
||||
stroke="#161513"
|
||||
strokeWidth="1.6"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{TENDRILS_2.map((d, i) => (
|
||||
<Tendril key={i} className="vine-tendril-2" d={d} />
|
||||
))}
|
||||
{LEAF_NODES_2.map((node, i) => (
|
||||
<Leaf
|
||||
key={i}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
rotate={node.rotate}
|
||||
scale={node.scale}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Ranke 3: Untere Ranke Links – Wächst erst in der 2. Seitenhälfte bis in den Footer */}
|
||||
<div className="vine-container-3 absolute left-[-3vw] top-[42vh] w-[58vw] max-w-[920px] min-w-[360px] opacity-35 transition-opacity duration-500">
|
||||
<svg
|
||||
viewBox="0 0 950 2100"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
className="w-full h-auto pointer-events-auto"
|
||||
>
|
||||
<path
|
||||
className="vine-stem vine-stem-3"
|
||||
d={STEM_3}
|
||||
stroke="#161513"
|
||||
strokeWidth="1.6"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{TENDRILS_3.map((d, i) => (
|
||||
<Tendril key={i} className="vine-tendril-3" d={d} />
|
||||
))}
|
||||
{LEAF_NODES_3.map((node, i) => (
|
||||
<Leaf
|
||||
key={i}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
rotate={node.rotate}
|
||||
scale={node.scale}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* Ranke 4: Untere Ranke Rechts – Wächst erst in der 2. Seitenhälfte bis in den Footer */}
|
||||
<div className="vine-container-4 absolute right-[-3vw] top-[48vh] w-[56vw] max-w-[900px] min-w-[350px] opacity-35 transition-opacity duration-500">
|
||||
<svg
|
||||
viewBox="0 0 950 2350"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
className="w-full h-auto pointer-events-auto"
|
||||
>
|
||||
<path
|
||||
className="vine-stem vine-stem-4"
|
||||
d={STEM_4}
|
||||
stroke="#161513"
|
||||
strokeWidth="1.6"
|
||||
vectorEffect="non-scaling-stroke"
|
||||
/>
|
||||
{TENDRILS_4.map((d, i) => (
|
||||
<Tendril key={i} className="vine-tendril-4" d={d} />
|
||||
))}
|
||||
{LEAF_NODES_4.map((node, i) => (
|
||||
<Leaf
|
||||
key={i}
|
||||
x={node.x}
|
||||
y={node.y}
|
||||
rotate={node.rotate}
|
||||
scale={node.scale}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
210
components/subpage-layout.tsx
Normal file
210
components/subpage-layout.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
import type { ReactNode } from "react";
|
||||
import Link from "next/link";
|
||||
import { ChevronDown } from "lucide-react";
|
||||
import Nav from "@/components/nav";
|
||||
import Footer from "@/components/footer";
|
||||
import RelatedTools from "@/components/related-tools";
|
||||
|
||||
export type FaqItem = {
|
||||
q: string;
|
||||
a: string;
|
||||
};
|
||||
|
||||
type SubpageLayoutProps = {
|
||||
toolSlug: string;
|
||||
toolName: string;
|
||||
h1Title: string;
|
||||
introText: string;
|
||||
steps: string[];
|
||||
examples: string[];
|
||||
vorteileText: ReactNode;
|
||||
additionalContent?: ReactNode;
|
||||
faqs: FaqItem[];
|
||||
updatedDate: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export default function SubpageLayout({
|
||||
toolSlug,
|
||||
toolName,
|
||||
h1Title,
|
||||
introText,
|
||||
steps,
|
||||
examples,
|
||||
vorteileText,
|
||||
additionalContent,
|
||||
faqs,
|
||||
updatedDate,
|
||||
children,
|
||||
}: SubpageLayoutProps) {
|
||||
const howItWorksName =
|
||||
toolSlug === "muenze-werfen" ? "der Münzwurf" : toolName;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Nav />
|
||||
<main className="relative flex-1 pt-16 overflow-hidden">
|
||||
{/* Full-width Viewport Background: Architektonisches Grid & Geometrie-Muster (wie im Referenzbild) mit sanftem Übergang nach unten */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[620px] w-full overflow-hidden [mask-image:linear-gradient(to_bottom,black_45%,transparent_98%)]"
|
||||
>
|
||||
{/* Weiche Aquarell-Glows (einheitlich in Salbeigrün) */}
|
||||
<div className="absolute top-8 left-[12%] h-72 w-72 rounded-full bg-[#8da399]/20 blur-3xl" />
|
||||
<div className="absolute top-48 left-[50%] h-64 w-64 rounded-full bg-[#8da399]/18 blur-3xl" />
|
||||
|
||||
{/* Vektor-Muster: Feines architektonisches Raster, Kreise & Punkte */}
|
||||
<svg
|
||||
className="absolute inset-0 h-full w-full opacity-75"
|
||||
width="100%"
|
||||
height="100%"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<defs>
|
||||
<pattern
|
||||
id="archPattern"
|
||||
width="160"
|
||||
height="160"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
{/* Feine Rasterlinien */}
|
||||
<line x1="0" y1="0" x2="160" y2="0" stroke="#747874" strokeWidth="0.5" strokeOpacity="0.22" />
|
||||
<line x1="0" y1="80" x2="160" y2="80" stroke="#747874" strokeWidth="0.5" strokeOpacity="0.15" strokeDasharray="4 4" />
|
||||
<line x1="0" y1="0" x2="0" y2="160" stroke="#747874" strokeWidth="0.5" strokeOpacity="0.22" />
|
||||
<line x1="80" y1="0" x2="80" y2="160" stroke="#747874" strokeWidth="0.5" strokeOpacity="0.15" strokeDasharray="4 4" />
|
||||
|
||||
{/* Diagonale feine Hilfslinien */}
|
||||
<line x1="0" y1="0" x2="160" y2="160" stroke="#8da399" strokeWidth="0.4" strokeOpacity="0.15" />
|
||||
|
||||
{/* Geometrische Bögen & Kreise */}
|
||||
<circle cx="80" cy="80" r="50" stroke="#8da399" strokeWidth="0.5" strokeOpacity="0.3" fill="none" />
|
||||
<circle cx="80" cy="80" r="75" stroke="#8da399" strokeWidth="0.4" strokeOpacity="0.2" strokeDasharray="2 4" fill="none" />
|
||||
<circle cx="0" cy="0" r="40" stroke="#8da399" strokeWidth="0.4" strokeOpacity="0.22" fill="none" />
|
||||
|
||||
{/* Punkte auf den Schnittstellen */}
|
||||
<circle cx="80" cy="80" r="2" fill="#707a65" fillOpacity="0.5" />
|
||||
<circle cx="0" cy="0" r="2.2" fill="#707a65" fillOpacity="0.45" />
|
||||
<circle cx="160" cy="0" r="1.6" fill="#707a65" fillOpacity="0.4" />
|
||||
<circle cx="0" cy="160" r="1.6" fill="#707a65" fillOpacity="0.4" />
|
||||
<circle cx="160" cy="160" r="2.2" fill="#707a65" fillOpacity="0.45" />
|
||||
|
||||
<circle cx="80" cy="30" r="1.6" fill="#8da399" fillOpacity="0.45" />
|
||||
<circle cx="80" cy="130" r="1.6" fill="#8da399" fillOpacity="0.45" />
|
||||
<circle cx="30" cy="80" r="1.6" fill="#8da399" fillOpacity="0.4" />
|
||||
<circle cx="130" cy="80" r="1.6" fill="#8da399" fillOpacity="0.4" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#archPattern)" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="relative mx-auto w-full max-w-6xl px-5 py-12 sm:px-8 sm:py-16 md:py-20">
|
||||
{/* Header Section ohne Container-Box */}
|
||||
<header className="relative max-w-3xl mb-12 sm:mb-16">
|
||||
<nav
|
||||
aria-label="Breadcrumb"
|
||||
className="flex items-center gap-2 mb-3 text-xs font-semibold uppercase tracking-widest text-ink-soft"
|
||||
>
|
||||
<Link href="/" className="hover:text-[#c97d60] transition-colors">
|
||||
Entscheidomat
|
||||
</Link>
|
||||
<span className="text-line">/</span>
|
||||
<span className="text-ink font-medium">{toolName}</span>
|
||||
</nav>
|
||||
|
||||
<h1 className="font-display text-4xl sm:text-5xl lg:text-6xl font-semibold leading-[1.15] tracking-tight text-ink mb-4">
|
||||
{h1Title}
|
||||
</h1>
|
||||
|
||||
<p className="text-base sm:text-lg text-ink-soft leading-relaxed">
|
||||
{introText}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Main Interactive Tool Container */}
|
||||
<section className="mb-14 sm:mb-20 rounded-3xl border border-line bg-surface p-6 sm:p-8 md:p-10 shadow-lift relative overflow-hidden">
|
||||
{children}
|
||||
</section>
|
||||
|
||||
{/* 2-Column Content Structure: Explanations & FAQ */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-10 lg:gap-14">
|
||||
{/* Left Column: Guide & Info */}
|
||||
<div className="lg:col-span-7 space-y-10">
|
||||
<article>
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink mb-4">
|
||||
So funktioniert {howItWorksName.toLowerCase().startsWith("der") || howItWorksName.toLowerCase().startsWith("das") || howItWorksName.toLowerCase().startsWith("die") ? howItWorksName : `das ${howItWorksName}`}
|
||||
</h2>
|
||||
<ol className="space-y-3 text-base text-ink-soft pl-4 border-l-2 border-[#c97d60]/40">
|
||||
{steps.map((step, idx) => (
|
||||
<li key={idx} className="pl-2">
|
||||
<span className="font-semibold text-ink">{idx + 1}. </span>
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink mb-4">
|
||||
Beispiele
|
||||
</h2>
|
||||
<ul className="space-y-2 text-base text-ink-soft list-disc pl-5">
|
||||
{examples.map((ex, idx) => (
|
||||
<li key={idx}>{ex}</li>
|
||||
))}
|
||||
</ul>
|
||||
</article>
|
||||
|
||||
<article>
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink mb-4">
|
||||
Vorteile und Grenzen
|
||||
</h2>
|
||||
<div className="text-base text-ink-soft leading-relaxed">
|
||||
{vorteileText}
|
||||
</div>
|
||||
</article>
|
||||
|
||||
{additionalContent}
|
||||
|
||||
<article className="p-6 sm:p-7 bg-paper/90 rounded-2xl border border-line shadow-soft">
|
||||
<h2 className="font-display text-xl font-semibold text-ink mb-3">
|
||||
Verwandte Tools
|
||||
</h2>
|
||||
<RelatedTools current={toolSlug} />
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* Right Column: FAQ Accordion */}
|
||||
<div className="lg:col-span-5">
|
||||
<h2 className="font-display text-2xl sm:text-3xl font-semibold text-ink mb-6">
|
||||
Häufige Fragen
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{faqs.map((faq) => (
|
||||
<details
|
||||
key={faq.q}
|
||||
className="group rounded-2xl border border-line bg-surface transition-colors hover:border-[#c97d60]/40 [&_summary::-webkit-details-marker]:hidden"
|
||||
>
|
||||
<summary className="flex cursor-pointer items-center justify-between gap-3 p-4 sm:p-5 font-display text-base font-semibold text-ink">
|
||||
<span>{faq.q}</span>
|
||||
<span className="shrink-0 text-ink-soft transition-transform duration-300 group-open:rotate-180">
|
||||
<ChevronDown className="h-5 w-5" />
|
||||
</span>
|
||||
</summary>
|
||||
<div className="px-4 pb-4 sm:px-5 sm:pb-5 text-sm sm:text-base leading-relaxed text-ink-soft border-t border-line/50 pt-3 mt-1">
|
||||
{faq.a}
|
||||
</div>
|
||||
</details>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-6 text-xs text-ink-soft">
|
||||
Zuletzt aktualisiert: {updatedDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</>
|
||||
);
|
||||
}
|
||||
185
components/tools/coin-flip.tsx
Normal file
185
components/tools/coin-flip.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Coins } from "lucide-react";
|
||||
import Coin3D from "@/components/coin-3d";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
import { coinEase } from "@/lib/motion";
|
||||
import { popConfetti } from "@/lib/confetti";
|
||||
import { useSound } from "@/components/providers/sound-provider";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
|
||||
|
||||
export default function CoinFlip() {
|
||||
const [frontLabel, setFrontLabel] = useLocalStorage("coin-front", "Kopf");
|
||||
const [backLabel, setBackLabel] = useLocalStorage("coin-back", "Zahl");
|
||||
const [history, setHistory] = useState<string[]>([]);
|
||||
const [flipping, setFlipping] = useState(false);
|
||||
const [result, setResult] = useState<string | null>(null);
|
||||
|
||||
const coinRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const rotationRef = useRef(0);
|
||||
|
||||
const { play } = useSound();
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
const front = frontLabel.trim() || "Kopf";
|
||||
const back = backLabel.trim() || "Zahl";
|
||||
|
||||
const flip = () => {
|
||||
if (flipping) return;
|
||||
|
||||
const isBack = Math.random() < 0.5;
|
||||
const label = isBack ? back : front;
|
||||
setFlipping(true);
|
||||
setResult(null);
|
||||
play("coin");
|
||||
|
||||
const current = rotationRef.current;
|
||||
const base = Math.ceil(current / 360) * 360;
|
||||
let target = base + 360 * 5 + (isBack ? 180 : 0);
|
||||
if (target - current < 360 * 4) target += 360;
|
||||
|
||||
const dur = reduced ? 0.3 : 2.6;
|
||||
|
||||
gsap.to(coinRef.current, {
|
||||
rotationX: target,
|
||||
duration: dur,
|
||||
ease: reduced ? "power1.out" : coinEase,
|
||||
onComplete: () => {
|
||||
rotationRef.current = target;
|
||||
setFlipping(false);
|
||||
setResult(label);
|
||||
setHistory((h) => [label, ...h].slice(0, 10));
|
||||
play("pop");
|
||||
popConfetti();
|
||||
},
|
||||
});
|
||||
|
||||
if (!reduced) {
|
||||
gsap.to(stageRef.current, {
|
||||
y: -54,
|
||||
duration: dur / 2,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
{/* Bühne */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div ref={stageRef} className="coin-scene p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={flip}
|
||||
aria-label="Münze werfen"
|
||||
className="block cursor-pointer rounded-full drop-shadow-[0_10px_18px_rgba(17,20,18,0.12)] transition-transform duration-200 hover:scale-[1.04] active:scale-95"
|
||||
>
|
||||
<Coin3D ref={coinRef} frontLabel={front} backLabel={back} size={188} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-14" aria-live="polite">
|
||||
<AnimatePresence mode="wait">
|
||||
{result && (
|
||||
<motion.p
|
||||
key={result + history.length}
|
||||
initial={{ opacity: 0, scale: 0.6, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 22 }}
|
||||
className="font-display text-3xl font-semibold tracking-tight"
|
||||
>
|
||||
<span className="text-primary">{result}!</span>
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={flip}
|
||||
disabled={flipping}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="mt-2 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Coins className="h-5 w-5" />
|
||||
{flipping ? "Fliegt …" : "Münze werfen"}
|
||||
</motion.button>
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
Tipp: Du kannst auch direkt auf die Münze tippen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Einstellungen */}
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
Seiten beschriften
|
||||
</h3>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-ink-soft">
|
||||
Vorderseite
|
||||
</span>
|
||||
<input
|
||||
value={frontLabel}
|
||||
onChange={(e) => setFrontLabel(e.target.value)}
|
||||
maxLength={14}
|
||||
placeholder="Kopf"
|
||||
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-ink-soft">
|
||||
Rückseite
|
||||
</span>
|
||||
<input
|
||||
value={backLabel}
|
||||
onChange={(e) => setBackLabel(e.target.value)}
|
||||
maxLength={14}
|
||||
placeholder="Zahl"
|
||||
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
Z. B. „Ja“ & „Nein“, „Kino“ & „Couch“ – deine Beschriftung
|
||||
bleibt gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
Verlauf
|
||||
</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<AnimatePresence initial={false}>
|
||||
{history.map((entry, i) => (
|
||||
<motion.span
|
||||
key={`${entry}-${history.length - i}`}
|
||||
initial={{ opacity: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
i === 0
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface text-ink-soft ring-1 ring-line"
|
||||
}`}
|
||||
>
|
||||
{entry}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
270
components/tools/dice-roller.tsx
Normal file
270
components/tools/dice-roller.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Dices } from "lucide-react";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
import { popConfetti } from "@/lib/confetti";
|
||||
import { useSound } from "@/components/providers/sound-provider";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
|
||||
|
||||
const SIZE = 104;
|
||||
const HALF = SIZE / 2;
|
||||
|
||||
/** Würfelaugen-Positionen im 3×3-Raster (viewBox 40×40). */
|
||||
const PIPS: Record<number, number[]> = {
|
||||
1: [4],
|
||||
2: [0, 8],
|
||||
3: [0, 4, 8],
|
||||
4: [0, 2, 6, 8],
|
||||
5: [0, 2, 4, 6, 8],
|
||||
6: [0, 2, 3, 5, 6, 8],
|
||||
};
|
||||
|
||||
/** Platzierung der Seiten im Würfel (gegenüberliegende Seiten = 7). */
|
||||
const FACE_TRANSFORMS = [
|
||||
`translateZ(${HALF}px)`, // 1 – vorne
|
||||
`rotateX(90deg) translateZ(${HALF}px)`, // 2 – oben
|
||||
`rotateY(90deg) translateZ(${HALF}px)`, // 3 – rechts
|
||||
`rotateY(-90deg) translateZ(${HALF}px)`, // 4 – links
|
||||
`rotateX(-90deg) translateZ(${HALF}px)`, // 5 – unten
|
||||
`rotateY(180deg) translateZ(${HALF}px)`, // 6 – hinten
|
||||
];
|
||||
|
||||
/** Welche Würfel-Rotation bringt Wert v nach vorne? */
|
||||
const FACE_ROTATION: Record<number, { rx: number; ry: number }> = {
|
||||
1: { rx: 0, ry: 0 },
|
||||
2: { rx: -90, ry: 0 },
|
||||
3: { rx: 0, ry: -90 },
|
||||
4: { rx: 0, ry: 90 },
|
||||
5: { rx: 90, ry: 0 },
|
||||
6: { rx: 0, ry: 180 },
|
||||
};
|
||||
|
||||
const DEFAULT_CUSTOM = ["Ja", "Nein", "Vielleicht", "Frag später", "Gute Idee", "Lass es"];
|
||||
|
||||
export default function DiceRoller() {
|
||||
const [mode, setMode] = useLocalStorage<"numbers" | "custom">(
|
||||
"dice-mode",
|
||||
"numbers",
|
||||
);
|
||||
const [customFaces, setCustomFaces] = useLocalStorage<string[]>(
|
||||
"dice-custom",
|
||||
DEFAULT_CUSTOM,
|
||||
);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [result, setResult] = useState<number | null>(null);
|
||||
|
||||
const diceRef = useRef<HTMLDivElement>(null);
|
||||
const stageRef = useRef<HTMLDivElement>(null);
|
||||
const rotRef = useRef({ rx: -18, ry: 24 }); // hübscher Startwinkel
|
||||
|
||||
const { play } = useSound();
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
const roll = () => {
|
||||
if (rolling) return;
|
||||
|
||||
const value = 1 + Math.floor(Math.random() * 6);
|
||||
setRolling(true);
|
||||
setResult(null);
|
||||
play("dice");
|
||||
|
||||
const { rx, ry } = rotRef.current;
|
||||
const target = FACE_ROTATION[value];
|
||||
const targetRx = Math.ceil(rx / 360) * 360 + 720 + target.rx;
|
||||
const targetRy = Math.ceil(ry / 360) * 360 + 720 + target.ry;
|
||||
|
||||
const dur = reduced ? 0.35 : 1.9;
|
||||
|
||||
gsap.to(diceRef.current, {
|
||||
rotationX: targetRx,
|
||||
rotationY: targetRy,
|
||||
duration: dur,
|
||||
ease: reduced ? "power1.out" : "power3.out",
|
||||
onComplete: () => {
|
||||
rotRef.current = { rx: targetRx, ry: targetRy };
|
||||
setRolling(false);
|
||||
setResult(value);
|
||||
play("pop");
|
||||
popConfetti();
|
||||
},
|
||||
});
|
||||
|
||||
if (!reduced) {
|
||||
gsap.to(stageRef.current, {
|
||||
y: -40,
|
||||
duration: dur / 2,
|
||||
yoyo: true,
|
||||
repeat: 1,
|
||||
ease: "power2.out",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const resultLabel =
|
||||
result === null
|
||||
? null
|
||||
: mode === "custom"
|
||||
? customFaces[result - 1]?.trim() || `Seite ${result}`
|
||||
: String(result);
|
||||
|
||||
return (
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
{/* Bühne */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div ref={stageRef} className="dice-scene p-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={roll}
|
||||
aria-label="Würfel rollen"
|
||||
className="block cursor-pointer drop-shadow-[0_22px_26px_rgb(23_23_28/0.22)] transition-transform duration-200 hover:scale-[1.05] active:scale-95"
|
||||
>
|
||||
<div
|
||||
ref={diceRef}
|
||||
className="dice"
|
||||
style={{
|
||||
width: SIZE,
|
||||
height: SIZE,
|
||||
// Startwinkel als Literal (deckt sich mit rotRef's Initialwert) –
|
||||
// GSAP übernimmt danach die Rotation imperativ am DOM-Node.
|
||||
transform: "rotateX(-18deg) rotateY(24deg)",
|
||||
}}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6].map((v, i) => (
|
||||
<div
|
||||
key={v}
|
||||
className="dice-face"
|
||||
style={{ transform: FACE_TRANSFORMS[i] }}
|
||||
>
|
||||
{mode === "numbers" ? (
|
||||
<svg viewBox="0 0 40 40" className="h-full w-full">
|
||||
{PIPS[v].map((p) => (
|
||||
<circle
|
||||
key={p}
|
||||
cx={8 + (p % 3) * 12}
|
||||
cy={8 + Math.floor(p / 3) * 12}
|
||||
r={3.6}
|
||||
fill="#17171c"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
) : (
|
||||
<span className="dice-face-label">
|
||||
{customFaces[v - 1]?.trim() || `Seite ${v}`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 h-14" aria-live="polite">
|
||||
<AnimatePresence mode="wait">
|
||||
{resultLabel !== null && (
|
||||
<motion.p
|
||||
key={resultLabel + String(result)}
|
||||
initial={{ opacity: 0, scale: 0.6, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 22 }}
|
||||
className="font-display text-3xl font-semibold tracking-tight"
|
||||
>
|
||||
<span className="text-primary">{resultLabel}</span>
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={roll}
|
||||
disabled={rolling}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="mt-2 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Dices className="h-5 w-5" />
|
||||
{rolling ? "Rollt …" : "Würfeln!"}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Einstellungen */}
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
Würfel-Seiten
|
||||
</h3>
|
||||
|
||||
<div className="mt-4 flex rounded-full border border-line bg-surface p-1">
|
||||
{(
|
||||
[
|
||||
{ id: "numbers", label: "Zahlen 1–6" },
|
||||
{ id: "custom", label: "Eigene Texte" },
|
||||
] as const
|
||||
).map((m) => (
|
||||
<button
|
||||
key={m.id}
|
||||
type="button"
|
||||
onClick={() => setMode(m.id)}
|
||||
className={`relative flex-1 cursor-pointer rounded-full px-3 py-2 text-sm font-semibold transition-colors duration-200 ${
|
||||
mode === m.id ? "text-white" : "text-ink-soft hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
{mode === m.id && (
|
||||
<motion.span
|
||||
layoutId="dice-mode-pill"
|
||||
transition={{ type: "spring", stiffness: 420, damping: 34 }}
|
||||
className="absolute inset-0 rounded-full bg-primary"
|
||||
/>
|
||||
)}
|
||||
<span className="relative z-10">{m.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{mode === "custom" && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 grid grid-cols-2 gap-2">
|
||||
{customFaces.map((face, i) => (
|
||||
<label key={i} className="block">
|
||||
<span className="mb-1 block text-[11px] font-medium text-ink-soft">
|
||||
Seite {i + 1}
|
||||
</span>
|
||||
<input
|
||||
value={face}
|
||||
onChange={(e) =>
|
||||
setCustomFaces((faces) =>
|
||||
faces.map((f, idx) =>
|
||||
idx === i ? e.target.value : f,
|
||||
),
|
||||
)
|
||||
}
|
||||
maxLength={18}
|
||||
placeholder={`Seite ${i + 1}`}
|
||||
className="w-full rounded-xl border border-line bg-surface px-3 py-2 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
Klassisch mit Würfelaugen oder mit eigenen Texten – perfekt für
|
||||
„Wer spült heute ab?“.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
188
components/tools/ja-nein-generator.tsx
Normal file
188
components/tools/ja-nein-generator.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { CheckCircle2, HelpCircle, XCircle } from "lucide-react";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
import { popConfetti } from "@/lib/confetti";
|
||||
import { useSound } from "@/components/providers/sound-provider";
|
||||
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
|
||||
|
||||
type Answer = "Ja" | "Nein";
|
||||
|
||||
export default function JaNeinGenerator() {
|
||||
const [question, setQuestion] = useState("");
|
||||
const [answer, setAnswer] = useState<Answer | null>(null);
|
||||
const [asking, setAsking] = useState(false);
|
||||
const [history, setHistory] = useState<Answer[]>([]);
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const { play } = useSound();
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
const ask = () => {
|
||||
if (asking) return;
|
||||
|
||||
const pick: Answer = Math.random() < 0.5 ? "Ja" : "Nein";
|
||||
setAsking(true);
|
||||
setAnswer(null);
|
||||
play("whoosh");
|
||||
|
||||
const reveal = () => {
|
||||
setAsking(false);
|
||||
setAnswer(pick);
|
||||
setHistory((h) => [pick, ...h].slice(0, 10));
|
||||
play(pick === "Ja" ? "win" : "pop");
|
||||
if (pick === "Ja") popConfetti();
|
||||
};
|
||||
|
||||
if (reduced) {
|
||||
gsap.delayedCall(0.25, reveal);
|
||||
return;
|
||||
}
|
||||
|
||||
const tl = gsap.timeline({ onComplete: reveal });
|
||||
tl.to(cardRef.current, {
|
||||
rotate: -6,
|
||||
x: -10,
|
||||
duration: 0.09,
|
||||
ease: "power1.inOut",
|
||||
})
|
||||
.to(cardRef.current, {
|
||||
rotate: 6,
|
||||
x: 10,
|
||||
duration: 0.09,
|
||||
ease: "power1.inOut",
|
||||
})
|
||||
.repeat(3)
|
||||
.to(cardRef.current, {
|
||||
rotate: 0,
|
||||
x: 0,
|
||||
duration: 0.2,
|
||||
ease: "power2.out",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
{/* Bühne */}
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
maxLength={100}
|
||||
placeholder="Deine Ja/Nein-Frage (optional) …"
|
||||
aria-label="Deine Frage an den Ja-Nein-Generator"
|
||||
className="w-full max-w-sm rounded-full border border-line bg-surface px-5 py-3 text-center text-sm font-medium shadow-soft outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
|
||||
<div ref={cardRef} className="mt-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={ask}
|
||||
aria-label="Ja-Nein-Generator fragen"
|
||||
className="flex h-52 w-52 cursor-pointer items-center justify-center rounded-[2rem] border border-line bg-surface shadow-lift transition-transform duration-200 hover:scale-[1.03] active:scale-95 sm:h-60 sm:w-60"
|
||||
>
|
||||
<div
|
||||
className="relative flex h-full w-full items-center justify-center px-6"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{answer ? (
|
||||
<motion.div
|
||||
key={answer + history.length}
|
||||
initial={{ opacity: 0, scale: 0.7 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 22 }}
|
||||
className={`flex flex-col items-center gap-2 ${
|
||||
answer === "Ja" ? "text-[#0d7a57]" : "text-clay"
|
||||
}`}
|
||||
>
|
||||
{answer === "Ja" ? (
|
||||
<CheckCircle2 className="h-14 w-14" />
|
||||
) : (
|
||||
<XCircle className="h-14 w-14" />
|
||||
)}
|
||||
<span className="font-display text-3xl font-semibold tracking-tight">
|
||||
{answer}
|
||||
</span>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="idle"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex flex-col items-center gap-2 text-ink-soft"
|
||||
>
|
||||
<HelpCircle className="h-14 w-14" />
|
||||
<span className="text-sm font-medium">Tippen zum Fragen</span>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={ask}
|
||||
disabled={asking}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="mt-6 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
{asking ? "Überlegt …" : "Ja oder Nein?"}
|
||||
</motion.button>
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
{question.trim()
|
||||
? `„${question.trim()}“`
|
||||
: "Stell eine Frage – oder lass dich einfach überraschen."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info & Verlauf */}
|
||||
<div className="space-y-5">
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
So funktioniert's
|
||||
</h3>
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
Frage eingeben (optional), auf die Karte oder den Button tippen –
|
||||
der Generator entscheidet zufällig zwischen Ja und Nein. Perfekt
|
||||
für schnelle Entscheidungen ohne langes Abwägen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
Verlauf
|
||||
</h3>
|
||||
<div className="mt-3 flex flex-wrap gap-1.5">
|
||||
<AnimatePresence initial={false}>
|
||||
{history.map((entry, i) => (
|
||||
<motion.span
|
||||
key={`${entry}-${history.length - i}`}
|
||||
initial={{ opacity: 0, scale: 0.6 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
className={`rounded-full px-3 py-1 text-xs font-semibold ${
|
||||
i === 0
|
||||
? entry === "Ja"
|
||||
? "bg-[#0d7a57] text-white"
|
||||
: "bg-clay text-white"
|
||||
: "bg-surface text-ink-soft ring-1 ring-line"
|
||||
}`}
|
||||
>
|
||||
{entry}
|
||||
</motion.span>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
178
components/tools/list-picker.tsx
Normal file
178
components/tools/list-picker.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { ListChecks, Sparkles } from "lucide-react";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
import { celebrateConfetti } from "@/lib/confetti";
|
||||
import { useSound } from "@/components/providers/sound-provider";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
|
||||
|
||||
const DEFAULT_LIST = "Anna\nBen\nClara\nDavid";
|
||||
|
||||
export default function ListPicker() {
|
||||
const [text, setText] = useLocalStorage("list-items", DEFAULT_LIST);
|
||||
const [removeAfterDraw, setRemoveAfterDraw] = useLocalStorage(
|
||||
"list-remove",
|
||||
false,
|
||||
);
|
||||
const [display, setDisplay] = useState<string | null>(null);
|
||||
const [drawing, setDrawing] = useState(false);
|
||||
const [winner, setWinner] = useState<string | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
const lastPaintRef = useRef(0);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { play } = useSound();
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
const items = text
|
||||
.split("\n")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const draw = () => {
|
||||
if (drawing) return;
|
||||
if (items.length < 2) {
|
||||
setError(true);
|
||||
play("tick");
|
||||
gsap.fromTo(
|
||||
textareaRef.current,
|
||||
{ x: -7 },
|
||||
{ x: 0, duration: 0.4, ease: "elastic.out(2.5, 0.35)" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
setError(false);
|
||||
|
||||
const pick = items[Math.floor(Math.random() * items.length)];
|
||||
setDrawing(true);
|
||||
setWinner(null);
|
||||
play("whoosh");
|
||||
|
||||
const dur = reduced ? 0.35 : 2.0;
|
||||
const proxy = { p: 0 };
|
||||
lastPaintRef.current = 0;
|
||||
|
||||
gsap.to(proxy, {
|
||||
p: 1,
|
||||
duration: dur,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
const now = performance.now();
|
||||
const interval = reduced ? 60 : 30 + 260 * proxy.p;
|
||||
if (now - lastPaintRef.current > interval) {
|
||||
lastPaintRef.current = now;
|
||||
setDisplay(items[Math.floor(Math.random() * items.length)]);
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
setDisplay(pick);
|
||||
setWinner(pick);
|
||||
setDrawing(false);
|
||||
play("win");
|
||||
celebrateConfetti();
|
||||
if (removeAfterDraw) {
|
||||
setText(
|
||||
items
|
||||
.filter((item) => item !== pick)
|
||||
.join("\n"),
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[0.9fr_1.1fr]">
|
||||
{/* Liste */}
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
Deine Liste ({items.length})
|
||||
</h3>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={8}
|
||||
placeholder={"Ein Eintrag pro Zeile, z. B.\nAnna\nBen\nClara"}
|
||||
aria-label="Liste der Einträge, ein Eintrag pro Zeile"
|
||||
className={`mt-4 w-full resize-none rounded-xl border bg-surface px-4 py-3 font-mono text-sm leading-relaxed outline-none transition focus:ring-2 ${
|
||||
error
|
||||
? "border-clay/60 focus:border-clay/60 focus:ring-clay/20"
|
||||
: "border-line focus:border-primary/60 focus:ring-primary/20"
|
||||
}`}
|
||||
/>
|
||||
<label className="mt-3 flex cursor-pointer items-center gap-2.5 text-sm font-medium text-ink-soft">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={removeAfterDraw}
|
||||
onChange={(e) => setRemoveAfterDraw(e.target.checked)}
|
||||
className="h-4 w-4 cursor-pointer accent-primary"
|
||||
/>
|
||||
Gezogene Einträge entfernen (ohne Zurücklegen)
|
||||
</label>
|
||||
<p className={`mt-3 text-xs leading-relaxed ${error ? "font-semibold text-clay" : "text-ink-soft"}`}>
|
||||
{error
|
||||
? "Bitte mindestens 2 Einträge eintragen."
|
||||
: "Ideal für Teams, Gruppen-Reihenfolgen oder Gewinnspiele."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Anzeige */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex min-h-[190px] w-full items-center justify-center rounded-[2rem] border border-line bg-cream/70 px-8 py-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{display === null ? (
|
||||
<motion.div
|
||||
key="idle"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="flex flex-col items-center gap-3 text-center"
|
||||
>
|
||||
<ListChecks className="h-8 w-8 text-primary/50" />
|
||||
<p className="font-display text-xl font-medium text-ink-soft">
|
||||
{items.length >= 2
|
||||
? `${items.length} Einträge warten auf die Ziehung.`
|
||||
: "Trage links mindestens 2 Einträge ein."}
|
||||
</p>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.p
|
||||
key={display + (winner ? "-win" : "")}
|
||||
initial={winner ? { scale: 0.6, opacity: 0, y: 14 } : false}
|
||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||
transition={{ type: "spring", stiffness: 340, damping: 20 }}
|
||||
className={`break-words text-center font-display text-5xl font-semibold tracking-tight sm:text-6xl ${
|
||||
winner ? "text-primary" : "text-ink/60"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{display}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={draw}
|
||||
disabled={drawing}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="mt-6 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{drawing ? "Zieht …" : "Zufällig ziehen"}
|
||||
</motion.button>
|
||||
{winner && (
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
{removeAfterDraw
|
||||
? `„${winner}“ wurde von der Liste entfernt.`
|
||||
: "Nochmal ziehen? Einfach erneut klicken."}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
252
components/tools/magic-8-ball.tsx
Normal file
252
components/tools/magic-8-ball.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Pencil, Plus, Sparkles, X } from "lucide-react";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
import { popConfetti } from "@/lib/confetti";
|
||||
import { useSound } from "@/components/providers/sound-provider";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
|
||||
|
||||
const DEFAULT_ANSWERS = [
|
||||
"Ja, definitiv.",
|
||||
"Ohne Zweifel.",
|
||||
"Sehr wahrscheinlich.",
|
||||
"Sieht gut aus.",
|
||||
"Frag später nochmal.",
|
||||
"Kann ich jetzt nicht sagen.",
|
||||
"Konzentrier dich und frag erneut.",
|
||||
"Rechne nicht damit.",
|
||||
"Meine Antwort: Nein.",
|
||||
"Eher nicht.",
|
||||
];
|
||||
|
||||
const MIN_ANSWERS = 2;
|
||||
const MAX_ANSWERS = 20;
|
||||
|
||||
export default function Magic8Ball() {
|
||||
const [answers, setAnswers] = useLocalStorage<string[]>(
|
||||
"ball-answers",
|
||||
DEFAULT_ANSWERS,
|
||||
);
|
||||
const [question, setQuestion] = useState("");
|
||||
const [answer, setAnswer] = useState<string | null>(null);
|
||||
const [shaking, setShaking] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
|
||||
const ballRef = useRef<HTMLDivElement>(null);
|
||||
const { play } = useSound();
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
const validAnswers = answers.map((a) => a.trim()).filter(Boolean);
|
||||
|
||||
const shake = () => {
|
||||
if (shaking || validAnswers.length === 0) return;
|
||||
|
||||
const pick =
|
||||
validAnswers[Math.floor(Math.random() * validAnswers.length)];
|
||||
setShaking(true);
|
||||
setAnswer(null);
|
||||
play("whoosh");
|
||||
|
||||
const reveal = () => {
|
||||
setAnswer(pick);
|
||||
setShaking(false);
|
||||
play("pop");
|
||||
popConfetti();
|
||||
};
|
||||
|
||||
if (reduced) {
|
||||
gsap.delayedCall(0.25, reveal);
|
||||
return;
|
||||
}
|
||||
|
||||
const tl = gsap.timeline({ onComplete: reveal });
|
||||
tl.to(ballRef.current, {
|
||||
x: -18,
|
||||
rotation: -9,
|
||||
duration: 0.08,
|
||||
ease: "power1.inOut",
|
||||
})
|
||||
.to(ballRef.current, {
|
||||
x: 16,
|
||||
rotation: 8,
|
||||
duration: 0.08,
|
||||
ease: "power1.inOut",
|
||||
})
|
||||
.repeat(4)
|
||||
.to(ballRef.current, {
|
||||
x: 0,
|
||||
rotation: 0,
|
||||
duration: 0.22,
|
||||
ease: "power2.out",
|
||||
});
|
||||
};
|
||||
|
||||
const updateAnswer = (i: number, value: string) =>
|
||||
setAnswers((list) => list.map((a, idx) => (idx === i ? value : a)));
|
||||
|
||||
const addAnswer = () => {
|
||||
if (answers.length >= MAX_ANSWERS) return;
|
||||
setAnswers((list) => [...list, ""]);
|
||||
};
|
||||
|
||||
const removeAnswer = (i: number) => {
|
||||
if (answers.length <= MIN_ANSWERS) return;
|
||||
setAnswers((list) => list.filter((_, idx) => idx !== i));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
{/* Bühne */}
|
||||
<div className="flex flex-col items-center">
|
||||
<input
|
||||
value={question}
|
||||
onChange={(e) => setQuestion(e.target.value)}
|
||||
maxLength={80}
|
||||
placeholder="Deine Frage (optional) …"
|
||||
aria-label="Deine Frage an den 8-Ball"
|
||||
className="w-full max-w-sm rounded-full border border-line bg-surface px-5 py-3 text-center text-sm font-medium shadow-soft outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
|
||||
<div ref={ballRef} className="mt-6">
|
||||
<button
|
||||
type="button"
|
||||
onClick={shake}
|
||||
aria-label="8-Ball schütteln"
|
||||
className="ball-body relative flex h-64 w-64 cursor-pointer items-center justify-center rounded-full transition-transform duration-200 hover:scale-[1.03] active:scale-95 sm:h-72 sm:w-72"
|
||||
>
|
||||
<div className="ball-window relative flex h-32 w-32 items-center justify-center overflow-hidden rounded-full sm:h-36 sm:w-36">
|
||||
{/* Prisma-Dreieck */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="absolute inset-4 bg-primary/60"
|
||||
style={{
|
||||
clipPath: "polygon(50% 6%, 94% 90%, 6% 90%)",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="relative z-10 flex h-full w-full items-center justify-center px-7"
|
||||
aria-live="polite"
|
||||
>
|
||||
<AnimatePresence mode="wait">
|
||||
{answer ? (
|
||||
<motion.p
|
||||
key={answer}
|
||||
initial={{ opacity: 0, scale: 0.7, filter: "blur(8px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
exit={{ opacity: 0, filter: "blur(6px)" }}
|
||||
transition={{ duration: 0.5, ease: "easeOut" }}
|
||||
className="text-center font-display text-[13px] font-semibold leading-snug text-[#dde5dd]"
|
||||
>
|
||||
{answer}
|
||||
</motion.p>
|
||||
) : (
|
||||
<motion.p
|
||||
key="eight"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="font-display text-4xl font-semibold text-[#dde5dd]"
|
||||
>
|
||||
8
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={shake}
|
||||
disabled={shaking || validAnswers.length === 0}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="mt-6 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Sparkles className="h-5 w-5" />
|
||||
{shaking ? "Schüttelt …" : "8-Ball schütteln"}
|
||||
</motion.button>
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
{question.trim()
|
||||
? `„${question.trim()}“`
|
||||
: "Stell eine Ja/Nein-Frage – oder lass dich einfach überraschen."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Antworten-Editor */}
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
Antworten ({validAnswers.length})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setEditing((e) => !e)}
|
||||
aria-expanded={editing}
|
||||
className={`inline-flex cursor-pointer items-center gap-1.5 rounded-full px-3.5 py-1.5 text-xs font-semibold transition-colors ${
|
||||
editing
|
||||
? "bg-primary text-white"
|
||||
: "bg-surface text-ink-soft ring-1 ring-line hover:text-primary"
|
||||
}`}
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
{editing ? "Fertig" : "Bearbeiten"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{editing && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: "auto", opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-4 max-h-[260px] space-y-2 overflow-y-auto pr-1">
|
||||
{answers.map((a, i) => (
|
||||
<div key={i} className="flex items-center gap-2">
|
||||
<input
|
||||
value={a}
|
||||
onChange={(e) => updateAnswer(i, e.target.value)}
|
||||
maxLength={60}
|
||||
placeholder={`Antwort ${i + 1}`}
|
||||
aria-label={`Antwort ${i + 1}`}
|
||||
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAnswer(i)}
|
||||
disabled={answers.length <= MIN_ANSWERS}
|
||||
aria-label={`Antwort ${i + 1} entfernen`}
|
||||
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-xl text-ink-soft transition-colors hover:bg-clay-soft hover:text-clay disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addAnswer}
|
||||
disabled={answers.length >= MAX_ANSWERS}
|
||||
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl border-2 border-dashed border-line px-4 py-2.5 text-sm font-semibold text-ink-soft transition-colors hover:border-primary/50 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Antwort hinzufügen
|
||||
</button>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
Der Ball sagt, was du ihm vorgibst – von klassisch bis fies.{" "}
|
||||
{MIN_ANSWERS}–{MAX_ANSWERS} Antworten, automatisch gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
147
components/tools/random-number.tsx
Normal file
147
components/tools/random-number.tsx
Normal file
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Hash } from "lucide-react";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
import { popConfetti } from "@/lib/confetti";
|
||||
import { useSound } from "@/components/providers/sound-provider";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
|
||||
|
||||
const randInt = (lo: number, hi: number) =>
|
||||
lo + Math.floor(Math.random() * (hi - lo + 1));
|
||||
|
||||
export default function RandomNumber() {
|
||||
const [min, setMin] = useLocalStorage("num-min", 1);
|
||||
const [max, setMax] = useLocalStorage("num-max", 100);
|
||||
const [display, setDisplay] = useState<string | null>(null);
|
||||
const [rolling, setRolling] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
|
||||
const lastPaintRef = useRef(0);
|
||||
const { play } = useSound();
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
const roll = () => {
|
||||
if (rolling) return;
|
||||
|
||||
const lo = Math.min(min, max);
|
||||
const hi = Math.max(min, max);
|
||||
const result = randInt(lo, hi);
|
||||
|
||||
setRolling(true);
|
||||
setDone(false);
|
||||
play("whoosh");
|
||||
|
||||
const dur = reduced ? 0.3 : 1.7;
|
||||
const proxy = { p: 0 };
|
||||
lastPaintRef.current = 0;
|
||||
|
||||
gsap.to(proxy, {
|
||||
p: 1,
|
||||
duration: dur,
|
||||
ease: "power2.out",
|
||||
onUpdate: () => {
|
||||
const now = performance.now();
|
||||
// Wechsel-Intervall wächst → Slot-Machine-Effekt
|
||||
const interval = reduced ? 60 : 28 + 240 * proxy.p;
|
||||
if (now - lastPaintRef.current > interval) {
|
||||
lastPaintRef.current = now;
|
||||
setDisplay(String(randInt(lo, hi)));
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
setDisplay(String(result));
|
||||
setRolling(false);
|
||||
setDone(true);
|
||||
play("pop");
|
||||
popConfetti();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[1.15fr_0.85fr]">
|
||||
{/* Anzeige */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex min-h-[190px] items-center justify-center rounded-[2rem] border border-line bg-cream/70 px-10 py-8">
|
||||
<AnimatePresence mode="wait">
|
||||
{display === null ? (
|
||||
<motion.p
|
||||
key="idle"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-center font-display text-xl font-medium text-ink-soft"
|
||||
>
|
||||
Bereit, wenn du es bist.
|
||||
</motion.p>
|
||||
) : (
|
||||
<motion.p
|
||||
key={display + (done ? "-done" : "")}
|
||||
initial={done ? { scale: 1.35, opacity: 0 } : false}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ type: "spring", stiffness: 320, damping: 18 }}
|
||||
className={`tabular font-display text-8xl font-semibold tracking-tight sm:text-9xl ${
|
||||
done ? "text-primary" : "text-ink/70"
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
{display}
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={roll}
|
||||
disabled={rolling}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="mt-6 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Hash className="h-5 w-5" />
|
||||
{rolling ? "Rollt …" : "Zahl generieren"}
|
||||
</motion.button>
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
Zufallszahl zwischen {Math.min(min, max)} und {Math.max(min, max)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Einstellungen */}
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
Zahlenraum
|
||||
</h3>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-ink-soft">
|
||||
Minimum
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={min}
|
||||
onChange={(e) => setMin(Number(e.target.value))}
|
||||
className="tabular w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-ink-soft">
|
||||
Maximum
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
value={max}
|
||||
onChange={(e) => setMax(Number(e.target.value))}
|
||||
className="tabular w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
Perfekt für Lose, Teams oder „Wie viele Liegestütze?“. Reihenfolge
|
||||
von Min & Max ist egal.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
299
components/tools/wheel-of-fortune.tsx
Normal file
299
components/tools/wheel-of-fortune.tsx
Normal file
@@ -0,0 +1,299 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState } from "react";
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { Plus, RotateCcw, Sparkles, X } from "lucide-react";
|
||||
import { gsap } from "@/lib/gsap";
|
||||
import { wheelEase } from "@/lib/motion";
|
||||
import { celebrateConfetti } from "@/lib/confetti";
|
||||
import { useSound } from "@/components/providers/sound-provider";
|
||||
import { useLocalStorage } from "@/hooks/use-local-storage";
|
||||
import { usePrefersReducedMotion } from "@/hooks/use-prefers-reduced-motion";
|
||||
|
||||
const DEFAULT_OPTIONS = ["1", "2", "3", "4", "5", "6"];
|
||||
|
||||
/** Natürliche, gedeckte Farben – weiße Schrift bleibt lesbar. */
|
||||
const PALETTE = [
|
||||
"#33523f",
|
||||
"#a8623f",
|
||||
"#b98a3c",
|
||||
"#5e7a5e",
|
||||
"#6b7f8c",
|
||||
"#8a6b52",
|
||||
"#5f8a80",
|
||||
"#9a5b45",
|
||||
"#4f6b8a",
|
||||
"#8c7a4f",
|
||||
"#5c6e4f",
|
||||
"#3f5e66",
|
||||
];
|
||||
|
||||
const C = 210;
|
||||
const R = 196;
|
||||
const MIN_OPTIONS = 2;
|
||||
const MAX_OPTIONS = 12;
|
||||
|
||||
/** Winkel → Punkt auf dem Kreis (0° = oben, im Uhrzeigersinn). */
|
||||
function polar(angleDeg: number, radius: number): [number, number] {
|
||||
const a = (angleDeg * Math.PI) / 180;
|
||||
return [C + radius * Math.sin(a), C - radius * Math.cos(a)];
|
||||
}
|
||||
|
||||
export default function WheelOfFortune() {
|
||||
const [options, setOptions] = useLocalStorage<string[]>(
|
||||
"wheel-options",
|
||||
DEFAULT_OPTIONS,
|
||||
);
|
||||
const [spinning, setSpinning] = useState(false);
|
||||
const [winner, setWinner] = useState<number | null>(null);
|
||||
|
||||
const wheelRef = useRef<SVGGElement>(null);
|
||||
const pointerRef = useRef<HTMLDivElement>(null);
|
||||
const rotationRef = useRef(0);
|
||||
const lastTickRef = useRef(-1);
|
||||
|
||||
const { play } = useSound();
|
||||
const reduced = usePrefersReducedMotion();
|
||||
|
||||
const labels = options.map((o, i) => o.trim() || `Option ${i + 1}`);
|
||||
const n = labels.length;
|
||||
const seg = 360 / n;
|
||||
|
||||
const spin = () => {
|
||||
if (spinning || n < MIN_OPTIONS) return;
|
||||
|
||||
const winnerIndex = Math.floor(Math.random() * n);
|
||||
setWinner(null);
|
||||
setSpinning(true);
|
||||
play("whoosh");
|
||||
lastTickRef.current = -1;
|
||||
|
||||
// Zufällige Position innerhalb des Gewinner-Segments
|
||||
const offset = 0.18 + Math.random() * 0.64;
|
||||
const targetAngle = winnerIndex * seg + seg * offset;
|
||||
|
||||
const current = rotationRef.current;
|
||||
const currentMod = ((current % 360) + 360) % 360;
|
||||
const delta =
|
||||
(((360 - targetAngle - currentMod) % 360) + 360) % 360;
|
||||
const target = current + 360 * 6 + delta;
|
||||
|
||||
const dur = reduced ? 0.5 : 5.6;
|
||||
|
||||
gsap.to(wheelRef.current, {
|
||||
rotation: target,
|
||||
svgOrigin: `${C} ${C}`,
|
||||
duration: dur,
|
||||
ease: reduced ? "power1.out" : wheelEase,
|
||||
onUpdate: () => {
|
||||
if (reduced) return;
|
||||
const rot = Number(gsap.getProperty(wheelRef.current, "rotation"));
|
||||
const m = ((rot % 360) + 360) % 360;
|
||||
const idx = Math.floor(((360 - m) % 360) / seg);
|
||||
if (idx !== lastTickRef.current) {
|
||||
lastTickRef.current = idx;
|
||||
play("tick");
|
||||
gsap.fromTo(
|
||||
pointerRef.current,
|
||||
{ rotation: -16 },
|
||||
{
|
||||
rotation: 0,
|
||||
duration: 0.22,
|
||||
ease: "elastic.out(2.2, 0.4)",
|
||||
transformOrigin: "50% 12%",
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
onComplete: () => {
|
||||
rotationRef.current = target;
|
||||
setSpinning(false);
|
||||
setWinner(winnerIndex);
|
||||
play("win");
|
||||
celebrateConfetti();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const updateOption = (i: number, value: string) => {
|
||||
setOptions((opts) => opts.map((o, idx) => (idx === i ? value : o)));
|
||||
setWinner(null);
|
||||
};
|
||||
|
||||
const addOption = () => {
|
||||
if (n >= MAX_OPTIONS) return;
|
||||
setOptions((opts) => [...opts, ""]);
|
||||
setWinner(null);
|
||||
};
|
||||
|
||||
const removeOption = (i: number) => {
|
||||
if (n <= MIN_OPTIONS) return;
|
||||
setOptions((opts) => opts.filter((_, idx) => idx !== i));
|
||||
setWinner(null);
|
||||
};
|
||||
|
||||
const fontSize = n <= 6 ? 15 : n <= 9 ? 13 : 11.5;
|
||||
|
||||
return (
|
||||
<div className="grid items-center gap-10 lg:grid-cols-[1.1fr_0.9fr]">
|
||||
{/* Rad */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="relative w-full max-w-[400px]">
|
||||
{/* Zeiger */}
|
||||
<div
|
||||
ref={pointerRef}
|
||||
aria-hidden
|
||||
className="absolute -top-1.5 left-1/2 z-10 h-9 w-7 -translate-x-1/2 bg-primary shadow-[0_6px_16px_rgb(108_92_231/0.5)]"
|
||||
style={{
|
||||
clipPath: "polygon(50% 100%, 0 0, 100% 0)",
|
||||
}}
|
||||
/>
|
||||
<svg viewBox="0 0 420 420" className="w-full drop-shadow-[0_20px_36px_rgb(23_23_28/0.14)]">
|
||||
{/* äußerer Ring */}
|
||||
<circle cx={C} cy={C} r={206} fill="#ffffff" stroke="#e9e9e4" />
|
||||
<g ref={wheelRef}>
|
||||
{labels.map((label, i) => {
|
||||
const [x0, y0] = polar(i * seg, R);
|
||||
const [x1, y1] = polar((i + 1) * seg, R);
|
||||
const large = seg > 180 ? 1 : 0;
|
||||
const isWinner = winner === i;
|
||||
const dimmed = winner !== null && !isWinner;
|
||||
return (
|
||||
<g key={i}>
|
||||
<path
|
||||
d={`M ${C} ${C} L ${x0} ${y0} A ${R} ${R} 0 ${large} 1 ${x1} ${y1} Z`}
|
||||
fill={PALETTE[i % PALETTE.length]}
|
||||
stroke="#ffffff"
|
||||
strokeWidth={isWinner ? 3.5 : 1.5}
|
||||
className="transition-opacity duration-500"
|
||||
style={{
|
||||
opacity: dimmed ? 0.4 : 1,
|
||||
filter: isWinner
|
||||
? "drop-shadow(0 0 14px rgb(242 179 61 / 0.9))"
|
||||
: undefined,
|
||||
}}
|
||||
/>
|
||||
<g transform={`rotate(${i * seg + seg / 2} ${C} ${C})`}>
|
||||
<text
|
||||
x={C}
|
||||
y={C - 118}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="central"
|
||||
transform={`rotate(90 ${C} ${C - 118})`}
|
||||
fill="#ffffff"
|
||||
fontSize={fontSize}
|
||||
fontWeight={600}
|
||||
fontFamily="var(--font-display)"
|
||||
style={{
|
||||
textShadow: "0 1px 3px rgb(0 0 0 / 0.35)",
|
||||
opacity: dimmed ? 0.4 : 1,
|
||||
}}
|
||||
className="transition-opacity duration-500"
|
||||
>
|
||||
{label.length > 16 ? `${label.slice(0, 15)}…` : label}
|
||||
</text>
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
{/* Nabe */}
|
||||
<circle cx={C} cy={C} r={30} fill="#ffffff" stroke="#e9e9e4" strokeWidth={2} />
|
||||
<circle cx={C} cy={C} r={9} fill="#6c5ce7" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 h-14" aria-live="polite">
|
||||
<AnimatePresence mode="wait">
|
||||
{winner !== null && (
|
||||
<motion.p
|
||||
key={`${winner}-${labels[winner]}`}
|
||||
initial={{ opacity: 0, scale: 0.6, y: 12 }}
|
||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||
exit={{ opacity: 0, scale: 0.8 }}
|
||||
transition={{ type: "spring", stiffness: 380, damping: 22 }}
|
||||
className="flex items-center gap-2 font-display text-3xl font-semibold tracking-tight"
|
||||
>
|
||||
<Sparkles className="h-6 w-6 text-clay" />
|
||||
<span className="text-primary">{labels[winner]}!</span>
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
<motion.button
|
||||
type="button"
|
||||
onClick={spin}
|
||||
disabled={spinning || n < MIN_OPTIONS}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="mt-2 inline-flex cursor-pointer items-center gap-2 rounded-full bg-primary px-9 py-3.5 text-base font-semibold text-white shadow-cta transition-colors duration-200 hover:bg-primary-strong disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<RotateCcw className="h-5 w-5" />
|
||||
{spinning ? "Dreht sich …" : "Drehen!"}
|
||||
</motion.button>
|
||||
</div>
|
||||
|
||||
{/* Optionen-Editor */}
|
||||
<div className="rounded-3xl border border-line bg-cream/70 p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-[0.16em] text-ink-soft">
|
||||
Optionen ({n}/{MAX_OPTIONS})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOptions(DEFAULT_OPTIONS);
|
||||
setWinner(null);
|
||||
}}
|
||||
className="cursor-pointer text-xs font-medium text-ink-soft underline-offset-2 transition-colors hover:text-primary hover:underline"
|
||||
>
|
||||
Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-[300px] space-y-2 overflow-y-auto pr-1">
|
||||
{options.map((option, i) => (
|
||||
<div key={i} className="flex items-center gap-2.5">
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-3.5 w-3.5 shrink-0 rounded-full ring-2 ring-white"
|
||||
style={{ background: PALETTE[i % PALETTE.length] }}
|
||||
/>
|
||||
<input
|
||||
value={option}
|
||||
onChange={(e) => updateOption(i, e.target.value)}
|
||||
maxLength={24}
|
||||
placeholder={`Option ${i + 1}`}
|
||||
aria-label={`Option ${i + 1}`}
|
||||
className="w-full rounded-xl border border-line bg-surface px-3.5 py-2.5 text-sm font-medium outline-none transition focus:border-primary/60 focus:ring-2 focus:ring-primary/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeOption(i)}
|
||||
disabled={n <= MIN_OPTIONS}
|
||||
aria-label={`Option ${i + 1} entfernen`}
|
||||
className="flex h-9 w-9 shrink-0 cursor-pointer items-center justify-center rounded-xl text-ink-soft transition-colors hover:bg-clay-soft hover:text-clay disabled:cursor-not-allowed disabled:opacity-30"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addOption}
|
||||
disabled={n >= MAX_OPTIONS}
|
||||
className="mt-3 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-xl border-2 border-dashed border-line px-4 py-2.5 text-sm font-semibold text-ink-soft transition-colors hover:border-primary/50 hover:text-primary disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
Option hinzufügen
|
||||
</button>
|
||||
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
|
||||
{MIN_OPTIONS}–{MAX_OPTIONS} Optionen. Deine Liste bleibt automatisch
|
||||
gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
28
docker-compose.yml
Normal file
28
docker-compose.yml
Normal file
@@ -0,0 +1,28 @@
|
||||
# Startet nur die Entscheidomat-App (entscheidomat.com).
|
||||
# TLS und Domain-Routing übernimmt der Reverse Proxy (Caddy) auf dem
|
||||
# Produktionsserver — der ist NICHT Teil dieses Setups. Caddy muss lediglich
|
||||
# auf den unten published Port (Default 3000) dieses Containers zeigen, z. B.:
|
||||
#
|
||||
# entscheidomat.com {
|
||||
# reverse_proxy localhost:3000
|
||||
# }
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- path: .env
|
||||
required: false
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
ports:
|
||||
- "${APP_PORT:-3000}:3000"
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://localhost:3000/"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
18
eslint.config.mjs
Normal file
18
eslint.config.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
68
hooks/use-local-storage.ts
Normal file
68
hooks/use-local-storage.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useSyncExternalStore } from "react";
|
||||
|
||||
type Listener = () => void;
|
||||
|
||||
const listeners = new Map<string, Set<Listener>>();
|
||||
const cache = new Map<string, unknown>();
|
||||
|
||||
function readFromStorage<T>(key: string, initial: T): T {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
return raw != null ? (JSON.parse(raw) as T) : initial;
|
||||
} catch {
|
||||
return initial;
|
||||
}
|
||||
}
|
||||
|
||||
function notify(key: string) {
|
||||
listeners.get(key)?.forEach((l) => l());
|
||||
}
|
||||
|
||||
/**
|
||||
* State, der in localStorage persistiert wird.
|
||||
* SSR-sicher über useSyncExternalStore: liefert serverseitig `initial`,
|
||||
* hydriert clientseitig aus localStorage.
|
||||
*/
|
||||
export function useLocalStorage<T>(key: string, initial: T) {
|
||||
const subscribe = useCallback(
|
||||
(callback: Listener) => {
|
||||
let set = listeners.get(key);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(key, set);
|
||||
}
|
||||
set.add(callback);
|
||||
return () => set!.delete(callback);
|
||||
},
|
||||
[key],
|
||||
);
|
||||
|
||||
const getSnapshot = useCallback(() => {
|
||||
if (!cache.has(key)) cache.set(key, readFromStorage(key, initial));
|
||||
return cache.get(key) as T;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `initial` is only used as a one-time fallback
|
||||
}, [key]);
|
||||
|
||||
const getServerSnapshot = useCallback(() => initial, [initial]);
|
||||
|
||||
const value = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const setValue = useCallback(
|
||||
(next: T | ((prev: T) => T)) => {
|
||||
const prev = cache.has(key) ? (cache.get(key) as T) : readFromStorage(key, initial);
|
||||
const resolved = typeof next === "function" ? (next as (p: T) => T)(prev) : next;
|
||||
cache.set(key, resolved);
|
||||
try {
|
||||
window.localStorage.setItem(key, JSON.stringify(resolved));
|
||||
} catch {
|
||||
// Speicher voll o.ä. – egal
|
||||
}
|
||||
notify(key);
|
||||
},
|
||||
[key, initial],
|
||||
);
|
||||
|
||||
return [value, setValue] as const;
|
||||
}
|
||||
24
hooks/use-prefers-reduced-motion.ts
Normal file
24
hooks/use-prefers-reduced-motion.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useSyncExternalStore } from "react";
|
||||
|
||||
const QUERY = "(prefers-reduced-motion: reduce)";
|
||||
|
||||
function subscribe(callback: () => void) {
|
||||
const mq = window.matchMedia(QUERY);
|
||||
mq.addEventListener("change", callback);
|
||||
return () => mq.removeEventListener("change", callback);
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return window.matchMedia(QUERY).matches;
|
||||
}
|
||||
|
||||
function getServerSnapshot() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** true, wenn der Nutzer reduzierte Bewegung wünscht (A11y-Gate). */
|
||||
export function usePrefersReducedMotion() {
|
||||
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
}
|
||||
53
lib/confetti.ts
Normal file
53
lib/confetti.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
export const CONFETTI_COLORS = [
|
||||
"#2f4a3a",
|
||||
"#b4633f",
|
||||
"#d9b36a",
|
||||
"#7c9077",
|
||||
"#ffffff",
|
||||
];
|
||||
|
||||
/** Dezenter Burst bei Ergebnissen. */
|
||||
export function popConfetti() {
|
||||
confetti({
|
||||
particleCount: 70,
|
||||
spread: 60,
|
||||
startVelocity: 32,
|
||||
scalar: 0.9,
|
||||
origin: { y: 0.55 },
|
||||
colors: CONFETTI_COLORS,
|
||||
disableForReducedMotion: true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Großer Gewinner-Regen mit zwei Seitenkanonen. */
|
||||
export function celebrateConfetti() {
|
||||
const defaults: confetti.Options = {
|
||||
spread: 70,
|
||||
startVelocity: 42,
|
||||
colors: CONFETTI_COLORS,
|
||||
disableForReducedMotion: true,
|
||||
};
|
||||
confetti({ ...defaults, particleCount: 120, origin: { y: 0.55 } });
|
||||
setTimeout(
|
||||
() =>
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 60,
|
||||
angle: 60,
|
||||
origin: { x: 0, y: 0.7 },
|
||||
}),
|
||||
150,
|
||||
);
|
||||
setTimeout(
|
||||
() =>
|
||||
confetti({
|
||||
...defaults,
|
||||
particleCount: 60,
|
||||
angle: 120,
|
||||
origin: { x: 1, y: 0.7 },
|
||||
}),
|
||||
250,
|
||||
);
|
||||
}
|
||||
8
lib/gsap.ts
Normal file
8
lib/gsap.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { gsap } from "gsap";
|
||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
gsap.registerPlugin(ScrollTrigger);
|
||||
}
|
||||
|
||||
export { gsap, ScrollTrigger };
|
||||
17
lib/motion.ts
Normal file
17
lib/motion.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Zentrale Motion-Tokens (aus dem Landing-Page-Animations-Skill).
|
||||
* Micro: 120–220ms · UI: 300–600ms · Story: 800–1400ms
|
||||
*/
|
||||
export const DUR = {
|
||||
micro: 0.18,
|
||||
ui: 0.45,
|
||||
story: 1.1,
|
||||
} as const;
|
||||
|
||||
export const EASE = "power3.out";
|
||||
|
||||
/** Langer, physikalisch wirkender Auslauf (Glücksrad). */
|
||||
export const wheelEase = (t: number) => 1 - Math.pow(1 - t, 5);
|
||||
|
||||
/** Münz-Auslauf: dramatisches Slow-down am Ende. */
|
||||
export const coinEase = (t: number) => 1 - Math.pow(1 - t, 4);
|
||||
78
lib/og-image.tsx
Normal file
78
lib/og-image.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
export const ogImageSize = { width: 1200, height: 630 };
|
||||
|
||||
type OgImageContentProps = {
|
||||
visualSrc: string;
|
||||
};
|
||||
|
||||
/** Gemeinsames Layout für Open-Graph- und X/Twitter-Vorschauen. */
|
||||
export function OgImageContent({ visualSrc }: OgImageContentProps) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
backgroundColor: "#f7f4ed",
|
||||
fontFamily: "Arial, sans-serif",
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- next/image is unsupported inside next/og's ImageResponse (Satori) rendering */}
|
||||
<img
|
||||
src={visualSrc}
|
||||
alt=""
|
||||
width={1200}
|
||||
height={630}
|
||||
style={{ display: "flex", position: "absolute", inset: 0, width: "100%", height: "100%" }}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
position: "absolute",
|
||||
inset: 0,
|
||||
width: 705,
|
||||
backgroundImage: "linear-gradient(90deg, rgba(247,244,237,0.98) 0%, rgba(247,244,237,0.92) 78%, rgba(247,244,237,0) 100%)",
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
position: "relative",
|
||||
flexDirection: "column",
|
||||
width: 650,
|
||||
padding: "63px 0 58px 76px",
|
||||
color: "#111412",
|
||||
}}
|
||||
>
|
||||
<div style={{ display: "flex", alignItems: "center", fontSize: 40, fontWeight: 700, letterSpacing: -1.5 }}>
|
||||
<span>Entscheid</span>
|
||||
<span style={{ color: "#0d7a57", fontFamily: "Georgia, serif", fontStyle: "italic", fontWeight: 400 }}>omat</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", flexDirection: "column", marginTop: 78, fontSize: 66, lineHeight: 1.04, fontWeight: 700, letterSpacing: -3.2 }}>
|
||||
<span>Lass den</span>
|
||||
<span>
|
||||
<span style={{ color: "#0d7a57", fontFamily: "Georgia, serif", fontStyle: "italic", fontWeight: 400, marginRight: 16 }}>Zufall</span>entscheiden.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", marginTop: 27, color: "#4c5c54", fontSize: 24, lineHeight: 1.35 }}>
|
||||
Münze werfen, Glücksrad drehen, würfeln & mehr.
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", marginTop: "auto", alignItems: "center" }}>
|
||||
<div style={{ display: "flex", padding: "12px 20px", borderRadius: 999, backgroundColor: "#0d7a57", color: "#ffffff", fontSize: 18, fontWeight: 700, letterSpacing: 1.2 }}>
|
||||
7 TOOLS · KOSTENLOS · OHNE ANMELDUNG
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", position: "absolute", right: 540, bottom: 34, color: "#405249", fontSize: 16, fontWeight: 700, letterSpacing: 0.4 }}>
|
||||
entscheidomat.com
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
lib/sounds.ts
Normal file
122
lib/sounds.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Prozedurale Sound-Engine auf Basis der WebAudio API.
|
||||
* Keine Audio-Dateien nötig – alles wird live synthetisiert.
|
||||
*/
|
||||
|
||||
export type SoundName = "tick" | "coin" | "dice" | "pop" | "win" | "whoosh";
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
|
||||
function ac(): AudioContext | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
if (!ctx) {
|
||||
const AC =
|
||||
window.AudioContext ||
|
||||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
|
||||
.webkitAudioContext;
|
||||
if (!AC) return null;
|
||||
ctx = new AC();
|
||||
}
|
||||
if (ctx.state === "suspended") {
|
||||
ctx.resume().catch(() => {});
|
||||
}
|
||||
return ctx;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function tone(
|
||||
c: AudioContext,
|
||||
freq: number,
|
||||
type: OscillatorType,
|
||||
dur: number,
|
||||
peak = 0.2,
|
||||
when = 0,
|
||||
slideTo?: number,
|
||||
) {
|
||||
const t0 = c.currentTime + when;
|
||||
const osc = c.createOscillator();
|
||||
const gain = c.createGain();
|
||||
osc.type = type;
|
||||
osc.frequency.setValueAtTime(freq, t0);
|
||||
if (slideTo) {
|
||||
osc.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur);
|
||||
}
|
||||
gain.gain.setValueAtTime(0, t0);
|
||||
gain.gain.linearRampToValueAtTime(peak, t0 + 0.008);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
osc.connect(gain).connect(c.destination);
|
||||
osc.start(t0);
|
||||
osc.stop(t0 + dur + 0.05);
|
||||
}
|
||||
|
||||
function noiseBurst(
|
||||
c: AudioContext,
|
||||
dur: number,
|
||||
peak: number,
|
||||
filterFreq: number,
|
||||
when = 0,
|
||||
sweepTo?: number,
|
||||
) {
|
||||
const t0 = c.currentTime + when;
|
||||
const len = Math.max(1, Math.floor(c.sampleRate * dur));
|
||||
const buffer = c.createBuffer(1, len, c.sampleRate);
|
||||
const data = buffer.getChannelData(0);
|
||||
for (let i = 0; i < len; i++) data[i] = Math.random() * 2 - 1;
|
||||
|
||||
const src = c.createBufferSource();
|
||||
src.buffer = buffer;
|
||||
const filter = c.createBiquadFilter();
|
||||
filter.type = "bandpass";
|
||||
filter.frequency.setValueAtTime(filterFreq, t0);
|
||||
filter.Q.value = 1.1;
|
||||
if (sweepTo) {
|
||||
filter.frequency.exponentialRampToValueAtTime(sweepTo, t0 + dur);
|
||||
}
|
||||
const gain = c.createGain();
|
||||
gain.gain.setValueAtTime(0, t0);
|
||||
gain.gain.linearRampToValueAtTime(peak, t0 + 0.01);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||
src.connect(filter).connect(gain).connect(c.destination);
|
||||
src.start(t0);
|
||||
}
|
||||
|
||||
export function playSound(name: SoundName) {
|
||||
const c = ac();
|
||||
if (!c) return;
|
||||
try {
|
||||
switch (name) {
|
||||
case "tick":
|
||||
tone(c, 1900, "square", 0.035, 0.07);
|
||||
break;
|
||||
case "coin":
|
||||
// Metallischer Doppelton + Glitzer
|
||||
tone(c, 2093, "triangle", 0.55, 0.16);
|
||||
tone(c, 2637, "triangle", 0.45, 0.1, 0.012);
|
||||
tone(c, 3520, "sine", 0.3, 0.05, 0.02);
|
||||
break;
|
||||
case "dice":
|
||||
// Prasselndes Klackern
|
||||
[0, 0.09, 0.21, 0.31].forEach((w, i) =>
|
||||
noiseBurst(c, 0.05, 0.2 - i * 0.03, 1600 + Math.random() * 1000, w),
|
||||
);
|
||||
break;
|
||||
case "pop":
|
||||
tone(c, 420, "sine", 0.14, 0.22, 0, 880);
|
||||
break;
|
||||
case "whoosh":
|
||||
noiseBurst(c, 0.55, 0.1, 500, 0, 2600);
|
||||
break;
|
||||
case "win":
|
||||
// Kleine Fanfare C5–E5–G5–C6
|
||||
[523.25, 659.25, 783.99, 1046.5].forEach((f, i) =>
|
||||
tone(c, f, "triangle", 0.4, 0.13, i * 0.09),
|
||||
);
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Sound ist Dekoration – niemals crashen
|
||||
}
|
||||
}
|
||||
10
lib/tool-pages.ts
Normal file
10
lib/tool-pages.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/** Dedizierte SEO-Landingpages der Tools, für konsistentes internes Linking. */
|
||||
export const TOOL_PAGES = [
|
||||
{ slug: "ja-nein-generator", label: "Ja-Nein-Generator", lastModified: "2026-07-23" },
|
||||
{ slug: "gluecksrad", label: "Glücksrad", lastModified: "2026-07-23" },
|
||||
{ slug: "wuerfel-online", label: "Würfel Online", lastModified: "2026-07-23" },
|
||||
{ slug: "namen-auslosen", label: "Namen-Auslosung", lastModified: "2026-07-23" },
|
||||
{ slug: "muenze-werfen", label: "Münze werfen", lastModified: "2026-07-23" },
|
||||
{ slug: "zufallszahl-generator", label: "Zufallszahl-Generator", lastModified: "2026-07-23" },
|
||||
{ slug: "magic-8-ball", label: "Magic 8-Ball", lastModified: "2026-07-23" },
|
||||
] as const;
|
||||
7
next.config.ts
Normal file
7
next.config.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
BIN
og-image-generated-preview.png
Normal file
BIN
og-image-generated-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 872 KiB |
BIN
og-preview.png
Normal file
BIN
og-preview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 110 KiB |
6797
package-lock.json
generated
Normal file
6797
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
package.json
Normal file
31
package.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "webseite",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"framer-motion": "^12.42.2",
|
||||
"gsap": "^3.15.0",
|
||||
"lucide-react": "^1.25.0",
|
||||
"next": "16.2.11",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.2.11",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
postcss.config.mjs
Normal file
7
postcss.config.mjs
Normal file
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
1
public/googleccd5315437d68a49.html
Normal file
1
public/googleccd5315437d68a49.html
Normal file
@@ -0,0 +1 @@
|
||||
google-site-verification: googleccd5315437d68a49.html
|
||||
9
public/images/entscheidomat-icon-option-2.svg
Normal file
9
public/images/entscheidomat-icon-option-2.svg
Normal file
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#0d7a57"/>
|
||||
<path d="M143 168h226c18 0 33 15 33 33v110c0 18-15 33-33 33H143c-18 0-33-15-33-33V201c0-18 15-33 33-33Z" fill="#faf8f3"/>
|
||||
<rect x="142" y="200" width="94" height="112" rx="20" fill="#dcefe6"/>
|
||||
<rect x="276" y="200" width="94" height="112" rx="20" fill="#0d7a57"/>
|
||||
<path d="m300 255 17 17 31-37" fill="none" stroke="#faf8f3" stroke-linecap="round" stroke-linejoin="round" stroke-width="14"/>
|
||||
<path d="M256 344v36" fill="none" stroke="#faf8f3" stroke-linecap="round" stroke-width="16"/>
|
||||
<circle cx="256" cy="408" r="25" fill="#8bb79d"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 676 B |
BIN
public/images/og-decision-tools.jpg
Normal file
BIN
public/images/og-decision-tools.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 107 KiB |
BIN
public/images/og-decision-tools.png
Normal file
BIN
public/images/og-decision-tools.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
22
public/llms.txt
Normal file
22
public/llms.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
# Entscheidomat
|
||||
|
||||
> Entscheidomat ist ein kostenloser Zufallsgenerator und eine digitale Entscheidungshilfe. Sieben Tools – Ja-Nein-Generator, Münze, Glücksrad, Würfel, Zufallszahl, Magic 8-Ball und Listen-Auslosung – laufen direkt im Browser, ohne Anmeldung und kostenlos. Zielgruppe: Alltagsentscheidungen für Einzelpersonen und Gruppen (z. B. Restaurantwahl, Teameinteilung, spielerische Fragen). Nicht gedacht für wichtige persönliche, finanzielle, rechtliche oder gesundheitliche Entscheidungen.
|
||||
|
||||
## Tools
|
||||
|
||||
- [Ja-Nein-Generator](https://entscheidomat.com/ja-nein-generator): Beantwortet eine Frage zufällig mit Ja oder Nein.
|
||||
- [Glücksrad](https://entscheidomat.com/gluecksrad): Digitales Entscheidungsrad mit bis zu 12 frei einstellbaren Optionen.
|
||||
- [Würfel Online](https://entscheidomat.com/wuerfel-online): Virtueller 6-seitiger Würfel mit Zahlen oder eigenen Texten.
|
||||
- [Namen-Auslosung](https://entscheidomat.com/namen-auslosen): Zieht zufällig einen Eintrag aus einer eigenen Liste, wahlweise ohne Zurücklegen.
|
||||
- [Münze werfen](https://entscheidomat.com/muenze-werfen): Münzwurf mit frei beschriftbaren Seiten (Standard: Kopf/Zahl).
|
||||
- [Zufallszahl-Generator](https://entscheidomat.com/zufallszahl-generator): Erzeugt eine Zufallszahl in einem frei wählbaren Bereich.
|
||||
- [Magic 8-Ball](https://entscheidomat.com/magic-8-ball): Zufällige Antwort aus einer anpassbaren Antwortliste (2–20 Antworten).
|
||||
|
||||
## Rechtliches
|
||||
|
||||
- [Impressum](https://entscheidomat.com/impressum)
|
||||
- [Datenschutzerklärung](https://entscheidomat.com/datenschutz)
|
||||
|
||||
## Kontakt
|
||||
|
||||
info@entscheidomat.com
|
||||
34
tsconfig.json
Normal file
34
tsconfig.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user