Initial commit: ToDo Kids v1.0.0
Some checks failed
🚀 Continuous Integration / 🔧 Backend Tests (18.x) (push) Has been cancelled
🚀 Continuous Integration / 🔧 Backend Tests (20.x) (push) Has been cancelled
🚀 Continuous Integration / 🎨 Frontend Tests (18.x) (push) Has been cancelled
🚀 Continuous Integration / 🎨 Frontend Tests (20.x) (push) Has been cancelled
🚀 Continuous Integration / 🔍 Code Quality (push) Has been cancelled
🚀 Continuous Integration / 🔒 Security Checks (push) Has been cancelled
🚀 Continuous Integration / 🎨 Theme Tests (push) Has been cancelled
🚀 Continuous Integration / ♿ Accessibility Tests (push) Has been cancelled
🚀 Continuous Integration / 📱 Cross-Browser Tests (push) Has been cancelled
🚀 Continuous Integration / 🏗️ Build Tests (push) Has been cancelled
🚀 Continuous Integration / 📊 Performance Tests (push) Has been cancelled
🚀 Continuous Integration / 🎯 Integration Tests (push) Has been cancelled
🚀 Continuous Integration / ✅ All Tests Passed (push) Has been cancelled

This commit is contained in:
Michi 2025-08-04 15:46:08 +02:00
commit 0ebe7fa13d
64 changed files with 36748 additions and 0 deletions

108
.gitattributes vendored Normal file
View File

@ -0,0 +1,108 @@
# Auto detect text files and perform LF normalization
* text=auto
# Explicitly declare text files you want to always be normalized and converted
# to native line endings on checkout.
*.js text
*.jsx text
*.ts text
*.tsx text
*.json text
*.css text
*.scss text
*.sass text
*.html text
*.htm text
*.xml text
*.md text
*.txt text
*.yml text
*.yaml text
*.env text
*.gitignore text
*.gitattributes text
# Declare files that will always have CRLF line endings on checkout.
*.bat text eol=crlf
*.cmd text eol=crlf
# Declare files that will always have LF line endings on checkout.
*.sh text eol=lf
Makefile text eol=lf
# Denote all files that are truly binary and should not be modified.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.mov binary
*.mp4 binary
*.mp3 binary
*.flv binary
*.fla binary
*.swf binary
*.gz binary
*.zip binary
*.7z binary
*.ttf binary
*.eot binary
*.woff binary
*.woff2 binary
*.pyc binary
*.pdf binary
*.ez binary
*.bz2 binary
*.swp binary
*.jar binary
*.class binary
*.tar binary
*.tar.gz binary
*.exe binary
*.dll binary
*.so binary
*.dylib binary
# Language-specific settings
*.js linguist-language=JavaScript
*.jsx linguist-language=JavaScript
*.ts linguist-language=TypeScript
*.tsx linguist-language=TypeScript
*.css linguist-language=CSS
*.scss linguist-language=SCSS
*.sass linguist-language=Sass
*.html linguist-language=HTML
*.md linguist-language=Markdown
# Exclude files from language statistics
/client/build/* linguist-generated=true
/client/public/* linguist-vendored=true
/node_modules/* linguist-vendored=true
/client/node_modules/* linguist-vendored=true
package-lock.json linguist-generated=true
/client/package-lock.json linguist-generated=true
yarn.lock linguist-generated=true
/client/yarn.lock linguist-generated=true
# Documentation
*.md linguist-documentation=true
CONTRIBUTING.md linguist-documentation=true
CHANGELOG.md linguist-documentation=true
DEPLOYMENT.md linguist-documentation=true
README.md linguist-documentation=true
LICENSE linguist-documentation=true
# Configuration files
*.json linguist-language=JSON
*.yml linguist-language=YAML
*.yaml linguist-language=YAML
.gitignore linguist-language=gitignore
.gitattributes linguist-language=gitattributes
.env* linguist-language=Shell
# Merge strategies
*.json merge=ours
package-lock.json merge=ours
/client/package-lock.json merge=ours
yarn.lock merge=ours
/client/yarn.lock merge=ours

70
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,70 @@
---
name: 🐛 Bug Report
about: Erstelle einen Bericht über einen Fehler
title: '[BUG] '
labels: ['bug', 'needs-triage']
assignees: ''
---
## 🐛 Beschreibung des Bugs
Eine klare und präzise Beschreibung des Problems.
## 🔄 Schritte zur Reproduktion
Schritte, um das Verhalten zu reproduzieren:
1. Gehe zu '...'
2. Klicke auf '....'
3. Scrolle nach unten zu '....'
4. Siehe Fehler
## ✅ Erwartetes Verhalten
Eine klare und präzise Beschreibung dessen, was du erwartet hast.
## 📱 Screenshots
Falls zutreffend, füge Screenshots hinzu, um das Problem zu erklären.
## 🖥️ System-Informationen
**Desktop:**
- OS: [z.B. Windows 11, macOS 13, Ubuntu 22.04]
- Browser: [z.B. Chrome 120, Firefox 121, Safari 17]
- Version: [z.B. 1.0.0]
**Smartphone:**
- Device: [z.B. iPhone 15, Samsung Galaxy S23]
- OS: [z.B. iOS 17.1, Android 14]
- Browser: [z.B. Safari, Chrome]
- Version: [z.B. 1.0.0]
## 🎨 Theme
Welches Theme war aktiv, als der Fehler auftrat?
- [ ] 🌈 Buntes Theme
- [ ] 🌲 Wald-Theme
- [ ] 🚀 Weltraum-Theme
- [ ] 🌊 Ozean-Theme
- [ ] 🌸 Natur-Theme
## 👤 Benutzer-Kontext
- [ ] Eltern-Account
- [ ] Kind-Account
- [ ] Gast-Modus
## 📝 Zusätzlicher Kontext
Füge hier weitere Informationen über das Problem hinzu.
## 🔍 Browser-Console
Falls vorhanden, füge relevante Console-Logs hinzu:
```
// Console-Logs hier einfügen
```
## ⚡ Priorität
Wie dringend ist dieser Bug?
- [ ] 🔴 Kritisch (App funktioniert nicht)
- [ ] 🟠 Hoch (Wichtige Funktion betroffen)
- [ ] 🟡 Mittel (Störend, aber Workaround möglich)
- [ ] 🟢 Niedrig (Kosmetisches Problem)
## 🤝 Bereitschaft zur Hilfe
- [ ] Ich bin bereit, bei der Lösung zu helfen
- [ ] Ich kann weitere Informationen bereitstellen
- [ ] Ich kann das Problem testen, wenn es behoben ist

View File

@ -0,0 +1,167 @@
---
name: ✨ Feature Request
about: Schlage eine neue Funktion oder Verbesserung vor
title: '[FEATURE] '
labels: ['enhancement', 'needs-discussion']
assignees: ''
---
## 🎯 Feature-Beschreibung
Eine klare und präzise Beschreibung der gewünschten Funktion.
## 🤔 Problem/Motivation
Beschreibe das Problem, das diese Funktion lösen würde, oder die Motivation dahinter.
**Beispiel:** "Ich bin frustriert, wenn [...] passiert, weil [...]"
## 💡 Vorgeschlagene Lösung
Eine klare und präzise Beschreibung dessen, was du dir wünschst.
## 🔄 Alternativen
Beschreibe alternative Lösungen oder Features, die du in Betracht gezogen hast.
## 👨‍👩‍👧‍👦 Zielgruppe
Wer würde von dieser Funktion profitieren?
- [ ] 👨‍👩‍👦 Eltern
- [ ] 🧒 Kinder (3-7 Jahre)
- [ ] 👦👧 Kinder (8-12 Jahre)
- [ ] 👨‍👩‍👧‍👦 Ganze Familie
- [ ] 👩‍🏫 Lehrer/Erzieher
- [ ] 🔧 Entwickler
## 🎨 UI/UX Überlegungen
Wie sollte diese Funktion aussehen und sich verhalten?
### Mockups/Wireframes
<!-- Füge hier Bilder, Skizzen oder Links zu Mockups ein -->
### Interaktion
- Wie sollten Benutzer mit der Funktion interagieren?
- Welche Animationen oder Übergänge wären sinnvoll?
- Sollte es Theme-spezifische Anpassungen geben?
## 🛠️ Technische Überlegungen
### Frontend
- [ ] Neue React-Komponenten erforderlich
- [ ] Material-UI Komponenten verwenden
- [ ] Neue Animationen/Effekte
- [ ] Theme-System Erweiterungen
### Backend
- [ ] Neue API-Endpunkte
- [ ] Datenbank-Schema Änderungen
- [ ] Neue Firebase-Funktionen
- [ ] Authentication/Authorization Änderungen
### Externe Services
- [ ] Neue Dependencies
- [ ] Third-Party APIs
- [ ] Cloud-Services
## 📊 Erfolgs-Metriken
Wie würdest du den Erfolg dieser Funktion messen?
- [ ] Benutzer-Engagement
- [ ] Feature-Adoption Rate
- [ ] Benutzer-Feedback
- [ ] Performance-Metriken
- [ ] Andere: ___________
## 🎯 Priorität
Wie wichtig ist diese Funktion für dich?
- [ ] 🔴 Sehr hoch (Ohne diese Funktion ist die App nicht nutzbar)
- [ ] 🟠 Hoch (Würde die App erheblich verbessern)
- [ ] 🟡 Mittel (Nette Ergänzung)
- [ ] 🟢 Niedrig (Nice-to-have)
## 🚀 Implementierungs-Vorschlag
### Phase 1 (MVP)
- [ ] Grundfunktionalität
- [ ] Basis-UI
- [ ] Core-Features
### Phase 2 (Erweiterungen)
- [ ] Erweiterte Features
- [ ] Verbessertes UI/UX
- [ ] Optimierungen
### Phase 3 (Polish)
- [ ] Animationen
- [ ] Theme-Integration
- [ ] Performance-Optimierung
## 🎨 Theme-Integration
Wie sollte diese Funktion in verschiedenen Themes aussehen?
### 🌲 Wald-Theme
- Natürliche Elemente verwenden
- Erdige Farben
- Organische Formen
### 🚀 Weltraum-Theme
- Futuristische Elemente
- Neon-Akzente
- Sci-Fi Animationen
### 🌊 Ozean-Theme
- Unterwasser-Elemente
- Blaue Farbpalette
- Fließende Animationen
### 🌸 Natur-Theme
- Blumen und Schmetterlinge
- Pastellfarben
- Sanfte Übergänge
## 📱 Plattform-Überlegungen
- [ ] Desktop-optimiert
- [ ] Tablet-freundlich
- [ ] Mobile-first
- [ ] PWA-Features
- [ ] Offline-Funktionalität
## ♿ Accessibility
- [ ] Screen-Reader kompatibel
- [ ] Keyboard-Navigation
- [ ] Hoher Kontrast
- [ ] Große Touch-Targets
- [ ] Einfache Sprache
## 🔒 Sicherheit & Datenschutz
- [ ] Keine sensiblen Daten
- [ ] COPPA-konform
- [ ] GDPR-konform
- [ ] Elterliche Kontrolle
## 📚 Dokumentation
Welche Dokumentation wäre für diese Funktion erforderlich?
- [ ] Benutzer-Anleitung
- [ ] Entwickler-Dokumentation
- [ ] API-Dokumentation
- [ ] Tutorial/Onboarding
## 🤝 Bereitschaft zur Hilfe
- [ ] Ich kann bei der Implementierung helfen
- [ ] Ich kann beim Design helfen
- [ ] Ich kann beim Testen helfen
- [ ] Ich kann bei der Dokumentation helfen
## 📝 Zusätzliche Informationen
Füge hier weitere relevante Informationen, Links oder Referenzen hinzu.
### Referenzen
- Ähnliche Features in anderen Apps
- Design-Inspirationen
- Technische Artikel
- Benutzer-Feedback
### Abhängigkeiten
- Welche anderen Features müssen zuerst implementiert werden?
- Gibt es Konflikte mit bestehenden Features?
---
**Vielen Dank für deinen Vorschlag! 🎉**
*Das ToDo Kids Team wird deinen Feature-Request prüfen und sich bei Fragen melden.*

198
.github/pull_request_template.md vendored Normal file
View File

@ -0,0 +1,198 @@
## 📝 Beschreibung
<!-- Beschreibe deine Änderungen in wenigen Sätzen -->
## 🎯 Art der Änderung
<!-- Markiere die zutreffenden Optionen -->
- [ ] 🐛 Bug Fix (non-breaking change, der ein Problem behebt)
- [ ] ✨ Neues Feature (non-breaking change, das Funktionalität hinzufügt)
- [ ] 💥 Breaking Change (Fix oder Feature, das bestehende Funktionalität verändert)
- [ ] 📚 Dokumentation (Änderungen an der Dokumentation)
- [ ] 🎨 Style/UI (Änderungen, die das Aussehen betreffen)
- [ ] ♻️ Refactoring (Code-Änderungen, die weder Bugs beheben noch Features hinzufügen)
- [ ] ⚡ Performance (Änderungen, die die Performance verbessern)
- [ ] 🧪 Tests (Hinzufügen oder Korrigieren von Tests)
- [ ] 🔧 Chore (Änderungen am Build-Prozess oder Hilfswerkzeugen)
## 🔗 Verwandte Issues
<!-- Verlinke verwandte Issues -->
Fixes #(issue_number)
Closes #(issue_number)
Related to #(issue_number)
## 🧪 Tests
<!-- Beschreibe die Tests, die du durchgeführt hast -->
### ✅ Test-Checklist
- [ ] Bestehende Tests laufen durch
- [ ] Neue Tests hinzugefügt (falls nötig)
- [ ] Manuelle Tests durchgeführt
- [ ] Edge Cases getestet
### 🖥️ Getestete Umgebungen
- [ ] Chrome (Desktop)
- [ ] Firefox (Desktop)
- [ ] Safari (Desktop)
- [ ] Edge (Desktop)
- [ ] Chrome (Mobile)
- [ ] Safari (Mobile)
### 🎨 Getestete Themes
- [ ] 🌈 Buntes Theme
- [ ] 🌲 Wald-Theme
- [ ] 🚀 Weltraum-Theme
- [ ] 🌊 Ozean-Theme
- [ ] 🌸 Natur-Theme
## 📱 Screenshots/Videos
<!-- Füge Screenshots oder Videos hinzu, besonders bei UI-Änderungen -->
### Vorher
<!-- Screenshot/Video des alten Zustands -->
### Nachher
<!-- Screenshot/Video des neuen Zustands -->
## 🔍 Code-Änderungen
### 📁 Geänderte Dateien
<!-- Liste der wichtigsten geänderten Dateien -->
- `path/to/file1.js` - Beschreibung der Änderung
- `path/to/file2.css` - Beschreibung der Änderung
### 🏗️ Architektur-Änderungen
<!-- Beschreibe größere Architektur-Änderungen -->
- [ ] Neue Komponenten hinzugefügt
- [ ] Bestehende Komponenten geändert
- [ ] API-Änderungen
- [ ] Datenbank-Schema Änderungen
- [ ] Neue Dependencies
## ⚡ Performance-Impact
<!-- Beschreibe den Performance-Impact deiner Änderungen -->
- [ ] Keine Performance-Auswirkungen
- [ ] Performance-Verbesserung
- [ ] Mögliche Performance-Verschlechterung (erkläre warum es notwendig ist)
### 📊 Bundle-Größe
- [ ] Bundle-Größe unverändert
- [ ] Bundle-Größe reduziert
- [ ] Bundle-Größe erhöht (Begründung: ___________)
## 🔒 Sicherheits-Überlegungen
<!-- Beschreibe Sicherheitsaspekte deiner Änderungen -->
- [ ] Keine Sicherheitsauswirkungen
- [ ] Sicherheitsverbesserung
- [ ] Neue Sicherheitsüberlegungen (erkläre)
### 🛡️ Sicherheits-Checklist
- [ ] Input-Validierung implementiert
- [ ] XSS-Schutz beachtet
- [ ] CSRF-Schutz beachtet
- [ ] Keine sensiblen Daten in Logs
- [ ] Proper Error Handling
## ♿ Accessibility
<!-- Beschreibe Accessibility-Aspekte -->
- [ ] Keine Accessibility-Auswirkungen
- [ ] Accessibility-Verbesserung
- [ ] Neue Accessibility-Features
### 🎯 Accessibility-Checklist
- [ ] ARIA-Labels hinzugefügt/aktualisiert
- [ ] Keyboard-Navigation funktioniert
- [ ] Screen-Reader kompatibel
- [ ] Ausreichender Farbkontrast
- [ ] Focus-Management implementiert
## 👨‍👩‍👧‍👦 Kinder-Freundlichkeit
<!-- Spezielle Überlegungen für Kinder -->
- [ ] Kindgerechte Sprache verwendet
- [ ] Einfache Navigation
- [ ] Große Touch-Targets
- [ ] Keine komplexen Interaktionen
- [ ] Positive Verstärkung
## 📚 Dokumentation
<!-- Dokumentations-Änderungen -->
- [ ] README aktualisiert
- [ ] Code-Kommentare hinzugefügt
- [ ] API-Dokumentation aktualisiert
- [ ] CHANGELOG aktualisiert
- [ ] Keine Dokumentation erforderlich
## 🚀 Deployment-Überlegungen
<!-- Spezielle Deployment-Anforderungen -->
- [ ] Keine speziellen Anforderungen
- [ ] Environment Variables hinzugefügt/geändert
- [ ] Database Migration erforderlich
- [ ] Neue Dependencies installieren
- [ ] Build-Prozess geändert
### 🔧 Deployment-Schritte
<!-- Falls spezielle Schritte erforderlich sind -->
1.
2.
3.
## ✅ Checklist
<!-- Allgemeine Checklist vor dem Merge -->
- [ ] Code folgt den Projekt-Konventionen
- [ ] Self-Review durchgeführt
- [ ] Code-Kommentare hinzugefügt (wo nötig)
- [ ] Entsprechende Dokumentation aktualisiert
- [ ] Keine neuen Warnungen/Errors
- [ ] Tests hinzugefügt/aktualisiert
- [ ] Alle Tests bestehen
- [ ] Funktioniert in verschiedenen Browsern
- [ ] Responsive Design getestet
- [ ] Performance-Impact überprüft
## 🤝 Review-Anfragen
<!-- Spezielle Punkte, auf die Reviewer achten sollen -->
- [ ] Code-Qualität
- [ ] Performance
- [ ] Sicherheit
- [ ] Accessibility
- [ ] UI/UX
- [ ] Dokumentation
### 🎯 Fokus-Bereiche
<!-- Bereiche, die besondere Aufmerksamkeit benötigen -->
-
-
-
## 📝 Zusätzliche Notizen
<!-- Weitere Informationen für Reviewer -->
### 🔮 Zukünftige Verbesserungen
<!-- Ideen für zukünftige Iterationen -->
-
-
-
### 🤔 Offene Fragen
<!-- Fragen, die während der Entwicklung aufgekommen sind -->
-
-
-
---
**Vielen Dank für deinen Beitrag! 🎉**
/cc @maintainer-username

461
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,461 @@
name: 🚀 Continuous Integration
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main, develop ]
jobs:
# 🧪 Backend Tests
backend-tests:
name: 🔧 Backend Tests
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: 📦 Install dependencies
run: npm ci
- name: 🔍 Lint backend code
run: npm run lint || echo "Linting not configured"
- name: 🧪 Run backend tests
run: npm test || echo "Tests not configured"
env:
NODE_ENV: test
JWT_SECRET: test-secret-key-for-ci
- name: 🔒 Security audit
run: npm audit --audit-level=high
# 🎨 Frontend Tests
frontend-tests:
name: 🎨 Frontend Tests
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 20.x]
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
cache-dependency-path: 'client/package-lock.json'
- name: 📦 Install frontend dependencies
run: |
cd client
npm ci
- name: 🔍 Lint frontend code
run: |
cd client
npm run lint || echo "Linting not configured"
- name: 🧪 Run frontend tests
run: |
cd client
npm test -- --coverage --watchAll=false
env:
CI: true
REACT_APP_API_URL: http://localhost:5000
- name: 📊 Upload coverage to Codecov
uses: codecov/codecov-action@v3
with:
file: ./client/coverage/lcov.info
flags: frontend
name: frontend-coverage
- name: 🔒 Security audit
run: |
cd client
npm audit --audit-level=high
# 🏗️ Build Tests
build-tests:
name: 🏗️ Build Tests
runs-on: ubuntu-latest
needs: [backend-tests, frontend-tests]
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: 📦 Install all dependencies
run: |
npm ci
cd client && npm ci
- name: 🏗️ Build frontend
run: |
cd client
npm run build
env:
REACT_APP_API_URL: https://todo-kids-api.herokuapp.com
GENERATE_SOURCEMAP: false
- name: 📏 Check bundle size
run: |
cd client
npx bundlesize || echo "Bundle size check not configured"
- name: 💾 Upload build artifacts
uses: actions/upload-artifact@v3
with:
name: build-files
path: client/build/
retention-days: 7
# 🔍 Code Quality
code-quality:
name: 🔍 Code Quality
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Shallow clones should be disabled for better analysis
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: 📦 Install dependencies
run: |
npm ci
cd client && npm ci
- name: 🔍 SonarCloud Scan
uses: SonarSource/sonarcloud-github-action@master
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
continue-on-error: true
- name: 🛡️ CodeQL Analysis
uses: github/codeql-action/init@v2
with:
languages: javascript
continue-on-error: true
- name: 🛡️ Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
continue-on-error: true
# 🔒 Security Checks
security-checks:
name: 🔒 Security Checks
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: 📦 Install dependencies
run: |
npm ci
cd client && npm ci
- name: 🛡️ Run Snyk security scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=high
continue-on-error: true
- name: 🔍 Dependency Review
uses: actions/dependency-review-action@v3
if: github.event_name == 'pull_request'
# 🎨 Theme Tests
theme-tests:
name: 🎨 Theme Tests
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: 'client/package-lock.json'
- name: 📦 Install dependencies
run: |
cd client
npm ci
- name: 🎨 Test all themes
run: |
cd client
npm test -- --testNamePattern="theme" --watchAll=false || echo "Theme tests not configured"
- name: 🖼️ Visual regression tests
run: |
cd client
npm run test:visual || echo "Visual tests not configured"
continue-on-error: true
# ♿ Accessibility Tests
accessibility-tests:
name: ♿ Accessibility Tests
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: 'client/package-lock.json'
- name: 📦 Install dependencies
run: |
cd client
npm ci
- name: 🏗️ Build app
run: |
cd client
npm run build
env:
REACT_APP_API_URL: http://localhost:5000
- name: ♿ Run accessibility tests
run: |
cd client
npx serve -s build -p 3000 &
sleep 5
npx axe-cli http://localhost:3000 || echo "Accessibility tests not configured"
continue-on-error: true
# 📱 Cross-Browser Tests
cross-browser-tests:
name: 📱 Cross-Browser Tests
runs-on: ubuntu-latest
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: 'client/package-lock.json'
- name: 📦 Install dependencies
run: |
cd client
npm ci
- name: 🏗️ Build app
run: |
cd client
npm run build
env:
REACT_APP_API_URL: http://localhost:5000
- name: 🌐 Run cross-browser tests
run: |
cd client
npm run test:e2e || echo "E2E tests not configured"
continue-on-error: true
# 📊 Performance Tests
performance-tests:
name: 📊 Performance Tests
runs-on: ubuntu-latest
needs: [build-tests]
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
cache-dependency-path: 'client/package-lock.json'
- name: 📦 Install dependencies
run: |
cd client
npm ci
- name: 💾 Download build artifacts
uses: actions/download-artifact@v3
with:
name: build-files
path: client/build/
- name: 📊 Lighthouse CI
run: |
npm install -g @lhci/cli@0.12.x
cd client
npx serve -s build -p 3000 &
sleep 5
lhci autorun || echo "Lighthouse CI not configured"
continue-on-error: true
# 🎯 Integration Tests
integration-tests:
name: 🎯 Integration Tests
runs-on: ubuntu-latest
needs: [backend-tests, frontend-tests]
services:
mongodb:
image: mongo:5
env:
MONGO_INITDB_ROOT_USERNAME: test
MONGO_INITDB_ROOT_PASSWORD: test
options: >-
--health-cmd mongo
--health-interval 10s
--health-timeout 5s
--health-retries 5
ports:
- 27017:27017
steps:
- name: 📥 Checkout code
uses: actions/checkout@v4
- name: 🟢 Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20.x'
cache: 'npm'
- name: 📦 Install all dependencies
run: |
npm ci
cd client && npm ci
- name: 🚀 Start backend server
run: |
npm start &
sleep 10
env:
NODE_ENV: test
MONGODB_URI: mongodb://test:test@localhost:27017/todo-kids-test?authSource=admin
JWT_SECRET: test-secret-key-for-ci
PORT: 5000
- name: 🏗️ Build and start frontend
run: |
cd client
npm run build
npx serve -s build -p 3000 &
sleep 5
env:
REACT_APP_API_URL: http://localhost:5000
- name: 🧪 Run integration tests
run: |
npm run test:integration || echo "Integration tests not configured"
env:
API_URL: http://localhost:5000
FRONTEND_URL: http://localhost:3000
# ✅ All Tests Passed
all-tests-passed:
name: ✅ All Tests Passed
runs-on: ubuntu-latest
needs: [
backend-tests,
frontend-tests,
build-tests,
code-quality,
security-checks,
theme-tests,
accessibility-tests,
cross-browser-tests,
performance-tests,
integration-tests
]
if: always()
steps:
- name: ✅ Check all jobs
run: |
echo "Backend Tests: ${{ needs.backend-tests.result }}"
echo "Frontend Tests: ${{ needs.frontend-tests.result }}"
echo "Build Tests: ${{ needs.build-tests.result }}"
echo "Code Quality: ${{ needs.code-quality.result }}"
echo "Security Checks: ${{ needs.security-checks.result }}"
echo "Theme Tests: ${{ needs.theme-tests.result }}"
echo "Accessibility Tests: ${{ needs.accessibility-tests.result }}"
echo "Cross-Browser Tests: ${{ needs.cross-browser-tests.result }}"
echo "Performance Tests: ${{ needs.performance-tests.result }}"
echo "Integration Tests: ${{ needs.integration-tests.result }}"
if [[ "${{ needs.backend-tests.result }}" == "failure" ||
"${{ needs.frontend-tests.result }}" == "failure" ||
"${{ needs.build-tests.result }}" == "failure" ]]; then
echo "❌ Critical tests failed!"
exit 1
else
echo "✅ All critical tests passed!"
fi
- name: 🎉 Success notification
if: success()
run: echo "🎉 All tests passed! Ready for deployment."
- name: 💬 Comment on PR
if: github.event_name == 'pull_request' && success()
uses: actions/github-script@v6
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: '🎉 All CI checks passed! This PR is ready for review.'
})

143
.gitignore vendored Normal file
View File

@ -0,0 +1,143 @@
# Dependencies
node_modules/
client/node_modules/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Environment variables
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
client/.env
client/.env.local
client/.env.development.local
client/.env.test.local
client/.env.production.local
# Build outputs
client/build/
client/dist/
build/
dist/
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Coverage directory used by tools like istanbul
coverage/
*.lcov
# nyc test coverage
.nyc_output
# Dependency directories
node_modules/
jspm_packages/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
public
# Storybook build outputs
.out
.storybook-out
# Temporary folders
tmp/
temp/
# Editor directories and files
.vscode/
.idea/
*.swp
*.swo
*~
# OS generated files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Firebase
.firebase/
.firebaserc
firebase-debug.log
# Database
*.db
*.sqlite
*.sqlite3
# Backup files
*.backup
*.bak
# Package lock files (optional - uncomment if you want to ignore them)
# package-lock.json
# client/package-lock.json
# Local development files
.local
.cache
# Testing
/coverage
# Production
/build
# Misc
.env.local
.env.development.local
.env.test.local
.env.production.local

166
CHANGELOG.md Normal file
View File

@ -0,0 +1,166 @@
# 📝 Changelog
Alle wichtigen Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
Das Format basiert auf [Keep a Changelog](https://keepachangelog.com/de/1.0.0/),
und dieses Projekt folgt [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Geplant
- [ ] Offline-Modus
- [ ] Push-Benachrichtigungen
- [ ] Erweiterte Statistiken
- [ ] Mehr Belohnungs-Optionen
- [ ] Kalender-Integration
## [1.0.0] - 2024-01-XX
### ✨ Hinzugefügt
- **Basis-Funktionalität**
- Benutzer-Authentifizierung (Eltern/Kinder)
- Aufgaben erstellen, bearbeiten, löschen
- Belohnungssystem mit Punkten
- Familien-Management
- **Theme-System**
- 🌈 Buntes Theme (Standard)
- 🌲 Wald-Theme mit animierten Bäumen und Tieren
- 🚀 Weltraum-Theme mit Sternen und Planeten
- 🌊 Ozean-Theme mit Meeresbewohnern
- 🌸 Natur-Theme mit Blumen und Schmetterlingen
- **UI/UX Features**
- Responsive Design für alle Geräte
- Material-UI Komponenten
- Smooth Animationen
- Konfetti-Effekte bei Erfolgen
- Intuitive Navigation
- **Sicherheit**
- Firebase-Authentifizierung
- Sichere Datenübertragung
- Eingabe-Validierung
- Schutz vor XSS/CSRF
### 🎨 Theme-Details
#### Wald-Theme 🌲
- Himmel-zu-Wald Farbverlauf
- Animierte Bäume mit "forest-sway" Animation
- Waldtiere (Eichhörnchen, Vögel, Rehe)
- Natürliche Büsche und Vegetation
- Holz-Design für Karten und Buttons
- Organische Formen und erdige Farben
#### Weltraum-Theme 🚀
- Dunkler Weltraum-Hintergrund
- Funkelnde Sterne (statisch)
- Futuristische UI-Elemente
- Neon-Akzente
- Sci-Fi Typografie
- Glowing-Effekte
#### Ozean-Theme 🌊
- Unterwasser-Atmosphäre
- Animierte Meeresbewohner
- Wellen-Animationen
- Blaue Farbpalette
- Korallen und Seegras
- Bubble-Effekte
#### Natur-Theme 🌸
- Frühlingshafte Farben
- Blumen und Schmetterlinge
- Sanfte Animationen
- Pastelltöne
- Organische Formen
- Natürliche Texturen
### 🛠️ Technische Implementierung
- **Frontend**: React 19.1.1, Material-UI 7.2.0
- **Backend**: Node.js, Express.js
- **Database**: Firebase Firestore
- **Authentication**: Firebase Auth
- **Styling**: Emotion, CSS-in-JS
- **Animationen**: CSS Keyframes, SVG
- **Build**: Create React App
- **Deployment**: Heroku-ready
### 📱 Unterstützte Plattformen
- ✅ Desktop (Chrome, Firefox, Safari, Edge)
- ✅ Tablet (iOS Safari, Android Chrome)
- ✅ Mobile (iOS Safari, Android Chrome)
- ✅ PWA-fähig
### 🔧 Entwickler-Features
- ESLint-Konfiguration
- Prettier-Integration
- Hot-Reload Development
- Environment-basierte Konfiguration
- Modulare Theme-Architektur
- Komponenten-basierte Struktur
### 📚 Dokumentation
- Ausführliche README.md
- Beitragsrichtlinien (CONTRIBUTING.md)
- Deployment-Guide (DEPLOYMENT.md)
- Code-Kommentare
- Theme-Entwicklungsguide
### 🧪 Testing
- React Testing Library Setup
- Jest-Konfiguration
- Component Tests
- Integration Tests
- E2E Test-Vorbereitung
### 🚀 Performance
- Code-Splitting
- Lazy Loading
- Optimierte SVG-Animationen
- Minimierte Bundle-Größe
- Efficient Re-rendering
### ♿ Accessibility
- ARIA-Labels
- Keyboard-Navigation
- Screen-Reader Support
- Kontrast-optimierte Farben
- Focus-Management
## [0.9.0] - 2024-01-XX (Beta)
### ✨ Hinzugefügt
- Beta-Version der Kern-Funktionalität
- Grundlegendes Theme-System
- Basis-Authentifizierung
### 🐛 Behoben
- Initiale Bug-Fixes
- Performance-Optimierungen
## [0.1.0] - 2024-01-XX (Alpha)
### ✨ Hinzugefügt
- Projekt-Setup
- Grundlegende Struktur
- Erste Prototypen
---
## 📋 Legende
- ✨ **Hinzugefügt**: Neue Features
- 🔄 **Geändert**: Änderungen an bestehenden Features
- ❌ **Entfernt**: Entfernte Features
- 🐛 **Behoben**: Bug-Fixes
- 🔒 **Sicherheit**: Sicherheits-Updates
- 📚 **Dokumentation**: Dokumentations-Änderungen
- 🎨 **Design**: UI/UX Verbesserungen
- ⚡ **Performance**: Performance-Verbesserungen
- 🧪 **Testing**: Test-bezogene Änderungen
---
**Hinweis**: Dieses Projekt befindet sich in aktiver Entwicklung. Features und APIs können sich ändern.

240
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,240 @@
# 🤝 Beitragen zu ToDo Kids
Vielen Dank für dein Interesse, zu ToDo Kids beizutragen! Wir freuen uns über jede Art von Beitrag.
## 🎯 Arten von Beiträgen
### 🐛 Bug Reports
- Verwende die GitHub Issues
- Beschreibe das Problem detailliert
- Füge Screenshots hinzu, wenn möglich
- Gib deine Browser-/System-Informationen an
### 💡 Feature Requests
- Öffne ein Issue mit dem Label "enhancement"
- Erkläre den Nutzen für Kinder und Familien
- Beschreibe die gewünschte Funktionalität
### 🎨 Theme-Beiträge
- Neue Themes sind sehr willkommen!
- Achte auf kindgerechte Farben und Designs
- Teste die Accessibility
- Füge SVG-Animationen hinzu (optional)
### 📝 Dokumentation
- Verbesserungen der README
- Code-Kommentare
- Tutorials und Guides
## 🚀 Entwicklungs-Workflow
### 1. Repository forken
```bash
# Fork das Repository auf GitHub
# Dann klone dein Fork
git clone https://github.com/DEIN-USERNAME/todo-kids.git
cd todo-kids
```
### 2. Development Environment einrichten
```bash
# Dependencies installieren
npm install
cd client && npm install && cd ..
# Environment Variables kopieren
cp .env.example .env
cp client/.env.example client/.env
# Firebase konfigurieren (siehe FIREBASE_SETUP.md)
```
### 3. Feature Branch erstellen
```bash
git checkout -b feature/amazing-new-feature
# oder
git checkout -b bugfix/fix-important-bug
# oder
git checkout -b theme/new-awesome-theme
```
### 4. Entwickeln und Testen
```bash
# Backend starten
npm run dev
# Frontend starten (neues Terminal)
cd client
npm start
# Tests ausführen
npm test
cd client && npm test
```
### 5. Code Style beachten
#### JavaScript/React
- Verwende ESLint-Konfiguration
- Funktionale Komponenten mit Hooks
- Aussagekräftige Variablennamen
- Kommentare für komplexe Logik
#### CSS/Styling
- Material-UI Theming verwenden
- Responsive Design beachten
- Accessibility-Standards einhalten
#### Git Commits
```bash
# Gute Commit-Messages
git commit -m "feat: add forest theme with animated trees"
git commit -m "fix: resolve login issue for child accounts"
git commit -m "docs: update installation instructions"
```
### 6. Pull Request erstellen
#### Vor dem PR
- [ ] Code funktioniert lokal
- [ ] Tests laufen durch
- [ ] Keine Console-Errors
- [ ] Responsive Design getestet
- [ ] Accessibility überprüft
#### PR-Beschreibung
```markdown
## 📝 Beschreibung
Kurze Beschreibung der Änderungen
## 🎯 Art der Änderung
- [ ] Bug Fix
- [ ] Neues Feature
- [ ] Breaking Change
- [ ] Dokumentation
## 🧪 Tests
- [ ] Bestehende Tests laufen durch
- [ ] Neue Tests hinzugefügt (falls nötig)
## 📱 Screenshots
(Falls UI-Änderungen)
## ✅ Checklist
- [ ] Code folgt den Style-Guidelines
- [ ] Self-Review durchgeführt
- [ ] Kommentare hinzugefügt
- [ ] Dokumentation aktualisiert
```
## 🎨 Theme-Entwicklung
### Neue Themes erstellen
1. **Theme-Datei erweitern**
```javascript
// In client/src/themes/childThemes.js
const myAwesomeTheme = createTheme({
...baseTheme,
palette: {
// Deine Farben
},
themeIcon: '🎭',
components: {
// Deine Styling-Overrides
}
});
```
2. **SVG-Animationen hinzufügen**
```javascript
backgroundImage: 'url("data:image/svg+xml,...")',
animation: 'my-animation 10s ease-in-out infinite',
```
3. **Theme registrieren**
```javascript
export const childThemes = {
// ... bestehende themes
myAwesome: myAwesomeTheme,
};
```
### Theme-Guidelines
- **Kindgerecht**: Helle, freundliche Farben
- **Accessibility**: Ausreichender Kontrast
- **Performance**: Optimierte SVGs
- **Konsistenz**: Einheitliches Design
## 🐛 Bug Fixes
### Debugging-Tipps
```bash
# Browser DevTools verwenden
# React DevTools installieren
# Console-Logs überprüfen
# Backend-Logs
npm run dev # Zeigt Server-Logs
# Frontend-Logs
# Browser Console öffnen
```
### Häufige Probleme
- **Firebase-Konfiguration**: Überprüfe .env-Dateien
- **CORS-Errors**: Backend/Frontend URLs prüfen
- **Theme-Probleme**: Browser-Cache leeren
## 📋 Code Review Process
### Was wir überprüfen
- **Funktionalität**: Arbeitet der Code wie erwartet?
- **Code Quality**: Ist der Code sauber und verständlich?
- **Performance**: Gibt es Performance-Probleme?
- **Security**: Sind Sicherheitsaspekte beachtet?
- **UX**: Ist die Benutzererfahrung gut?
### Review-Feedback
- Konstruktives Feedback geben
- Verbesserungsvorschläge machen
- Positive Aspekte hervorheben
- Bei Fragen nachfragen
## 🎯 Prioritäten
### Hoch
- Sicherheitslücken
- Kritische Bugs
- Accessibility-Verbesserungen
### Mittel
- Neue Features
- Performance-Optimierungen
- UI/UX-Verbesserungen
### Niedrig
- Code-Refactoring
- Dokumentation
- Nice-to-have Features
## 🏆 Anerkennung
Alle Beitragenden werden in der README erwähnt. Besonders wertvolle Beiträge werden speziell hervorgehoben.
## 📞 Hilfe bekommen
- **GitHub Issues**: Für Fragen und Diskussionen
- **Discussions**: Für allgemeine Gespräche
- **Email**: Für private Anfragen
## 📜 Code of Conduct
- Respektvoller Umgang miteinander
- Konstruktive Kritik
- Hilfsbereitschaft
- Fokus auf das Projekt
---
**Vielen Dank für deinen Beitrag zu ToDo Kids! 🎉**

454
DEPLOYMENT.md Normal file
View File

@ -0,0 +1,454 @@
# 🚀 Deployment Guide für ToDo Kids
Diese Anleitung zeigt dir verschiedene Möglichkeiten, ToDo Kids zu deployen.
## 📋 Voraussetzungen
- Node.js 18+ installiert
- Git installiert
- Firebase-Projekt eingerichtet
- Alle Environment Variables konfiguriert
## 🔧 Vorbereitung
### 1. Production Build erstellen
```bash
# Dependencies installieren
npm install
cd client && npm install && cd ..
# Frontend Build
cd client
npm run build
cd ..
# Backend für Production vorbereiten
npm run build # Falls vorhanden
```
### 2. Environment Variables prüfen
```bash
# Root .env
PORT=5000
MONGODB_URI=your_mongodb_connection_string
JWT_SECRET=your_jwt_secret
NODE_ENV=production
# client/.env
REACT_APP_API_URL=https://your-backend-url.com
REACT_APP_FIREBASE_API_KEY=your_firebase_api_key
REACT_APP_FIREBASE_AUTH_DOMAIN=your-project.firebaseapp.com
REACT_APP_FIREBASE_PROJECT_ID=your-project-id
# ... weitere Firebase-Konfiguration
```
## 🌐 Deployment-Optionen
### Option 1: Heroku (Empfohlen für Anfänger)
#### Vorbereitung
```bash
# Heroku CLI installieren
# https://devcenter.heroku.com/articles/heroku-cli
# Heroku Login
heroku login
# App erstellen
heroku create todo-kids-app
```
#### Environment Variables setzen
```bash
heroku config:set NODE_ENV=production
heroku config:set JWT_SECRET=your_jwt_secret
heroku config:set MONGODB_URI=your_mongodb_uri
# Firebase-Konfiguration
heroku config:set REACT_APP_FIREBASE_API_KEY=your_api_key
heroku config:set REACT_APP_FIREBASE_AUTH_DOMAIN=your_domain
# ... weitere Firebase-Variablen
```
#### Deployment
```bash
# Git Repository vorbereiten
git add .
git commit -m "Prepare for deployment"
# Zu Heroku deployen
git push heroku main
# App öffnen
heroku open
```
### Option 2: Vercel (Frontend) + Railway/Render (Backend)
#### Frontend auf Vercel
```bash
# Vercel CLI installieren
npm i -g vercel
# Login
vercel login
# Client-Ordner deployen
cd client
vercel
# Environment Variables in Vercel Dashboard setzen
# https://vercel.com/dashboard
```
#### Backend auf Railway
```bash
# Railway CLI installieren
npm install -g @railway/cli
# Login
railway login
# Projekt erstellen
railway new
# Environment Variables setzen
railway variables set NODE_ENV=production
railway variables set JWT_SECRET=your_secret
# Deployen
railway up
```
### Option 3: DigitalOcean App Platform
#### App erstellen
1. DigitalOcean Dashboard öffnen
2. "Apps" → "Create App"
3. GitHub Repository verbinden
4. Build-Einstellungen konfigurieren:
```yaml
# .do/app.yaml
name: todo-kids
services:
- name: api
source_dir: /
github:
repo: your-username/todo-kids
branch: main
run_command: npm start
environment_slug: node-js
instance_count: 1
instance_size_slug: basic-xxs
envs:
- key: NODE_ENV
value: production
- key: JWT_SECRET
value: your_jwt_secret
type: SECRET
- key: MONGODB_URI
value: your_mongodb_uri
type: SECRET
- name: web
source_dir: /client
github:
repo: your-username/todo-kids
branch: main
build_command: npm run build
run_command: npx serve -s build
environment_slug: node-js
instance_count: 1
instance_size_slug: basic-xxs
envs:
- key: REACT_APP_API_URL
value: ${api.PUBLIC_URL}
```
### Option 4: Docker Deployment
#### Dockerfile erstellen
```dockerfile
# Root Dockerfile
FROM node:18-alpine
WORKDIR /app
# Backend Dependencies
COPY package*.json ./
RUN npm ci --only=production
# Frontend Build
COPY client/package*.json ./client/
WORKDIR /app/client
RUN npm ci --only=production
COPY client/ .
RUN npm run build
# Backend Setup
WORKDIR /app
COPY . .
EXPOSE 5000
CMD ["npm", "start"]
```
#### Docker Compose
```yaml
# docker-compose.yml
version: '3.8'
services:
app:
build: .
ports:
- "5000:5000"
environment:
- NODE_ENV=production
- JWT_SECRET=${JWT_SECRET}
- MONGODB_URI=${MONGODB_URI}
depends_on:
- mongo
mongo:
image: mongo:5
volumes:
- mongo_data:/data/db
environment:
- MONGO_INITDB_ROOT_USERNAME=admin
- MONGO_INITDB_ROOT_PASSWORD=password
volumes:
mongo_data:
```
#### Deployment
```bash
# Image bauen
docker build -t todo-kids .
# Container starten
docker-compose up -d
# Oder einzeln
docker run -p 5000:5000 -e NODE_ENV=production todo-kids
```
### Option 5: VPS (Ubuntu/Debian)
#### Server vorbereiten
```bash
# Server Updates
sudo apt update && sudo apt upgrade -y
# Node.js installieren
curl -fsSL https://deb.nodesource.com/setup_18.x | sudo -E bash -
sudo apt-get install -y nodejs
# PM2 installieren (Process Manager)
sudo npm install -g pm2
# Nginx installieren
sudo apt install nginx -y
```
#### App deployen
```bash
# Repository klonen
git clone https://github.com/your-username/todo-kids.git
cd todo-kids
# Dependencies installieren
npm install
cd client && npm install && npm run build && cd ..
# Environment Variables setzen
cp .env.example .env
# .env bearbeiten
# PM2 starten
pm2 start ecosystem.config.js
pm2 save
pm2 startup
```
#### Nginx konfigurieren
```nginx
# /etc/nginx/sites-available/todo-kids
server {
listen 80;
server_name your-domain.com;
location / {
root /path/to/todo-kids/client/build;
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://localhost:5000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
}
```
```bash
# Nginx-Konfiguration aktivieren
sudo ln -s /etc/nginx/sites-available/todo-kids /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl restart nginx
```
## 🔒 SSL/HTTPS einrichten
### Let's Encrypt (Kostenlos)
```bash
# Certbot installieren
sudo apt install certbot python3-certbot-nginx -y
# SSL-Zertifikat erstellen
sudo certbot --nginx -d your-domain.com
# Auto-Renewal testen
sudo certbot renew --dry-run
```
## 📊 Monitoring & Logs
### PM2 Monitoring
```bash
# Status anzeigen
pm2 status
# Logs anzeigen
pm2 logs
# Restart
pm2 restart all
# Monitoring Dashboard
pm2 monit
```
### Heroku Logs
```bash
# Live-Logs
heroku logs --tail
# Letzte 100 Zeilen
heroku logs -n 100
```
## 🔧 Troubleshooting
### Häufige Probleme
#### Build-Fehler
```bash
# Node-Version prüfen
node --version
npm --version
# Cache leeren
npm cache clean --force
rm -rf node_modules package-lock.json
npm install
```
#### Environment Variables
```bash
# Variablen prüfen
echo $NODE_ENV
echo $REACT_APP_API_URL
# Heroku Variablen
heroku config
```
#### Database-Verbindung
```bash
# MongoDB-Verbindung testen
mongo "your_connection_string"
# Logs prüfen
tail -f /var/log/mongodb/mongod.log
```
### Performance-Optimierung
#### Frontend
- Code-Splitting aktivieren
- Images optimieren
- Bundle-Größe analysieren: `npm run build -- --analyze`
#### Backend
- Database-Indizes erstellen
- Caching implementieren
- Compression aktivieren
## 📈 Skalierung
### Horizontal Scaling
- Load Balancer einrichten
- Multiple App-Instanzen
- Database-Clustering
### Vertical Scaling
- Server-Ressourcen erhöhen
- Database-Performance optimieren
- CDN für statische Assets
## 🔄 CI/CD Pipeline
### GitHub Actions
```yaml
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [ main ]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup Node.js
uses: actions/setup-node@v2
with:
node-version: '18'
- name: Install dependencies
run: |
npm install
cd client && npm install
- name: Run tests
run: |
npm test
cd client && npm test -- --coverage --watchAll=false
- name: Build
run: |
cd client && npm run build
- name: Deploy to Heroku
uses: akhileshns/heroku-deploy@v3.12.12
with:
heroku_api_key: ${{secrets.HEROKU_API_KEY}}
heroku_app_name: "todo-kids-app"
heroku_email: "your-email@example.com"
```
---
**Viel Erfolg beim Deployment! 🚀**
Bei Fragen oder Problemen, erstelle gerne ein Issue im Repository.

165
FIREBASE_MIGRATION.md Normal file
View File

@ -0,0 +1,165 @@
# 🔥 Firebase Migration Anleitung
## Übersicht
Diese Anleitung hilft dir dabei, von dem lokalen Node.js/MongoDB Backend zu Firebase zu migrieren.
## 📋 Schritt-für-Schritt Anleitung
### 1. Firebase Projekt erstellen
1. Gehe zu [Firebase Console](https://console.firebase.google.com/)
2. Klicke auf "Projekt hinzufügen"
3. Gib einen Projektnamen ein (z.B. "todo-kids-app")
4. Wähle deine Einstellungen (Google Analytics optional)
5. Erstelle das Projekt
### 2. Firebase Services aktivieren
#### Authentication einrichten:
1. Gehe zu "Authentication" > "Get started"
2. Wähle "Sign-in method"
3. Aktiviere "E-Mail/Passwort"
4. Speichere die Einstellungen
#### Firestore Database einrichten:
1. Gehe zu "Firestore Database" > "Datenbank erstellen"
2. Wähle "Im Testmodus starten" (für Entwicklung)
3. Wähle eine Region (z.B. europe-west3 für Deutschland)
4. Erstelle die Datenbank
### 3. Web-App konfigurieren
1. Gehe zu Projekteinstellungen (Zahnrad-Symbol)
2. Scrolle zu "Deine Apps" und klicke "</>"
3. Gib einen App-Namen ein (z.B. "ToDo Kids Web")
4. Registriere die App
5. Kopiere die Konfigurationsdaten
### 4. Umgebungsvariablen einrichten
Öffne die Datei `client/.env` und füge deine Firebase-Konfiguration ein:
```env
REACT_APP_FIREBASE_API_KEY=dein_api_key
REACT_APP_FIREBASE_AUTH_DOMAIN=dein_projekt.firebaseapp.com
REACT_APP_FIREBASE_PROJECT_ID=dein_projekt_id
REACT_APP_FIREBASE_STORAGE_BUCKET=dein_projekt.appspot.com
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=deine_sender_id
REACT_APP_FIREBASE_APP_ID=deine_app_id
```
### 5. App.js aktualisieren
Ersetze den alten AuthContext mit dem neuen FirebaseAuthContext:
```javascript
// Alte Imports entfernen:
// import { AuthProvider } from './contexts/AuthContext';
// Neue Imports hinzufügen:
import { FirebaseAuthProvider } from './contexts/FirebaseAuthContext';
// Provider in App.js ändern:
function App() {
return (
<FirebaseAuthProvider>
{/* Rest deiner App */}
</FirebaseAuthProvider>
);
}
```
### 6. Komponenten aktualisieren
In allen Komponenten, die Authentication verwenden:
```javascript
// Alte Imports ersetzen:
// import { useAuth } from '../contexts/AuthContext';
// Mit neuen Imports:
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
// Hook-Verwendung aktualisieren:
const { currentUser, login, logout } = useFirebaseAuth();
```
### 7. API-Aufrufe ersetzen
Ersetze alle Axios-API-Aufrufe mit Firebase-Funktionen:
```javascript
// Alte API-Aufrufe:
// const response = await axios.get('/api/children');
// Neue Firebase-Aufrufe:
import { getChildren } from '../firebase/database';
const result = await getChildren();
```
## 🔧 Wichtige Dateien
### Neue Firebase-Dateien:
- `client/src/firebase/config.js` - Firebase Konfiguration
- `client/src/firebase/auth.js` - Authentication Services
- `client/src/firebase/database.js` - Firestore Database Services
- `client/src/contexts/FirebaseAuthContext.js` - React Context für Auth
- `client/.env` - Umgebungsvariablen
### Zu aktualisierende Dateien:
- `client/src/App.js` - AuthProvider wechseln
- `client/src/components/LoginPage.js` - Firebase Auth verwenden
- `client/src/components/ParentDashboard.js` - Firebase Database verwenden
- `client/src/components/ChildrenManagement.js` - Firebase Database verwenden
- `client/src/components/TaskManagement.js` - Firebase Database verwenden
- `client/src/components/RewardManagement.js` - Firebase Database verwenden
## 🚀 Vorteile von Firebase
**Keine Server-Wartung** - Firebase übernimmt das Backend
**Automatische Skalierung** - Wächst mit deiner App
**Echtzeit-Updates** - Daten werden automatisch synchronisiert
**Offline-Support** - App funktioniert auch ohne Internet
**Sichere Authentication** - Professionelle Benutzer-Verwaltung
**Kostenlos für kleine Apps** - Großzügige kostenlose Limits
## 🔒 Sicherheitsregeln (später)
Nach der Migration solltest du Firestore-Sicherheitsregeln einrichten:
```javascript
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Nur authentifizierte Benutzer
match /{document=**} {
allow read, write: if request.auth != null;
}
// Benutzer können nur ihre eigenen Daten sehen
match /children/{childId} {
allow read, write: if request.auth.uid == resource.data.parentId;
}
}
}
```
## 🆘 Hilfe
Bei Problemen:
1. Überprüfe die Browser-Konsole auf Fehler
2. Stelle sicher, dass alle Umgebungsvariablen korrekt sind
3. Überprüfe die Firebase-Konsole auf Fehler
4. Teste zuerst die Authentication, dann die Database-Funktionen
## 📚 Nächste Schritte
1. Firebase-Konfiguration einrichten
2. App.js aktualisieren
3. Login-Komponente migrieren
4. Dashboard-Komponente migrieren
5. Alle anderen Komponenten schrittweise migrieren
6. Lokales Backend deaktivieren
7. Testen und optimieren
**Viel Erfolg bei der Migration! 🎉**

107
FIREBASE_SETUP.md Normal file
View File

@ -0,0 +1,107 @@
# 🔥 Firebase Setup - Schnellstart
## 1. Firebase Projekt erstellen (5 Minuten)
### Schritt 1: Firebase Console
1. Gehe zu [Firebase Console](https://console.firebase.google.com/)
2. Klicke "Projekt hinzufügen"
3. Projektname: `todo-kids-app` (oder dein Wunschname)
4. Google Analytics: **Deaktivieren** (für einfacheren Start)
5. "Projekt erstellen" klicken
### Schritt 2: Authentication aktivieren
1. Im Firebase-Projekt: **Authentication** → "Loslegen"
2. **Sign-in method** Tab
3. **E-Mail/Passwort** aktivieren
4. **Speichern**
### Schritt 3: Firestore Database erstellen
1. **Firestore Database** → "Datenbank erstellen"
2. **Im Testmodus starten** wählen
3. Standort: **europe-west3** (Deutschland)
4. **Fertig**
### Schritt 4: Web-App registrieren
1. Projektübersicht → **Web-App hinzufügen** `</>`
2. App-Name: `ToDo Kids Web`
3. **App registrieren**
4. **Konfiguration kopieren** (wichtig!)
## 2. Lokale Konfiguration (2 Minuten)
### Firebase-Konfiguration einfügen
Öffne `client/.env` und ersetze die Platzhalter:
```env
REACT_APP_FIREBASE_API_KEY=AIzaSyC...
REACT_APP_FIREBASE_AUTH_DOMAIN=todo-kids-app.firebaseapp.com
REACT_APP_FIREBASE_PROJECT_ID=todo-kids-app
REACT_APP_FIREBASE_STORAGE_BUCKET=todo-kids-app.appspot.com
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=123456789
REACT_APP_FIREBASE_APP_ID=1:123456789:web:abc123
```
**Wichtig:** Verwende deine echten Werte aus der Firebase Console!
## 3. App starten
```bash
cd client
npm start
```
## 4. Testen
1. **Registrierung testen:**
- Gehe zu `/register`
- Erstelle einen Account
- Sollte funktionieren! ✅
2. **Login testen:**
- Gehe zu `/login`
- Melde dich an
- Dashboard sollte laden ✅
## 🚨 Häufige Probleme
### Problem: "Firebase: Error (auth/configuration-not-found)"
**Lösung:** Überprüfe deine `.env` Datei - alle Werte müssen korrekt sein
### Problem: "Firebase: Error (auth/api-key-not-valid)"
**Lösung:** API Key in `.env` ist falsch - kopiere ihn nochmal aus Firebase Console
### Problem: "Firestore: Missing or insufficient permissions"
**Lösung:**
1. Firebase Console → Firestore Database → Rules
2. Ändere zu:
```javascript
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
allow read, write: if request.auth != null;
}
}
}
```
3. **Veröffentlichen** klicken
## 🎉 Fertig!
Deine App läuft jetzt mit Firebase!
**Vorteile:**
- ✅ Kein lokaler Server nötig
- ✅ Automatische Backups
- ✅ Skaliert automatisch
- ✅ Professionelle Authentication
- ✅ Kostenlos für kleine Apps
## 📱 Nächste Schritte
1. **Sicherheitsregeln verfeinern** (später)
2. **Offline-Support aktivieren** (automatisch)
3. **Push-Benachrichtigungen** (optional)
4. **Analytics hinzufügen** (optional)
**Viel Spaß mit deiner Firebase-App! 🚀**

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 ToDo Kids
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

212
README.md Normal file
View File

@ -0,0 +1,212 @@
# 📝 ToDo Kids - Aufgaben-App für Kinder
Eine kindgerechte To-Do-Listen-App mit verschiedenen Themes, Belohnungssystem und Firebase-Integration.
## 🌟 Features
### 🎨 Verschiedene Themes
- **🌈 Bunt & Fröhlich**: Farbenfrohes Standard-Theme
- **🌲 Wald**: Authentische Waldatmosphäre mit Bäumen und Tieren
- **🚀 Weltraum**: Futuristische Weltraum-Optik mit Sternen und Planeten
- **🌊 Ozean**: Unterwasser-Theme mit Fischen und Wellen
- **🌿 Natur**: Grünes Natur-Theme mit Pflanzen
### 👨‍👩‍👧‍👦 Familien-Management
- Mehrere Kinder pro Familie
- Individuelle Profile und Einstellungen
- Altersgerechte Benutzeroberflächen
### ✅ Aufgaben-System
- Erstellen und Verwalten von Aufgaben
- Prioritäten und Kategorien
- Fortschrittsverfolgung
- Belohnungspunkte
### 🏆 Belohnungs-System
- Punkte sammeln für erledigte Aufgaben
- Achievements und Erfolge
- Motivierende Belohnungen
## 🛠️ Technologie-Stack
### Frontend
- **React** 18.x
- **Material-UI (MUI)** 5.x
- **React Router** für Navigation
- **Context API** für State Management
### Backend
- **Node.js** mit Express
- **Firebase** Authentication & Firestore
- **JWT** für Session-Management
### Styling
- **Material-UI Theming**
- **CSS-in-JS**
- **Responsive Design**
- **SVG-Animationen**
## 🚀 Installation
### Voraussetzungen
- Node.js (Version 16 oder höher)
- npm oder yarn
- Firebase-Projekt
### 1. Repository klonen
```bash
git clone <repository-url>
cd "ToDo Kids"
```
### 2. Dependencies installieren
```bash
# Backend Dependencies
npm install
# Frontend Dependencies
cd client
npm install
cd ..
```
### 3. Environment Variables einrichten
#### Backend (.env)
```env
PORT=5000
JWT_SECRET=your_jwt_secret_here
FIREBASE_PROJECT_ID=your_firebase_project_id
FIREBASE_PRIVATE_KEY=your_firebase_private_key
FIREBASE_CLIENT_EMAIL=your_firebase_client_email
```
#### Frontend (client/.env)
```env
REACT_APP_FIREBASE_API_KEY=your_api_key
REACT_APP_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
REACT_APP_FIREBASE_PROJECT_ID=your_project_id
REACT_APP_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
REACT_APP_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
REACT_APP_FIREBASE_APP_ID=your_app_id
REACT_APP_API_URL=http://localhost:5000/api
```
### 4. Firebase einrichten
1. Firebase-Projekt erstellen
2. Authentication aktivieren (Email/Password)
3. Firestore Database erstellen
4. Service Account Key generieren
5. Environment Variables konfigurieren
Detaillierte Anweisungen: [FIREBASE_SETUP.md](FIREBASE_SETUP.md)
## 🏃‍♂️ Entwicklung starten
### Backend starten
```bash
npm run dev
```
### Frontend starten
```bash
cd client
npm start
```
Die App ist dann verfügbar unter:
- Frontend: http://localhost:3000
- Backend: http://localhost:5000
## 📁 Projektstruktur
```
ToDo Kids/
├── client/ # React Frontend
│ ├── public/
│ ├── src/
│ │ ├── components/ # Wiederverwendbare Komponenten
│ │ ├── contexts/ # React Context Provider
│ │ ├── firebase/ # Firebase Konfiguration
│ │ ├── pages/ # Seiten-Komponenten
│ │ └── themes/ # Theme-Definitionen
│ └── package.json
├── middleware/ # Express Middleware
├── models/ # Datenmodelle
├── routes/ # API Routes
├── .env # Backend Environment Variables
├── .gitignore
├── package.json
└── server.js # Express Server
```
## 🎨 Theme-System
Das Theme-System ermöglicht es Kindern, ihre bevorzugte Optik zu wählen:
- **Dynamische Themes**: Jedes Kind kann sein eigenes Theme wählen
- **SVG-Animationen**: Lebendige, aber nicht ablenkende Animationen
- **Responsive Design**: Funktioniert auf allen Geräten
- **Accessibility**: Kindgerechte Farben und Kontraste
## 🔐 Sicherheit
- **Firebase Authentication**: Sichere Benutzeranmeldung
- **JWT Tokens**: Session-Management
- **Input Validation**: Schutz vor schädlichen Eingaben
- **Environment Variables**: Sensible Daten sind geschützt
## 🧪 Testing
```bash
# Frontend Tests
cd client
npm test
# Backend Tests (falls implementiert)
npm test
```
## 📦 Deployment
### Frontend (Netlify/Vercel)
```bash
cd client
npm run build
```
### Backend (Heroku/Railway)
```bash
npm start
```
## 🤝 Beitragen
1. Fork das Repository
2. Erstelle einen Feature Branch (`git checkout -b feature/AmazingFeature`)
3. Committe deine Änderungen (`git commit -m 'Add some AmazingFeature'`)
4. Push zum Branch (`git push origin feature/AmazingFeature`)
5. Öffne einen Pull Request
## 📄 Lizenz
Dieses Projekt steht unter der MIT-Lizenz. Siehe [LICENSE](LICENSE) für Details.
## 📞 Support
Bei Fragen oder Problemen:
- Erstelle ein Issue im Repository
- Kontaktiere das Entwicklungsteam
## 🔄 Changelog
### Version 1.0.0
- ✅ Grundlegende Aufgaben-Funktionalität
- ✅ Firebase-Integration
- ✅ 5 verschiedene Themes
- ✅ Familien-Management
- ✅ Belohnungssystem
---
**Entwickelt mit ❤️ für Kinder und Familien**

210
SECURITY.md Normal file
View File

@ -0,0 +1,210 @@
# 🔒 Sicherheitsrichtlinien
## 🛡️ Unterstützte Versionen
Wir unterstützen die folgenden Versionen von ToDo Kids mit Sicherheitsupdates:
| Version | Unterstützt |
| ------- | ------------------ |
| 1.0.x | ✅ Ja |
| < 1.0 | Nein |
## 🚨 Sicherheitslücke melden
Wir nehmen die Sicherheit von ToDo Kids sehr ernst. Wenn du eine Sicherheitslücke entdeckst, befolge bitte diese Schritte:
### 📧 Verantwortungsvolle Offenlegung
**BITTE ERSTELLE KEINE ÖFFENTLICHEN GITHUB ISSUES FÜR SICHERHEITSPROBLEME**
Stattdessen:
1. **E-Mail senden**: Sende eine E-Mail an `security@todo-kids.com`
2. **Beschreibung**: Beschreibe die Sicherheitslücke detailliert
3. **Schritte**: Gib Schritte zur Reproduktion an
4. **Impact**: Erkläre den möglichen Schaden
5. **Beweis**: Füge Screenshots oder Code-Beispiele hinzu (falls möglich)
### 📝 Was in den Bericht gehört
```
Betreff: [SECURITY] Kurze Beschreibung der Sicherheitslücke
1. Zusammenfassung:
- Was ist das Problem?
- Welche Komponente ist betroffen?
2. Technische Details:
- Genaue Schritte zur Reproduktion
- Betroffene URLs/Endpunkte
- Browser/System-Informationen
3. Impact:
- Wer ist betroffen?
- Was kann ein Angreifer tun?
- Wie schwerwiegend ist das Problem?
4. Vorgeschlagene Lösung (optional):
- Wie könnte das Problem behoben werden?
5. Anhänge:
- Screenshots
- Code-Beispiele
- Logs (ohne sensible Daten)
```
### ⏱️ Response-Zeiten
- **Bestätigung**: Innerhalb von 24 Stunden
- **Erste Bewertung**: Innerhalb von 72 Stunden
- **Status-Update**: Wöchentlich bis zur Lösung
- **Fix-Deployment**: Je nach Schweregrad (siehe unten)
### 🎯 Schweregrad-Klassifizierung
#### 🔴 Kritisch (24-48 Stunden)
- Remote Code Execution
- SQL Injection
- Authentication Bypass
- Vollständiger Datenverlust möglich
#### 🟠 Hoch (1 Woche)
- Cross-Site Scripting (XSS)
- Cross-Site Request Forgery (CSRF)
- Privilege Escalation
- Sensible Daten-Exposition
#### 🟡 Mittel (2-4 Wochen)
- Information Disclosure
- Denial of Service
- Schwache Kryptographie
- Session-Management Probleme
#### 🟢 Niedrig (1-3 Monate)
- Konfigurationsprobleme
- Schwache Passwort-Richtlinien
- Informative Error Messages
- Minor Information Leaks
## 🛡️ Sicherheitsmaßnahmen
### 🔐 Authentifizierung & Autorisierung
- **Firebase Authentication**: Sichere Benutzer-Authentifizierung
- **JWT Tokens**: Sichere Session-Verwaltung
- **Role-based Access**: Eltern/Kind-Rollen-System
- **Input Validation**: Alle Eingaben werden validiert
### 🌐 Web-Sicherheit
- **HTTPS**: Verschlüsselte Datenübertragung
- **CORS**: Konfigurierte Cross-Origin-Richtlinien
- **CSP**: Content Security Policy Headers
- **XSS Protection**: Input-Sanitization
- **CSRF Protection**: Token-basierter Schutz
### 🗄️ Daten-Sicherheit
- **Encryption at Rest**: Firebase-Verschlüsselung
- **Encryption in Transit**: TLS 1.3
- **Data Minimization**: Nur notwendige Daten speichern
- **Regular Backups**: Automatische Datensicherung
- **Access Logs**: Überwachung von Datenzugriffen
### 🔧 Infrastruktur-Sicherheit
- **Dependency Scanning**: Regelmäßige Überprüfung
- **Security Headers**: Sichere HTTP-Headers
- **Rate Limiting**: Schutz vor Brute-Force
- **Error Handling**: Keine sensiblen Informationen in Fehlern
## 🔍 Sicherheits-Audit
### 📊 Regelmäßige Überprüfungen
- **Dependency Updates**: Wöchentlich
- **Security Scans**: Monatlich
- **Code Reviews**: Bei jedem Pull Request
- **Penetration Tests**: Halbjährlich
### 🛠️ Tools & Services
- **npm audit**: Dependency-Vulnerabilities
- **Snyk**: Kontinuierliche Sicherheitsüberwachung
- **ESLint Security**: Code-Analyse
- **OWASP ZAP**: Web-Application Security Testing
## 👨‍👩‍👧‍👦 Kinder-Sicherheit
### 🔒 Datenschutz für Kinder
- **COPPA Compliance**: Schutz von Kindern unter 13
- **GDPR Compliance**: Europäische Datenschutz-Grundverordnung
- **Minimal Data Collection**: Nur notwendige Informationen
- **Parental Consent**: Elterliche Zustimmung erforderlich
### 🛡️ Content-Sicherheit
- **Content Filtering**: Keine unangemessenen Inhalte
- **Safe Themes**: Kindgerechte Designs
- **No External Links**: Keine Verlinkung zu externen Seiten
- **Moderated Content**: Überwachung von Benutzer-Inhalten
## 🚨 Incident Response
### 📋 Notfall-Verfahren
1. **Sofortige Maßnahmen**
- Betroffene Systeme isolieren
- Schaden begrenzen
- Logs sichern
2. **Kommunikation**
- Interne Teams benachrichtigen
- Benutzer informieren (falls nötig)
- Behörden kontaktieren (falls erforderlich)
3. **Wiederherstellung**
- Sicherheitslücke schließen
- Systeme wiederherstellen
- Monitoring verstärken
4. **Post-Incident**
- Ursachen-Analyse
- Verbesserungen implementieren
- Dokumentation aktualisieren
### 📞 Notfall-Kontakte
- **Security Team**: security@todo-kids.com
- **Technical Lead**: tech@todo-kids.com
- **Management**: management@todo-kids.com
## 📚 Sicherheits-Ressourcen
### 🔗 Hilfreiche Links
- [OWASP Top 10](https://owasp.org/www-project-top-ten/)
- [Firebase Security Rules](https://firebase.google.com/docs/rules)
- [React Security Best Practices](https://snyk.io/blog/10-react-security-best-practices/)
- [Node.js Security Checklist](https://blog.risingstack.com/node-js-security-checklist/)
### 📖 Weitere Dokumentation
- [Privacy Policy](./PRIVACY.md)
- [Terms of Service](./TERMS.md)
- [Contributing Guidelines](./CONTRIBUTING.md)
## 🏆 Anerkennung
Wir danken allen Sicherheitsforschern, die verantwortungsvoll Sicherheitslücken melden. Anerkannte Beiträge werden in unserer Hall of Fame aufgeführt:
### 🌟 Security Hall of Fame
*Noch keine Einträge - sei der Erste!*
---
**Vielen Dank, dass du zur Sicherheit von ToDo Kids beiträgst! 🛡️**
*Letzte Aktualisierung: Januar 2024*

23
client/.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*

70
client/README.md Normal file
View File

@ -0,0 +1,70 @@
# Getting Started with Create React App
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
## Available Scripts
In the project directory, you can run:
### `npm start`
Runs the app in the development mode.\
Open [http://localhost:3000](http://localhost:3000) to view it in your browser.
The page will reload when you make changes.\
You may also see any lint errors in the console.
### `npm test`
Launches the test runner in the interactive watch mode.\
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### `npm run build`
Builds the app for production to the `build` folder.\
It correctly bundles React in production mode and optimizes the build for the best performance.
The build is minified and the filenames include the hashes.\
Your app is ready to be deployed!
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
### `npm run eject`
**Note: this is a one-way operation. Once you `eject`, you can't go back!**
If you aren't satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you're on your own.
You don't have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn't feel obligated to use this feature. However we understand that this tool wouldn't be useful if you couldn't customize it when you are ready for it.
## Learn More
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
To learn React, check out the [React documentation](https://reactjs.org/).
### Code Splitting
This section has moved here: [https://facebook.github.io/create-react-app/docs/code-splitting](https://facebook.github.io/create-react-app/docs/code-splitting)
### Analyzing the Bundle Size
This section has moved here: [https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size](https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size)
### Making a Progressive Web App
This section has moved here: [https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app](https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app)
### Advanced Configuration
This section has moved here: [https://facebook.github.io/create-react-app/docs/advanced-configuration](https://facebook.github.io/create-react-app/docs/advanced-configuration)
### Deployment
This section has moved here: [https://facebook.github.io/create-react-app/docs/deployment](https://facebook.github.io/create-react-app/docs/deployment)
### `npm run build` fails to minify
This section has moved here: [https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify](https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify)

19210
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

61
client/package.json Normal file
View File

@ -0,0 +1,61 @@
{
"name": "todo-kids-client",
"version": "1.0.0",
"description": "React Frontend für ToDo Kids - Eine spielerische To-Do- & Belohnungs-App für Kinder",
"private": true,
"homepage": "https://github.com/DEIN-USERNAME/todo-kids",
"repository": {
"type": "git",
"url": "git+https://github.com/DEIN-USERNAME/todo-kids.git"
},
"keywords": ["react", "material-ui", "todo", "kids", "themes", "family"],
"author": "ToDo Kids Team",
"license": "MIT",
"dependencies": {
"@date-io/date-fns": "^3.2.1",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.2.0",
"@mui/lab": "^7.0.0-beta.14",
"@mui/material": "^7.2.0",
"@mui/x-date-pickers": "^8.9.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^6.6.4",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.11.0",
"date-fns": "^4.1.0",
"firebase": "^12.0.0",
"framer-motion": "^12.23.12",
"react": "^19.1.1",
"react-confetti": "^6.4.0",
"react-dom": "^19.1.1",
"react-router-dom": "^7.7.1",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

38
client/src/App.css Normal file
View File

@ -0,0 +1,38 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

154
client/src/App.js Normal file
View File

@ -0,0 +1,154 @@
import React from 'react';
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { ThemeProvider, createTheme } from '@mui/material/styles';
import CssBaseline from '@mui/material/CssBaseline';
import { FirebaseAuthProvider } from './contexts/FirebaseAuthContext';
import { FirebaseChildAuthProvider } from './contexts/FirebaseChildAuthContext';
import { ChildThemeProvider } from './contexts/ChildThemeContext';
import { NotificationProvider } from './contexts/NotificationContext';
import ProtectedRoute from './components/ProtectedRoute';
import ChildProtectedRoute from './components/ChildProtectedRoute';
import LandingPage from './pages/LandingPage';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import ChildLoginPage from './pages/ChildLoginPage';
import ParentDashboard from './pages/ParentDashboard';
import ChildDashboard from './pages/ChildDashboard';
import TaskManagement from './pages/TaskManagement';
import RewardManagement from './pages/RewardManagement';
import ChildrenManagement from './pages/ChildrenManagement';
import './App.css';
// Familien-Held Theme
const theme = createTheme({
palette: {
primary: {
main: '#4CAF50', // Freundliches Grün
light: '#81C784',
dark: '#388E3C',
},
secondary: {
main: '#FF9800', // Warmes Orange
light: '#FFB74D',
dark: '#F57C00',
},
success: {
main: '#4CAF50',
},
warning: {
main: '#FF9800',
},
error: {
main: '#F44336',
},
background: {
default: '#F8F9FA',
paper: '#FFFFFF',
},
},
typography: {
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
h1: {
fontWeight: 700,
fontSize: '2.5rem',
},
h2: {
fontWeight: 600,
fontSize: '2rem',
},
h3: {
fontWeight: 600,
fontSize: '1.5rem',
},
button: {
textTransform: 'none',
fontWeight: 600,
},
},
shape: {
borderRadius: 12,
},
components: {
MuiButton: {
styleOverrides: {
root: {
borderRadius: 12,
padding: '10px 24px',
fontSize: '1rem',
},
contained: {
boxShadow: '0 4px 12px rgba(76, 175, 80, 0.3)',
'&:hover': {
boxShadow: '0 6px 16px rgba(76, 175, 80, 0.4)',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 16,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)',
},
},
},
},
});
function App() {
return (
<ThemeProvider theme={theme}>
<CssBaseline />
<FirebaseAuthProvider>
<FirebaseChildAuthProvider>
<NotificationProvider>
<Router>
<div className="App">
<Routes>
{/* Öffentliche Routen */}
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/child-login" element={<ChildLoginPage />} />
{/* Geschützte Routen für Eltern */}
<Route path="/dashboard" element={
<ProtectedRoute>
<ParentDashboard />
</ProtectedRoute>
} />
<Route path="/tasks" element={
<ProtectedRoute>
<TaskManagement />
</ProtectedRoute>
} />
<Route path="/rewards" element={
<ProtectedRoute>
<RewardManagement />
</ProtectedRoute>
} />
<Route path="/children" element={
<ProtectedRoute>
<ChildrenManagement />
</ProtectedRoute>
} />
{/* Kinder-Ansicht */}
<Route path="/child-dashboard" element={
<ChildProtectedRoute>
<ChildThemeProvider>
<ChildDashboard />
</ChildThemeProvider>
</ChildProtectedRoute>
} />
</Routes>
</div>
</Router>
</NotificationProvider>
</FirebaseChildAuthProvider>
</FirebaseAuthProvider>
</ThemeProvider>
);
}
export default App;

8
client/src/App.test.js Normal file
View File

@ -0,0 +1,8 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -0,0 +1,24 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useFirebaseChildAuth } from '../contexts/FirebaseChildAuthContext';
import { CircularProgress, Box } from '@mui/material';
const ChildProtectedRoute = ({ children }) => {
const { child, loading } = useFirebaseChildAuth();
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<CircularProgress />
</Box>
);
}
if (!child) {
return <Navigate to="/child-login" replace />;
}
return children;
};
export default ChildProtectedRoute;

View File

@ -0,0 +1,66 @@
import React from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
import { Box, CircularProgress, Typography } from '@mui/material';
import { motion } from 'framer-motion';
const ProtectedRoute = ({ children }) => {
const { isAuthenticated, loading } = useFirebaseAuth();
const location = useLocation();
// Während des Ladens
if (loading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)',
color: 'white'
}}
>
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.5 }}
>
<Box sx={{ textAlign: 'center' }}>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
>
<CircularProgress
size={60}
sx={{
color: 'white',
mb: 3
}}
/>
</motion.div>
<Typography variant="h4" sx={{ mb: 2, fontWeight: 700 }}>
🦸 Familien-Held
</Typography>
<Typography variant="h6" sx={{ opacity: 0.9 }}>
Lade deine Helden-Zentrale...
</Typography>
</Box>
</motion.div>
</Box>
);
}
// Nicht authentifiziert - zur Login-Seite weiterleiten
if (!isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
// Authentifiziert - Kinder-Komponente rendern
return children;
};
export default ProtectedRoute;

View File

@ -0,0 +1,168 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
const AuthContext = createContext();
// API Base URL
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:5000/api';
// Axios Konfiguration
axios.defaults.baseURL = API_BASE_URL;
// Token aus localStorage setzen
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
export const useAuth = () => {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth muss innerhalb eines AuthProvider verwendet werden');
}
return context;
};
export const AuthProvider = ({ children }) => {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [isAuthenticated, setIsAuthenticated] = useState(false);
// Benutzer beim App-Start laden
useEffect(() => {
const loadUser = async () => {
const token = localStorage.getItem('token');
if (token) {
try {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
const response = await axios.get('/auth/me');
if (response.data.success) {
setUser(response.data.user);
setIsAuthenticated(true);
} else {
// Token ungültig
localStorage.removeItem('token');
delete axios.defaults.headers.common['Authorization'];
}
} catch (error) {
// Nur echte Fehler loggen, nicht Verbindungsfehler beim App-Start
if (error.code !== 'ERR_NETWORK' && error.code !== 'ERR_CONNECTION_REFUSED') {
console.error('Fehler beim Laden des Benutzers:', error);
}
localStorage.removeItem('token');
delete axios.defaults.headers.common['Authorization'];
}
}
setLoading(false);
};
loadUser();
}, []);
// Registrierung
const register = async (userData) => {
try {
const response = await axios.post('/auth/register', userData);
if (response.data.success) {
const { token, user } = response.data;
// Token speichern
localStorage.setItem('token', token);
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
// Benutzer setzen
setUser(user);
setIsAuthenticated(true);
return { success: true, message: response.data.message };
}
} catch (error) {
const message = error.response?.data?.message || 'Registrierung fehlgeschlagen';
return { success: false, message, errors: error.response?.data?.errors };
}
};
// Anmeldung
const login = async (credentials) => {
try {
const response = await axios.post('/auth/login', credentials);
if (response.data.success) {
const { token, user } = response.data;
// Token speichern
localStorage.setItem('token', token);
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
// Benutzer setzen
setUser(user);
setIsAuthenticated(true);
return { success: true, message: response.data.message };
}
} catch (error) {
const message = error.response?.data?.message || 'Anmeldung fehlgeschlagen';
return { success: false, message };
}
};
// Abmeldung
const logout = async () => {
try {
await axios.post('/auth/logout');
} catch (error) {
console.error('Fehler beim Logout:', error);
} finally {
// Lokale Daten löschen
localStorage.removeItem('token');
delete axios.defaults.headers.common['Authorization'];
setUser(null);
setIsAuthenticated(false);
}
};
// Profil aktualisieren
const updateProfile = async (profileData) => {
try {
const response = await axios.put('/auth/profile', profileData);
if (response.data.success) {
setUser(response.data.user);
return { success: true, message: response.data.message };
}
} catch (error) {
const message = error.response?.data?.message || 'Profil-Update fehlgeschlagen';
return { success: false, message, errors: error.response?.data?.errors };
}
};
// Passwort ändern
const changePassword = async (passwordData) => {
try {
const response = await axios.put('/auth/password', passwordData);
if (response.data.success) {
return { success: true, message: response.data.message };
}
} catch (error) {
const message = error.response?.data?.message || 'Passwort-Änderung fehlgeschlagen';
return { success: false, message, errors: error.response?.data?.errors };
}
};
const value = {
user,
loading,
isAuthenticated,
register,
login,
logout,
updateProfile,
changePassword
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
};
export default AuthContext;

View File

@ -0,0 +1,125 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import axios from 'axios';
const ChildAuthContext = createContext();
export const useChildAuth = () => {
const context = useContext(ChildAuthContext);
if (!context) {
throw new Error('useChildAuth muss innerhalb eines ChildAuthProvider verwendet werden');
}
return context;
};
export const ChildAuthProvider = ({ children }) => {
const [child, setChild] = useState(null);
const [loading, setLoading] = useState(true);
const [token, setToken] = useState(localStorage.getItem('childToken'));
// Axios Interceptor für automatische Token-Anhängung
useEffect(() => {
const interceptor = axios.interceptors.request.use(
(config) => {
if (token && config.url?.includes('/child-auth/') || config.url?.includes('/children/')) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
return () => axios.interceptors.request.eject(interceptor);
}, [token]);
// Token-Validierung beim Start
useEffect(() => {
const validateToken = async () => {
if (!token) {
setLoading(false);
return;
}
try {
const response = await axios.get('/child-auth/verify-token', {
headers: { Authorization: `Bearer ${token}` }
});
if (response.data.success) {
setChild(response.data.child);
} else {
// Token ungültig
localStorage.removeItem('childToken');
localStorage.removeItem('childData');
setToken(null);
setChild(null);
}
} catch (error) {
console.error('Token-Validierung fehlgeschlagen:', error);
localStorage.removeItem('childToken');
localStorage.removeItem('childData');
setToken(null);
setChild(null);
} finally {
setLoading(false);
}
};
validateToken();
}, [token]);
const login = async (username, pin) => {
try {
const response = await axios.post('/child-auth/login', {
username,
pin
});
if (response.data.success) {
const { token: newToken, child: childData } = response.data;
localStorage.setItem('childToken', newToken);
localStorage.setItem('childData', JSON.stringify(childData));
setToken(newToken);
setChild(childData);
return { success: true, child: childData };
}
} catch (error) {
console.error('Login-Fehler:', error);
return {
success: false,
message: error.response?.data?.message || 'Anmeldung fehlgeschlagen'
};
}
};
const logout = () => {
localStorage.removeItem('childToken');
localStorage.removeItem('childData');
setToken(null);
setChild(null);
};
const updateChildData = (updatedChild) => {
setChild(updatedChild);
localStorage.setItem('childData', JSON.stringify(updatedChild));
};
const value = {
child,
loading,
isAuthenticated: !!child,
login,
logout,
updateChildData
};
return (
<ChildAuthContext.Provider value={value}>
{children}
</ChildAuthContext.Provider>
);
};
export default ChildAuthContext;

View File

@ -0,0 +1,61 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { ThemeProvider } from '@mui/material/styles';
import { getChildTheme, childThemes } from '../themes/childThemes';
import { useFirebaseChildAuth } from './FirebaseChildAuthContext';
const ChildThemeContext = createContext();
export const useChildTheme = () => {
const context = useContext(ChildThemeContext);
if (!context) {
throw new Error('useChildTheme must be used within a ChildThemeProvider');
}
return context;
};
export const ChildThemeProvider = ({ children }) => {
const { child } = useFirebaseChildAuth();
const [currentTheme, setCurrentTheme] = useState(childThemes.colorful);
const [themeName, setThemeName] = useState('colorful');
// Theme basierend auf Kinder-Preferences aktualisieren
useEffect(() => {
if (child && child.preferences) {
const newThemeName = child.preferences.theme || 'colorful';
const newTheme = getChildTheme(child.preferences);
setThemeName(newThemeName);
setCurrentTheme(newTheme);
console.log('🎨 Theme aktualisiert:', newThemeName, child.preferences);
} else {
// Fallback zum Standard-Theme
setThemeName('colorful');
setCurrentTheme(childThemes.colorful);
}
}, [child]);
const changeTheme = (newThemeName) => {
if (childThemes[newThemeName]) {
setThemeName(newThemeName);
setCurrentTheme(childThemes[newThemeName]);
}
};
const value = {
currentTheme,
themeName,
changeTheme,
availableThemes: Object.keys(childThemes),
};
return (
<ChildThemeContext.Provider value={value}>
<ThemeProvider theme={currentTheme}>
{children}
</ThemeProvider>
</ChildThemeContext.Provider>
);
};
export default ChildThemeProvider;

View File

@ -0,0 +1,279 @@
// Firebase Authentication Context
import React, { createContext, useContext, useState, useEffect } from 'react';
import {
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signOut,
onAuthStateChanged,
updateProfile,
updatePassword,
sendPasswordResetEmail
} from 'firebase/auth';
import { auth } from '../firebase/config';
// Context erstellen
const FirebaseAuthContext = createContext();
// Hook zum Verwenden des Contexts
export const useFirebaseAuth = () => {
const context = useContext(FirebaseAuthContext);
if (!context) {
throw new Error('useFirebaseAuth muss innerhalb eines FirebaseAuthProvider verwendet werden');
}
return context;
};
// Provider Component
export const FirebaseAuthProvider = ({ children }) => {
const [currentUser, setCurrentUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
// Benutzer-Status überwachen
useEffect(() => {
const unsubscribe = onAuthStateChanged(auth, (user) => {
setCurrentUser(user);
setLoading(false);
});
return unsubscribe; // Cleanup
}, []);
// Registrierung
const register = async (email, password, displayName) => {
try {
setError(null);
setLoading(true);
// Benutzer erstellen
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
// Profil aktualisieren
if (displayName) {
await updateProfile(userCredential.user, {
displayName: displayName
});
}
return {
success: true,
message: 'Registrierung erfolgreich! 🎉'
};
} catch (error) {
console.error('Registrierungs-Fehler:', error);
let errorMessage = 'Fehler bei der Registrierung';
switch (error.code) {
case 'auth/email-already-in-use':
errorMessage = 'Diese E-Mail-Adresse wird bereits verwendet';
break;
case 'auth/weak-password':
errorMessage = 'Das Passwort ist zu schwach (mindestens 6 Zeichen)';
break;
case 'auth/invalid-email':
errorMessage = 'Ungültige E-Mail-Adresse';
break;
default:
errorMessage = error.message;
}
setError(errorMessage);
return {
success: false,
message: errorMessage
};
} finally {
setLoading(false);
}
};
// Anmeldung
const login = async (email, password) => {
try {
setError(null);
setLoading(true);
await signInWithEmailAndPassword(auth, email, password);
return {
success: true,
message: 'Anmeldung erfolgreich! 👋'
};
} catch (error) {
console.error('Anmelde-Fehler:', error);
let errorMessage = 'Fehler bei der Anmeldung';
switch (error.code) {
case 'auth/user-not-found':
errorMessage = 'Benutzer nicht gefunden';
break;
case 'auth/wrong-password':
errorMessage = 'Falsches Passwort';
break;
case 'auth/invalid-email':
errorMessage = 'Ungültige E-Mail-Adresse';
break;
case 'auth/too-many-requests':
errorMessage = 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut';
break;
default:
errorMessage = error.message;
}
setError(errorMessage);
return {
success: false,
message: errorMessage
};
} finally {
setLoading(false);
}
};
// Abmeldung
const logout = async () => {
try {
setError(null);
await signOut(auth);
return {
success: true,
message: 'Erfolgreich abgemeldet! 👋'
};
} catch (error) {
console.error('Abmelde-Fehler:', error);
setError('Fehler bei der Abmeldung');
return {
success: false,
message: 'Fehler bei der Abmeldung'
};
}
};
// Profil aktualisieren
const updateUserProfile = async (profileData) => {
try {
setError(null);
if (!currentUser) {
throw new Error('Kein Benutzer angemeldet');
}
await updateProfile(currentUser, profileData);
return {
success: true,
message: 'Profil erfolgreich aktualisiert! ✅'
};
} catch (error) {
console.error('Profil-Update-Fehler:', error);
setError('Fehler beim Aktualisieren des Profils');
return {
success: false,
message: 'Fehler beim Aktualisieren des Profils'
};
}
};
// Passwort ändern
const changePassword = async (newPassword) => {
try {
setError(null);
if (!currentUser) {
throw new Error('Kein Benutzer angemeldet');
}
await updatePassword(currentUser, newPassword);
return {
success: true,
message: 'Passwort erfolgreich geändert! 🔒'
};
} catch (error) {
console.error('Passwort-Änderungs-Fehler:', error);
let errorMessage = 'Fehler beim Ändern des Passworts';
switch (error.code) {
case 'auth/weak-password':
errorMessage = 'Das neue Passwort ist zu schwach (mindestens 6 Zeichen)';
break;
case 'auth/requires-recent-login':
errorMessage = 'Bitte melden Sie sich erneut an, um das Passwort zu ändern';
break;
default:
errorMessage = error.message;
}
setError(errorMessage);
return {
success: false,
message: errorMessage
};
}
};
// Passwort zurücksetzen
const resetPassword = async (email) => {
try {
setError(null);
await sendPasswordResetEmail(auth, email);
return {
success: true,
message: 'Passwort-Reset-E-Mail gesendet! 📧'
};
} catch (error) {
console.error('Passwort-Reset-Fehler:', error);
let errorMessage = 'Fehler beim Senden der Reset-E-Mail';
switch (error.code) {
case 'auth/user-not-found':
errorMessage = 'Benutzer mit dieser E-Mail-Adresse nicht gefunden';
break;
case 'auth/invalid-email':
errorMessage = 'Ungültige E-Mail-Adresse';
break;
default:
errorMessage = error.message;
}
setError(errorMessage);
return {
success: false,
message: errorMessage
};
}
};
// Context-Wert
const value = {
currentUser,
loading,
error,
register,
login,
logout,
updateUserProfile,
changePassword,
resetPassword,
// Hilfsfunktionen
isAuthenticated: !!currentUser,
userEmail: currentUser?.email,
userName: currentUser?.displayName || currentUser?.email,
userId: currentUser?.uid
};
return (
<FirebaseAuthContext.Provider value={value}>
{!loading && children}
</FirebaseAuthContext.Provider>
);
};
export default FirebaseAuthContext;

View File

@ -0,0 +1,116 @@
// Firebase Child Authentication Context
import React, { createContext, useContext, useState, useEffect } from 'react';
import { loginChild, validateChildSession, logoutChild } from '../firebase/childAuth';
import { useFirebaseAuth } from './FirebaseAuthContext';
const FirebaseChildAuthContext = createContext();
export const useFirebaseChildAuth = () => {
const context = useContext(FirebaseChildAuthContext);
if (!context) {
throw new Error('useFirebaseChildAuth muss innerhalb eines FirebaseChildAuthProvider verwendet werden');
}
return context;
};
export const FirebaseChildAuthProvider = ({ children }) => {
const [child, setChild] = useState(null);
const [loading, setLoading] = useState(true);
const { currentUser } = useFirebaseAuth(); // Eltern müssen angemeldet sein
// Session beim Start validieren
useEffect(() => {
const validateSession = async () => {
const storedChildData = localStorage.getItem('currentChild');
if (!storedChildData) {
setLoading(false);
return;
}
try {
const childData = JSON.parse(storedChildData);
const result = await validateChildSession(childData.id);
if (result.success) {
setChild(result.child);
} else {
// Session ungültig - lokale Daten löschen
localStorage.removeItem('currentChild');
setChild(null);
}
} catch (error) {
console.error('Session-Validierung fehlgeschlagen:', error);
localStorage.removeItem('currentChild');
setChild(null);
} finally {
setLoading(false);
}
};
validateSession();
}, [currentUser]);
// Kinder-Login
const login = async (username, pin) => {
setLoading(true);
try {
const result = await loginChild(username, pin, currentUser);
if (result.success) {
setChild(result.child);
localStorage.setItem('currentChild', JSON.stringify(result.child));
}
return result;
} catch (error) {
console.error('Login-Fehler:', error);
return {
success: false,
message: 'Anmeldung fehlgeschlagen'
};
} finally {
setLoading(false);
}
};
// Kinder-Logout
const logout = () => {
const result = logoutChild();
setChild(null);
return result;
};
// Kind-Daten aktualisieren
const updateChildData = (updatedChild) => {
setChild(updatedChild);
localStorage.setItem('currentChild', JSON.stringify(updatedChild));
};
// Optional: Wenn Eltern sich abmelden, Kind auch abmelden
// Kommentiert aus, damit Kinder unabhängig angemeldet bleiben können
// useEffect(() => {
// if (!currentUser && child) {
// logout();
// }
// }, [currentUser, child]);
const value = {
child,
loading,
login,
logout,
updateChildData,
isChildAuthenticated: !!child,
childName: child?.name,
childId: child?.id
};
return (
<FirebaseChildAuthContext.Provider value={value}>
{children}
</FirebaseChildAuthContext.Provider>
);
};
export default FirebaseChildAuthContext;

View File

@ -0,0 +1,180 @@
import React, { createContext, useContext, useState } from 'react';
import { Snackbar, Alert, AlertTitle } from '@mui/material';
const NotificationContext = createContext();
export const useNotification = () => {
const context = useContext(NotificationContext);
if (!context) {
throw new Error('useNotification muss innerhalb eines NotificationProvider verwendet werden');
}
return context;
};
export const NotificationProvider = ({ children }) => {
const [notifications, setNotifications] = useState([]);
// Benachrichtigung hinzufügen
const showNotification = (message, type = 'info', title = null, duration = 6000) => {
const id = Date.now() + Math.random();
const notification = {
id,
message,
type, // 'success', 'error', 'warning', 'info'
title,
duration,
open: true
};
setNotifications(prev => [...prev, notification]);
// Auto-Hide nach duration
if (duration > 0) {
setTimeout(() => {
hideNotification(id);
}, duration);
}
return id;
};
// Benachrichtigung ausblenden
const hideNotification = (id) => {
setNotifications(prev =>
prev.map(notification =>
notification.id === id
? { ...notification, open: false }
: notification
)
);
// Nach Animation entfernen
setTimeout(() => {
setNotifications(prev =>
prev.filter(notification => notification.id !== id)
);
}, 300);
};
// Alle Benachrichtigungen löschen
const clearNotifications = () => {
setNotifications([]);
};
// Convenience-Methoden
const showSuccess = (message, title = null, duration = 4000) => {
return showNotification(message, 'success', title, duration);
};
const showError = (message, title = 'Fehler', duration = 8000) => {
return showNotification(message, 'error', title, duration);
};
const showWarning = (message, title = 'Warnung', duration = 6000) => {
return showNotification(message, 'warning', title, duration);
};
const showInfo = (message, title = null, duration = 5000) => {
return showNotification(message, 'info', title, duration);
};
// Spezielle Methoden für Familien-Held
const showTaskCompleted = (taskTitle, childName, points) => {
return showSuccess(
`${childName} hat "${taskTitle}" erledigt und ${points} Punkte erhalten! 🎉`,
'Aufgabe erledigt!',
5000
);
};
const showRewardRedeemed = (rewardTitle, childName, points) => {
return showSuccess(
`${childName} hat "${rewardTitle}" für ${points} Punkte eingelöst! 🎁`,
'Belohnung eingelöst!',
5000
);
};
const showLevelUp = (childName, newLevel, levelTitle) => {
return showSuccess(
`${childName} hat Level ${newLevel} erreicht: ${levelTitle}! 🆙`,
'Level Up!',
6000
);
};
const showAchievement = (childName, achievementName) => {
return showSuccess(
`${childName} hat ein neues Achievement erhalten: ${achievementName}! 🏆`,
'Neues Achievement!',
6000
);
};
const value = {
notifications,
showNotification,
hideNotification,
clearNotifications,
showSuccess,
showError,
showWarning,
showInfo,
// Spezielle Methoden
showTaskCompleted,
showRewardRedeemed,
showLevelUp,
showAchievement
};
return (
<NotificationContext.Provider value={value}>
{children}
{/* Benachrichtigungen rendern */}
{notifications.map((notification) => (
<Snackbar
key={notification.id}
open={notification.open}
autoHideDuration={null} // Wir handhaben das manuell
onClose={() => hideNotification(notification.id)}
anchorOrigin={{ vertical: 'top', horizontal: 'right' }}
sx={{
mt: notification.id === notifications[0]?.id ? 2 : 0,
'& .MuiSnackbar-root': {
position: 'relative',
transform: 'none',
mb: 1
}
}}
>
<Alert
onClose={() => hideNotification(notification.id)}
severity={notification.type}
variant="filled"
sx={{
width: '100%',
minWidth: 300,
maxWidth: 500,
borderRadius: 2,
boxShadow: 3,
'& .MuiAlert-message': {
fontSize: '0.95rem',
lineHeight: 1.4
}
}}
>
{notification.title && (
<AlertTitle sx={{ fontWeight: 600, mb: 0.5 }}>
{notification.title}
</AlertTitle>
)}
{notification.message}
</Alert>
</Snackbar>
))}
</NotificationContext.Provider>
);
};
export default NotificationContext;

217
client/src/firebase/auth.js Normal file
View File

@ -0,0 +1,217 @@
// Firebase Authentication Service
import {
signInWithEmailAndPassword,
createUserWithEmailAndPassword,
signOut,
updateProfile,
updatePassword,
EmailAuthProvider,
reauthenticateWithCredential
} from 'firebase/auth';
import { doc, setDoc, getDoc, updateDoc } from 'firebase/firestore';
import { auth, db } from './config';
// Benutzer registrieren
export const registerUser = async (userData) => {
try {
const { email, password, familyName, parentName } = userData;
// Firebase Auth Account erstellen
const userCredential = await createUserWithEmailAndPassword(auth, email, password);
const user = userCredential.user;
// Profil in Firebase Auth aktualisieren
await updateProfile(user, {
displayName: parentName
});
// Zusätzliche Benutzerdaten in Firestore speichern
await setDoc(doc(db, 'users', user.uid), {
email: user.email,
familyName,
parentName,
role: 'parent',
isActive: true,
createdAt: new Date(),
preferences: {
notifications: true,
theme: 'light'
}
});
return {
success: true,
message: 'Registrierung erfolgreich! 🎉',
user: {
uid: user.uid,
email: user.email,
familyName,
parentName
}
};
} catch (error) {
console.error('Registrierungsfehler:', error);
return {
success: false,
message: getErrorMessage(error.code)
};
}
};
// Benutzer anmelden
export const loginUser = async (credentials) => {
try {
const { email, password } = credentials;
const userCredential = await signInWithEmailAndPassword(auth, email, password);
const user = userCredential.user;
// Zusätzliche Benutzerdaten aus Firestore laden
const userDoc = await getDoc(doc(db, 'users', user.uid));
const userData = userDoc.data();
if (!userData || !userData.isActive) {
throw new Error('Account ist deaktiviert');
}
return {
success: true,
message: 'Anmeldung erfolgreich! 🎉',
user: {
uid: user.uid,
email: user.email,
...userData
}
};
} catch (error) {
console.error('Anmeldungsfehler:', error);
return {
success: false,
message: getErrorMessage(error.code)
};
}
};
// Benutzer abmelden
export const logoutUser = async () => {
try {
await signOut(auth);
return {
success: true,
message: 'Erfolgreich abgemeldet! 👋'
};
} catch (error) {
console.error('Abmeldungsfehler:', error);
return {
success: false,
message: 'Fehler beim Abmelden'
};
}
};
// Profil aktualisieren
export const updateUserProfile = async (profileData) => {
try {
const user = auth.currentUser;
if (!user) throw new Error('Nicht angemeldet');
const { familyName, parentName, preferences } = profileData;
// Firebase Auth Profil aktualisieren
if (parentName) {
await updateProfile(user, {
displayName: parentName
});
}
// Firestore Dokument aktualisieren
const updateData = {};
if (familyName) updateData.familyName = familyName;
if (parentName) updateData.parentName = parentName;
if (preferences) updateData.preferences = preferences;
updateData.updatedAt = new Date();
await updateDoc(doc(db, 'users', user.uid), updateData);
return {
success: true,
message: 'Profil erfolgreich aktualisiert! ✅'
};
} catch (error) {
console.error('Profil-Update-Fehler:', error);
return {
success: false,
message: 'Fehler beim Aktualisieren des Profils'
};
}
};
// Passwort ändern
export const changeUserPassword = async (passwordData) => {
try {
const user = auth.currentUser;
if (!user) throw new Error('Nicht angemeldet');
const { currentPassword, newPassword } = passwordData;
// Benutzer re-authentifizieren
const credential = EmailAuthProvider.credential(user.email, currentPassword);
await reauthenticateWithCredential(user, credential);
// Neues Passwort setzen
await updatePassword(user, newPassword);
return {
success: true,
message: 'Passwort erfolgreich geändert! 🔒'
};
} catch (error) {
console.error('Passwort-Änderungs-Fehler:', error);
return {
success: false,
message: getErrorMessage(error.code)
};
}
};
// Aktuellen Benutzer laden
export const getCurrentUser = async () => {
try {
const user = auth.currentUser;
if (!user) return null;
const userDoc = await getDoc(doc(db, 'users', user.uid));
const userData = userDoc.data();
return {
uid: user.uid,
email: user.email,
...userData
};
} catch (error) {
console.error('Benutzer-Abruf-Fehler:', error);
return null;
}
};
// Fehler-Nachrichten übersetzen
const getErrorMessage = (errorCode) => {
switch (errorCode) {
case 'auth/user-not-found':
return 'Benutzer nicht gefunden';
case 'auth/wrong-password':
return 'Falsches Passwort';
case 'auth/email-already-in-use':
return 'E-Mail-Adresse wird bereits verwendet';
case 'auth/weak-password':
return 'Passwort ist zu schwach';
case 'auth/invalid-email':
return 'Ungültige E-Mail-Adresse';
case 'auth/too-many-requests':
return 'Zu viele Anmeldeversuche. Bitte versuche es später erneut';
case 'auth/requires-recent-login':
return 'Bitte melde dich erneut an, um diese Aktion durchzuführen';
default:
return 'Ein unbekannter Fehler ist aufgetreten';
}
};

View File

@ -0,0 +1,153 @@
// Firebase Child Authentication Service
import {
collection,
query,
where,
getDocs,
doc,
getDoc,
updateDoc,
serverTimestamp
} from 'firebase/firestore';
import { auth, db } from './config';
// Kinder-Login mit Benutzername und PIN
export const loginChild = async (username, pin, currentUser = null) => {
try {
// Versuche zuerst mit angemeldetem Elternteil
const user = currentUser || auth.currentUser;
let q;
const childrenRef = collection(db, 'children');
if (user) {
// Kind mit Benutzername und parentId suchen (wenn Eltern angemeldet)
q = query(
childrenRef,
where('username', '==', username),
where('parentId', '==', user.uid),
where('isActive', '==', true)
);
} else {
// Kind nur mit Benutzername suchen (wenn keine Eltern angemeldet)
q = query(
childrenRef,
where('username', '==', username),
where('isActive', '==', true)
);
}
const querySnapshot = await getDocs(q);
if (querySnapshot.empty) {
if (!user) {
return {
success: false,
message: 'Benutzername nicht gefunden. Stelle sicher, dass deine Eltern angemeldet sind.'
};
} else {
return {
success: false,
message: 'Benutzername nicht gefunden oder gehört nicht zu diesem Elternkonto.'
};
}
}
const childDoc = querySnapshot.docs[0];
const childData = childDoc.data();
// PIN überprüfen (einfacher Vergleich - in Produktion sollte gehashed werden)
if (childData.pin !== pin) {
return {
success: false,
message: 'Falsche PIN'
};
}
// Letzte Aktivität aktualisieren
await updateDoc(doc(db, 'children', childDoc.id), {
lastActivity: serverTimestamp()
});
// Kind-Daten für Session vorbereiten
const child = {
id: childDoc.id,
...childData,
lastActivity: new Date()
};
return {
success: true,
message: `Willkommen zurück, ${childData.name}! 🎉`,
child
};
} catch (error) {
console.error('Kinder-Login-Fehler:', error);
return {
success: false,
message: 'Anmeldung fehlgeschlagen'
};
}
};
// Kind-Token validieren (für Session-Management)
export const validateChildSession = async (childId) => {
try {
const user = auth.currentUser;
// Kind-Daten laden
const childDoc = await getDoc(doc(db, 'children', childId));
if (!childDoc.exists()) {
return {
success: false,
message: 'Kind nicht gefunden'
};
}
const childData = childDoc.data();
// Wenn Eltern angemeldet sind, überprüfen ob Kind zu ihnen gehört
if (user && (childData.parentId !== user.uid || !childData.isActive)) {
return {
success: false,
message: 'Ungültige Session - Kind gehört nicht zu diesem Elternkonto'
};
}
// Wenn keine Eltern angemeldet sind, nur prüfen ob Kind aktiv ist
if (!user && !childData.isActive) {
return {
success: false,
message: 'Kind-Account ist nicht aktiv'
};
}
return {
success: true,
child: {
id: childDoc.id,
...childData
}
};
} catch (error) {
console.error('Session-Validierung-Fehler:', error);
return {
success: false,
message: 'Session-Validierung fehlgeschlagen'
};
}
};
// Kind abmelden
export const logoutChild = () => {
// Lokale Session-Daten löschen
localStorage.removeItem('childToken');
localStorage.removeItem('childData');
localStorage.removeItem('currentChild');
return {
success: true,
message: 'Erfolgreich abgemeldet'
};
};

View File

@ -0,0 +1,33 @@
// Firebase Konfiguration
import { initializeApp } from 'firebase/app';
import { getAuth } from 'firebase/auth';
import { getFirestore } from 'firebase/firestore';
import { getFunctions } from 'firebase/functions';
// Firebase Konfiguration - Diese Werte bekommst du aus der Firebase Console
const firebaseConfig = {
apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID,
storageBucket: process.env.REACT_APP_FIREBASE_STORAGE_BUCKET,
messagingSenderId: process.env.REACT_APP_FIREBASE_MESSAGING_SENDER_ID,
appId: process.env.REACT_APP_FIREBASE_APP_ID
};
// Firebase initialisieren
const app = initializeApp(firebaseConfig);
// Firebase Services
export const auth = getAuth(app);
export const db = getFirestore(app);
export const functions = getFunctions(app);
// Für Entwicklung - Firebase Emulator verwenden
if (process.env.NODE_ENV === 'development') {
// Uncomment these lines if you want to use Firebase Emulator
// connectAuthEmulator(auth, 'http://localhost:9099');
// connectFirestoreEmulator(db, 'localhost', 8080);
// connectFunctionsEmulator(functions, 'localhost', 5001);
}
export default app;

File diff suppressed because it is too large Load Diff

13
client/src/index.css Normal file
View File

@ -0,0 +1,13 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
}

17
client/src/index.js Normal file
View File

@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

1
client/src/logo.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

View File

@ -0,0 +1,625 @@
import React, { useState, useEffect } from 'react';
import { useFirebaseChildAuth } from '../contexts/FirebaseChildAuthContext';
import { useChildTheme } from '../contexts/ChildThemeContext';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
Grid,
Card,
CardContent,
Typography,
Button,
Avatar,
Chip,
LinearProgress,
List,
ListItem,
ListItemIcon,
ListItemText,
AppBar,
Toolbar,
IconButton,
Dialog,
DialogContent,
useMediaQuery
} from '@mui/material';
import {
ArrowBack,
CheckCircle,
Star,
EmojiEvents,
Assignment,
ShoppingCart,
Celebration,
Logout
} from '@mui/icons-material';
import { motion, AnimatePresence } from 'framer-motion';
import Confetti from 'react-confetti';
import { getChildTasks, completeTask, getChildRewards, redeemReward } from '../firebase/database';
import { useNotification } from '../contexts/NotificationContext';
const ChildDashboard = () => {
const navigate = useNavigate();
const { currentTheme, themeName } = useChildTheme();
const isMobile = useMediaQuery(currentTheme.breakpoints.down('md'));
const { child, logout } = useFirebaseChildAuth();
const { showSuccess, showError } = useNotification();
// Redirect if no child is authenticated
useEffect(() => {
if (!child) {
navigate('/child-login');
}
}, [child, navigate]);
const [tasks, setTasks] = useState([]);
const [rewards, setRewards] = useState([]);
const [loading, setLoading] = useState(true);
const [showConfetti, setShowConfetti] = useState(false);
const [celebrationDialog, setCelebrationDialog] = useState({ open: false, task: null });
const [windowSize, setWindowSize] = useState({ width: window.innerWidth, height: window.innerHeight });
// Fenster-Größe für Konfetti tracken
useEffect(() => {
const handleResize = () => {
setWindowSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// Kind-Daten laden
useEffect(() => {
const loadChildData = async () => {
if (!child) return;
try {
const [tasksResult, rewardsResult] = await Promise.all([
getChildTasks(child.uid),
getChildRewards(child.uid)
]);
if (tasksResult.success) {
setTasks(tasksResult.tasks || []);
}
if (rewardsResult.success) {
setRewards(rewardsResult.rewards || []);
}
} catch (error) {
console.error('Fehler beim Laden der Kind-Daten:', error);
showError('Fehler beim Laden der Daten');
} finally {
setLoading(false);
}
};
if (child) {
loadChildData();
}
}, [child, showError, logout]);
const handleTaskComplete = async (taskId, taskTitle) => {
try {
const result = await completeTask(taskId);
if (result.success) {
// Aufgabe als erledigt markieren
setTasks(prev => prev.map(task =>
task.id === taskId
? { ...task, status: 'completed', completedAt: new Date() }
: task
));
// Celebration anzeigen
setCelebrationDialog({ open: true, task: { title: taskTitle } });
setShowConfetti(true);
// Konfetti nach 3 Sekunden ausblenden
setTimeout(() => setShowConfetti(false), 3000);
showSuccess(result.message);
} else {
showError(result.message);
}
} catch (error) {
showError('Fehler beim Markieren der Aufgabe');
}
};
const handleRewardRedeem = async (rewardId, rewardTitle, cost) => {
try {
const result = await redeemReward(rewardId, child.uid);
if (result.success) {
// Kind-Daten über Context aktualisieren
// Die Punkte werden automatisch über den Context aktualisiert
showSuccess(result.message);
// Belohnungen neu laden um aktuelle Punkte zu zeigen
const rewardsResult = await getChildRewards(child.uid);
if (rewardsResult.success) {
setRewards(rewardsResult.rewards || []);
}
} else {
showError(result.message);
}
} catch (error) {
showError(error.message || 'Fehler beim Einlösen der Belohnung');
}
};
const getTaskIcon = (category) => {
const icons = {
household: '🏠',
personal: '👤',
homework: '📚',
chores: '🧹',
hygiene: '🚿',
pets: '🐕',
garden: '🌱',
helping: '🤝',
learning: '🎓',
exercise: '🏃',
other: '📝'
};
return icons[category] || '📝';
};
const getRewardIcon = (category) => {
const icons = {
'screen-time': '📱',
treats: '🍭',
toys: '🧸',
activities: '🎨',
outings: '🎢',
privileges: '⭐',
money: '💰',
special: '🎁',
electronics: '🎮',
books: '📚',
sports: '⚽',
art: '🎨',
other: '🎁'
};
return icons[category] || '🎁';
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<motion.div
animate={{ rotate: 360, scale: [1, 1.2, 1] }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
>
<Typography variant="h1" sx={{ fontSize: '4rem' }}>🦸</Typography>
</motion.div>
</Box>
);
}
if (!child) {
return (
<Box sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h4">Kind nicht gefunden</Typography>
<Button onClick={() => navigate('/child-login')} sx={{ mt: 2 }}>
Zurück zur Anmeldung
</Button>
</Box>
);
}
const pendingTasks = tasks.filter(task => task.status === 'pending');
const completedTasks = tasks.filter(task => task.status === 'completed');
const affordableRewards = rewards.filter(reward => reward.cost <= (child.points || 0));
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* Konfetti */}
<AnimatePresence>
{showConfetti && (
<Confetti
width={windowSize.width}
height={windowSize.height}
recycle={false}
numberOfPieces={200}
gravity={0.3}
/>
)}
</AnimatePresence>
{/* App Bar */}
<AppBar
position="static"
elevation={0}
sx={{
background: `linear-gradient(135deg, ${currentTheme.palette.primary.main} 0%, ${currentTheme.palette.primary.light} 100%)`,
boxShadow: '0 4px 20px rgba(0, 0, 0, 0.1)'
}}
>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => {
logout();
navigate('/child-login');
}}
sx={{ mr: 2 }}
>
<Logout />
</IconButton>
<Chip
label={`🎨 ${themeName}`}
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
mr: 2,
fontWeight: 600
}}
/>
<Avatar
sx={{
width: 40,
height: 40,
mr: 2,
bgcolor: 'rgba(255, 255, 255, 0.2)'
}}
>
{child.name.charAt(0)}
</Avatar>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
Hallo {child.name}! 👋
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
{child.level?.title || 'Nachwuchs-Held'}
</Typography>
</Box>
<Box sx={{ textAlign: 'right' }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
{child.points || 0}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Punkte
</Typography>
</Box>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Level-Fortschritt */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Card sx={{ mb: 4, background: 'linear-gradient(135deg, #FF9800 0%, #FFB74D 100%)', color: 'white' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" sx={{ mr: 2 }}>🏆</Typography>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 700 }}>
Level {child.level?.current || 1}: {child.level?.title || 'Nachwuchs-Held'}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
{((child.points?.total || child.points || 0) % 100)}/100 Punkte bis zum nächsten Level
</Typography>
</Box>
</Box>
<LinearProgress
variant="determinate"
value={((child.points?.total || child.points || 0) % 100)}
sx={{
height: 12,
borderRadius: 6,
bgcolor: 'rgba(255, 255, 255, 0.3)',
'& .MuiLinearProgress-bar': {
bgcolor: 'white'
}
}}
/>
</CardContent>
</Card>
</motion.div>
<Grid container spacing={3}>
{/* Heutige Aufgaben */}
<Grid size={{ xs: 12, md: 8 }}>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<Card>
<CardContent>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3, display: 'flex', alignItems: 'center' }}>
📝 Deine Aufgaben heute
<Chip
label={`${pendingTasks.length} offen`}
color="primary"
size="small"
sx={{ ml: 2 }}
/>
</Typography>
{pendingTasks.length > 0 ? (
<List>
{pendingTasks.map((task, index) => (
<motion.div
key={task.id}
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
>
<ListItem
sx={{
border: '2px solid',
borderColor: 'primary.light',
borderRadius: 3,
mb: 2,
bgcolor: 'background.paper',
'&:hover': {
bgcolor: 'action.hover',
transform: 'scale(1.02)',
transition: 'all 0.2s ease'
}
}}
>
<ListItemIcon>
<Typography variant="h3">
{getTaskIcon(task.category)}
</Typography>
</ListItemIcon>
<ListItemText
primary={
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{task.title || 'Unbekannte Aufgabe'}
</Typography>
}
secondary={
<span>
{task.description && (
<Typography variant="body2" color="text.secondary" component="div" sx={{ mb: 1 }}>
{task.description}
</Typography>
)}
<Chip
label={`${task.points} Punkte`}
color="secondary"
size="small"
/>
</span>
}
/>
<Button
variant="contained"
size="large"
startIcon={<CheckCircle />}
onClick={() => handleTaskComplete(task.id, task.title || 'Unbekannte Aufgabe')}
sx={{
borderRadius: 3,
px: 3,
py: 1.5,
fontSize: '1.1rem',
fontWeight: 600
}}
>
Erledigt!
</Button>
</ListItem>
</motion.div>
))}
</List>
) : (
<Box sx={{ textAlign: 'center', py: 6 }}>
<Typography variant="h2" sx={{ mb: 2 }}>🎉</Typography>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
Super! Alle Aufgaben erledigt!
</Typography>
<Typography variant="body1" color="text.secondary">
Du bist ein echter Held! 🦸
</Typography>
</Box>
)}
{/* Erledigte Aufgaben */}
{completedTasks.length > 0 && (
<Box sx={{ mt: 4 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 2, color: 'success.main' }}>
Heute erledigt ({completedTasks.length})
</Typography>
<List>
{completedTasks.map((task) => (
<ListItem
key={task.id}
sx={{
border: '1px solid',
borderColor: 'success.light',
borderRadius: 2,
mb: 1,
bgcolor: 'success.light',
opacity: 0.8
}}
>
<ListItemIcon>
<CheckCircle color="success" />
</ListItemIcon>
<ListItemText
primary={task.title}
secondary="Wartet auf Bestätigung der Eltern"
/>
<Chip
label={`${task.points}`}
color="success"
size="small"
/>
</ListItem>
))}
</List>
</Box>
)}
</CardContent>
</Card>
</motion.div>
</Grid>
{/* Belohnungs-Shop */}
<Grid size={{ xs: 12, md: 4 }}>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
<Card>
<CardContent>
<Typography variant="h5" sx={{ fontWeight: 700, mb: 3, display: 'flex', alignItems: 'center' }}>
🛒 Belohnungs-Shop
</Typography>
{affordableRewards.length > 0 ? (
<List>
{affordableRewards.slice(0, 5).map((reward, index) => (
<motion.div
key={reward.id}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: index * 0.1 }}
>
<ListItem
sx={{
border: '1px solid',
borderColor: 'secondary.light',
borderRadius: 2,
mb: 2,
flexDirection: 'column',
alignItems: 'stretch',
p: 2
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" sx={{ mr: 2 }}>
{getRewardIcon(reward.category)}
</Typography>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{reward.title || 'Unbekannte Belohnung'}
</Typography>
<Typography variant="body2" color="text.secondary">
{reward.description}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Chip
label={`${reward.cost}`}
color="secondary"
size="small"
/>
<Button
variant="contained"
size="small"
startIcon={<ShoppingCart />}
onClick={() => handleRewardRedeem(reward.id, reward.title || 'Unbekannte Belohnung', reward.cost)}
disabled={(child.points || 0) < reward.cost}
sx={{ borderRadius: 2 }}
>
Einlösen
</Button>
</Box>
</ListItem>
</motion.div>
))}
</List>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h3" sx={{ mb: 2 }}>💪</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
Sammle mehr Punkte, um tolle Belohnungen zu bekommen!
</Typography>
<Typography variant="body2" color="text.secondary">
Du hast {child.points || 0} Punkte
</Typography>
</Box>
)}
{rewards.length > affordableRewards.length && (
<Typography
variant="body2"
color="text.secondary"
sx={{ mt: 2, textAlign: 'center' }}
>
{rewards.length - affordableRewards.length} weitere Belohnungen verfügbar
</Typography>
)}
</CardContent>
</Card>
</motion.div>
</Grid>
</Grid>
</Container>
{/* Celebration Dialog */}
<Dialog
open={celebrationDialog.open}
onClose={() => setCelebrationDialog({ open: false, task: null })}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
borderRadius: 4,
textAlign: 'center',
background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)',
color: 'white'
}
}}
>
<DialogContent sx={{ py: 6 }}>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ duration: 0.5, type: 'spring' }}
>
<Typography variant="h2" sx={{ mb: 2 }}>🎉</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
Fantastisch!
</Typography>
<Typography variant="h6" sx={{ mb: 3, opacity: 0.9 }}>
Du hast "{celebrationDialog.task?.title || 'die Aufgabe'}" erledigt!
</Typography>
<Typography variant="body1" sx={{ mb: 4, opacity: 0.8 }}>
Deine Eltern werden das überprüfen und dir dann die Punkte geben.
</Typography>
<Button
variant="contained"
size="large"
onClick={() => setCelebrationDialog({ open: false, task: null })}
sx={{
bgcolor: 'white',
color: 'primary.main',
fontWeight: 600,
px: 4,
py: 1.5,
'&:hover': {
bgcolor: 'grey.100'
}
}}
>
Weiter so! 💪
</Button>
</motion.div>
</DialogContent>
</Dialog>
</Box>
);
};
export default ChildDashboard;

View File

@ -0,0 +1,227 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
Grid,
Card,
CardContent,
Typography,
Button,
TextField,
AppBar,
Toolbar,
IconButton,
CircularProgress,
Alert,
InputAdornment
} from '@mui/material';
import {
ArrowBack,
Person,
Lock,
Visibility,
VisibilityOff
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useFirebaseChildAuth } from '../contexts/FirebaseChildAuthContext';
import { useNotification } from '../contexts/NotificationContext';
const ChildLoginPage = () => {
const navigate = useNavigate();
const { showSuccess, showError } = useNotification();
const { login } = useFirebaseChildAuth();
const [formData, setFormData] = useState({
username: '',
pin: ''
});
const [loading, setLoading] = useState(false);
const [showPin, setShowPin] = useState(false);
const [error, setError] = useState('');
const handleInputChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (error) setError(''); // Clear error when user starts typing
};
const handleLogin = async () => {
if (!formData.username || !formData.pin) {
setError('Bitte gib deinen Benutzernamen und deine PIN ein');
return;
}
setLoading(true);
setError('');
try {
const result = await login(formData.username, formData.pin);
if (result.success) {
showSuccess(result.message);
navigate('/child-dashboard');
} else {
setError(result.message);
}
} catch (error) {
console.error('Login-Fehler:', error);
setError('Anmeldung fehlgeschlagen. Überprüfe deine Daten.');
} finally {
setLoading(false);
}
};
const handleKeyPress = (event) => {
if (event.key === 'Enter') {
handleLogin();
}
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* Header */}
<AppBar position="static" elevation={0}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => navigate('/')}
sx={{ mr: 2 }}
>
<ArrowBack />
</IconButton>
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
🦸 Kinder-Anmeldung
</Typography>
</Toolbar>
</AppBar>
<Container maxWidth="sm" sx={{ py: 4 }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
{/* Willkommens-Text */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Typography variant="h3" sx={{ fontWeight: 700, mb: 2 }}>
👋 Hallo kleiner Held!
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ mb: 2 }}>
Melde dich mit deinem Benutzernamen und deiner PIN an
</Typography>
<Typography variant="body2" color="text.secondary" sx={{
bgcolor: 'info.light',
color: 'info.contrastText',
p: 2,
borderRadius: 2,
maxWidth: 400,
mx: 'auto'
}}>
💡 <strong>Tipp:</strong> Wenn deine Eltern angemeldet sind, funktioniert die Anmeldung am besten!
</Typography>
</Box>
{/* Login-Formular */}
<Card sx={{ p: 3 }}>
<CardContent>
{/* Fehler-Anzeige */}
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
<Grid container spacing={3}>
<Grid item xs={12}>
<TextField
fullWidth
label="Benutzername"
value={formData.username}
onChange={(e) => handleInputChange('username', e.target.value)}
onKeyPress={handleKeyPress}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Person />
</InputAdornment>
),
}}
disabled={loading}
/>
</Grid>
<Grid item xs={12}>
<TextField
fullWidth
label="PIN"
type={showPin ? 'text' : 'password'}
value={formData.pin}
onChange={(e) => handleInputChange('pin', e.target.value)}
onKeyPress={handleKeyPress}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPin(!showPin)}
edge="end"
>
{showPin ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
disabled={loading}
/>
</Grid>
<Grid item xs={12}>
<Button
fullWidth
variant="contained"
size="large"
onClick={handleLogin}
disabled={loading || !formData.username || !formData.pin}
sx={{
py: 1.5,
fontSize: '1.1rem',
fontWeight: 600,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)'
}}
>
{loading ? (
<CircularProgress size={24} color="inherit" />
) : (
'Anmelden 🚀'
)}
</Button>
</Grid>
</Grid>
<Box sx={{ textAlign: 'center', mt: 3 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Probleme beim Anmelden? Frage deine Eltern! 👨👩👧👦
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => navigate('/login')}
sx={{ mt: 1 }}
>
Eltern-Anmeldung
</Button>
</Box>
</CardContent>
</Card>
</motion.div>
</Container>
</Box>
);
};
export default ChildLoginPage;

View File

@ -0,0 +1,986 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
Grid,
Card,
CardContent,
Typography,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
Avatar,
IconButton,
AppBar,
Toolbar,
LinearProgress,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
Divider,
Alert
} from '@mui/material';
import {
ArrowBack,
Add,
Edit,
Delete,
Person,
Star,
TrendingUp,
Assignment,
CardGiftcard,
EmojiEvents,
Settings
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
import { getChildren, createChild, updateChild, getChildStats, deleteChild, updateChildPoints, addChildAchievement } from '../firebase/database';
import { useNotification } from '../contexts/NotificationContext';
const ChildrenManagement = () => {
const navigate = useNavigate();
const { currentUser } = useFirebaseAuth();
const { showSuccess, showError } = useNotification();
const [children, setChildren] = useState([]);
const [loading, setLoading] = useState(true);
const [selectedChild, setSelectedChild] = useState(null);
const [childStats, setChildStats] = useState(null);
// Dialog States
const [childDialog, setChildDialog] = useState({ open: false, child: null, mode: 'create' });
const [deleteDialog, setDeleteDialog] = useState({ open: false, child: null });
const [pointsDialog, setPointsDialog] = useState({ open: false, child: null });
const [achievementDialog, setAchievementDialog] = useState({ open: false, child: null });
// Form State
const [formData, setFormData] = useState({
name: '',
age: 6,
username: '',
pin: '',
avatar: 'default-child.png',
preferences: {
theme: 'colorful',
language: 'de',
notifications: true
}
});
const [pointsData, setPointsData] = useState({
amount: 0,
reason: '',
type: 'add'
});
const [achievementData, setAchievementData] = useState({
title: '',
description: '',
icon: '🏆',
category: 'general'
});
const avatars = [
{ value: 'default-child.png', label: '👦 Standard Junge', emoji: '👦' },
{ value: 'superhero-boy.png', label: '🦸‍♂️ Superheld Junge', emoji: '🦸‍♂️' },
{ value: 'superhero-girl.png', label: '🦸‍♀️ Superheldin Mädchen', emoji: '🦸‍♀️' },
{ value: 'pirate-boy.png', label: '🏴‍☠️ Pirat Junge', emoji: '🏴‍☠️' },
{ value: 'pirate-girl.png', label: '🏴‍☠️ Piratin Mädchen', emoji: '🏴‍☠️' },
{ value: 'princess.png', label: '👸 Prinzessin', emoji: '👸' },
{ value: 'knight.png', label: '🛡️ Ritter', emoji: '🛡️' },
{ value: 'astronaut.png', label: '🚀 Astronaut', emoji: '🚀' },
{ value: 'detective.png', label: '🕵️ Detektiv', emoji: '🕵️' },
{ value: 'artist.png', label: '🎨 Künstler', emoji: '🎨' }
];
// Hilfsfunktion um Emoji für Avatar zu finden
const getAvatarEmoji = (avatarValue) => {
const avatar = avatars.find(a => a.value === avatarValue);
return avatar ? avatar.emoji : '👦';
};
// Helper function to get level title
const getLevelTitle = (level) => {
if (!level || typeof level !== 'object') {
return 'Nachwuchs-Held';
}
return level.title || 'Nachwuchs-Held';
};
const themes = [
{ value: 'colorful', label: 'Bunt & Fröhlich' },
{ value: 'nature', label: 'Natur' },
{ value: 'space', label: 'Weltraum' },
{ value: 'ocean', label: 'Ozean' },
{ value: 'forest', label: 'Wald' }
];
const achievementCategories = [
{ value: 'general', label: 'Allgemein', icon: '🏆' },
{ value: 'tasks', label: 'Aufgaben', icon: '✅' },
{ value: 'streak', label: 'Serie', icon: '🔥' },
{ value: 'points', label: 'Punkte', icon: '⭐' },
{ value: 'special', label: 'Besonders', icon: '🌟' }
];
const achievementIcons = [
'🏆', '🥇', '🥈', '🥉', '🎖️', '🏅', '⭐', '🌟',
'✅', '🔥', '💪', '🎯', '🚀', '👑', '💎', '🎊'
];
// Kind-Statistiken laden
const loadChildStats = useCallback(async (childId) => {
try {
const result = await getChildStats(childId);
if (result.success) {
setChildStats(result.stats);
} else {
console.error('Fehler beim Laden der Statistiken:', result.message);
}
} catch (error) {
console.error('Fehler beim Laden der Statistiken:', error);
}
}, []);
// Kinder laden
const loadChildren = useCallback(async () => {
// Nur laden wenn Benutzer authentifiziert ist
if (!currentUser) {
setLoading(false);
return;
}
try {
const result = await getChildren();
if (result.success) {
setChildren(result.children);
if (result.children.length > 0) {
setSelectedChild(result.children[0]);
loadChildStats(result.children[0].id);
}
} else {
showError(result.message || 'Fehler beim Laden der Kinderprofile');
}
} catch (error) {
console.error('Fehler beim Laden der Kinder:', error);
showError('Fehler beim Laden der Kinderprofile');
} finally {
setLoading(false);
}
}, [currentUser, showError, loadChildStats]);
// Daten laden
useEffect(() => {
loadChildren();
}, [currentUser, showError]);
const handleChildSelect = (child) => {
setSelectedChild(child);
loadChildStats(child.id);
};
const handleFormChange = (field, value) => {
if (field.includes('.')) {
const [parent, child] = field.split('.');
setFormData(prev => ({
...prev,
[parent]: {
...prev[parent],
[child]: value
}
}));
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
};
const handleCreateChild = () => {
setFormData({
name: '',
age: 6,
username: '',
pin: '',
avatar: 'default-child.png',
preferences: {
theme: 'colorful',
language: 'de',
notifications: true
}
});
setChildDialog({ open: true, child: null, mode: 'create' });
};
const handleEditChild = (child) => {
// Fallback für alte Emoji-Avatare zu gültigen Werten
let avatarValue = child.avatar;
if (!avatars.find(a => a.value === child.avatar)) {
avatarValue = 'default-child.png';
}
setFormData({
name: child.name,
age: child.age,
username: child.username || '',
pin: '', // PIN wird aus Sicherheitsgründen nicht vorausgefüllt
avatar: avatarValue,
preferences: child.preferences || {
theme: 'colorful',
language: 'de',
notifications: true
}
});
setChildDialog({ open: true, child, mode: 'edit' });
};
const handleSaveChild = async () => {
try {
let response;
if (childDialog.mode === 'create') {
response = await createChild(formData);
} else {
response = await updateChild(childDialog.child.id, formData);
}
if (response.success) {
showSuccess(response.message);
setChildDialog({ open: false, child: null, mode: 'create' });
// Kinder neu laden
loadChildren();
} else {
showError(response.message);
}
} catch (error) {
showError('Fehler beim Speichern des Kinderprofils');
}
};
const handleDeleteChild = async () => {
try {
const response = await deleteChild(deleteDialog.child.id);
if (response.success) {
showSuccess(response.message);
setDeleteDialog({ open: false, child: null });
// Kinder neu laden
loadChildren();
// Neues ausgewähltes Kind setzen
if (selectedChild?.id === deleteDialog.child.id) {
setSelectedChild(null);
setChildStats(null);
}
} else {
showError(response.message);
}
} catch (error) {
showError('Fehler beim Löschen des Kinderprofils');
}
};
const handlePointsAction = async () => {
try {
const amount = pointsData.type === 'add' ? pointsData.amount : -pointsData.amount;
const response = await updateChildPoints(pointsDialog.child.id, amount, pointsData.reason);
if (response.success) {
showSuccess(response.message);
setPointsDialog({ open: false, child: null });
setPointsData({ amount: 0, reason: '', type: 'add' });
// Kinder und Statistiken neu laden
loadChildren();
if (selectedChild) {
loadChildStats(selectedChild.id);
}
} else {
showError(response.message);
}
} catch (error) {
showError('Fehler beim Bearbeiten der Punkte');
}
};
const handleAddAchievement = async () => {
try {
const response = await addChildAchievement(achievementDialog.child.id, achievementData);
if (response.success) {
showSuccess(response.message);
setAchievementDialog({ open: false, child: null });
setAchievementData({ title: '', description: '', icon: '🏆', category: 'general' });
// Kinder neu laden
loadChildren();
if (selectedChild) {
loadChildStats(selectedChild.id);
}
} else {
showError(response.message);
}
} catch (error) {
showError('Fehler beim Hinzufügen der Auszeichnung');
}
};
const getLevelProgress = (child) => {
const currentLevel = child.level?.current || 1;
const currentLevelPoints = currentLevel * 100;
const nextLevelPoints = (currentLevel + 1) * 100;
const progress = ((child.points.total - currentLevelPoints) / (nextLevelPoints - currentLevelPoints)) * 100;
return Math.max(0, Math.min(100, progress));
};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* App Bar */}
<AppBar position="static" elevation={0}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => navigate('/dashboard')}
sx={{ mr: 2 }}
>
<ArrowBack />
</IconButton>
<Person sx={{ mr: 2 }} />
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
Kinder-Management
</Typography>
<Button
color="inherit"
startIcon={<Add />}
onClick={handleCreateChild}
sx={{ fontWeight: 600 }}
>
Kind hinzufügen
</Button>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ py: 4 }}>
<Grid container spacing={3}>
{/* Kinder-Liste */}
<Grid size={{ xs: 12, md: 4 }}>
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
Familien-Helden ({children.length})
</Typography>
{children.length > 0 ? (
<List>
{children.map((child, index) => (
<React.Fragment key={child._id}>
<ListItem
component="div"
selected={selectedChild?._id === child._id}
onClick={() => handleChildSelect(child)}
sx={{
borderRadius: 2,
mb: 1,
cursor: 'pointer',
'&.Mui-selected': {
bgcolor: 'primary.light',
'&:hover': {
bgcolor: 'primary.light'
}
},
'&:hover': {
bgcolor: 'action.hover'
}
}}
>
<Avatar sx={{ mr: 2, bgcolor: 'primary.main' }}>
{getAvatarEmoji(child.avatar)}
</Avatar>
<ListItemText
primary={
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{child.name}
</Typography>
}
secondary={
<React.Fragment>
<span style={{ fontSize: '0.875rem', color: 'rgba(0, 0, 0, 0.6)', display: 'block' }}>
{child.age} Jahre Level {child.level?.current || 1}
</span>
<span style={{ fontSize: '0.875rem', color: 'rgb(156, 39, 176)', display: 'block' }}>
{child.points.available} Punkte
</span>
</React.Fragment>
}
/>
<ListItemSecondaryAction>
<IconButton
size="small"
onClick={(e) => {
e.stopPropagation();
handleEditChild(child);
}}
>
<Edit />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
{index < children.length - 1 && <Divider />}
</React.Fragment>
))}
</List>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h4" sx={{ mb: 2 }}>👨👩👧👦</Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
Noch keine Kinder hinzugefügt
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
Füge deine ersten Familien-Helden hinzu!
</Typography>
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreateChild}
>
Erstes Kind hinzufügen
</Button>
</Box>
)}
</CardContent>
</Card>
</Grid>
{/* Kind-Details */}
<Grid size={{ xs: 12, md: 8 }}>
{selectedChild ? (
<>
{/* Kind-Übersicht */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Grid container spacing={3} alignItems="center">
<Grid>
<Avatar
sx={{
width: 80,
height: 80,
fontSize: '2rem',
bgcolor: 'primary.main'
}}
>
{getAvatarEmoji(selectedChild.avatar)}
</Avatar>
</Grid>
<Grid size="grow">
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
{selectedChild.name}
</Typography>
<Typography variant="h6" color="text.secondary" sx={{ mb: 2 }}>
{selectedChild.age} Jahre {getLevelTitle(selectedChild.level)}
</Typography>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<Chip
icon={<Star />}
label={`Level ${selectedChild.level?.current || 1}`}
color="primary"
variant="filled"
/>
<Chip
label={`${selectedChild.points.available} Punkte`}
color="secondary"
variant="filled"
/>
<Chip
label={`🔥 ${selectedChild.statistics?.currentStreak || 0} Tage Serie`}
color="warning"
variant="outlined"
/>
</Box>
{/* Level-Fortschritt */}
<Box sx={{ mb: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="body2" color="text.secondary">
Fortschritt zu Level {(selectedChild.level?.current || 1) + 1}
</Typography>
<Typography variant="body2" color="text.secondary">
{Math.round(getLevelProgress(selectedChild))}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={getLevelProgress(selectedChild)}
sx={{ height: 8, borderRadius: 4 }}
/>
</Box>
</Grid>
<Grid item>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Button
variant="outlined"
startIcon={<Star />}
onClick={() => setPointsDialog({ open: true, child: selectedChild })}
size="small"
>
Punkte bearbeiten
</Button>
<Button
variant="outlined"
startIcon={<EmojiEvents />}
onClick={() => setAchievementDialog({ open: true, child: selectedChild })}
size="small"
>
Auszeichnung
</Button>
<Button
variant="outlined"
color="error"
startIcon={<Delete />}
onClick={() => setDeleteDialog({ open: true, child: selectedChild })}
size="small"
>
Löschen
</Button>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Statistiken */}
{childStats && (
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 12, md: 3 }}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Assignment sx={{ fontSize: 40, color: 'primary.main', mb: 1 }} />
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
{childStats.tasksCompleted || 0}
</Typography>
<Typography variant="body2" color="text.secondary">
Aufgaben erledigt
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<Star sx={{ fontSize: 40, color: 'secondary.main', mb: 1 }} />
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
{selectedChild.points.total || 0}
</Typography>
<Typography variant="body2" color="text.secondary">
Punkte gesamt
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<CardGiftcard sx={{ fontSize: 40, color: 'success.main', mb: 1 }} />
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
{childStats.rewardsRedeemed || 0}
</Typography>
<Typography variant="body2" color="text.secondary">
Belohnungen eingelöst
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Card>
<CardContent sx={{ textAlign: 'center' }}>
<TrendingUp sx={{ fontSize: 40, color: 'warning.main', mb: 1 }} />
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
{selectedChild.statistics?.longestStreak || 0}
</Typography>
<Typography variant="body2" color="text.secondary">
Längste Serie
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
)}
{/* Auszeichnungen */}
<Card>
<CardContent>
<Typography variant="h6" sx={{ mb: 2, fontWeight: 600 }}>
🏆 Auszeichnungen ({selectedChild.achievements?.length || 0})
</Typography>
{selectedChild.achievements && selectedChild.achievements.length > 0 ? (
<Grid container spacing={2}>
{selectedChild.achievements.map((achievement, index) => (
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={achievement.id || `achievement-${index}`}>
<motion.div
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3, delay: index * 0.1 }}
>
<Card variant="outlined">
<CardContent sx={{ textAlign: 'center', py: 2 }}>
<Typography variant="h4" sx={{ mb: 1 }}>
{achievement.icon}
</Typography>
<Typography variant="subtitle1" sx={{ fontWeight: 600, mb: 1 }}>
{achievement.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{achievement.description}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, display: 'block' }}>
{new Date(achievement.earnedAt).toLocaleDateString('de-DE')}
</Typography>
</CardContent>
</Card>
</motion.div>
</Grid>
))}
</Grid>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="h4" sx={{ mb: 2 }}>🏆</Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
Noch keine Auszeichnungen
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{selectedChild.name} kann Auszeichnungen durch das Erledigen von Aufgaben verdienen!
</Typography>
<Button
variant="contained"
startIcon={<EmojiEvents />}
onClick={() => setAchievementDialog({ open: true, child: selectedChild })}
>
Erste Auszeichnung verleihen
</Button>
</Box>
)}
</CardContent>
</Card>
</>
) : (
<Card>
<CardContent sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h4" sx={{ mb: 2 }}>👨👩👧👦</Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
Wähle ein Kind aus
</Typography>
<Typography variant="body1" color="text.secondary">
Wähle ein Kind aus der Liste aus, um Details und Statistiken zu sehen.
</Typography>
</CardContent>
</Card>
)}
</Grid>
</Grid>
</Container>
{/* Kind erstellen/bearbeiten Dialog */}
<Dialog
open={childDialog.open}
onClose={() => setChildDialog({ open: false, child: null, mode: 'create' })}
maxWidth="sm"
fullWidth
>
<DialogTitle>
{childDialog.mode === 'create' ? 'Neues Kind hinzufügen' : 'Kind bearbeiten'}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<TextField
fullWidth
label="Name"
value={formData.name}
onChange={(e) => handleFormChange('name', e.target.value)}
required
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Alter"
type="number"
value={formData.age}
onChange={(e) => handleFormChange('age', parseInt(e.target.value))}
inputProps={{ min: 3, max: 18 }}
required
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Benutzername"
value={formData.username}
onChange={(e) => handleFormChange('username', e.target.value)}
helperText="Für die Anmeldung des Kindes (3-20 Zeichen, nur Buchstaben, Zahlen und _)"
required
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="PIN"
type="password"
value={formData.pin}
onChange={(e) => handleFormChange('pin', e.target.value)}
helperText="4-6 stellige Zahlen-PIN für die Anmeldung"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
required={childDialog.mode === 'create'}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel>Avatar</InputLabel>
<Select
value={formData.avatar}
onChange={(e) => handleFormChange('avatar', e.target.value)}
label="Avatar"
>
{avatars.map(avatar => (
<MenuItem key={avatar.value} value={avatar.value}>
{avatar.emoji} {avatar.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={12}>
<FormControl fullWidth>
<InputLabel>Design-Thema</InputLabel>
<Select
value={formData.preferences.theme}
onChange={(e) => handleFormChange('preferences.theme', e.target.value)}
label="Design-Thema"
>
{themes.map(theme => (
<MenuItem key={theme.value} value={theme.value}>
{theme.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setChildDialog({ open: false, child: null, mode: 'create' })}>
Abbrechen
</Button>
<Button
onClick={handleSaveChild}
variant="contained"
disabled={
!formData.name ||
!formData.age ||
!formData.username ||
(childDialog.mode === 'create' && !formData.pin)
}
>
{childDialog.mode === 'create' ? 'Hinzufügen' : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
{/* Punkte bearbeiten Dialog */}
<Dialog
open={pointsDialog.open}
onClose={() => setPointsDialog({ open: false, child: null })}
maxWidth="sm"
fullWidth
>
<DialogTitle>Punkte bearbeiten</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<FormControl fullWidth>
<InputLabel>Aktion</InputLabel>
<Select
value={pointsData.type}
onChange={(e) => setPointsData(prev => ({ ...prev, type: e.target.value }))}
label="Aktion"
>
<MenuItem value="add">Punkte hinzufügen</MenuItem>
<MenuItem value="subtract">Punkte abziehen</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Anzahl Punkte"
type="number"
value={pointsData.amount}
onChange={(e) => setPointsData(prev => ({ ...prev, amount: parseInt(e.target.value) || 0 }))}
inputProps={{ min: 1, max: 1000 }}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Grund (optional)"
value={pointsData.reason}
onChange={(e) => setPointsData(prev => ({ ...prev, reason: e.target.value }))}
multiline
rows={2}
/>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setPointsDialog({ open: false, child: null })}>
Abbrechen
</Button>
<Button
onClick={handlePointsAction}
variant="contained"
disabled={!pointsData.amount}
>
{pointsData.type === 'add' ? 'Hinzufügen' : 'Abziehen'}
</Button>
</DialogActions>
</Dialog>
{/* Auszeichnung hinzufügen Dialog */}
<Dialog
open={achievementDialog.open}
onClose={() => setAchievementDialog({ open: false, child: null })}
maxWidth="sm"
fullWidth
>
<DialogTitle>Auszeichnung verleihen</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<TextField
fullWidth
label="Titel"
value={achievementData.title}
onChange={(e) => setAchievementData(prev => ({ ...prev, title: e.target.value }))}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Beschreibung"
value={achievementData.description}
onChange={(e) => setAchievementData(prev => ({ ...prev, description: e.target.value }))}
multiline
rows={2}
required
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel>Icon</InputLabel>
<Select
value={achievementData.icon}
onChange={(e) => setAchievementData(prev => ({ ...prev, icon: e.target.value }))}
label="Icon"
>
{achievementIcons.map(icon => (
<MenuItem key={icon} value={icon}>
{icon} {icon}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel>Kategorie</InputLabel>
<Select
value={achievementData.category}
onChange={(e) => setAchievementData(prev => ({ ...prev, category: e.target.value }))}
label="Kategorie"
>
{achievementCategories.map(category => (
<MenuItem key={category.value} value={category.value}>
{category.icon} {category.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setAchievementDialog({ open: false, child: null })}>
Abbrechen
</Button>
<Button
onClick={handleAddAchievement}
variant="contained"
disabled={!achievementData.title || !achievementData.description}
>
Verleihen
</Button>
</DialogActions>
</Dialog>
{/* Löschen-Bestätigung Dialog */}
<Dialog
open={deleteDialog.open}
onClose={() => setDeleteDialog({ open: false, child: null })}
>
<DialogTitle>Kind löschen</DialogTitle>
<DialogContent>
<Alert severity="warning" sx={{ mb: 2 }}>
Diese Aktion kann nicht rückgängig gemacht werden!
</Alert>
<Typography>
Möchtest du das Profil von "{deleteDialog.child?.name}" wirklich löschen?
Alle Aufgaben, Punkte und Auszeichnungen gehen dabei verloren.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialog({ open: false, child: null })}>
Abbrechen
</Button>
<Button onClick={handleDeleteChild} color="error" variant="contained">
Löschen
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default ChildrenManagement;

View File

@ -0,0 +1,345 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
Typography,
Button,
Grid,
Card,
CardContent,
AppBar,
Toolbar,
useTheme,
useMediaQuery
} from '@mui/material';
import EmojiEvents from '@mui/icons-material/EmojiEvents';
import Assignment from '@mui/icons-material/Assignment';
import FamilyRestroom from '@mui/icons-material/FamilyRestroom';
import Star from '@mui/icons-material/Star';
import PlayArrow from '@mui/icons-material/PlayArrow';
import Login from '@mui/icons-material/Login';
import PersonAdd from '@mui/icons-material/PersonAdd';
import ChildCare from '@mui/icons-material/ChildCare';
import { motion } from 'framer-motion';
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
const LandingPage = () => {
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { currentUser } = useFirebaseAuth();
// Wenn bereits angemeldet, zum Dashboard weiterleiten
React.useEffect(() => {
if (currentUser) {
navigate('/dashboard');
}
}, [currentUser, navigate]);
const features = [
{
icon: <Assignment sx={{ fontSize: 48, color: theme.palette.primary.main }} />,
title: 'Aufgaben-Management',
description: 'Erstelle und verwalte Aufgaben für deine Kinder. Von Hausarbeit bis Hausaufgaben - alles an einem Ort.'
},
{
icon: <EmojiEvents sx={{ fontSize: 48, color: theme.palette.secondary.main }} />,
title: 'Belohnungs-System',
description: 'Motiviere deine Kinder mit einem spielerischen Punktesystem und individuellen Belohnungen.'
},
{
icon: <FamilyRestroom sx={{ fontSize: 48, color: theme.palette.success.main }} />,
title: 'Familien-Dashboard',
description: 'Behalte den Überblick über alle Familienmitglieder und deren Fortschritte an einem Ort.'
},
{
icon: <Star sx={{ fontSize: 48, color: theme.palette.warning.main }} />,
title: 'Gamification',
description: 'Verwandle alltägliche Aufgaben in spannende Herausforderungen mit Levels und Achievements.'
}
];
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* Navigation */}
<AppBar position="static" elevation={0} sx={{ bgcolor: 'transparent' }}>
<Toolbar>
<Typography
variant="h5"
sx={{
flexGrow: 1,
fontWeight: 800,
background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
🏆 Familien-Held
</Typography>
<Button
color="primary"
startIcon={<Login />}
onClick={() => navigate('/login')}
sx={{ mr: 1 }}
>
Anmelden
</Button>
<Button
variant="contained"
startIcon={<PersonAdd />}
onClick={() => navigate('/register')}
>
Registrieren
</Button>
</Toolbar>
</AppBar>
{/* Hero Section */}
<Container maxWidth="lg" sx={{ py: 8 }}>
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8 }}
>
<Box sx={{ textAlign: 'center', mb: 8 }}>
<Typography
variant={isMobile ? 'h3' : 'h2'}
sx={{
fontWeight: 800,
mb: 3,
background: 'linear-gradient(45deg, #2196F3 30%, #21CBF3 90%)',
backgroundClip: 'text',
WebkitBackgroundClip: 'text',
WebkitTextFillColor: 'transparent'
}}
>
Verwandle Aufgaben in Abenteuer! 🚀
</Typography>
<Typography
variant="h5"
color="text.secondary"
sx={{ mb: 4, maxWidth: 600, mx: 'auto' }}
>
Die spielerische To-Do- & Belohnungs-App für Familien.
Motiviere deine Kinder und bringe Spaß in den Alltag!
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
<Button
variant="contained"
size="large"
startIcon={<PlayArrow />}
onClick={() => navigate('/register')}
sx={{
py: 2,
px: 4,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 3
}}
>
Jetzt kostenlos starten
</Button>
<Button
variant="outlined"
size="large"
onClick={() => navigate('/login')}
sx={{
py: 2,
px: 4,
fontSize: '1.1rem',
borderRadius: 3
}}
>
Bereits registriert?
</Button>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 2 }}>
💡 Tipp: Probiere die Demo mit dem Account "demo@familie.de" und Passwort "demo123"
</Typography>
</Box>
</motion.div>
{/* Features Section */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 0.3 }}
>
<Typography
variant="h3"
sx={{
textAlign: 'center',
fontWeight: 700,
mb: 6,
color: 'text.primary'
}}
>
Warum Familien-Held?
</Typography>
<Grid container spacing={4}>
{features.map((feature, index) => (
<Grid size={{ xs: 12, md: 6 }} key={index}>
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 * index }}
>
<Card
sx={{
height: '100%',
position: 'relative',
overflow: 'visible',
'&:hover': {
transform: 'translateY(-8px)',
transition: 'transform 0.3s ease-in-out'
}
}}
>
<CardContent sx={{ p: 4, textAlign: 'center' }}>
<Box sx={{ mb: 3 }}>
{feature.icon}
</Box>
<Typography variant="h5" sx={{ fontWeight: 600, mb: 2 }}>
{feature.title}
</Typography>
<Typography variant="body1" color="text.secondary">
{feature.description}
</Typography>
</CardContent>
</Card>
</motion.div>
</Grid>
))}
</Grid>
</motion.div>
{/* Call to Action */}
<motion.div
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.6 }}
>
<Card
sx={{
mt: 8,
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
position: 'relative',
overflow: 'hidden'
}}
>
<CardContent sx={{ p: 6, textAlign: 'center', position: 'relative', zIndex: 1 }}>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 2 }}>
Bereit für das Abenteuer? 🎯
</Typography>
<Typography variant="h6" sx={{ mb: 4, opacity: 0.9 }}>
Starte noch heute und verwandle dein Zuhause in eine Spielwelt voller Motivation!
</Typography>
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'center', flexWrap: 'wrap' }}>
<Button
variant="contained"
size="large"
onClick={() => navigate('/register')}
sx={{
bgcolor: 'white',
color: 'primary.main',
py: 2,
px: 4,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 3,
'&:hover': {
bgcolor: 'grey.100'
}
}}
>
Familien-Held werden! 🚀
</Button>
<Button
variant="outlined"
size="large"
startIcon={<ChildCare />}
onClick={() => navigate('/child-login')}
sx={{
borderColor: 'white',
color: 'white',
py: 2,
px: 4,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 3,
'&:hover': {
borderColor: 'white',
bgcolor: 'rgba(255, 255, 255, 0.1)'
}
}}
>
Ich bin ein Kind! 👦
</Button>
</Box>
</CardContent>
{/* Dekorative Elemente */}
<Box
sx={{
position: 'absolute',
top: -20,
right: -20,
width: 100,
height: 100,
borderRadius: '50%',
bgcolor: 'rgba(255, 255, 255, 0.1)'
}}
/>
<Box
sx={{
position: 'absolute',
bottom: -30,
left: -30,
width: 80,
height: 80,
borderRadius: '50%',
bgcolor: 'rgba(255, 255, 255, 0.05)'
}}
/>
</Card>
</motion.div>
</Container>
{/* Footer */}
<Box
sx={{
bgcolor: 'grey.100',
py: 4,
mt: 8,
textAlign: 'center'
}}
>
<Container maxWidth="lg">
<Typography variant="body1" color="text.secondary">
© 2024 Familien-Held - Verwandle Aufgaben in Abenteuer! 🏆
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Entwickelt mit für Familien
</Typography>
</Container>
</Box>
</Box>
);
};
export default LandingPage;

View File

@ -0,0 +1,273 @@
import React, { useState } from 'react';
import { useNavigate, useLocation, Link } from 'react-router-dom';
import {
Box,
Container,
Paper,
TextField,
Button,
Typography,
Alert,
InputAdornment,
IconButton,
Divider,
CircularProgress
} from '@mui/material';
import Email from '@mui/icons-material/Email';
import Lock from '@mui/icons-material/Lock';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import ArrowBack from '@mui/icons-material/ArrowBack';
import LoginIcon from '@mui/icons-material/Login';
import { motion } from 'framer-motion';
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
import { useNotification } from '../contexts/NotificationContext';
const LoginPage = () => {
const navigate = useNavigate();
const location = useLocation();
const { login } = useFirebaseAuth();
const { showError, showSuccess } = useNotification();
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [showPassword, setShowPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
// Wohin nach erfolgreichem Login weiterleiten
const from = location.state?.from?.pathname || '/dashboard';
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Fehler zurücksetzen wenn Benutzer tippt
if (error) setError('');
};
const handleSubmit = async (e) => {
e.preventDefault();
setLoading(true);
setError('');
try {
const result = await login(formData.email, formData.password);
if (result.success) {
showSuccess(result.message);
navigate(from, { replace: true });
} else {
setError(result.message);
showError(result.message);
}
} catch (error) {
const message = 'Ein unerwarteter Fehler ist aufgetreten';
setError(message);
showError(message);
} finally {
setLoading(false);
}
};
const isFormValid = formData.email && formData.password;
return (
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)',
display: 'flex',
alignItems: 'center',
py: 4
}}
>
<Container maxWidth="sm">
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Paper
elevation={8}
sx={{
p: 4,
borderRadius: 3,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)'
}}
>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/')}
sx={{
position: 'absolute',
top: 16,
left: 16,
color: 'text.secondary'
}}
>
Zurück
</Button>
<Typography variant="h3" sx={{ mb: 1, fontSize: '3rem' }}>
🦸
</Typography>
<Typography
variant="h4"
sx={{
fontWeight: 700,
color: 'primary.main',
mb: 1
}}
>
Willkommen zurück!
</Typography>
<Typography variant="body1" color="text.secondary">
Melde dich an, um deine Familien-Helden zu verwalten
</Typography>
</Box>
{/* Fehler-Anzeige */}
{error && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{error}
</Alert>
</motion.div>
)}
{/* Login-Formular */}
<Box component="form" onSubmit={handleSubmit}>
<TextField
fullWidth
name="email"
type="email"
label="E-Mail-Adresse"
value={formData.email}
onChange={handleChange}
required
autoComplete="email"
autoFocus
sx={{ mb: 3 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Email color="action" />
</InputAdornment>
),
}}
/>
<TextField
fullWidth
name="password"
type={showPassword ? 'text' : 'password'}
label="Passwort"
value={formData.password}
onChange={handleChange}
required
autoComplete="current-password"
sx={{ mb: 4 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock color="action" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
type="submit"
fullWidth
variant="contained"
size="large"
disabled={!isFormValid || loading}
startIcon={loading ? <CircularProgress size={20} /> : <LoginIcon />}
sx={{
py: 1.5,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 2,
mb: 3
}}
>
{loading ? 'Anmeldung läuft...' : 'Anmelden'}
</Button>
</Box>
<Divider sx={{ my: 3 }}>
<Typography variant="body2" color="text.secondary">
oder
</Typography>
</Divider>
{/* Registrierung */}
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Noch kein Familien-Held?
</Typography>
<Button
component={Link}
to="/register"
variant="outlined"
size="large"
fullWidth
sx={{
py: 1.5,
fontSize: '1rem',
fontWeight: 600,
borderRadius: 2
}}
>
Jetzt kostenlos registrieren
</Button>
</Box>
{/* Demo-Hinweis */}
<Box
sx={{
mt: 4,
p: 2,
bgcolor: 'info.light',
borderRadius: 2,
textAlign: 'center'
}}
>
<Typography variant="body2" sx={{ color: 'info.contrastText' }}>
💡 <strong>Demo-Tipp:</strong> Erstelle einfach einen neuen Account
und teste alle Funktionen kostenlos!
</Typography>
</Box>
</Paper>
</motion.div>
</Container>
</Box>
);
};
export default LoginPage;

View File

@ -0,0 +1,530 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
Grid,
Card,
CardContent,
Typography,
Button,
Avatar,
Chip,
LinearProgress,
List,
ListItem,
ListItemAvatar,
ListItemText,
ListItemSecondaryAction,
IconButton,
AppBar,
Toolbar,
Menu,
MenuItem,
Badge,
Fab,
useTheme,
useMediaQuery
} from '@mui/material';
import {
Dashboard,
Assignment,
EmojiEvents,
People,
Add,
CheckCircle,
Schedule,
Star,
TrendingUp,
Notifications,
AccountCircle,
Logout,
Settings,
MoreVert
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
import { getDashboardData, approveTask, rejectTask } from '../firebase/database';
import { useNotification } from '../contexts/NotificationContext';
const ParentDashboard = () => {
const navigate = useNavigate();
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const { currentUser, logout, userName } = useFirebaseAuth();
const { showError, showSuccess } = useNotification();
const [dashboardData, setDashboardData] = useState(null);
const [loading, setLoading] = useState(true);
const [anchorEl, setAnchorEl] = useState(null);
// Dashboard-Daten laden
const loadDashboard = async () => {
// Nur laden wenn Benutzer authentifiziert ist
if (!currentUser) {
setLoading(false);
return;
}
try {
const result = await getDashboardData();
if (result.success) {
setDashboardData(result.dashboard);
} else {
showError(result.message || 'Fehler beim Laden der Dashboard-Daten');
}
} catch (error) {
console.error('Fehler beim Laden des Dashboards:', error);
showError('Fehler beim Laden der Dashboard-Daten');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadDashboard();
}, [currentUser, showError]);
const handleLogout = async () => {
try {
await logout();
showSuccess('Erfolgreich abgemeldet!');
navigate('/');
} catch (error) {
showError('Fehler beim Abmelden');
}
setAnchorEl(null);
};
const handleTaskApproval = async (taskId, action) => {
try {
let result;
if (action === 'approve') {
result = await approveTask(taskId);
} else {
result = await rejectTask(taskId, 'Bitte nochmal versuchen');
}
if (result.success) {
showSuccess(result.message);
// Dashboard neu laden
loadDashboard();
} else {
showError(result.message);
}
} catch (error) {
showError('Fehler beim Bearbeiten der Aufgabe');
}
};
if (loading) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 2, repeat: Infinity, ease: 'linear' }}
>
<Typography variant="h2">🦸</Typography>
</motion.div>
</Box>
);
}
const { family, children, tasks, rewards, recentActivities } = dashboardData || {};
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* App Bar */}
<AppBar position="static" elevation={0}>
<Toolbar>
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
🦸 {family?.name || 'Familien-Held'}
</Typography>
<IconButton color="inherit" sx={{ mr: 1 }}>
<Badge badgeContent={tasks?.pendingApprovals?.length || 0} color="error">
<Notifications />
</Badge>
</IconButton>
<IconButton
color="inherit"
onClick={(e) => setAnchorEl(e.currentTarget)}
>
<AccountCircle />
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={() => setAnchorEl(null)}
>
<MenuItem onClick={() => navigate('/settings')}>
<Settings sx={{ mr: 1 }} /> Einstellungen
</MenuItem>
<MenuItem onClick={handleLogout}>
<Logout sx={{ mr: 1 }} /> Abmelden
</MenuItem>
</Menu>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Willkommens-Bereich */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Box sx={{ mb: 4 }}>
<Typography variant="h4" sx={{ fontWeight: 700, mb: 1 }}>
Willkommen zurück, {userName}! 👋
</Typography>
<Typography variant="h6" color="text.secondary">
Hier ist ein Überblick über eure Familien-Aktivitäten
</Typography>
</Box>
</motion.div>
{/* Statistik-Karten */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.1 }}
>
<Card sx={{ height: '100%', background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)', color: 'white' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Assignment sx={{ fontSize: 40, mr: 2 }} />
<Box>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{tasks?.today || 0}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Heutige Aufgaben
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.2 }}
>
<Card sx={{ height: '100%', background: 'linear-gradient(135deg, #FF9800 0%, #FFB74D 100%)', color: 'white' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Schedule sx={{ fontSize: 40, mr: 2 }} />
<Box>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{tasks?.pendingApprovals?.length || 0}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Warten auf Genehmigung
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.3 }}
>
<Card sx={{ height: '100%', background: 'linear-gradient(135deg, #2196F3 0%, #64B5F6 100%)', color: 'white' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<People sx={{ fontSize: 40, mr: 2 }} />
<Box>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{children?.length || 0}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Familien-Helden
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.4 }}
>
<Card sx={{ height: '100%', background: 'linear-gradient(135deg, #9C27B0 0%, #BA68C8 100%)', color: 'white' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<TrendingUp sx={{ fontSize: 40, mr: 2 }} />
<Box>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{family?.stats?.completionRate || 0}%
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
Erfolgsrate
</Typography>
</Box>
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
</Grid>
<Grid container spacing={3}>
{/* Kinder-Übersicht */}
<Grid size={{ xs: 12, md: 6 }}>
<motion.div
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.5 }}
>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
👨👩👧👦 Deine Helden
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => navigate('/children')}
>
Verwalten
</Button>
</Box>
{children?.length > 0 ? (
<List>
{children.map((child, index) => (
<ListItem
key={child.id}
sx={{
border: '1px solid',
borderColor: 'divider',
borderRadius: 2,
mb: 1,
cursor: 'pointer',
'&:hover': { bgcolor: 'action.hover' }
}}
onClick={() => navigate(`/child/${child.id}`)}
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
{child.avatar ? child.avatar.charAt(0) : child.name.charAt(0)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={child.name}
secondary={
<React.Fragment>
<span style={{ fontSize: '0.875rem', color: 'rgba(0, 0, 0, 0.6)', display: 'block' }}>
Level {child.level?.current || 1} {child.points.available} Punkte
</span>
<LinearProgress
variant="determinate"
value={(child.points.total % 100)}
sx={{ mt: 1, height: 6, borderRadius: 3 }}
/>
</React.Fragment>
}
/>
<ListItemSecondaryAction>
<Chip
label={child.level?.title || 'Nachwuchs-Held'}
size="small"
color="primary"
variant="outlined"
/>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1" color="text.secondary" sx={{ mb: 2 }}>
Noch keine Kinder hinzugefügt
</Typography>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => navigate('/children')}
>
Erstes Kind hinzufügen
</Button>
</Box>
)}
</CardContent>
</Card>
</motion.div>
</Grid>
{/* Wartende Genehmigungen */}
<Grid size={{ xs: 12, md: 6 }}>
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.6, delay: 0.6 }}
>
<Card>
<CardContent>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 3 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
Warten auf Genehmigung
</Typography>
<Badge badgeContent={tasks?.pendingApprovals?.length || 0} color="error">
<CheckCircle color="action" />
</Badge>
</Box>
{tasks?.pendingApprovals?.length > 0 ? (
<List>
{tasks.pendingApprovals.slice(0, 3).map((task) => (
<ListItem
key={task._id}
sx={{
border: '1px solid',
borderColor: 'warning.light',
borderRadius: 2,
mb: 1,
bgcolor: 'warning.light',
color: 'warning.contrastText'
}}
>
<ListItemAvatar>
<Avatar sx={{ bgcolor: 'warning.main' }}>
{task.assignedTo.name.charAt(0)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={task.title}
secondary={`${task.assignedTo.name}${task.points} Punkte`}
/>
<ListItemSecondaryAction>
<IconButton
size="small"
onClick={() => handleTaskApproval(task._id, 'approve')}
sx={{ color: 'success.main', mr: 1 }}
>
<CheckCircle />
</IconButton>
<IconButton
size="small"
onClick={() => handleTaskApproval(task._id, 'reject')}
sx={{ color: 'error.main' }}
>
<MoreVert />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
) : (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body1" color="text.secondary">
Keine Aufgaben warten auf Genehmigung 🎉
</Typography>
</Box>
)}
</CardContent>
</Card>
</motion.div>
</Grid>
{/* Schnellaktionen */}
<Grid size={12}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6, delay: 0.7 }}
>
<Card>
<CardContent>
<Typography variant="h6" sx={{ fontWeight: 600, mb: 3 }}>
🚀 Schnellaktionen
</Typography>
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Button
fullWidth
variant="outlined"
startIcon={<Assignment />}
onClick={() => navigate('/tasks')}
sx={{ py: 2 }}
>
Aufgaben verwalten
</Button>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Button
fullWidth
variant="outlined"
startIcon={<EmojiEvents />}
onClick={() => navigate('/rewards')}
sx={{ py: 2 }}
>
Belohnungen erstellen
</Button>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Button
fullWidth
variant="outlined"
startIcon={<People />}
onClick={() => navigate('/children')}
sx={{ py: 2 }}
>
Kinder verwalten
</Button>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<Button
fullWidth
variant="outlined"
startIcon={<Dashboard />}
onClick={() => navigate('/stats')}
sx={{ py: 2 }}
>
Statistiken
</Button>
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
</Grid>
</Grid>
</Container>
{/* Floating Action Button */}
<Fab
color="primary"
sx={{
position: 'fixed',
bottom: 16,
right: 16
}}
onClick={() => navigate('/tasks/new')}
>
<Add />
</Fab>
</Box>
);
};
export default ParentDashboard;

View File

@ -0,0 +1,519 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
Box,
Container,
Paper,
TextField,
Button,
Typography,
Alert,
InputAdornment,
IconButton,
Divider,
CircularProgress,
Stepper,
Step,
StepLabel
} from '@mui/material';
import Email from '@mui/icons-material/Email';
import Lock from '@mui/icons-material/Lock';
import Visibility from '@mui/icons-material/Visibility';
import VisibilityOff from '@mui/icons-material/VisibilityOff';
import ArrowBack from '@mui/icons-material/ArrowBack';
import PersonAdd from '@mui/icons-material/PersonAdd';
import FamilyRestroom from '@mui/icons-material/FamilyRestroom';
import Person from '@mui/icons-material/Person';
import { motion } from 'framer-motion';
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
import { useNotification } from '../contexts/NotificationContext';
const RegisterPage = () => {
const navigate = useNavigate();
const { register } = useFirebaseAuth();
const { showError, showSuccess } = useNotification();
const [formData, setFormData] = useState({
email: '',
password: '',
confirmPassword: '',
familyName: '',
parentName: ''
});
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState({});
const [currentStep, setCurrentStep] = useState(0);
const steps = ['Account-Daten', 'Familien-Info', 'Bestätigung'];
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Fehler für dieses Feld zurücksetzen
if (errors[name]) {
setErrors(prev => ({
...prev,
[name]: ''
}));
}
};
const validateStep = (step) => {
const newErrors = {};
switch (step) {
case 0: // Account-Daten
if (!formData.email) {
newErrors.email = 'E-Mail-Adresse ist erforderlich';
} else if (!/\S+@\S+\.\S+/.test(formData.email)) {
newErrors.email = 'Ungültige E-Mail-Adresse';
}
if (!formData.password) {
newErrors.password = 'Passwort ist erforderlich';
} else if (formData.password.length < 6) {
newErrors.password = 'Passwort muss mindestens 6 Zeichen lang sein';
}
if (!formData.confirmPassword) {
newErrors.confirmPassword = 'Passwort-Bestätigung ist erforderlich';
} else if (formData.password !== formData.confirmPassword) {
newErrors.confirmPassword = 'Passwörter stimmen nicht überein';
}
break;
case 1: // Familien-Info
if (!formData.familyName) {
newErrors.familyName = 'Familienname ist erforderlich';
} else if (formData.familyName.length < 2) {
newErrors.familyName = 'Familienname muss mindestens 2 Zeichen lang sein';
}
if (!formData.parentName) {
newErrors.parentName = 'Ihr Name ist erforderlich';
} else if (formData.parentName.length < 2) {
newErrors.parentName = 'Name muss mindestens 2 Zeichen lang sein';
}
break;
default:
break;
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleNext = () => {
if (validateStep(currentStep)) {
setCurrentStep(prev => prev + 1);
}
};
const handleBack = () => {
setCurrentStep(prev => prev - 1);
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateStep(1)) return;
setLoading(true);
setErrors({});
try {
const result = await register(
formData.email,
formData.password,
formData.parentName
);
if (result.success) {
showSuccess(result.message);
navigate('/dashboard');
} else {
if (result.errors) {
const errorObj = {};
result.errors.forEach(error => {
errorObj[error.param] = error.msg;
});
setErrors(errorObj);
} else {
setErrors({ general: result.message });
}
showError(result.message);
setCurrentStep(0); // Zurück zum ersten Schritt bei Fehlern
}
} catch (error) {
const message = 'Ein unerwarteter Fehler ist aufgetreten';
setErrors({ general: message });
showError(message);
setCurrentStep(0);
} finally {
setLoading(false);
}
};
const renderStepContent = (step) => {
switch (step) {
case 0:
return (
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<Typography variant="h6" sx={{ mb: 3, textAlign: 'center' }}>
Erstelle deinen Account
</Typography>
<TextField
fullWidth
name="email"
type="email"
label="E-Mail-Adresse"
value={formData.email}
onChange={handleChange}
error={!!errors.email}
helperText={errors.email}
required
autoComplete="email"
autoFocus
sx={{ mb: 3 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Email color="action" />
</InputAdornment>
),
}}
/>
<TextField
fullWidth
name="password"
type={showPassword ? 'text' : 'password'}
label="Passwort"
value={formData.password}
onChange={handleChange}
error={!!errors.password}
helperText={errors.password || 'Mindestens 6 Zeichen'}
required
autoComplete="new-password"
sx={{ mb: 3 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock color="action" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
<TextField
fullWidth
name="confirmPassword"
type={showConfirmPassword ? 'text' : 'password'}
label="Passwort bestätigen"
value={formData.confirmPassword}
onChange={handleChange}
error={!!errors.confirmPassword}
helperText={errors.confirmPassword}
required
autoComplete="new-password"
sx={{ mb: 3 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Lock color="action" />
</InputAdornment>
),
endAdornment: (
<InputAdornment position="end">
<IconButton
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
edge="end"
>
{showConfirmPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
</motion.div>
);
case 1:
return (
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<Typography variant="h6" sx={{ mb: 3, textAlign: 'center' }}>
Erzähle uns von deiner Familie
</Typography>
<TextField
fullWidth
name="familyName"
label="Familienname"
value={formData.familyName}
onChange={handleChange}
error={!!errors.familyName}
helperText={errors.familyName || 'z.B. Familie Müller'}
required
autoFocus
sx={{ mb: 3 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<FamilyRestroom color="action" />
</InputAdornment>
),
}}
/>
<TextField
fullWidth
name="parentName"
label="Ihr Name"
value={formData.parentName}
onChange={handleChange}
error={!!errors.parentName}
helperText={errors.parentName || 'Wie sollen wir Sie nennen?'}
required
sx={{ mb: 3 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Person color="action" />
</InputAdornment>
),
}}
/>
</motion.div>
);
case 2:
return (
<motion.div
initial={{ opacity: 0, x: 50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3 }}
>
<Typography variant="h6" sx={{ mb: 3, textAlign: 'center' }}>
Alles bereit!
</Typography>
<Box sx={{ bgcolor: 'grey.50', p: 3, borderRadius: 2, mb: 3 }}>
<Typography variant="subtitle2" color="text.secondary" gutterBottom>
Zusammenfassung:
</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Familie:</strong> {formData.familyName}
</Typography>
<Typography variant="body1" sx={{ mb: 1 }}>
<strong>Elternteil:</strong> {formData.parentName}
</Typography>
<Typography variant="body1">
<strong>E-Mail:</strong> {formData.email}
</Typography>
</Box>
<Alert severity="info" sx={{ mb: 3 }}>
Nach der Registrierung können Sie sofort Kinder-Profile erstellen
und mit dem Aufgaben-Management beginnen!
</Alert>
</motion.div>
);
default:
return null;
}
};
return (
<Box
sx={{
minHeight: '100vh',
background: 'linear-gradient(135deg, #4CAF50 0%, #81C784 100%)',
display: 'flex',
alignItems: 'center',
py: 4
}}
>
<Container maxWidth="sm">
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.6 }}
>
<Paper
elevation={8}
sx={{
p: 4,
borderRadius: 3,
background: 'rgba(255, 255, 255, 0.95)',
backdropFilter: 'blur(10px)'
}}
>
{/* Header */}
<Box sx={{ textAlign: 'center', mb: 4 }}>
<Button
startIcon={<ArrowBack />}
onClick={() => navigate('/')}
sx={{
position: 'absolute',
top: 16,
left: 16,
color: 'text.secondary'
}}
>
Zurück
</Button>
<Typography variant="h3" sx={{ mb: 1, fontSize: '3rem' }}>
🦸
</Typography>
<Typography
variant="h4"
sx={{
fontWeight: 700,
color: 'primary.main',
mb: 1
}}
>
Werde ein Familien-Held!
</Typography>
<Typography variant="body1" color="text.secondary">
Erstelle deinen kostenlosen Account
</Typography>
</Box>
{/* Stepper */}
<Stepper activeStep={currentStep} sx={{ mb: 4 }}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
{/* Allgemeine Fehler */}
{errors.general && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
{errors.general}
</Alert>
</motion.div>
)}
{/* Formular-Inhalt */}
<Box component="form" onSubmit={handleSubmit}>
{renderStepContent(currentStep)}
{/* Navigation */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 4 }}>
<Button
onClick={handleBack}
disabled={currentStep === 0}
sx={{ visibility: currentStep === 0 ? 'hidden' : 'visible' }}
>
Zurück
</Button>
{currentStep === steps.length - 1 ? (
<Button
type="submit"
variant="contained"
size="large"
disabled={loading}
startIcon={loading ? <CircularProgress size={20} /> : <PersonAdd />}
sx={{
py: 1.5,
px: 4,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 2
}}
>
{loading ? 'Registrierung läuft...' : 'Account erstellen'}
</Button>
) : (
<Button
onClick={handleNext}
variant="contained"
size="large"
sx={{
py: 1.5,
px: 4,
fontSize: '1.1rem',
fontWeight: 600,
borderRadius: 2
}}
>
Weiter
</Button>
)}
</Box>
</Box>
<Divider sx={{ my: 3 }}>
<Typography variant="body2" color="text.secondary">
oder
</Typography>
</Divider>
{/* Login */}
<Box sx={{ textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
Bereits ein Familien-Held?
</Typography>
<Button
component={Link}
to="/login"
variant="outlined"
size="large"
fullWidth
sx={{
py: 1.5,
fontSize: '1rem',
fontWeight: 600,
borderRadius: 2
}}
>
Jetzt anmelden
</Button>
</Box>
</Paper>
</motion.div>
</Container>
</Box>
);
};
export default RegisterPage;

View File

@ -0,0 +1,921 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
Grid,
Card,
CardContent,
Typography,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
AppBar,
Toolbar,
Tabs,
Tab,
Avatar,
Switch,
FormControlLabel,
Alert,
Divider
} from '@mui/material';
import {
ArrowBack,
Add,
Edit,
Delete,
CheckCircle,
Cancel,
Redeem,
CardGiftcard,
FilterList,
Search,
Star,
Schedule
} from '@mui/icons-material';
import { motion } from 'framer-motion';
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
import { getRewards, createReward, updateReward, deleteReward, getChildren, getRewardRequests, approveRewardRequest, rejectRewardRequest, redeemRewardRequest } from '../firebase/database';
import { useNotification } from '../contexts/NotificationContext';
const RewardManagement = () => {
const navigate = useNavigate();
const { currentUser } = useFirebaseAuth();
const { showSuccess, showError } = useNotification();
const [rewards, setRewards] = useState([]);
const [rewardRequests, setRewardRequests] = useState([]);
const [children, setChildren] = useState([]);
const [loading, setLoading] = useState(true);
const [currentTab, setCurrentTab] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [filterCategory, setFilterCategory] = useState('');
// Dialog States
const [rewardDialog, setRewardDialog] = useState({ open: false, reward: null, mode: 'create' });
const [deleteDialog, setDeleteDialog] = useState({ open: false, reward: null });
const [requestDialog, setRequestDialog] = useState({ open: false, request: null });
// Form State
const [formData, setFormData] = useState({
title: '',
description: '',
cost: 100,
category: 'entertainment',
icon: '🎁',
availableFor: [],
ageRestriction: {
min: 5,
max: 12
},
availability: {
isLimited: false,
quantity: 1,
resetPeriod: 'weekly'
},
timeRestriction: {
hasTimeLimit: false,
validFrom: '',
validUntil: ''
},
requiresApproval: true
});
const categories = [
{ value: 'entertainment', label: 'Unterhaltung', icon: '🎮' },
{ value: 'treats', label: 'Leckereien', icon: '🍭' },
{ value: 'activities', label: 'Aktivitäten', icon: '🎨' },
{ value: 'privileges', label: 'Privilegien', icon: '⭐' },
{ value: 'outings', label: 'Ausflüge', icon: '🚗' },
{ value: 'toys', label: 'Spielzeug', icon: '🧸' },
{ value: 'books', label: 'Bücher', icon: '📚' },
{ value: 'clothes', label: 'Kleidung', icon: '👕' },
{ value: 'money', label: 'Taschengeld', icon: '💰' },
{ value: 'special', label: 'Besonders', icon: '🌟' },
{ value: 'other', label: 'Sonstiges', icon: '🎁' }
];
const icons = [
'🎁', '🎮', '🍭', '🎨', '⭐', '🚗', '🧸', '📚', '👕', '💰',
'🌟', '🎪', '🎭', '🎬', '🎵', '🏆', '🎯', '🎲', '🎊', '🎈',
'🍰', '🍕', '🍔', '🍟', '🍦', '🧁', '🍪', '🥤', '🍿', '🍩'
];
// Daten laden
useEffect(() => {
const loadData = async () => {
// Nur laden wenn Benutzer authentifiziert ist
if (!currentUser) {
setLoading(false);
return;
}
try {
const [rewardsResponse, requestsResponse, childrenResponse] = await Promise.all([
getRewards(),
getRewardRequests(),
getChildren()
]);
if (rewardsResponse.success) {
setRewards(rewardsResponse.rewards);
}
if (requestsResponse.success) {
setRewardRequests(requestsResponse.requests);
}
if (childrenResponse.success) {
setChildren(childrenResponse.children);
}
} catch (error) {
console.error('Fehler beim Laden der Daten:', error);
showError('Fehler beim Laden der Belohnungen');
} finally {
setLoading(false);
}
};
loadData();
}, [currentUser, showError]);
// Belohnungen filtern
const getFilteredRewards = () => {
let filtered = rewards;
// Nach Suchbegriff filtern
if (searchTerm) {
filtered = filtered.filter(reward =>
reward.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
reward.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Nach Kategorie filtern
if (filterCategory) {
filtered = filtered.filter(reward => reward.category === filterCategory);
}
return filtered;
};
const handleFormChange = (field, value) => {
if (field.includes('.')) {
const [parent, child] = field.split('.');
setFormData(prev => ({
...prev,
[parent]: {
...prev[parent],
[child]: value
}
}));
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
};
const handleCreateReward = () => {
setFormData({
title: '',
description: '',
cost: 100,
category: 'entertainment',
icon: '🎁',
availableFor: [],
ageRestriction: {
min: 5,
max: 12
},
availability: {
isLimited: false,
quantity: 1,
resetPeriod: 'weekly'
},
timeRestriction: {
hasTimeLimit: false,
validFrom: '',
validUntil: ''
},
requiresApproval: true
});
setRewardDialog({ open: true, reward: null, mode: 'create' });
};
const handleEditReward = (reward) => {
setFormData({
title: reward.title,
description: reward.description || '',
cost: reward.cost,
category: reward.category,
icon: reward.icon,
availableFor: reward.availableFor?.map(child => child._id) || [],
ageRestriction: reward.ageRestriction || { min: 5, max: 12 },
availability: reward.availability || {
isLimited: false,
quantity: 1,
resetPeriod: 'weekly'
},
timeRestriction: reward.timeRestriction || {
hasTimeLimit: false,
validFrom: '',
validUntil: ''
},
requiresApproval: reward.requiresApproval !== false
});
setRewardDialog({ open: true, reward, mode: 'edit' });
};
const handleSaveReward = async () => {
try {
let response;
if (rewardDialog.mode === 'create') {
response = await createReward(formData);
} else {
response = await updateReward(rewardDialog.reward.id, formData);
}
if (response.success) {
showSuccess(response.message);
setRewardDialog({ open: false, reward: null, mode: 'create' });
// Belohnungen neu laden
const rewardsResponse = await getRewards();
if (rewardsResponse.success) {
setRewards(rewardsResponse.rewards);
}
} else {
showError(response.message);
}
} catch (error) {
showError('Fehler beim Speichern der Belohnung');
}
};
const handleDeleteReward = async () => {
try {
const response = await deleteReward(deleteDialog.reward.id);
if (response.success) {
showSuccess(response.message);
setDeleteDialog({ open: false, reward: null });
// Belohnungen neu laden
const rewardsResponse = await getRewards();
if (rewardsResponse.success) {
setRewards(rewardsResponse.rewards);
}
} else {
showError(response.message);
}
} catch (error) {
showError('Fehler beim Löschen der Belohnung');
}
};
const handleRequestAction = async (requestId, action, reason = '') => {
try {
let response;
if (action === 'approve') {
response = await approveRewardRequest(requestId);
} else if (action === 'reject') {
response = await rejectRewardRequest(requestId, reason);
} else if (action === 'redeem') {
response = await redeemRewardRequest(requestId);
}
if (response.success) {
showSuccess(response.message);
setRequestDialog({ open: false, request: null });
// Anfragen neu laden
const requestsResponse = await getRewardRequests();
if (requestsResponse.success) {
setRewardRequests(requestsResponse.requests);
}
} else {
showError(response.message);
}
} catch (error) {
showError('Fehler beim Bearbeiten der Anfrage');
}
};
const getRequestStatusColor = (status) => {
switch (status) {
case 'pending': return 'warning';
case 'approved': return 'info';
case 'redeemed': return 'success';
case 'rejected': return 'error';
default: return 'default';
}
};
const getRequestStatusLabel = (status) => {
switch (status) {
case 'pending': return 'Wartend';
case 'approved': return 'Genehmigt';
case 'redeemed': return 'Eingelöst';
case 'rejected': return 'Abgelehnt';
default: return status;
}
};
const filteredRewards = getFilteredRewards();
const pendingRequests = rewardRequests.filter(req => req.status === 'pending');
const approvedRequests = rewardRequests.filter(req => req.status === 'approved');
return (
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* App Bar */}
<AppBar position="static" elevation={0}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => navigate('/dashboard')}
sx={{ mr: 2 }}
>
<ArrowBack />
</IconButton>
<CardGiftcard sx={{ mr: 2 }} />
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
Belohnungs-Management
</Typography>
<Button
color="inherit"
startIcon={<Add />}
onClick={handleCreateReward}
sx={{ fontWeight: 600 }}
>
Neue Belohnung
</Button>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Tabs */}
<Card sx={{ mb: 3 }}>
<Tabs
value={currentTab}
onChange={(e, newValue) => setCurrentTab(newValue)}
variant="fullWidth"
>
<Tab label={`Belohnungen (${rewards.length})`} />
<Tab label={`Anfragen (${pendingRequests.length})`} />
<Tab label={`Genehmigt (${approvedRequests.length})`} />
</Tabs>
</Card>
{/* Tab Content */}
{currentTab === 0 && (
<>
{/* Filter und Suche */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
placeholder="Belohnungen durchsuchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: <Search sx={{ mr: 1, color: 'action.active' }} />
}}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth>
<InputLabel>Kategorie filtern</InputLabel>
<Select
value={filterCategory}
onChange={(e) => setFilterCategory(e.target.value)}
label="Kategorie filtern"
>
<MenuItem value="">Alle Kategorien</MenuItem>
{categories.map(category => (
<MenuItem key={category.value} value={category.value}>
{category.icon} {category.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Typography variant="body2" color="text.secondary">
{filteredRewards.length} von {rewards.length} Belohnungen
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Belohnungen-Liste */}
{filteredRewards.length > 0 ? (
<Grid container spacing={2}>
{filteredRewards.map((reward, index) => (
<Grid size={{ xs: 12, md: 6, lg: 4 }} key={reward._id}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<Card sx={{ height: '100%' }}>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Typography variant="h4" sx={{ mr: 2 }}>
{reward.icon}
</Typography>
<Box sx={{ flexGrow: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{reward.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{categories.find(c => c.value === reward.category)?.label || reward.category}
</Typography>
</Box>
</Box>
{reward.description && (
<Typography variant="body2" sx={{ mb: 2 }}>
{reward.description}
</Typography>
)}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Chip
label={`${reward.cost} Punkte`}
color="secondary"
sx={{ fontWeight: 600 }}
/>
{reward.requiresApproval && (
<Chip
label="Genehmigung erforderlich"
size="small"
variant="outlined"
/>
)}
</Box>
{reward.availableFor && reward.availableFor.length > 0 && (
<Box sx={{ mb: 2 }}>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Verfügbar für:
</Typography>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{reward.availableFor.map(child => (
<Chip
key={child._id}
label={child.name}
size="small"
variant="outlined"
/>
))}
</Box>
</Box>
)}
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<IconButton
size="small"
onClick={() => handleEditReward(reward)}
>
<Edit />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialog({ open: true, reward })}
>
<Delete />
</IconButton>
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
))}
</Grid>
) : (
<Card>
<CardContent sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h4" sx={{ mb: 2 }}>🎁</Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
{searchTerm || filterCategory ? 'Keine Belohnungen gefunden' : 'Noch keine Belohnungen erstellt'}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{searchTerm || filterCategory
? 'Versuche andere Suchkriterien oder Filter'
: 'Erstelle die ersten Belohnungen für deine Familien-Helden!'
}
</Typography>
{!searchTerm && !filterCategory && (
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreateReward}
>
Erste Belohnung erstellen
</Button>
)}
</CardContent>
</Card>
)}
</>
)}
{/* Anfragen Tab */}
{currentTab === 1 && (
<>
{pendingRequests.length > 0 ? (
<Grid container spacing={2}>
{pendingRequests.map((request, index) => (
<Grid size={12} key={request._id}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<Card>
<CardContent>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="h4" sx={{ mr: 2 }}>
{request.reward.icon}
</Typography>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{request.reward.title}
</Typography>
<Typography variant="body2" color="text.secondary">
{request.child.name} möchte diese Belohnung
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
label={`${request.pointsSpent} Punkte`}
color="secondary"
size="small"
/>
<Chip
label={getRequestStatusLabel(request.status)}
color={getRequestStatusColor(request.status)}
size="small"
/>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Typography variant="body2" color="text.secondary">
Angefragt: {new Date(request.createdAt).toLocaleDateString('de-DE')}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
variant="contained"
color="success"
onClick={() => handleRequestAction(request._id, 'approve')}
>
Genehmigen
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleRequestAction(request._id, 'reject', 'Nicht verfügbar')}
>
Ablehnen
</Button>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
</Grid>
))}
</Grid>
) : (
<Card>
<CardContent sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h4" sx={{ mb: 2 }}>📬</Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
Keine offenen Anfragen
</Typography>
<Typography variant="body1" color="text.secondary">
Alle Belohnungsanfragen wurden bearbeitet.
</Typography>
</CardContent>
</Card>
)}
</>
)}
{/* Genehmigte Anfragen Tab */}
{currentTab === 2 && (
<>
{approvedRequests.length > 0 ? (
<Grid container spacing={2}>
{approvedRequests.map((request, index) => (
<Grid size={12} key={request._id}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<Card>
<CardContent>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="h4" sx={{ mr: 2 }}>
{request.reward.icon}
</Typography>
<Box>
<Typography variant="h6" sx={{ fontWeight: 600 }}>
{request.reward.title}
</Typography>
<Typography variant="body2" color="text.secondary">
Für {request.child.name}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', gap: 1 }}>
<Chip
label={`${request.pointsSpent} Punkte`}
color="secondary"
size="small"
/>
<Chip
label="Bereit zur Einlösung"
color="info"
size="small"
/>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Typography variant="body2" color="text.secondary">
Genehmigt: {new Date(request.approvedAt).toLocaleDateString('de-DE')}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
<Button
size="small"
variant="contained"
color="primary"
startIcon={<Redeem />}
onClick={() => handleRequestAction(request._id, 'redeem')}
>
Als eingelöst markieren
</Button>
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
</Grid>
))}
</Grid>
) : (
<Card>
<CardContent sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h4" sx={{ mb: 2 }}></Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
Keine genehmigten Anfragen
</Typography>
<Typography variant="body1" color="text.secondary">
Alle genehmigten Belohnungen wurden bereits eingelöst.
</Typography>
</CardContent>
</Card>
)}
</>
)}
</Container>
{/* Belohnung erstellen/bearbeiten Dialog */}
<Dialog
open={rewardDialog.open}
onClose={() => setRewardDialog({ open: false, reward: null, mode: 'create' })}
maxWidth="md"
fullWidth
>
<DialogTitle>
{rewardDialog.mode === 'create' ? 'Neue Belohnung erstellen' : 'Belohnung bearbeiten'}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<TextField
fullWidth
label="Belohnungs-Titel"
value={formData.title}
onChange={(e) => handleFormChange('title', e.target.value)}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Beschreibung (optional)"
value={formData.description}
onChange={(e) => handleFormChange('description', e.target.value)}
multiline
rows={2}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
label="Kosten (Punkte)"
type="number"
value={formData.cost}
onChange={(e) => handleFormChange('cost', parseInt(e.target.value))}
inputProps={{ min: 1, max: 10000 }}
required
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth>
<InputLabel>Kategorie</InputLabel>
<Select
value={formData.category}
onChange={(e) => handleFormChange('category', e.target.value)}
label="Kategorie"
>
{categories.map(category => (
<MenuItem key={category.value} value={category.value}>
{category.icon} {category.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth>
<InputLabel>Icon</InputLabel>
<Select
value={formData.icon}
onChange={(e) => handleFormChange('icon', e.target.value)}
label="Icon"
>
{icons.map(icon => (
<MenuItem key={icon} value={icon}>
{icon} {icon}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={12}>
<FormControl fullWidth>
<InputLabel>Verfügbar für Kinder</InputLabel>
<Select
multiple
value={formData.availableFor}
onChange={(e) => handleFormChange('availableFor', e.target.value)}
label="Verfügbar für Kinder"
renderValue={(selected) => (
<Box sx={{ display: 'flex', flexWrap: 'wrap', gap: 0.5 }}>
{selected.map((value) => {
const child = children.find(c => c._id === value);
return (
<Chip key={value} label={child?.name || value} size="small" />
);
})}
</Box>
)}
>
{children.map(child => (
<MenuItem key={child._id} value={child._id}>
{child.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={12}>
<FormControlLabel
control={
<Switch
checked={formData.requiresApproval}
onChange={(e) => handleFormChange('requiresApproval', e.target.checked)}
/>
}
label="Genehmigung durch Eltern erforderlich"
/>
</Grid>
<Grid size={12}>
<FormControlLabel
control={
<Switch
checked={formData.availability.isLimited}
onChange={(e) => handleFormChange('availability.isLimited', e.target.checked)}
/>
}
label="Begrenzte Verfügbarkeit"
/>
</Grid>
{formData.availability.isLimited && (
<>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
label="Anzahl"
type="number"
value={formData.availability.quantity}
onChange={(e) => handleFormChange('availability.quantity', parseInt(e.target.value))}
inputProps={{ min: 1 }}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel>Reset-Periode</InputLabel>
<Select
value={formData.availability.resetPeriod}
onChange={(e) => handleFormChange('availability.resetPeriod', e.target.value)}
label="Reset-Periode"
>
<MenuItem value="daily">Täglich</MenuItem>
<MenuItem value="weekly">Wöchentlich</MenuItem>
<MenuItem value="monthly">Monatlich</MenuItem>
<MenuItem value="never">Nie</MenuItem>
</Select>
</FormControl>
</Grid>
</>
)}
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setRewardDialog({ open: false, reward: null, mode: 'create' })}>
Abbrechen
</Button>
<Button
onClick={handleSaveReward}
variant="contained"
disabled={!formData.title || !formData.cost}
>
{rewardDialog.mode === 'create' ? 'Erstellen' : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
{/* Löschen-Bestätigung Dialog */}
<Dialog
open={deleteDialog.open}
onClose={() => setDeleteDialog({ open: false, reward: null })}
>
<DialogTitle>Belohnung löschen</DialogTitle>
<DialogContent>
<Typography>
Möchtest du die Belohnung "{deleteDialog.reward?.title}" wirklich löschen?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialog({ open: false, reward: null })}>
Abbrechen
</Button>
<Button onClick={handleDeleteReward} color="error" variant="contained">
Löschen
</Button>
</DialogActions>
</Dialog>
</Box>
);
};
export default RewardManagement;

View File

@ -0,0 +1,733 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import {
Box,
Container,
Grid,
Card,
CardContent,
Typography,
Button,
TextField,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
FormControl,
InputLabel,
Select,
MenuItem,
Chip,
List,
ListItem,
ListItemText,
ListItemSecondaryAction,
IconButton,
AppBar,
Toolbar,
Tabs,
Tab,
Avatar,
Switch,
FormControlLabel,
Alert
} from '@mui/material';
import {
ArrowBack,
Add,
Edit,
Delete,
CheckCircle,
Schedule,
Assignment,
FilterList,
Search
} from '@mui/icons-material';
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { motion } from 'framer-motion';
import { useFirebaseAuth } from '../contexts/FirebaseAuthContext';
import { getTasks, createTask, updateTask, deleteTask, approveTask, rejectTask, getChildren } from '../firebase/database';
import { useNotification } from '../contexts/NotificationContext';
const TaskManagement = () => {
const navigate = useNavigate();
const { currentUser } = useFirebaseAuth();
const { showSuccess, showError } = useNotification();
const [tasks, setTasks] = useState([]);
const [children, setChildren] = useState([]);
const [loading, setLoading] = useState(true);
const [currentTab, setCurrentTab] = useState(0);
const [searchTerm, setSearchTerm] = useState('');
const [filterChild, setFilterChild] = useState('');
const [filterStatus, setFilterStatus] = useState('');
// Dialog States
const [taskDialog, setTaskDialog] = useState({ open: false, task: null, mode: 'create' });
const [deleteDialog, setDeleteDialog] = useState({ open: false, task: null });
// Form State
const [formData, setFormData] = useState({
title: '',
description: '',
assignedTo: '',
dueDate: new Date(),
points: 10,
category: 'household',
difficulty: 'easy',
priority: 'normal',
estimatedTime: '',
recurring: {
isRecurring: false,
frequency: 'daily',
endDate: null
}
});
const categories = [
{ value: 'household', label: 'Haushalt', icon: '🏠' },
{ value: 'personal', label: 'Persönlich', icon: '👤' },
{ value: 'homework', label: 'Hausaufgaben', icon: '📚' },
{ value: 'chores', label: 'Aufräumen', icon: '🧹' },
{ value: 'hygiene', label: 'Hygiene', icon: '🚿' },
{ value: 'pets', label: 'Haustiere', icon: '🐕' },
{ value: 'garden', label: 'Garten', icon: '🌱' },
{ value: 'helping', label: 'Helfen', icon: '🤝' },
{ value: 'learning', label: 'Lernen', icon: '🎓' },
{ value: 'exercise', label: 'Sport', icon: '🏃' },
{ value: 'other', label: 'Sonstiges', icon: '📝' }
];
const difficulties = [
{ value: 'easy', label: 'Einfach', color: 'success' },
{ value: 'medium', label: 'Mittel', color: 'warning' },
{ value: 'hard', label: 'Schwer', color: 'error' }
];
const priorities = [
{ value: 'low', label: 'Niedrig', color: 'default' },
{ value: 'normal', label: 'Normal', color: 'primary' },
{ value: 'high', label: 'Hoch', color: 'warning' },
{ value: 'urgent', label: 'Dringend', color: 'error' }
];
// Daten laden
useEffect(() => {
const loadData = async () => {
// Nur laden wenn Benutzer authentifiziert ist
if (!currentUser) {
setLoading(false);
return;
}
try {
const [tasksResponse, childrenResponse] = await Promise.all([
getTasks(),
getChildren()
]);
if (tasksResponse.success) {
setTasks(tasksResponse.tasks);
}
if (childrenResponse.success) {
setChildren(childrenResponse.children);
}
} catch (error) {
console.error('Fehler beim Laden der Daten:', error);
showError('Fehler beim Laden der Aufgaben');
} finally {
setLoading(false);
}
};
loadData();
}, [currentUser, showError]);
// Aufgaben filtern
const getFilteredTasks = () => {
let filtered = tasks;
// Nach Tab filtern
switch (currentTab) {
case 0: // Alle
break;
case 1: // Offen
filtered = filtered.filter(task => task.status === 'pending');
break;
case 2: // Warten auf Genehmigung
filtered = filtered.filter(task => task.status === 'completed');
break;
case 3: // Genehmigt
filtered = filtered.filter(task => task.status === 'approved');
break;
default:
break;
}
// Nach Suchbegriff filtern
if (searchTerm) {
filtered = filtered.filter(task =>
task.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
task.description?.toLowerCase().includes(searchTerm.toLowerCase())
);
}
// Nach Kind filtern
if (filterChild) {
filtered = filtered.filter(task => (task.assignedTo.id || task.assignedTo) === filterChild);
}
return filtered;
};
const handleFormChange = (field, value) => {
if (field.includes('.')) {
const [parent, child] = field.split('.');
setFormData(prev => ({
...prev,
[parent]: {
...prev[parent],
[child]: value
}
}));
} else {
setFormData(prev => ({ ...prev, [field]: value }));
}
};
const handleCreateTask = () => {
setFormData({
title: '',
description: '',
assignedTo: '',
dueDate: new Date(),
points: 10,
category: 'household',
difficulty: 'easy',
priority: 'normal',
estimatedTime: '',
recurring: {
isRecurring: false,
frequency: 'daily',
endDate: null
}
});
setTaskDialog({ open: true, task: null, mode: 'create' });
};
const handleEditTask = (task) => {
setFormData({
title: task.title,
description: task.description || '',
assignedTo: task.assignedTo.id || task.assignedTo,
dueDate: new Date(task.dueDate),
points: task.points,
category: task.category,
difficulty: task.difficulty,
priority: task.priority,
estimatedTime: task.estimatedTime || '',
recurring: task.recurring || {
isRecurring: false,
frequency: 'daily',
endDate: null
}
});
setTaskDialog({ open: true, task, mode: 'edit' });
};
const handleSaveTask = async () => {
try {
const taskData = {
...formData,
dueDate: formData.dueDate.toISOString()
};
let response;
if (taskDialog.mode === 'create') {
response = await createTask(taskData);
} else {
response = await updateTask(taskDialog.task.id, taskData);
}
if (response.success) {
showSuccess(response.message);
setTaskDialog({ open: false, task: null, mode: 'create' });
// Aufgaben neu laden
const tasksResponse = await getTasks();
if (tasksResponse.success) {
setTasks(tasksResponse.tasks);
}
}
} catch (error) {
showError(error.message || 'Fehler beim Speichern der Aufgabe');
}
};
const handleDeleteTask = async () => {
try {
const response = await deleteTask(deleteDialog.task.id);
if (response.success) {
showSuccess(response.message);
setDeleteDialog({ open: false, task: null });
// Aufgabe aus Liste entfernen
setTasks(prev => prev.filter(task => task.id !== deleteDialog.task.id));
}
} catch (error) {
showError('Fehler beim Löschen der Aufgabe');
}
};
const handleTaskAction = async (taskId, action) => {
try {
let response;
if (action === 'approve') {
response = await approveTask(taskId);
} else if (action === 'reject') {
response = await rejectTask(taskId, 'Bitte nochmal versuchen');
}
if (response.success) {
showSuccess(response.message);
// Aufgaben neu laden
const tasksResponse = await getTasks();
if (tasksResponse.success) {
setTasks(tasksResponse.tasks);
}
}
} catch (error) {
showError('Fehler beim Bearbeiten der Aufgabe');
}
};
const getStatusColor = (status) => {
switch (status) {
case 'pending': return 'warning';
case 'completed': return 'info';
case 'approved': return 'success';
case 'rejected': return 'error';
default: return 'default';
}
};
const getStatusLabel = (status) => {
switch (status) {
case 'pending': return 'Offen';
case 'completed': return 'Erledigt';
case 'approved': return 'Genehmigt';
case 'rejected': return 'Abgelehnt';
default: return status;
}
};
const filteredTasks = getFilteredTasks();
return (
<LocalizationProvider dateAdapter={AdapterDateFns}>
<Box sx={{ minHeight: '100vh', bgcolor: 'background.default' }}>
{/* App Bar */}
<AppBar position="static" elevation={0}>
<Toolbar>
<IconButton
edge="start"
color="inherit"
onClick={() => navigate('/dashboard')}
sx={{ mr: 2 }}
>
<ArrowBack />
</IconButton>
<Assignment sx={{ mr: 2 }} />
<Typography variant="h6" sx={{ flexGrow: 1, fontWeight: 700 }}>
Aufgaben-Management
</Typography>
<Button
color="inherit"
startIcon={<Add />}
onClick={handleCreateTask}
sx={{ fontWeight: 600 }}
>
Neue Aufgabe
</Button>
</Toolbar>
</AppBar>
<Container maxWidth="lg" sx={{ py: 4 }}>
{/* Filter und Suche */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
placeholder="Aufgaben durchsuchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: <Search sx={{ mr: 1, color: 'action.active' }} />
}}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth>
<InputLabel>Kind filtern</InputLabel>
<Select
value={filterChild}
onChange={(e) => setFilterChild(e.target.value)}
label="Kind filtern"
>
<MenuItem value="">Alle Kinder</MenuItem>
{children.map(child => (
<MenuItem key={child.id} value={child.id}>
{child.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<Typography variant="body2" color="text.secondary">
{filteredTasks.length} von {tasks.length} Aufgaben
</Typography>
</Grid>
</Grid>
</CardContent>
</Card>
{/* Tabs */}
<Card sx={{ mb: 3 }}>
<Tabs
value={currentTab}
onChange={(e, newValue) => setCurrentTab(newValue)}
variant="fullWidth"
>
<Tab label={`Alle (${tasks.length})`} />
<Tab label={`Offen (${tasks.filter(t => t.status === 'pending').length})`} />
<Tab label={`Warten (${tasks.filter(t => t.status === 'completed').length})`} />
<Tab label={`Genehmigt (${tasks.filter(t => t.status === 'approved').length})`} />
</Tabs>
</Card>
{/* Aufgaben-Liste */}
{filteredTasks.length > 0 ? (
<Grid container spacing={2}>
{filteredTasks.map((task, index) => (
<Grid size={12} key={task.id}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
>
<Card>
<CardContent>
<Grid container spacing={2} alignItems="center">
<Grid size={{ xs: 12, md: 6 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Typography variant="h6" sx={{ fontWeight: 600, mr: 2 }}>
{task.title}
</Typography>
<Chip
label={getStatusLabel(task.status)}
color={getStatusColor(task.status)}
size="small"
/>
</Box>
{task.description && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
{task.description}
</Typography>
)}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
<Chip
label={`${task.points} Punkte`}
color="secondary"
size="small"
/>
<Chip
label={categories.find(c => c.value === task.category)?.label || task.category}
size="small"
variant="outlined"
/>
<Chip
label={difficulties.find(d => d.value === task.difficulty)?.label || task.difficulty}
color={difficulties.find(d => d.value === task.difficulty)?.color || 'default'}
size="small"
variant="outlined"
/>
</Box>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 1 }}>
<Avatar sx={{ width: 32, height: 32, mr: 1, bgcolor: 'primary.main' }}>
{task.assignedTo.name.charAt(0)}
</Avatar>
<Typography variant="body2">
{task.assignedTo.name}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Fällig: {new Date(task.dueDate).toLocaleDateString('de-DE')}
</Typography>
</Grid>
<Grid size={{ xs: 12, md: 3 }}>
<Box sx={{ display: 'flex', gap: 1, justifyContent: 'flex-end' }}>
{task.status === 'completed' && (
<>
<Button
size="small"
variant="contained"
color="success"
onClick={() => handleTaskAction(task.id, 'approve')}
>
Genehmigen
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={() => handleTaskAction(task.id, 'reject')}
>
Ablehnen
</Button>
</>
)}
{(task.status === 'pending' || task.status === 'rejected') && (
<>
<IconButton
size="small"
onClick={() => handleEditTask(task)}
>
<Edit />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => setDeleteDialog({ open: true, task })}
>
<Delete />
</IconButton>
</>
)}
</Box>
</Grid>
</Grid>
</CardContent>
</Card>
</motion.div>
</Grid>
))}
</Grid>
) : (
<Card>
<CardContent sx={{ textAlign: 'center', py: 8 }}>
<Typography variant="h4" sx={{ mb: 2 }}>📝</Typography>
<Typography variant="h6" sx={{ mb: 2 }}>
{searchTerm || filterChild ? 'Keine Aufgaben gefunden' : 'Noch keine Aufgaben erstellt'}
</Typography>
<Typography variant="body1" color="text.secondary" sx={{ mb: 3 }}>
{searchTerm || filterChild
? 'Versuche andere Suchkriterien oder Filter'
: 'Erstelle die erste Aufgabe für deine Familien-Helden!'
}
</Typography>
{!searchTerm && !filterChild && (
<Button
variant="contained"
startIcon={<Add />}
onClick={handleCreateTask}
>
Erste Aufgabe erstellen
</Button>
)}
</CardContent>
</Card>
)}
</Container>
{/* Aufgabe erstellen/bearbeiten Dialog */}
<Dialog
open={taskDialog.open}
onClose={() => setTaskDialog({ open: false, task: null, mode: 'create' })}
maxWidth="md"
fullWidth
>
<DialogTitle>
{taskDialog.mode === 'create' ? 'Neue Aufgabe erstellen' : 'Aufgabe bearbeiten'}
</DialogTitle>
<DialogContent>
<Grid container spacing={2} sx={{ mt: 1 }}>
<Grid size={12}>
<TextField
fullWidth
label="Aufgaben-Titel"
value={formData.title}
onChange={(e) => handleFormChange('title', e.target.value)}
required
/>
</Grid>
<Grid size={12}>
<TextField
fullWidth
label="Beschreibung (optional)"
value={formData.description}
onChange={(e) => handleFormChange('description', e.target.value)}
multiline
rows={2}
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth required>
<InputLabel>Kind zuweisen</InputLabel>
<Select
value={formData.assignedTo}
onChange={(e) => handleFormChange('assignedTo', e.target.value)}
label="Kind zuweisen"
>
{children.map(child => (
<MenuItem key={child.id} value={child.id}>
{child.name}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<DatePicker
label="Fälligkeitsdatum"
value={formData.dueDate}
onChange={(date) => handleFormChange('dueDate', date)}
renderInput={(params) => <TextField {...params} fullWidth />}
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
label="Punkte"
type="number"
value={formData.points}
onChange={(e) => handleFormChange('points', parseInt(e.target.value))}
inputProps={{ min: 1, max: 500 }}
required
/>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth>
<InputLabel>Kategorie</InputLabel>
<Select
value={formData.category}
onChange={(e) => handleFormChange('category', e.target.value)}
label="Kategorie"
>
{categories.map(category => (
<MenuItem key={category.value} value={category.value}>
{category.icon} {category.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 12, md: 4 }}>
<FormControl fullWidth>
<InputLabel>Schwierigkeit</InputLabel>
<Select
value={formData.difficulty}
onChange={(e) => handleFormChange('difficulty', e.target.value)}
label="Schwierigkeit"
>
{difficulties.map(difficulty => (
<MenuItem key={difficulty.value} value={difficulty.value}>
{difficulty.label}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={12}>
<FormControlLabel
control={
<Switch
checked={formData.recurring.isRecurring}
onChange={(e) => handleFormChange('recurring.isRecurring', e.target.checked)}
/>
}
label="Wiederkehrende Aufgabe"
/>
</Grid>
{formData.recurring.isRecurring && (
<Grid size={{ xs: 12, md: 6 }}>
<FormControl fullWidth>
<InputLabel>Häufigkeit</InputLabel>
<Select
value={formData.recurring.frequency}
onChange={(e) => handleFormChange('recurring.frequency', e.target.value)}
label="Häufigkeit"
>
<MenuItem value="daily">Täglich</MenuItem>
<MenuItem value="weekly">Wöchentlich</MenuItem>
<MenuItem value="monthly">Monatlich</MenuItem>
</Select>
</FormControl>
</Grid>
)}
</Grid>
</DialogContent>
<DialogActions>
<Button onClick={() => setTaskDialog({ open: false, task: null, mode: 'create' })}>
Abbrechen
</Button>
<Button
onClick={handleSaveTask}
variant="contained"
disabled={!formData.title || !formData.assignedTo}
>
{taskDialog.mode === 'create' ? 'Erstellen' : 'Speichern'}
</Button>
</DialogActions>
</Dialog>
{/* Löschen-Bestätigung Dialog */}
<Dialog
open={deleteDialog.open}
onClose={() => setDeleteDialog({ open: false, task: null })}
>
<DialogTitle>Aufgabe löschen</DialogTitle>
<DialogContent>
<Typography>
Möchtest du die Aufgabe "{deleteDialog.task?.title}" wirklich löschen?
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteDialog({ open: false, task: null })}>
Abbrechen
</Button>
<Button onClick={handleDeleteTask} color="error" variant="contained">
Löschen
</Button>
</DialogActions>
</Dialog>
</Box>
</LocalizationProvider>
);
};
export default TaskManagement;

View File

@ -0,0 +1,13 @@
const reportWebVitals = onPerfEntry => {
if (onPerfEntry && onPerfEntry instanceof Function) {
import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => {
getCLS(onPerfEntry);
getFID(onPerfEntry);
getFCP(onPerfEntry);
getLCP(onPerfEntry);
getTTFB(onPerfEntry);
});
}
};
export default reportWebVitals;

5
client/src/setupTests.js Normal file
View File

@ -0,0 +1,5 @@
// jest-dom adds custom jest matchers for asserting on DOM nodes.
// allows you to do things like:
// expect(element).toHaveTextContent(/react/i)
// learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom';

View File

@ -0,0 +1,952 @@
import { createTheme } from '@mui/material/styles';
import { alpha } from '@mui/material/styles';
// Basis-Theme-Konfiguration
const baseTheme = {
typography: {
fontFamily: '"Comic Neue", "Roboto", "Helvetica", "Arial", sans-serif',
h1: { fontWeight: 700 },
h2: { fontWeight: 700 },
h3: { fontWeight: 600 },
h4: { fontWeight: 600 },
h5: { fontWeight: 600 },
h6: { fontWeight: 600 },
},
shape: {
borderRadius: 16,
},
components: {
MuiCssBaseline: {
styleOverrides: (theme) => ({
body: {
transition: 'background-image 0.5s ease-in-out',
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
backgroundAttachment: 'fixed',
minHeight: '100vh',
},
}),
},
MuiButton: {
styleOverrides: {
root: {
borderRadius: 20,
textTransform: 'none',
fontWeight: 600,
fontSize: '1.1rem',
padding: '12px 24px',
transition: 'transform 0.2s, box-shadow 0.2s',
'&:hover': {
transform: 'translateY(-3px)',
boxShadow: '0 10px 20px rgba(0, 0, 0, 0.15)',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
borderRadius: 20,
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.1)',
transition: 'transform 0.3s, box-shadow 0.3s',
'&:hover': {
transform: 'translateY(-5px)',
boxShadow: '0 12px 28px rgba(0, 0, 0, 0.15)',
},
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
opacity: 0.05,
pointerEvents: 'none',
},
},
},
},
MuiChip: {
styleOverrides: {
root: {
borderRadius: 16,
fontWeight: 600,
},
},
},
MuiPaper: {
styleOverrides: {
root: {
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
zIndex: 0,
opacity: 0.05,
pointerEvents: 'none',
},
},
},
},
},
};
// Farbenfrohe Theme (Standard)
const colorfulTheme = createTheme({
...baseTheme,
palette: {
mode: 'light',
primary: {
main: '#FF6B6B', // Freundliches Rot
light: '#FF8E8E',
dark: '#E55555',
contrastText: '#FFFFFF',
},
secondary: {
main: '#4ECDC4', // Türkis
light: '#7DD3CC',
dark: '#3BA99F',
contrastText: '#FFFFFF',
},
success: {
main: '#45B7D1', // Helles Blau
light: '#6BC5D8',
dark: '#3A9BC1',
},
warning: {
main: '#FFA726', // Orange
light: '#FFB74D',
dark: '#F57C00',
},
error: {
main: '#EF5350',
light: '#E57373',
dark: '#C62828',
},
background: {
default: '#FFF8E1', // Warmes Cremeweiß
paper: '#FFFFFF',
},
text: {
primary: '#2D3748',
secondary: '#4A5568',
},
},
themeIcon: '🌈',
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
backgroundImage: 'linear-gradient(135deg, #FFF8E1 0%, #FFECB3 100%)',
'&::before': {
content: '""',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3ClinearGradient id=\'rainbow\' x1=\'0%\' y1=\'0%\' x2=\'100%\' y2=\'0%\'%3E%3Cstop offset=\'0%\' stop-color=\'%23FF6B6B\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'20%\' stop-color=\'%23FFD93D\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'40%\' stop-color=\'%236BCB77\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'60%\' stop-color=\'%234ECDC4\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'80%\' stop-color=\'%236A0572\' stop-opacity=\'0.2\' /%3E%3Cstop offset=\'100%\' stop-color=\'%23FF6B6B\' stop-opacity=\'0.2\' /%3E%3C/linearGradient%3E%3C/defs%3E%3Cpath d=\'M0,40 Q50,60 100,40 Q150,20 200,40 Q250,60 300,40 Q350,20 400,40 Q450,60 500,40 Q550,20 600,40 Q650,60 700,40 Q750,20 800,40 Q850,60 900,40 Q950,20 1000,40 Q1050,60 1100,40 Q1150,20 1200,40 Q1250,60 1300,40 Q1350,20 1400,40 Q1450,60 1500,40 Q1550,20 1600,40 Q1650,60 1700,40 Q1750,20 1800,40 Q1850,60 1900,40 Q1950,20 2000,40\' stroke=\'url(%23rainbow)\' stroke-width=\'20\' fill=\'none\' /%3E%3Cpath d=\'M0,80 Q50,100 100,80 Q150,60 200,80 Q250,100 300,80 Q350,60 400,80 Q450,100 500,80 Q550,60 600,80 Q650,100 700,80 Q750,60 800,80 Q850,100 900,80 Q950,60 1000,80 Q1050,100 1100,80 Q1150,60 1200,80 Q1250,100 1300,80 Q1350,60 1400,80 Q1450,100 1500,80 Q1550,60 1600,80 Q1650,100 1700,80 Q1750,60 1800,80 Q1850,100 1900,80 Q1950,60 2000,80\' stroke=\'url(%23rainbow)\' stroke-width=\'20\' fill=\'none\' /%3E%3Cpath d=\'M0,120 Q50,140 100,120 Q150,100 200,120 Q250,140 300,120 Q350,100 400,120 Q450,140 500,120 Q550,100 600,120 Q650,140 700,120 Q750,100 800,120 Q850,140 900,120 Q950,100 1000,120 Q1050,140 1100,120 Q1150,100 1200,120 Q1250,140 1300,120 Q1350,100 1400,120 Q1450,140 1500,120 Q1550,100 1600,120 Q1650,140 1700,120 Q1750,100 1800,120 Q1850,140 1900,120 Q1950,100 2000,120\' stroke=\'url(%23rainbow)\' stroke-width=\'20\' fill=\'none\' /%3E%3C/svg%3E")',
opacity: 0.3,
zIndex: -1,
pointerEvents: 'none',
}
},
},
},
MuiCard: {
styleOverrides: {
root: {
'&::before': {
backgroundImage: 'linear-gradient(135deg, rgba(255,107,107,0.1) 0%, rgba(78,205,196,0.1) 100%)',
},
'&::after': {
content: '""',
position: 'absolute',
bottom: -5,
left: '10%',
width: '80%',
height: 10,
background: 'linear-gradient(90deg, #FF6B6B, #FFD93D, #6BCB77, #4ECDC4, #6A0572)',
borderRadius: 10,
opacity: 0.6,
filter: 'blur(3px)',
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
background: 'linear-gradient(45deg, #FF6B6B 0%, #4ECDC4 100%)',
backgroundSize: '200% 200%',
animation: 'gradient-animation 5s ease infinite',
'@keyframes gradient-animation': {
'0%': { backgroundPosition: '0% 50%' },
'50%': { backgroundPosition: '100% 50%' },
'100%': { backgroundPosition: '0% 50%' },
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
'&::before': {
backgroundImage: 'linear-gradient(135deg, rgba(255,107,107,0.05) 0%, rgba(78,205,196,0.05) 100%)',
},
},
},
},
},
});
// Natur Theme
const natureTheme = createTheme({
...baseTheme,
palette: {
mode: 'light',
primary: {
main: '#4CAF50', // Grün
light: '#81C784',
dark: '#388E3C',
contrastText: '#FFFFFF',
},
secondary: {
main: '#8BC34A', // Hellgrün
light: '#AED581',
dark: '#689F38',
contrastText: '#FFFFFF',
},
success: {
main: '#66BB6A',
light: '#81C784',
dark: '#4CAF50',
},
warning: {
main: '#FF8F00', // Bernstein
light: '#FFA726',
dark: '#E65100',
},
error: {
main: '#D32F2F',
light: '#EF5350',
dark: '#C62828',
},
background: {
default: '#F1F8E9', // Sehr helles Grün
paper: '#FFFFFF',
},
text: {
primary: '#1B5E20',
secondary: '#2E7D32',
},
},
themeIcon: '🌿',
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
backgroundImage: 'linear-gradient(135deg, #F1F8E9 0%, #DCEDC8 100%)',
'&::before': {
content: '""',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%234CAF50\' fill-opacity=\'0.1\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%238BC34A\' fill-opacity=\'0.1\' transform=\'translate(100,0)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%234CAF50\' fill-opacity=\'0.1\' transform=\'translate(200,0)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%238BC34A\' fill-opacity=\'0.1\' transform=\'translate(0,100)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%234CAF50\' fill-opacity=\'0.1\' transform=\'translate(100,100)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3Cg fill=\'%238BC34A\' fill-opacity=\'0.1\' transform=\'translate(200,100)\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '300px 300px',
opacity: 0.5,
zIndex: -1,
pointerEvents: 'none',
}
},
},
},
MuiCard: {
styleOverrides: {
root: {
'&::before': {
backgroundImage: 'linear-gradient(135deg, rgba(76,175,80,0.1) 0%, rgba(139,195,74,0.1) 100%)',
},
'&::after': {
content: '""',
position: 'absolute',
bottom: -2,
left: '5%',
width: '90%',
height: 5,
background: 'linear-gradient(90deg, #4CAF50, #8BC34A)',
borderRadius: 5,
opacity: 0.6,
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: -10,
left: -10,
right: -10,
bottom: -10,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100\' height=\'100\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M30,20 Q40,5 50,20 Q60,35 70,20 L70,50 L30,50 Z\' fill=\'%234CAF50\' fill-opacity=\'0.2\' /%3E%3C/svg%3E")',
backgroundSize: '100px 100px',
opacity: 0,
transition: 'opacity 0.3s',
zIndex: -1,
},
'&:hover::before': {
opacity: 1,
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
'&::before': {
backgroundImage: 'linear-gradient(135deg, rgba(76,175,80,0.05) 0%, rgba(139,195,74,0.05) 100%)',
},
},
},
},
},
});
// Weltraum Theme
const spaceTheme = createTheme({
...baseTheme,
palette: {
mode: 'dark',
primary: {
main: '#9C27B0', // Lila
light: '#BA68C8',
dark: '#7B1FA2',
contrastText: '#FFFFFF',
},
secondary: {
main: '#3F51B5', // Indigo
light: '#7986CB',
dark: '#303F9F',
contrastText: '#FFFFFF',
},
success: {
main: '#00BCD4', // Cyan
light: '#4DD0E1',
dark: '#0097A7',
},
warning: {
main: '#FF9800',
light: '#FFB74D',
dark: '#F57C00',
},
error: {
main: '#F44336',
light: '#EF5350',
dark: '#D32F2F',
},
background: {
default: '#0D1421', // Dunkles Weltraum-Blau
paper: '#1A202C',
},
text: {
primary: '#E2E8F0',
secondary: '#A0AEC0',
},
},
themeIcon: '🚀',
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
background: 'radial-gradient(ellipse at center, #0a0a0a 0%, #000000 100%)',
'&::before': {
content: '""',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3CradialGradient id=\'star\' cx=\'50%\' cy=\'50%\' r=\'50%\'%3E%3Cstop offset=\'0%\' style=\'stop-color:%23ffffff;stop-opacity:0.9\' /%3E%3Cstop offset=\'100%\' style=\'stop-color:%23ffffff;stop-opacity:0\' /%3E%3C/radialGradient%3E%3C/defs%3E%3Cg%3E%3Ccircle cx=\'5%\' cy=\'10%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'15%\' cy=\'25%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'25%\' cy=\'8%\' r=\'1.2\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'35%\' cy=\'30%\' r=\'0.6\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'45%\' cy=\'15%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'55%\' cy=\'35%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'65%\' cy=\'12%\' r=\'0.7\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'75%\' cy=\'28%\' r=\'1.1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'85%\' cy=\'18%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'95%\' cy=\'32%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'8%\' cy=\'45%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'18%\' cy=\'60%\' r=\'0.7\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'28%\' cy=\'50%\' r=\'1.2\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'38%\' cy=\'65%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'48%\' cy=\'55%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'58%\' cy=\'70%\' r=\'0.6\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'68%\' cy=\'48%\' r=\'1.1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'78%\' cy=\'62%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'88%\' cy=\'52%\' r=\'0.7\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'92%\' cy=\'68%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'12%\' cy=\'80%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'22%\' cy=\'85%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'32%\' cy=\'78%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'42%\' cy=\'88%\' r=\'0.7\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'52%\' cy=\'82%\' r=\'1.1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'62%\' cy=\'90%\' r=\'0.8\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'72%\' cy=\'85%\' r=\'1\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'82%\' cy=\'78%\' r=\'0.6\' fill=\'url(%23star)\' /%3E%3Ccircle cx=\'90%\' cy=\'88%\' r=\'0.9\' fill=\'url(%23star)\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: 'cover',
opacity: 0.7,
zIndex: -2,
pointerEvents: 'none',
animation: 'twinkle 4s ease-in-out infinite alternate',
},
'@keyframes twinkle': {
'0%': { opacity: 0.5 },
'100%': { opacity: 0.9 },
},
'&::after': {
content: '""',
position: 'fixed',
top: '20%',
right: '10%',
width: '150px',
height: '150px',
background: 'radial-gradient(circle, rgba(255,165,0,0.8) 0%, rgba(255,69,0,0.6) 30%, rgba(139,69,19,0.4) 60%, rgba(0,0,0,0) 100%)',
borderRadius: '50%',
zIndex: -1,
pointerEvents: 'none',
animation: 'planet-glow 8s ease-in-out infinite alternate',
},
'@keyframes planet-glow': {
'0%': { opacity: 0.6, transform: 'scale(1)' },
'100%': { opacity: 0.9, transform: 'scale(1.1)' },
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
background: 'linear-gradient(135deg, rgba(15, 15, 25, 0.95) 0%, rgba(25, 25, 40, 0.95) 100%)',
backdropFilter: 'blur(8px)',
border: '2px solid rgba(0, 255, 255, 0.3)',
borderRadius: 12,
boxShadow: '0 0 20px rgba(0, 255, 255, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.1)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100\' height=\'100\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3ClinearGradient id=\'circuit\' x1=\'0%\' y1=\'0%\' x2=\'100%\' y2=\'100%\'%3E%3Cstop offset=\'0%\' style=\'stop-color:%2300ffff;stop-opacity:0.3\' /%3E%3Cstop offset=\'100%\' style=\'stop-color:%230080ff;stop-opacity:0.1\' /%3E%3C/linearGradient%3E%3C/defs%3E%3Cg%3E%3Crect x=\'10\' y=\'10\' width=\'20\' height=\'2\' fill=\'url(%23circuit)\' /%3E%3Crect x=\'40\' y=\'20\' width=\'15\' height=\'2\' fill=\'url(%23circuit)\' /%3E%3Crect x=\'70\' y=\'15\' width=\'25\' height=\'2\' fill=\'url(%23circuit)\' /%3E%3Ccircle cx=\'15\' cy=\'30\' r=\'2\' fill=\'%2300ffff\' fill-opacity=\'0.4\' /%3E%3Ccircle cx=\'50\' cy=\'40\' r=\'1.5\' fill=\'%2300ffff\' fill-opacity=\'0.3\' /%3E%3Ccircle cx=\'80\' cy=\'35\' r=\'2.5\' fill=\'%2300ffff\' fill-opacity=\'0.5\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '100px 100px',
opacity: 0.6,
zIndex: 0,
pointerEvents: 'none',
animation: 'circuit-pulse 4s ease-in-out infinite',
},
'@keyframes circuit-pulse': {
'0%': { opacity: 0.4 },
'50%': { opacity: 0.8 },
'100%': { opacity: 0.4 },
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
background: 'linear-gradient(45deg, #001122 0%, #003366 100%)',
border: '2px solid rgba(0, 255, 255, 0.5)',
boxShadow: '0 0 15px rgba(0, 255, 255, 0.3), inset 0 1px 0 rgba(255, 255, 255, 0.1)',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'50\' height=\'50\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Crect x=\'5\' y=\'20\' width=\'10\' height=\'1\' fill=\'%2300ffff\' fill-opacity=\'0.4\' /%3E%3Crect x=\'25\' y=\'15\' width=\'8\' height=\'1\' fill=\'%2300ffff\' fill-opacity=\'0.3\' /%3E%3Crect x=\'35\' y=\'25\' width=\'12\' height=\'1\' fill=\'%2300ffff\' fill-opacity=\'0.5\' /%3E%3Ccircle cx=\'10\' cy=\'10\' r=\'1\' fill=\'%2300ffff\' fill-opacity=\'0.6\' /%3E%3Ccircle cx=\'40\' cy=\'35\' r=\'0.8\' fill=\'%2300ffff\' fill-opacity=\'0.4\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '50px 50px',
opacity: 0,
transition: 'opacity 0.3s ease',
animation: 'tech-pattern 6s linear infinite',
},
'@keyframes tech-pattern': {
'0%': { backgroundPosition: '0 0' },
'100%': { backgroundPosition: '50px 50px' },
},
'&:hover::before': {
opacity: 1,
},
'&::after': {
content: '""',
position: 'absolute',
top: 0,
left: '-100%',
width: '200%',
height: '100%',
background: 'linear-gradient(90deg, rgba(0,255,255,0) 0%, rgba(0,255,255,0.4) 50%, rgba(0,255,255,0) 100%)',
transform: 'skewX(-20deg)',
transition: 'all 0.6s ease',
},
'&:hover': {
boxShadow: '0 0 25px rgba(0, 255, 255, 0.6), inset 0 1px 0 rgba(255, 255, 255, 0.2)',
borderColor: 'rgba(0, 255, 255, 0.8)',
},
'&:hover::after': {
left: '100%',
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
background: 'linear-gradient(135deg, rgba(10, 10, 20, 0.9) 0%, rgba(20, 20, 35, 0.9) 100%)',
backdropFilter: 'blur(6px)',
border: '1px solid rgba(0, 255, 255, 0.2)',
borderRadius: 8,
boxShadow: '0 4px 15px rgba(0, 255, 255, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.05)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Crect x=\'5\' y=\'15\' width=\'8\' height=\'0.5\' fill=\'%2300ffff\' fill-opacity=\'0.2\' /%3E%3Crect x=\'20\' y=\'10\' width=\'6\' height=\'0.5\' fill=\'%2300ffff\' fill-opacity=\'0.15\' /%3E%3Crect x=\'30\' y=\'20\' width=\'7\' height=\'0.5\' fill=\'%2300ffff\' fill-opacity=\'0.25\' /%3E%3Ccircle cx=\'8\' cy=\'8\' r=\'0.5\' fill=\'%2300ffff\' fill-opacity=\'0.3\' /%3E%3Ccircle cx=\'25\' cy=\'25\' r=\'0.4\' fill=\'%2300ffff\' fill-opacity=\'0.2\' /%3E%3Ccircle cx=\'35\' cy=\'12\' r=\'0.6\' fill=\'%2300ffff\' fill-opacity=\'0.25\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '40px 40px',
opacity: 0.5,
zIndex: 0,
pointerEvents: 'none',
animation: 'subtle-tech 8s ease-in-out infinite',
},
'@keyframes subtle-tech': {
'0%': { opacity: 0.3, backgroundPosition: '0 0' },
'50%': { opacity: 0.7, backgroundPosition: '20px 20px' },
'100%': { opacity: 0.3, backgroundPosition: '0 0' },
},
},
},
},
},
});
// Ozean Theme
const oceanTheme = createTheme({
...baseTheme,
palette: {
mode: 'light',
primary: {
main: '#2196F3', // Blau
light: '#64B5F6',
dark: '#1976D2',
contrastText: '#FFFFFF',
},
secondary: {
main: '#00BCD4', // Cyan
light: '#4DD0E1',
dark: '#0097A7',
contrastText: '#FFFFFF',
},
success: {
main: '#009688', // Teal
light: '#4DB6AC',
dark: '#00695C',
},
warning: {
main: '#FF9800',
light: '#FFB74D',
dark: '#F57C00',
},
error: {
main: '#F44336',
light: '#EF5350',
dark: '#D32F2F',
},
background: {
default: '#E3F2FD', // Helles Blau
paper: '#FFFFFF',
},
text: {
primary: '#0D47A1',
secondary: '#1565C0',
},
},
themeIcon: '🌊',
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
backgroundImage: 'linear-gradient(180deg, #87CEEB 0%, #4682B4 30%, #1E90FF 60%, #0066CC 100%)',
'&::before': {
content: '""',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3Cpath id=\'fish\' d=\'M0,10 Q5,5 10,10 Q15,15 20,10 L15,8 L20,10 L15,12 Z\' /%3E%3Cpath id=\'wave\' d=\'M0,20 Q25,10 50,20 Q75,30 100,20 Q125,10 150,20 Q175,30 200,20\' /%3E%3C/defs%3E%3Cg fill=\'%23FFD700\' fill-opacity=\'0.3\'%3E%3Cuse href=\'%23fish\' x=\'50\' y=\'30\' /%3E%3Cuse href=\'%23fish\' x=\'150\' y=\'80\' transform=\'scale(-1,1) translate(-170,0)\' /%3E%3Cuse href=\'%23fish\' x=\'80\' y=\'120\' /%3E%3Cuse href=\'%23fish\' x=\'200\' y=\'60\' transform=\'scale(-1,1) translate(-220,0)\' /%3E%3C/g%3E%3Cg fill=\'%23FF6347\' fill-opacity=\'0.3\'%3E%3Cuse href=\'%23fish\' x=\'120\' y=\'40\' transform=\'scale(-1,1) translate(-140,0)\' /%3E%3Cuse href=\'%23fish\' x=\'30\' y=\'90\' /%3E%3Cuse href=\'%23fish\' x=\'180\' y=\'110\' transform=\'scale(-1,1) translate(-200,0)\' /%3E%3C/g%3E%3Cg stroke=\'%23FFFFFF\' stroke-width=\'2\' fill=\'none\' stroke-opacity=\'0.2\'%3E%3Cuse href=\'%23wave\' y=\'0\' /%3E%3Cuse href=\'%23wave\' y=\'40\' /%3E%3Cuse href=\'%23wave\' y=\'80\' /%3E%3Cuse href=\'%23wave\' y=\'120\' /%3E%3C/g%3E%3Cg fill=\'%23FFFFFF\' fill-opacity=\'0.1\'%3E%3Ccircle cx=\'40\' cy=\'25\' r=\'2\' /%3E%3Ccircle cx=\'90\' cy=\'45\' r=\'1.5\' /%3E%3Ccircle cx=\'160\' cy=\'35\' r=\'2.5\' /%3E%3Ccircle cx=\'70\' cy=\'75\' r=\'1\' /%3E%3Ccircle cx=\'130\' cy=\'95\' r=\'2\' /%3E%3Ccircle cx=\'190\' cy=\'85\' r=\'1.5\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '250px 150px',
opacity: 0.8,
zIndex: -1,
pointerEvents: 'none',
animation: 'ocean-flow 20s linear infinite',
},
'@keyframes ocean-flow': {
'0%': { backgroundPosition: '0 0' },
'100%': { backgroundPosition: '250px 0' },
},
'&::after': {
content: '""',
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '40%',
background: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M0,50 Q50,30 100,50 Q150,70 200,50 Q250,30 300,50 Q350,70 400,50 L400,100 L0,100 Z\' fill=\'%23006994\' fill-opacity=\'0.3\' /%3E%3Cpath d=\'M0,60 Q50,40 100,60 Q150,80 200,60 Q250,40 300,60 Q350,80 400,60 L400,100 L0,100 Z\' fill=\'%23004d6b\' fill-opacity=\'0.2\' /%3E%3C/svg%3E")',
backgroundSize: '400px 100px',
zIndex: -1,
pointerEvents: 'none',
animation: 'wave-bottom 12s ease-in-out infinite',
},
'@keyframes wave-bottom': {
'0%': { backgroundPosition: '0 0' },
'50%': { backgroundPosition: '200px 0' },
'100%': { backgroundPosition: '0 0' },
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
background: 'linear-gradient(135deg, rgba(135, 206, 235, 0.8) 0%, rgba(70, 130, 180, 0.9) 100%)',
backdropFilter: 'blur(8px)',
border: '2px solid rgba(255, 255, 255, 0.3)',
borderRadius: 20,
boxShadow: '0 8px 32px rgba(0, 102, 204, 0.3), inset 0 2px 4px rgba(255, 255, 255, 0.2)',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100\' height=\'100\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23FFFFFF\' fill-opacity=\'0.1\'%3E%3Ccircle cx=\'20\' cy=\'20\' r=\'3\' /%3E%3Ccircle cx=\'60\' cy=\'40\' r=\'2\' /%3E%3Ccircle cx=\'80\' cy=\'15\' r=\'2.5\' /%3E%3Ccircle cx=\'40\' cy=\'70\' r=\'1.5\' /%3E%3Ccircle cx=\'70\' cy=\'80\' r=\'2\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '100px 100px',
opacity: 0.6,
zIndex: 0,
pointerEvents: 'none',
animation: 'bubble-float 8s ease-in-out infinite',
},
'@keyframes bubble-float': {
'0%': { backgroundPosition: '0 0' },
'50%': { backgroundPosition: '50px -20px' },
'100%': { backgroundPosition: '0 0' },
},
'&::after': {
content: '""',
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 8,
background: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'8\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cpath d=\'M0,4 Q25,0 50,4 Q75,8 100,4 Q125,0 150,4 Q175,8 200,4\' stroke=\'%23FFFFFF\' stroke-width=\'2\' fill=\'none\' stroke-opacity=\'0.4\' /%3E%3C/svg%3E")',
backgroundSize: '200px 8px',
borderRadius: '0 0 20px 20px',
animation: 'water-ripple 4s ease-in-out infinite',
},
'@keyframes water-ripple': {
'0%': { backgroundPosition: '0 0', opacity: 0.4 },
'50%': { backgroundPosition: '100px 0', opacity: 0.7 },
'100%': { backgroundPosition: '0 0', opacity: 0.4 },
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
background: 'linear-gradient(45deg, #1E90FF 0%, #00CED1 100%)',
border: '2px solid rgba(255, 255, 255, 0.3)',
boxShadow: '0 4px 15px rgba(30, 144, 255, 0.4), inset 0 2px 4px rgba(255, 255, 255, 0.2)',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100\' height=\'100\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23FFFFFF\' fill-opacity=\'0.2\'%3E%3Ccircle cx=\'20\' cy=\'30\' r=\'2\' /%3E%3Ccircle cx=\'60\' cy=\'20\' r=\'1.5\' /%3E%3Ccircle cx=\'80\' cy=\'40\' r=\'1\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '100px 100px',
opacity: 0,
transition: 'opacity 0.3s ease',
animation: 'bubble-rise 6s ease-in-out infinite',
},
'@keyframes bubble-rise': {
'0%': { backgroundPosition: '0 100px' },
'100%': { backgroundPosition: '0 -100px' },
},
'&:hover::before': {
opacity: 1,
},
'&::after': {
content: '""',
position: 'absolute',
top: 0,
left: '-100%',
width: '200%',
height: '100%',
background: 'linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.3) 50%, rgba(255,255,255,0) 100%)',
transform: 'skewX(-20deg)',
transition: 'all 0.8s ease',
},
'&:hover::after': {
left: '100%',
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
background: 'linear-gradient(135deg, rgba(176, 224, 230, 0.85) 0%, rgba(135, 206, 235, 0.9) 100%)',
backdropFilter: 'blur(10px)',
border: '2px solid rgba(255, 255, 255, 0.2)',
borderRadius: 15,
boxShadow: '0 6px 20px rgba(0, 102, 204, 0.2), inset 0 1px 3px rgba(255, 255, 255, 0.3)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'50\' height=\'50\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg fill=\'%23FFFFFF\' fill-opacity=\'0.08\'%3E%3Ccircle cx=\'10\' cy=\'10\' r=\'1\' /%3E%3Ccircle cx=\'30\' cy=\'20\' r=\'1.5\' /%3E%3Ccircle cx=\'40\' cy=\'35\' r=\'1\' /%3E%3Ccircle cx=\'15\' cy=\'40\' r=\'0.8\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '50px 50px',
opacity: 0.7,
zIndex: 0,
pointerEvents: 'none',
animation: 'gentle-float 10s ease-in-out infinite',
},
'@keyframes gentle-float': {
'0%': { backgroundPosition: '0 0' },
'50%': { backgroundPosition: '25px -10px' },
'100%': { backgroundPosition: '0 0' },
},
},
},
},
},
});
// Wald Theme
const forestTheme = createTheme({
...baseTheme,
palette: {
mode: 'light',
primary: {
main: '#2E7D32', // Waldgrün
light: '#4CAF50',
dark: '#1B5E20',
contrastText: '#FFFFFF',
},
secondary: {
main: '#8D6E63', // Baumrinde Braun
light: '#A1887F',
dark: '#5D4037',
contrastText: '#FFFFFF',
},
success: {
main: '#66BB6A',
light: '#81C784',
dark: '#4CAF50',
},
warning: {
main: '#FF8F00', // Herbstfarbe
light: '#FFA726',
dark: '#E65100',
},
error: {
main: '#D32F2F',
light: '#EF5350',
dark: '#C62828',
},
background: {
default: '#E8F5E8', // Sanftes Waldgrün
paper: '#F1F8E9',
},
text: {
primary: '#1B5E20',
secondary: '#2E7D32',
},
},
themeIcon: '🌲',
components: {
MuiCssBaseline: {
styleOverrides: {
body: {
background: 'linear-gradient(180deg, #87CEEB 0%, #98FB98 20%, #228B22 40%, #006400 100%)',
'&::before': {
content: '""',
position: 'fixed',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'100%\' height=\'100%\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cdefs%3E%3Cg id=\'tree1\'%3E%3Crect x=\'45\' y=\'60\' width=\'10\' height=\'25\' fill=\'%238D6E63\' /%3E%3Cellipse cx=\'50\' cy=\'45\' rx=\'20\' ry=\'25\' fill=\'%232E7D32\' /%3E%3Cellipse cx=\'50\' cy=\'35\' rx=\'15\' ry=\'20\' fill=\'%234CAF50\' /%3E%3C/g%3E%3Cg id=\'tree2\'%3E%3Crect x=\'47\' y=\'65\' width=\'6\' height=\'20\' fill=\'%235D4037\' /%3E%3Cpath d=\'M50,20 L35,55 L65,55 Z\' fill=\'%231B5E20\' /%3E%3Cpath d=\'M50,30 L40,50 L60,50 Z\' fill=\'%232E7D32\' /%3E%3C/g%3E%3Cg id=\'bush\'%3E%3Cellipse cx=\'25\' cy=\'75\' rx=\'12\' ry=\'8\' fill=\'%234CAF50\' /%3E%3Cellipse cx=\'30\' cy=\'70\' rx=\'8\' ry=\'6\' fill=\'%2366BB6A\' /%3E%3C/g%3E%3Cg id=\'rabbit\'%3E%3Cellipse cx=\'80\' cy=\'78\' rx=\'4\' ry=\'3\' fill=\'%23D7CCC8\' /%3E%3Cellipse cx=\'78\' cy=\'75\' rx=\'2\' ry=\'3\' fill=\'%23BCAAA4\' /%3E%3Cellipse cx=\'77\' cy=\'72\' rx=\'1\' ry=\'2\' fill=\'%23BCAAA4\' /%3E%3Cellipse cx=\'79\' cy=\'72\' rx=\'1\' ry=\'2\' fill=\'%23BCAAA4\' /%3E%3C/g%3E%3Cg id=\'bird\'%3E%3Cellipse cx=\'15\' cy=\'25\' rx=\'3\' ry=\'2\' fill=\'%23FF5722\' /%3E%3Cpath d=\'M12,25 L8,23 L8,27 Z\' fill=\'%23FF8A65\' /%3E%3C/g%3E%3C/defs%3E%3Cuse href=\'%23tree1\' x=\'0\' y=\'0\' /%3E%3Cuse href=\'%23tree2\' x=\'120\' y=\'10\' /%3E%3Cuse href=\'%23tree1\' x=\'200\' y=\'-5\' /%3E%3Cuse href=\'%23tree2\' x=\'300\' y=\'5\' /%3E%3Cuse href=\'%23bush\' x=\'50\' y=\'0\' /%3E%3Cuse href=\'%23bush\' x=\'150\' y=\'10\' /%3E%3Cuse href=\'%23rabbit\' x=\'0\' y=\'0\' /%3E%3Cuse href=\'%23bird\' x=\'100\' y=\'0\' /%3E%3Cuse href=\'%23tree1\' x=\'0\' y=\'100\' /%3E%3Cuse href=\'%23tree2\' x=\'80\' y=\'120\' /%3E%3Cuse href=\'%23tree1\' x=\'180\' y=\'110\' /%3E%3Cuse href=\'%23bush\' x=\'250\' y=\'120\' /%3E%3Cuse href=\'%23rabbit\' x=\'200\' y=\'100\' /%3E%3C/svg%3E")',
backgroundSize: '400px 300px',
opacity: 0.4,
zIndex: -2,
pointerEvents: 'none',
animation: 'forest-sway 15s ease-in-out infinite',
},
'@keyframes forest-sway': {
'0%': { transform: 'translateX(0px)' },
'25%': { transform: 'translateX(-2px)' },
'50%': { transform: 'translateX(0px)' },
'75%': { transform: 'translateX(2px)' },
'100%': { transform: 'translateX(0px)' },
},
'&::after': {
content: '""',
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
height: '30%',
background: 'linear-gradient(to top, rgba(46, 125, 50, 0.3) 0%, rgba(46, 125, 50, 0.1) 50%, rgba(46, 125, 50, 0) 100%)',
zIndex: -1,
pointerEvents: 'none',
},
},
},
},
MuiCard: {
styleOverrides: {
root: {
background: 'linear-gradient(135deg, rgba(241, 248, 233, 0.95) 0%, rgba(232, 245, 232, 0.95) 100%)',
backdropFilter: 'blur(6px)',
border: '3px solid rgba(141, 110, 99, 0.4)',
borderRadius: 16,
boxShadow: '0 8px 25px rgba(46, 125, 50, 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.3)',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'60\' height=\'60\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Cpath d=\'M5,30 Q15,25 25,30 Q35,35 45,30 Q55,25 65,30\' stroke=\'%238D6E63\' stroke-width=\'0.5\' fill=\'none\' opacity=\'0.3\' /%3E%3Cpath d=\'M0,20 Q10,15 20,20 Q30,25 40,20 Q50,15 60,20\' stroke=\'%232E7D32\' stroke-width=\'0.5\' fill=\'none\' opacity=\'0.2\' /%3E%3Ccircle cx=\'15\' cy=\'45\' r=\'1\' fill=\'%234CAF50\' opacity=\'0.4\' /%3E%3Ccircle cx=\'35\' cy=\'15\' r=\'0.5\' fill=\'%2366BB6A\' opacity=\'0.3\' /%3E%3Ccircle cx=\'50\' cy=\'40\' r=\'0.8\' fill=\'%238BC34A\' opacity=\'0.3\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '60px 60px',
opacity: 0.6,
zIndex: 0,
pointerEvents: 'none',
animation: 'wood-grain 20s ease-in-out infinite',
},
'@keyframes wood-grain': {
'0%': { opacity: 0.4, backgroundPosition: '0 0' },
'50%': { opacity: 0.8, backgroundPosition: '30px 30px' },
'100%': { opacity: 0.4, backgroundPosition: '0 0' },
},
'&::after': {
content: '""',
position: 'absolute',
bottom: -3,
left: '10%',
width: '80%',
height: 6,
background: 'linear-gradient(90deg, #8D6E63 0%, #4CAF50 50%, #2E7D32 100%)',
borderRadius: 8,
opacity: 0.7,
boxShadow: '0 2px 8px rgba(46, 125, 50, 0.3)',
},
},
},
},
MuiButton: {
styleOverrides: {
root: {
background: 'linear-gradient(135deg, #8D6E63 0%, #A1887F 50%, #4CAF50 100%)',
border: '2px solid rgba(93, 64, 55, 0.6)',
borderRadius: 12,
boxShadow: '0 4px 15px rgba(46, 125, 50, 0.2), inset 0 1px 0 rgba(255, 255, 255, 0.2)',
position: 'relative',
overflow: 'hidden',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'40\' height=\'40\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Cpath d=\'M5,20 Q15,15 25,20 Q35,25 45,20\' stroke=\'%235D4037\' stroke-width=\'0.8\' fill=\'none\' opacity=\'0.4\' /%3E%3Cpath d=\'M0,30 Q10,25 20,30 Q30,35 40,30\' stroke=\'%235D4037\' stroke-width=\'0.6\' fill=\'none\' opacity=\'0.3\' /%3E%3Ccircle cx=\'10\' cy=\'10\' r=\'0.5\' fill=\'%232E7D32\' opacity=\'0.5\' /%3E%3Ccircle cx=\'30\' cy=\'35\' r=\'0.8\' fill=\'%234CAF50\' opacity=\'0.4\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '40px 40px',
opacity: 0,
transition: 'opacity 0.3s ease',
zIndex: 0,
},
'&:hover::before': {
opacity: 1,
},
'&::after': {
content: '""',
position: 'absolute',
top: '50%',
left: '50%',
width: '6px',
height: '6px',
background: 'radial-gradient(circle, rgba(93, 64, 55, 0.8) 0%, rgba(93, 64, 55, 0.4) 70%, transparent 100%)',
borderRadius: '50%',
transform: 'translate(-50%, -50%)',
opacity: 0.6,
},
},
},
},
MuiPaper: {
styleOverrides: {
root: {
background: 'linear-gradient(135deg, rgba(241, 248, 233, 0.9) 0%, rgba(232, 245, 232, 0.9) 100%)',
backdropFilter: 'blur(4px)',
border: '2px solid rgba(141, 110, 99, 0.3)',
borderRadius: 12,
boxShadow: '0 6px 20px rgba(46, 125, 50, 0.1), inset 0 1px 0 rgba(255, 255, 255, 0.2)',
position: 'relative',
'&::before': {
content: '""',
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
backgroundImage: 'url("data:image/svg+xml,%3Csvg width=\'50\' height=\'50\' xmlns=\'http://www.w3.org/2000/svg\'%3E%3Cg%3E%3Cpath d=\'M10,25 Q20,20 30,25 Q40,30 50,25\' stroke=\'%238D6E63\' stroke-width=\'0.3\' fill=\'none\' opacity=\'0.2\' /%3E%3Cpath d=\'M5,35 Q15,30 25,35 Q35,40 45,35\' stroke=\'%232E7D32\' stroke-width=\'0.3\' fill=\'none\' opacity=\'0.15\' /%3E%3Ccircle cx=\'12\' cy=\'15\' r=\'0.5\' fill=\'%234CAF50\' opacity=\'0.3\' /%3E%3Ccircle cx=\'38\' cy=\'40\' r=\'0.3\' fill=\'%2366BB6A\' opacity=\'0.25\' /%3E%3C/g%3E%3C/svg%3E")',
backgroundSize: '50px 50px',
opacity: 0.4,
zIndex: 0,
pointerEvents: 'none',
},
},
},
},
},
});
// Theme-Mapping
export const childThemes = {
colorful: colorfulTheme,
nature: natureTheme,
space: spaceTheme,
ocean: oceanTheme,
forest: forestTheme,
};
// Funktion zum Abrufen des Themes basierend auf Kinder-Preferences
export const getChildTheme = (childPreferences) => {
const themeName = childPreferences?.theme || 'colorful';
return childThemes[themeName] || childThemes.colorful;
};
export default childThemes;

112
middleware/auth.js Normal file
View File

@ -0,0 +1,112 @@
const jwt = require('jsonwebtoken');
const User = require('../models/User');
// Authentifizierungs-Middleware
const auth = async (req, res, next) => {
try {
// Token aus Header extrahieren
const token = req.header('Authorization')?.replace('Bearer ', '');
if (!token) {
return res.status(401).json({
success: false,
message: 'Kein Token gefunden. Zugriff verweigert.'
});
}
// Token verifizieren
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Benutzer aus Datenbank abrufen
const user = await User.findById(decoded.user.id).select('-password');
if (!user || !user.isActive) {
return res.status(401).json({
success: false,
message: 'Token ungültig. Benutzer nicht gefunden oder deaktiviert.'
});
}
// Benutzer zu Request hinzufügen
req.user = user;
next();
} catch (error) {
console.error('Auth-Middleware-Fehler:', error.message);
if (error.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
message: 'Token ungültig.'
});
}
if (error.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: 'Token abgelaufen. Bitte melde dich erneut an.'
});
}
res.status(500).json({
success: false,
message: 'Server-Fehler bei der Authentifizierung'
});
}
};
// Admin-Middleware (für zukünftige Erweiterungen)
const adminAuth = async (req, res, next) => {
try {
// Erst normale Authentifizierung
await auth(req, res, () => {});
// Dann Admin-Berechtigung prüfen
if (req.user.role !== 'admin') {
return res.status(403).json({
success: false,
message: 'Zugriff verweigert. Admin-Berechtigung erforderlich.'
});
}
next();
} catch (error) {
console.error('Admin-Auth-Middleware-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler bei der Admin-Authentifizierung'
});
}
};
// Optionale Authentifizierung (für öffentliche Endpunkte mit optionalen Benutzerdaten)
const optionalAuth = async (req, res, next) => {
try {
const token = req.header('Authorization')?.replace('Bearer ', '');
if (token) {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const user = await User.findById(decoded.user.id).select('-password');
if (user && user.isActive) {
req.user = user;
}
} catch (error) {
// Token ungültig, aber das ist okay für optionale Auth
console.log('Optionale Auth: Token ungültig, fortfahren ohne Benutzer');
}
}
next();
} catch (error) {
console.error('Optional-Auth-Middleware-Fehler:', error.message);
// Bei optionaler Auth trotzdem fortfahren
next();
}
};
module.exports = {
auth,
adminAuth,
optionalAuth
};

85
middleware/childAuth.js Normal file
View File

@ -0,0 +1,85 @@
const jwt = require('jsonwebtoken');
const Child = require('../models/Child');
// Middleware für Kinder-Authentifizierung
const childAuth = async (req, res, next) => {
// Token aus Header oder Body holen
const token = req.header('x-child-auth-token') ||
req.header('x-auth-token') ||
req.body.token;
// Prüfen ob Token vorhanden
if (!token) {
return res.status(401).json({
success: false,
message: 'Kein Zugangs-Token, Zugriff verweigert'
});
}
try {
// Token verifizieren
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Prüfen ob es ein Kind-Token ist
if (!decoded.child) {
return res.status(401).json({
success: false,
message: 'Ungültiger Token-Typ'
});
}
// Kind aus Datenbank laden
const child = await Child.findById(decoded.child.id);
if (!child || !child.isActive) {
return res.status(401).json({
success: false,
message: 'Kind nicht gefunden oder deaktiviert'
});
}
// Kind-Daten zu Request hinzufügen
req.child = child;
req.childId = child._id;
req.parentId = child.parent;
next();
} catch (error) {
console.error('Kinder-Auth-Middleware-Fehler:', error.message);
res.status(401).json({
success: false,
message: 'Token ist nicht gültig'
});
}
};
// Middleware für optionale Kinder-Authentifizierung
const optionalChildAuth = async (req, res, next) => {
const token = req.header('x-child-auth-token') ||
req.header('x-auth-token') ||
req.body.token;
if (!token) {
return next();
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
if (decoded.child) {
const child = await Child.findById(decoded.child.id);
if (child && child.isActive) {
req.child = child;
req.childId = child._id;
req.parentId = child.parent;
}
}
} catch (error) {
// Bei optionaler Auth ignorieren wir Fehler
console.log('Optionale Kinder-Auth fehlgeschlagen:', error.message);
}
next();
};
module.exports = { childAuth, optionalChildAuth };

275
models/Child.js Normal file
View File

@ -0,0 +1,275 @@
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
// Schema für Kinder-Profile
const childSchema = new mongoose.Schema({
name: {
type: String,
required: true,
trim: true,
maxlength: 50
},
age: {
type: Number,
required: true,
min: 3,
max: 18
},
avatar: {
type: String,
default: 'default-child.png',
enum: [
'default-child.png',
'superhero-boy.png',
'superhero-girl.png',
'pirate-boy.png',
'pirate-girl.png',
'princess.png',
'knight.png',
'astronaut.png',
'detective.png',
'artist.png'
]
},
parent: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
// Authentifizierung für Kinder
username: {
type: String,
required: true,
unique: true,
trim: true,
minlength: 3,
maxlength: 20,
match: /^[a-zA-Z0-9_]+$/
},
pin: {
type: String,
required: true,
minlength: 4,
maxlength: 6,
match: /^[0-9]+$/
},
// Für sichere PIN-Speicherung (gehashed)
hashedPin: {
type: String,
required: true
},
points: {
total: {
type: Number,
default: 0,
min: 0
},
available: {
type: Number,
default: 0,
min: 0
},
spent: {
type: Number,
default: 0,
min: 0
}
},
level: {
current: {
type: Number,
default: 1,
min: 1
},
title: {
type: String,
default: 'Nachwuchs-Held'
}
},
achievements: [{
name: String,
description: String,
icon: String,
earnedAt: {
type: Date,
default: Date.now
}
}],
preferences: {
theme: {
type: String,
enum: ['colorful', 'nature', 'space', 'ocean', 'forest'],
default: 'colorful'
},
favoriteColor: {
type: String,
default: '#4CAF50'
},
soundEffects: {
type: Boolean,
default: true
},
animations: {
type: Boolean,
default: true
}
},
stats: {
tasksCompleted: {
type: Number,
default: 0
},
tasksCompletedToday: {
type: Number,
default: 0
},
streak: {
current: {
type: Number,
default: 0
},
longest: {
type: Number,
default: 0
},
lastCompletionDate: Date
}
},
isActive: {
type: Boolean,
default: true
},
createdAt: {
type: Date,
default: Date.now
},
lastActivity: {
type: Date,
default: Date.now
}
});
// Indizes für bessere Performance
childSchema.index({ parent: 1, isActive: 1 });
childSchema.index({ 'points.total': -1 });
childSchema.index({ username: 1 }, { unique: true });
// Methode zum Punkte hinzufügen
childSchema.methods.addPoints = function(points) {
this.points.total += points;
this.points.available += points;
this.lastActivity = new Date();
// Level-System aktualisieren
this.updateLevel();
return this.save();
};
// PIN-Hashing vor dem Speichern
childSchema.pre('save', async function(next) {
// Nur hashen wenn PIN geändert wurde
if (!this.isModified('pin')) return next();
try {
// PIN hashen
const salt = await bcrypt.genSalt(10);
this.hashedPin = await bcrypt.hash(this.pin, salt);
// Klartext-PIN entfernen (nicht in DB speichern)
this.pin = undefined;
next();
} catch (error) {
next(error);
}
});
// PIN-Verifikation
childSchema.methods.verifyPin = async function(pin) {
return await bcrypt.compare(pin, this.hashedPin);
};
// Sichere Daten für Frontend (ohne hashedPin)
childSchema.methods.toSafeObject = function() {
const obj = this.toObject();
delete obj.hashedPin;
delete obj.pin;
return obj;
};
// Methode zum Punkte ausgeben
childSchema.methods.spendPoints = function(points) {
if (this.points.available >= points) {
this.points.available -= points;
this.points.spent += points;
this.lastActivity = new Date();
return this.save();
}
throw new Error('Nicht genügend Punkte verfügbar');
};
// Level-System aktualisieren
childSchema.methods.updateLevel = function() {
const totalPoints = this.points.total;
const newLevel = Math.floor(totalPoints / 100) + 1; // Alle 100 Punkte ein Level
if (newLevel > this.level.current) {
this.level.current = newLevel;
this.level.title = this.getLevelTitle(newLevel);
}
};
// Level-Titel bestimmen
childSchema.methods.getLevelTitle = function(level) {
const titles = {
1: 'Nachwuchs-Held',
2: 'Aufgaben-Entdecker',
3: 'Fleißiger Helfer',
4: 'Verantwortungs-Champion',
5: 'Familien-Superheld',
6: 'Aufgaben-Meister',
7: 'Pflichten-Profi',
8: 'Haushalts-Held',
9: 'Verantwortungs-Legende',
10: 'Familien-Champion'
};
return titles[level] || `Super-Held Level ${level}`;
};
// Streak aktualisieren
childSchema.methods.updateStreak = function() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const lastCompletion = this.stats.streak.lastCompletionDate;
if (!lastCompletion) {
// Erste Aufgabe
this.stats.streak.current = 1;
this.stats.streak.lastCompletionDate = today;
} else {
const lastDate = new Date(lastCompletion);
lastDate.setHours(0, 0, 0, 0);
const daysDiff = (today - lastDate) / (1000 * 60 * 60 * 24);
if (daysDiff === 1) {
// Aufeinanderfolgende Tage
this.stats.streak.current += 1;
this.stats.streak.lastCompletionDate = today;
} else if (daysDiff > 1) {
// Streak unterbrochen
this.stats.streak.current = 1;
this.stats.streak.lastCompletionDate = today;
}
// daysDiff === 0 bedeutet heute schon eine Aufgabe erledigt
}
// Längste Streak aktualisieren
if (this.stats.streak.current > this.stats.streak.longest) {
this.stats.streak.longest = this.stats.streak.current;
}
};
module.exports = mongoose.model('Child', childSchema);

359
models/Reward.js Normal file
View File

@ -0,0 +1,359 @@
const mongoose = require('mongoose');
// Schema für Belohnungen
const rewardSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
maxlength: 100
},
description: {
type: String,
trim: true,
maxlength: 500
},
icon: {
type: String,
default: 'gift',
enum: [
'gift', 'tv', 'game', 'movie', 'ice-cream', 'toy', 'book',
'outing', 'money', 'treat', 'activity', 'privilege', 'special',
'electronics', 'sports', 'art', 'music', 'food', 'other'
]
},
cost: {
type: Number,
required: true,
min: 1,
max: 10000
},
category: {
type: String,
enum: [
'screen-time', 'treats', 'toys', 'activities', 'outings',
'privileges', 'money', 'special', 'electronics', 'books',
'sports', 'art', 'other'
],
default: 'treats'
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
availableFor: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'Child'
}], // Leer = für alle Kinder verfügbar
ageRestriction: {
minAge: {
type: Number,
min: 3,
max: 18
},
maxAge: {
type: Number,
min: 3,
max: 18
}
},
availability: {
isLimited: {
type: Boolean,
default: false
},
totalQuantity: {
type: Number,
min: 1,
required: function() { return this.availability.isLimited; }
},
remainingQuantity: {
type: Number,
min: 0,
required: function() { return this.availability.isLimited; }
},
resetPeriod: {
type: String,
enum: ['daily', 'weekly', 'monthly', 'never'],
default: 'never'
},
lastReset: Date
},
timeRestrictions: {
validFrom: Date,
validUntil: Date,
daysOfWeek: [{
type: Number,
min: 0,
max: 6 // 0 = Sonntag, 6 = Samstag
}],
timeOfDay: {
startTime: String, // Format: "HH:MM"
endTime: String // Format: "HH:MM"
}
},
requiresApproval: {
type: Boolean,
default: true
},
isInstant: {
type: Boolean,
default: false // true = sofort verfügbar, false = muss genehmigt werden
},
tags: [{
type: String,
trim: true,
maxlength: 20
}],
image: {
type: String,
default: null
},
instructions: {
type: String,
trim: true,
maxlength: 1000
},
isActive: {
type: Boolean,
default: true
},
isPopular: {
type: Boolean,
default: false
},
sortOrder: {
type: Number,
default: 0
},
stats: {
timesRedeemed: {
type: Number,
default: 0
},
totalPointsSpent: {
type: Number,
default: 0
},
lastRedeemed: Date
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
// Schema für Belohnungs-Anfragen
const rewardRequestSchema = new mongoose.Schema({
reward: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Reward',
required: true
},
child: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Child',
required: true
},
pointsSpent: {
type: Number,
required: true,
min: 1
},
status: {
type: String,
enum: ['pending', 'approved', 'rejected', 'redeemed'],
default: 'pending'
},
requestedAt: {
type: Date,
default: Date.now
},
processedAt: Date,
processedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
redeemedAt: Date,
rejectionReason: {
type: String,
trim: true,
maxlength: 200
},
notes: {
type: String,
trim: true,
maxlength: 500
}
});
// Indizes für bessere Performance
rewardSchema.index({ createdBy: 1, isActive: 1 });
rewardSchema.index({ cost: 1, isActive: 1 });
rewardSchema.index({ category: 1, isActive: 1 });
rewardSchema.index({ isPopular: -1, sortOrder: 1 });
rewardRequestSchema.index({ child: 1, status: 1, requestedAt: -1 });
rewardRequestSchema.index({ status: 1, requestedAt: -1 });
// Middleware: updatedAt automatisch setzen
rewardSchema.pre('save', function(next) {
this.updatedAt = new Date();
next();
});
// Methode: Verfügbarkeit prüfen
rewardSchema.methods.isAvailableFor = function(child) {
// Altersrestriktion prüfen
if (this.ageRestriction.minAge && child.age < this.ageRestriction.minAge) {
return false;
}
if (this.ageRestriction.maxAge && child.age > this.ageRestriction.maxAge) {
return false;
}
// Verfügbarkeit für spezifische Kinder prüfen
if (this.availableFor.length > 0 && !this.availableFor.includes(child._id)) {
return false;
}
// Mengenbegrenzung prüfen
if (this.availability.isLimited && this.availability.remainingQuantity <= 0) {
return false;
}
// Zeitrestriktionen prüfen
const now = new Date();
if (this.timeRestrictions.validFrom && now < this.timeRestrictions.validFrom) {
return false;
}
if (this.timeRestrictions.validUntil && now > this.timeRestrictions.validUntil) {
return false;
}
return this.isActive;
};
// Methode: Belohnung einlösen
rewardSchema.methods.redeem = async function(childId) {
const Child = mongoose.model('Child');
const child = await Child.findById(childId);
if (!child) {
throw new Error('Kind nicht gefunden');
}
if (!this.isAvailableFor(child)) {
throw new Error('Belohnung nicht verfügbar für dieses Kind');
}
if (child.points.available < this.cost) {
throw new Error('Nicht genügend Punkte verfügbar');
}
// Punkte abziehen
await child.spendPoints(this.cost);
// Verfügbare Menge reduzieren
if (this.availability.isLimited) {
this.availability.remainingQuantity -= 1;
}
// Statistiken aktualisieren
this.stats.timesRedeemed += 1;
this.stats.totalPointsSpent += this.cost;
this.stats.lastRedeemed = new Date();
await this.save();
// Belohnungs-Anfrage erstellen (falls Genehmigung erforderlich)
if (this.requiresApproval) {
const RewardRequest = mongoose.model('RewardRequest');
const request = new RewardRequest({
reward: this._id,
child: childId,
pointsSpent: this.cost,
status: 'pending'
});
await request.save();
return request;
} else {
// Sofortige Einlösung
const RewardRequest = mongoose.model('RewardRequest');
const request = new RewardRequest({
reward: this._id,
child: childId,
pointsSpent: this.cost,
status: 'redeemed',
redeemedAt: new Date()
});
await request.save();
return request;
}
};
// Statische Methode: Verfügbare Belohnungen für ein Kind
rewardSchema.statics.findAvailableFor = function(child) {
const query = {
isActive: true,
$or: [
{ availableFor: { $size: 0 } }, // Für alle verfügbar
{ availableFor: child._id } // Spezifisch für dieses Kind
]
};
// Altersrestriktionen
if (child.age) {
query.$and = [
{ $or: [{ 'ageRestriction.minAge': { $exists: false } }, { 'ageRestriction.minAge': { $lte: child.age } }] },
{ $or: [{ 'ageRestriction.maxAge': { $exists: false } }, { 'ageRestriction.maxAge': { $gte: child.age } }] }
];
}
return this.find(query).sort({ isPopular: -1, sortOrder: 1, cost: 1 });
};
// Methoden für RewardRequest
rewardRequestSchema.methods.approve = function(approvedByUserId) {
this.status = 'approved';
this.processedAt = new Date();
this.processedBy = approvedByUserId;
return this.save();
};
rewardRequestSchema.methods.reject = function(reason, rejectedByUserId) {
this.status = 'rejected';
this.rejectionReason = reason;
this.processedAt = new Date();
this.processedBy = rejectedByUserId;
// Punkte zurückgeben
return this.refundPoints();
};
rewardRequestSchema.methods.markRedeemed = function() {
this.status = 'redeemed';
this.redeemedAt = new Date();
return this.save();
};
rewardRequestSchema.methods.refundPoints = async function() {
const Child = mongoose.model('Child');
const child = await Child.findById(this.child);
if (child) {
child.points.available += this.pointsSpent;
child.points.spent -= this.pointsSpent;
await child.save();
}
return this.save();
};
const Reward = mongoose.model('Reward', rewardSchema);
const RewardRequest = mongoose.model('RewardRequest', rewardRequestSchema);
module.exports = { Reward, RewardRequest };

278
models/Task.js Normal file
View File

@ -0,0 +1,278 @@
const mongoose = require('mongoose');
// Schema für Aufgaben
const taskSchema = new mongoose.Schema({
title: {
type: String,
required: true,
trim: true,
maxlength: 100
},
description: {
type: String,
trim: true,
maxlength: 500
},
icon: {
type: String,
default: 'task',
enum: [
'task', 'clean', 'dishes', 'vacuum', 'trash', 'bed', 'homework',
'teeth', 'shower', 'clothes', 'toys', 'pets', 'garden', 'help',
'read', 'practice', 'exercise', 'cook', 'organize', 'other'
]
},
points: {
type: Number,
required: true,
min: 1,
max: 500,
default: 10
},
difficulty: {
type: String,
enum: ['easy', 'medium', 'hard'],
default: 'easy'
},
category: {
type: String,
enum: [
'household', 'personal', 'homework', 'chores', 'hygiene',
'pets', 'garden', 'helping', 'learning', 'exercise', 'other'
],
default: 'household'
},
assignedTo: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Child',
required: true
},
createdBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
dueDate: {
type: Date,
required: true
},
status: {
type: String,
enum: ['pending', 'completed', 'approved', 'rejected'],
default: 'pending'
},
completedAt: {
type: Date
},
approvedAt: {
type: Date
},
approvedBy: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
rejectionReason: {
type: String,
trim: true,
maxlength: 200
},
recurring: {
isRecurring: {
type: Boolean,
default: false
},
frequency: {
type: String,
enum: ['daily', 'weekly', 'monthly'],
required: function() { return this.recurring.isRecurring; }
},
daysOfWeek: [{
type: Number,
min: 0,
max: 6 // 0 = Sonntag, 6 = Samstag
}],
endDate: Date,
nextDueDate: Date
},
priority: {
type: String,
enum: ['low', 'normal', 'high', 'urgent'],
default: 'normal'
},
estimatedTime: {
type: Number, // in Minuten
min: 1,
max: 480 // max 8 Stunden
},
tags: [{
type: String,
trim: true,
maxlength: 20
}],
notes: {
type: String,
trim: true,
maxlength: 1000
},
attachments: [{
filename: String,
url: String,
uploadedAt: {
type: Date,
default: Date.now
}
}],
isActive: {
type: Boolean,
default: true
},
createdAt: {
type: Date,
default: Date.now
},
updatedAt: {
type: Date,
default: Date.now
}
});
// Indizes für bessere Performance
taskSchema.index({ assignedTo: 1, status: 1, dueDate: 1 });
taskSchema.index({ createdBy: 1, createdAt: -1 });
taskSchema.index({ dueDate: 1, status: 1 });
taskSchema.index({ 'recurring.isRecurring': 1, 'recurring.nextDueDate': 1 });
// Middleware: updatedAt automatisch setzen
taskSchema.pre('save', function(next) {
this.updatedAt = new Date();
next();
});
// Methode: Aufgabe als erledigt markieren
taskSchema.methods.markCompleted = function() {
this.status = 'completed';
this.completedAt = new Date();
return this.save();
};
// Methode: Aufgabe genehmigen
taskSchema.methods.approve = async function(approvedByUserId) {
this.status = 'approved';
this.approvedAt = new Date();
this.approvedBy = approvedByUserId;
// Punkte dem Kind gutschreiben
const Child = mongoose.model('Child');
const child = await Child.findById(this.assignedTo);
if (child) {
await child.addPoints(this.points);
// Statistiken aktualisieren
child.stats.tasksCompleted += 1;
child.stats.tasksCompletedToday += 1;
child.updateStreak();
await child.save();
}
// Nächste wiederkehrende Aufgabe erstellen
if (this.recurring.isRecurring) {
await this.createNextRecurringTask();
}
return this.save();
};
// Methode: Aufgabe ablehnen
taskSchema.methods.reject = function(reason, rejectedByUserId) {
this.status = 'rejected';
this.rejectionReason = reason;
this.approvedBy = rejectedByUserId;
return this.save();
};
// Methode: Nächste wiederkehrende Aufgabe erstellen
taskSchema.methods.createNextRecurringTask = async function() {
if (!this.recurring.isRecurring) return;
const Task = mongoose.model('Task');
let nextDueDate = new Date(this.dueDate);
// Nächstes Fälligkeitsdatum berechnen
switch (this.recurring.frequency) {
case 'daily':
nextDueDate.setDate(nextDueDate.getDate() + 1);
break;
case 'weekly':
nextDueDate.setDate(nextDueDate.getDate() + 7);
break;
case 'monthly':
nextDueDate.setMonth(nextDueDate.getMonth() + 1);
break;
}
// Prüfen ob End-Datum erreicht
if (this.recurring.endDate && nextDueDate > this.recurring.endDate) {
return;
}
// Neue Aufgabe erstellen
const newTask = new Task({
title: this.title,
description: this.description,
icon: this.icon,
points: this.points,
difficulty: this.difficulty,
category: this.category,
assignedTo: this.assignedTo,
createdBy: this.createdBy,
dueDate: nextDueDate,
recurring: this.recurring,
priority: this.priority,
estimatedTime: this.estimatedTime,
tags: this.tags,
notes: this.notes
});
await newTask.save();
};
// Statische Methode: Überfällige Aufgaben finden
taskSchema.statics.findOverdue = function() {
const now = new Date();
return this.find({
dueDate: { $lt: now },
status: { $in: ['pending', 'completed'] },
isActive: true
});
};
// Statische Methode: Heutige Aufgaben für ein Kind
taskSchema.statics.findTodayTasks = function(childId) {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
return this.find({
assignedTo: childId,
dueDate: { $gte: today, $lt: tomorrow },
isActive: true
}).sort({ priority: -1, createdAt: 1 });
};
// Virtuelle Eigenschaft: Ist überfällig
taskSchema.virtual('isOverdue').get(function() {
return this.dueDate < new Date() && this.status === 'pending';
});
// Virtuelle Eigenschaft: Ist heute fällig
taskSchema.virtual('isDueToday').get(function() {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
return this.dueDate >= today && this.dueDate < tomorrow;
});
module.exports = mongoose.model('Task', taskSchema);

97
models/User.js Normal file
View File

@ -0,0 +1,97 @@
const mongoose = require('mongoose');
const bcrypt = require('bcryptjs');
// Schema für Eltern/Familie
const userSchema = new mongoose.Schema({
email: {
type: String,
required: true,
unique: true,
lowercase: true,
trim: true
},
password: {
type: String,
required: true,
minlength: 6
},
familyName: {
type: String,
required: true,
trim: true
},
parentName: {
type: String,
required: true,
trim: true
},
role: {
type: String,
enum: ['parent', 'admin'],
default: 'parent'
},
isActive: {
type: Boolean,
default: true
},
preferences: {
notifications: {
type: Boolean,
default: true
},
language: {
type: String,
default: 'de'
},
theme: {
type: String,
enum: ['light', 'dark'],
default: 'light'
}
},
createdAt: {
type: Date,
default: Date.now
},
lastLogin: {
type: Date,
default: Date.now
}
});
// Passwort hashen vor dem Speichern
userSchema.pre('save', async function(next) {
// Nur hashen wenn Passwort geändert wurde
if (!this.isModified('password')) return next();
try {
// Passwort hashen
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
next();
} catch (error) {
next(error);
}
});
// Methode zum Passwort-Vergleich
userSchema.methods.comparePassword = async function(candidatePassword) {
return await bcrypt.compare(candidatePassword, this.password);
};
// Virtuelle Eigenschaft für Kinder-Anzahl
userSchema.virtual('childrenCount', {
ref: 'Child',
localField: '_id',
foreignField: 'parent',
count: true
});
// JSON-Ausgabe anpassen (Passwort ausblenden)
userSchema.methods.toJSON = function() {
const user = this.toObject();
delete user.password;
return user;
};
module.exports = mongoose.model('User', userSchema);

1685
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
package.json Normal file
View File

@ -0,0 +1,37 @@
{
"name": "todo-kids",
"version": "1.0.0",
"description": "Eine spielerische To-Do- & Belohnungs-App für Kinder und Eltern mit verschiedenen Themes",
"main": "server.js",
"homepage": "https://github.com/DEIN-USERNAME/todo-kids",
"repository": {
"type": "git",
"url": "git+https://github.com/DEIN-USERNAME/todo-kids.git"
},
"bugs": {
"url": "https://github.com/DEIN-USERNAME/todo-kids/issues"
},
"scripts": {
"start": "node server.js",
"dev": "nodemon server.js",
"client": "cd client && npm start",
"server": "nodemon server.js",
"build": "cd client && npm run build",
"heroku-postbuild": "cd client && npm install && npm run build"
},
"keywords": ["todo", "family", "kids", "rewards", "gamification", "react", "material-ui", "themes", "children"],
"author": "ToDo Kids Team",
"license": "MIT",
"dependencies": {
"express": "^4.18.2",
"mongoose": "^7.5.0",
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"bcryptjs": "^2.4.3",
"jsonwebtoken": "^9.0.2",
"express-validator": "^7.0.1"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}

336
routes/auth.js Normal file
View File

@ -0,0 +1,336 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const User = require('../models/User');
const { auth } = require('../middleware/auth');
const router = express.Router();
// @route POST /api/auth/register
// @desc Registriere neuen Eltern-Account
// @access Public
router.post('/register', [
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Bitte gib eine gültige E-Mail-Adresse ein'),
body('password')
.isLength({ min: 6 })
.withMessage('Passwort muss mindestens 6 Zeichen lang sein'),
body('familyName')
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Familienname muss zwischen 2 und 50 Zeichen lang sein'),
body('parentName')
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Elternname muss zwischen 2 und 50 Zeichen lang sein')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { email, password, familyName, parentName } = req.body;
// Prüfen ob Benutzer bereits existiert
let user = await User.findOne({ email });
if (user) {
return res.status(400).json({
success: false,
message: 'Ein Account mit dieser E-Mail-Adresse existiert bereits'
});
}
// Neuen Benutzer erstellen
user = new User({
email,
password,
familyName,
parentName
});
await user.save();
// JWT Token erstellen
const payload = {
user: {
id: user.id,
email: user.email,
role: user.role
}
};
jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: '7d' },
(err, token) => {
if (err) throw err;
res.status(201).json({
success: true,
message: `Willkommen bei Familien-Held, ${parentName}! 🎉`,
token,
user: {
id: user.id,
email: user.email,
familyName: user.familyName,
parentName: user.parentName,
role: user.role,
createdAt: user.createdAt
}
});
}
);
} catch (error) {
console.error('Registrierungsfehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler bei der Registrierung'
});
}
});
// @route POST /api/auth/login
// @desc Eltern-Login
// @access Public
router.post('/login', [
body('email')
.isEmail()
.normalizeEmail()
.withMessage('Bitte gib eine gültige E-Mail-Adresse ein'),
body('password')
.exists()
.withMessage('Passwort ist erforderlich')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { email, password } = req.body;
// Benutzer finden
const user = await User.findOne({ email, isActive: true });
if (!user) {
return res.status(400).json({
success: false,
message: 'Ungültige Anmeldedaten'
});
}
// Passwort prüfen
const isMatch = await user.comparePassword(password);
if (!isMatch) {
return res.status(400).json({
success: false,
message: 'Ungültige Anmeldedaten'
});
}
// Letzten Login aktualisieren
user.lastLogin = new Date();
await user.save();
// JWT Token erstellen
const payload = {
user: {
id: user.id,
email: user.email,
role: user.role
}
};
jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: '7d' },
(err, token) => {
if (err) throw err;
res.json({
success: true,
message: `Willkommen zurück, ${user.parentName}! 👋`,
token,
user: {
id: user.id,
email: user.email,
familyName: user.familyName,
parentName: user.parentName,
role: user.role,
lastLogin: user.lastLogin
}
});
}
);
} catch (error) {
console.error('Login-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Login'
});
}
});
// @route GET /api/auth/me
// @desc Aktuellen Benutzer abrufen
// @access Private
router.get('/me', auth, async (req, res) => {
try {
const user = await User.findById(req.user.id).select('-password');
if (!user) {
return res.status(404).json({
success: false,
message: 'Benutzer nicht gefunden'
});
}
res.json({
success: true,
user
});
} catch (error) {
console.error('Benutzer-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Benutzerdaten'
});
}
});
// @route PUT /api/auth/profile
// @desc Profil aktualisieren
// @access Private
router.put('/profile', [
auth,
body('familyName')
.optional()
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Familienname muss zwischen 2 und 50 Zeichen lang sein'),
body('parentName')
.optional()
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Elternname muss zwischen 2 und 50 Zeichen lang sein')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { familyName, parentName, preferences } = req.body;
const updateFields = {};
if (familyName) updateFields.familyName = familyName;
if (parentName) updateFields.parentName = parentName;
if (preferences) updateFields.preferences = { ...preferences };
const user = await User.findByIdAndUpdate(
req.user.id,
{ $set: updateFields },
{ new: true, runValidators: true }
).select('-password');
res.json({
success: true,
message: 'Profil erfolgreich aktualisiert! ✅',
user
});
} catch (error) {
console.error('Profil-Update-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Aktualisieren des Profils'
});
}
});
// @route PUT /api/auth/password
// @desc Passwort ändern
// @access Private
router.put('/password', [
auth,
body('currentPassword')
.exists()
.withMessage('Aktuelles Passwort ist erforderlich'),
body('newPassword')
.isLength({ min: 6 })
.withMessage('Neues Passwort muss mindestens 6 Zeichen lang sein')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { currentPassword, newPassword } = req.body;
// Benutzer mit Passwort abrufen
const user = await User.findById(req.user.id);
if (!user) {
return res.status(404).json({
success: false,
message: 'Benutzer nicht gefunden'
});
}
// Aktuelles Passwort prüfen
const isMatch = await user.comparePassword(currentPassword);
if (!isMatch) {
return res.status(400).json({
success: false,
message: 'Aktuelles Passwort ist falsch'
});
}
// Neues Passwort setzen
user.password = newPassword;
await user.save();
res.json({
success: true,
message: 'Passwort erfolgreich geändert! 🔒'
});
} catch (error) {
console.error('Passwort-Änderungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Ändern des Passworts'
});
}
});
// @route POST /api/auth/logout
// @desc Logout (Token invalidieren - clientseitig)
// @access Private
router.post('/logout', auth, (req, res) => {
res.json({
success: true,
message: 'Erfolgreich abgemeldet! Bis bald! 👋'
});
});
module.exports = router;

137
routes/childAuth.js Normal file
View File

@ -0,0 +1,137 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const jwt = require('jsonwebtoken');
const Child = require('../models/Child');
const router = express.Router();
// @route POST /api/child-auth/login
// @desc Kind-Anmeldung mit Benutzername und PIN
// @access Public
router.post('/login', [
body('username')
.trim()
.isLength({ min: 3, max: 20 })
.withMessage('Benutzername muss zwischen 3 und 20 Zeichen lang sein')
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten'),
body('pin')
.isLength({ min: 4, max: 6 })
.withMessage('PIN muss zwischen 4 und 6 Zeichen lang sein')
.matches(/^[0-9]+$/)
.withMessage('PIN darf nur Zahlen enthalten')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Ungültige Eingaben',
errors: errors.array()
});
}
const { username, pin } = req.body;
// Kind finden
const child = await Child.findOne({
username: username.toLowerCase(),
isActive: true
}).populate('parent', 'familyName');
if (!child) {
return res.status(401).json({
success: false,
message: 'Benutzername oder PIN ist falsch'
});
}
// PIN verifizieren
const isValidPin = await child.verifyPin(pin);
if (!isValidPin) {
return res.status(401).json({
success: false,
message: 'Benutzername oder PIN ist falsch'
});
}
// JWT Token erstellen (für Kinder)
const payload = {
child: {
id: child._id,
username: child.username,
name: child.name,
parent: child.parent._id
}
};
const token = jwt.sign(
payload,
process.env.JWT_SECRET,
{ expiresIn: '7d' } // Längere Gültigkeit für Kinder
);
// Letzte Aktivität aktualisieren
child.lastActivity = new Date();
await child.save();
res.json({
success: true,
message: `Willkommen zurück, ${child.name}!`,
token,
child: child.toSafeObject()
});
} catch (error) {
console.error('Kind-Anmeldung-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler bei der Anmeldung'
});
}
});
// @route POST /api/child-auth/verify-token
// @desc Kind-Token verifizieren
// @access Public
router.post('/verify-token', async (req, res) => {
try {
const token = req.header('x-auth-token') || req.body.token;
if (!token) {
return res.status(401).json({
success: false,
message: 'Kein Token vorhanden'
});
}
// Token verifizieren
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Kind laden
const child = await Child.findById(decoded.child.id)
.populate('parent', 'familyName');
if (!child || !child.isActive) {
return res.status(401).json({
success: false,
message: 'Ungültiger Token'
});
}
res.json({
success: true,
child: child.toSafeObject()
});
} catch (error) {
console.error('Token-Verifikation-Fehler:', error.message);
res.status(401).json({
success: false,
message: 'Ungültiger Token'
});
}
});
module.exports = router;

477
routes/children.js Normal file
View File

@ -0,0 +1,477 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const Child = require('../models/Child');
const { auth } = require('../middleware/auth');
const router = express.Router();
// @route GET /api/children
// @desc Alle Kinder des angemeldeten Elternteils abrufen
// @access Private
router.get('/', auth, async (req, res) => {
try {
const children = await Child.find({
parent: req.user.id,
isActive: true
}).sort({ createdAt: 1 });
// Sichere Daten für Frontend (ohne hashedPin)
const safeChildren = children.map(child => child.toSafeObject());
res.json({
success: true,
count: safeChildren.length,
children: safeChildren
});
} catch (error) {
console.error('Kinder-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Kinder'
});
}
});
// @route GET /api/children/:id
// @desc Einzelnes Kind abrufen
// @access Private
router.get('/:id', auth, async (req, res) => {
try {
const child = await Child.findOne({
_id: req.params.id,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
res.json({
success: true,
child: child.toSafeObject()
});
} catch (error) {
console.error('Kind-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen des Kindes'
});
}
});
// @route POST /api/children
// @desc Neues Kind erstellen
// @access Private
router.post('/', [
auth,
body('name')
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Name muss zwischen 2 und 50 Zeichen lang sein'),
body('age')
.isInt({ min: 3, max: 18 })
.withMessage('Alter muss zwischen 3 und 18 Jahren liegen'),
body('username')
.trim()
.isLength({ min: 3, max: 20 })
.withMessage('Benutzername muss zwischen 3 und 20 Zeichen lang sein')
.matches(/^[a-zA-Z0-9_]+$/)
.withMessage('Benutzername darf nur Buchstaben, Zahlen und Unterstriche enthalten'),
body('pin')
.isLength({ min: 4, max: 6 })
.withMessage('PIN muss zwischen 4 und 6 Zeichen lang sein')
.matches(/^[0-9]+$/)
.withMessage('PIN darf nur Zahlen enthalten'),
body('avatar')
.optional()
.isIn([
'default-child.png', 'superhero-boy.png', 'superhero-girl.png',
'pirate-boy.png', 'pirate-girl.png', 'princess.png', 'knight.png',
'astronaut.png', 'detective.png', 'artist.png'
])
.withMessage('Ungültiger Avatar ausgewählt')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { name, age, username, pin, avatar, preferences } = req.body;
// Prüfen ob bereits ein Kind mit diesem Namen existiert
const existingChild = await Child.findOne({
parent: req.user.id,
name: name.trim(),
isActive: true
});
if (existingChild) {
return res.status(400).json({
success: false,
message: 'Ein Kind mit diesem Namen existiert bereits'
});
}
// Prüfen ob Benutzername bereits existiert
const existingUsername = await Child.findOne({
username: username.toLowerCase(),
isActive: true
});
if (existingUsername) {
return res.status(400).json({
success: false,
message: 'Dieser Benutzername ist bereits vergeben'
});
}
// Neues Kind erstellen
const child = new Child({
name: name.trim(),
age,
username: username.toLowerCase(),
pin, // Wird automatisch gehashed durch pre-save Hook
avatar: avatar || 'default-child.png',
parent: req.user.id,
preferences: preferences || {}
});
await child.save();
res.status(201).json({
success: true,
message: `${name} wurde erfolgreich als neuer Familien-Held hinzugefügt! 🎉\nBenutzername: ${username}\nPIN: Sicher gespeichert`,
child: child.toSafeObject()
});
} catch (error) {
console.error('Kind-Erstellungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Erstellen des Kindes'
});
}
});
// @route PUT /api/children/:id
// @desc Kind aktualisieren
// @access Private
router.put('/:id', [
auth,
body('name')
.optional()
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Name muss zwischen 2 und 50 Zeichen lang sein'),
body('age')
.optional()
.isInt({ min: 3, max: 18 })
.withMessage('Alter muss zwischen 3 und 18 Jahren liegen'),
body('avatar')
.optional()
.isIn([
'default-child.png', 'superhero-boy.png', 'superhero-girl.png',
'pirate-boy.png', 'pirate-girl.png', 'princess.png', 'knight.png',
'astronaut.png', 'detective.png', 'artist.png'
])
.withMessage('Ungültiger Avatar ausgewählt')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { name, age, avatar, preferences } = req.body;
const updateFields = {};
if (name) updateFields.name = name.trim();
if (age) updateFields.age = age;
if (avatar) updateFields.avatar = avatar;
if (preferences) updateFields.preferences = { ...preferences };
const child = await Child.findOneAndUpdate(
{ _id: req.params.id, parent: req.user.id, isActive: true },
{ $set: updateFields },
{ new: true, runValidators: true }
);
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
res.json({
success: true,
message: `${child.name}s Profil wurde erfolgreich aktualisiert! ✅`,
child
});
} catch (error) {
console.error('Kind-Update-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Aktualisieren des Kindes'
});
}
});
// @route DELETE /api/children/:id
// @desc Kind deaktivieren (soft delete)
// @access Private
router.delete('/:id', auth, async (req, res) => {
try {
const child = await Child.findOneAndUpdate(
{ _id: req.params.id, parent: req.user.id, isActive: true },
{ $set: { isActive: false } },
{ new: true }
);
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
res.json({
success: true,
message: `${child.name} wurde aus der Familie entfernt`
});
} catch (error) {
console.error('Kind-Löschungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Entfernen des Kindes'
});
}
});
// @route GET /api/children/:id/stats
// @desc Statistiken für ein Kind abrufen
// @access Private
router.get('/:id/stats', auth, async (req, res) => {
try {
const child = await Child.findOne({
_id: req.params.id,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
// Zusätzliche Statistiken berechnen
const Task = require('../models/Task');
const { RewardRequest } = require('../models/Reward');
const [completedTasks, pendingTasks, rewardRequests] = await Promise.all([
Task.countDocuments({ assignedTo: child._id, status: 'approved' }),
Task.countDocuments({ assignedTo: child._id, status: 'pending' }),
RewardRequest.countDocuments({ child: child._id })
]);
const stats = {
points: child.points,
level: child.level,
achievements: child.achievements,
tasks: {
completed: completedTasks,
pending: pendingTasks,
completedToday: child.stats.tasksCompletedToday
},
streak: child.stats.streak,
rewards: {
totalRequests: rewardRequests
},
memberSince: child.createdAt,
lastActivity: child.lastActivity
};
res.json({
success: true,
stats
});
} catch (error) {
console.error('Kind-Statistik-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Statistiken'
});
}
});
// @route POST /api/children/:id/points
// @desc Punkte manuell hinzufügen/abziehen (für Eltern)
// @access Private
router.post('/:id/points', [
auth,
body('points')
.isInt({ min: -1000, max: 1000 })
.withMessage('Punkte müssen zwischen -1000 und 1000 liegen'),
body('reason')
.trim()
.isLength({ min: 3, max: 200 })
.withMessage('Grund muss zwischen 3 und 200 Zeichen lang sein')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { points, reason } = req.body;
const child = await Child.findOne({
_id: req.params.id,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
// Punkte hinzufügen oder abziehen
if (points > 0) {
await child.addPoints(points);
} else {
// Negative Punkte (Abzug)
const absPoints = Math.abs(points);
if (child.points.available < absPoints) {
return res.status(400).json({
success: false,
message: 'Nicht genügend Punkte zum Abziehen verfügbar'
});
}
child.points.available -= absPoints;
child.points.total -= absPoints;
await child.save();
}
const action = points > 0 ? 'hinzugefügt' : 'abgezogen';
const emoji = points > 0 ? '🎉' : '⚠️';
res.json({
success: true,
message: `${Math.abs(points)} Punkte wurden ${child.name} ${action}! ${emoji}`,
reason,
child: {
id: child._id,
name: child.name,
points: child.points,
level: child.level
}
});
} catch (error) {
console.error('Punkte-Änderungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Ändern der Punkte'
});
}
});
// @route POST /api/children/:id/achievements
// @desc Achievement hinzufügen
// @access Private
router.post('/:id/achievements', [
auth,
body('name')
.trim()
.isLength({ min: 3, max: 50 })
.withMessage('Achievement-Name muss zwischen 3 und 50 Zeichen lang sein'),
body('description')
.trim()
.isLength({ min: 5, max: 200 })
.withMessage('Beschreibung muss zwischen 5 und 200 Zeichen lang sein'),
body('icon')
.optional()
.trim()
.isLength({ min: 1, max: 20 })
.withMessage('Icon muss zwischen 1 und 20 Zeichen lang sein')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { name, description, icon } = req.body;
const child = await Child.findOne({
_id: req.params.id,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
// Achievement hinzufügen
const achievement = {
name: name.trim(),
description: description.trim(),
icon: icon || '🏆',
earnedAt: new Date()
};
child.achievements.push(achievement);
await child.save();
res.status(201).json({
success: true,
message: `${child.name} hat ein neues Achievement erhalten: ${name}! 🏆`,
achievement,
child: {
id: child._id,
name: child.name,
achievements: child.achievements
}
});
} catch (error) {
console.error('Achievement-Hinzufügungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Hinzufügen des Achievements'
});
}
});
module.exports = router;

580
routes/families.js Normal file
View File

@ -0,0 +1,580 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const User = require('../models/User');
const Child = require('../models/Child');
const Task = require('../models/Task');
const { Reward, RewardRequest } = require('../models/Reward');
const { auth } = require('../middleware/auth');
const router = express.Router();
// @route GET /api/families/dashboard
// @desc Dashboard-Daten für Eltern abrufen
// @access Private
router.get('/dashboard', auth, async (req, res) => {
try {
// Alle Kinder der Familie abrufen
const children = await Child.find({
parent: req.user.id,
isActive: true
}).sort({ createdAt: 1 });
const childIds = children.map(child => child._id);
// Aufgaben-Statistiken
const [totalTasks, pendingTasks, completedTasks, approvedTasks] = await Promise.all([
Task.countDocuments({ createdBy: req.user.id, isActive: true }),
Task.countDocuments({ createdBy: req.user.id, status: 'pending', isActive: true }),
Task.countDocuments({ createdBy: req.user.id, status: 'completed', isActive: true }),
Task.countDocuments({ createdBy: req.user.id, status: 'approved', isActive: true })
]);
// Heutige Aufgaben
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const todayTasks = await Task.find({
createdBy: req.user.id,
dueDate: { $gte: today, $lt: tomorrow },
isActive: true
}).populate('assignedTo', 'name avatar');
// Überfällige Aufgaben
const overdueTasks = await Task.find({
createdBy: req.user.id,
status: 'pending',
dueDate: { $lt: today },
isActive: true
}).populate('assignedTo', 'name avatar');
// Wartende Genehmigungen
const pendingApprovals = await Task.find({
createdBy: req.user.id,
status: 'completed',
isActive: true
}).populate('assignedTo', 'name avatar').sort({ completedAt: 1 });
// Belohnungs-Anfragen
const pendingRewards = await RewardRequest.find({
child: { $in: childIds },
status: 'pending'
})
.populate('reward', 'title cost icon')
.populate('child', 'name avatar')
.sort({ requestedAt: 1 });
// Familien-Statistiken
const familyStats = {
totalChildren: children.length,
totalPoints: children.reduce((sum, child) => sum + child.points.total, 0),
availablePoints: children.reduce((sum, child) => sum + child.points.available, 0),
totalTasks,
completionRate: totalTasks > 0 ? Math.round((approvedTasks / totalTasks) * 100) : 0
};
// Aktivitäts-Feed (letzte 10 Aktivitäten)
const recentActivities = await Task.find({
createdBy: req.user.id,
status: { $in: ['approved', 'completed'] },
isActive: true
})
.populate('assignedTo', 'name avatar')
.sort({ updatedAt: -1 })
.limit(10);
res.json({
success: true,
dashboard: {
family: {
name: req.user.familyName,
parent: req.user.parentName,
stats: familyStats
},
children: children.map(child => ({
id: child._id,
name: child.name,
avatar: child.avatar,
age: child.age,
points: child.points,
level: child.level,
stats: child.stats,
lastActivity: child.lastActivity
})),
tasks: {
today: todayTasks.length,
pending: pendingTasks,
completed: completedTasks,
approved: approvedTasks,
overdue: overdueTasks.length,
todayList: todayTasks.slice(0, 5), // Nur erste 5 für Dashboard
overdueList: overdueTasks.slice(0, 5),
pendingApprovals: pendingApprovals.slice(0, 5)
},
rewards: {
pendingRequests: pendingRewards.length,
pendingList: pendingRewards.slice(0, 5)
},
recentActivities: recentActivities.map(activity => ({
id: activity._id,
title: activity.title,
child: activity.assignedTo,
status: activity.status,
points: activity.points,
completedAt: activity.completedAt,
approvedAt: activity.approvedAt
}))
}
});
} catch (error) {
console.error('Dashboard-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen des Dashboards'
});
}
});
// @route GET /api/families/stats
// @desc Detaillierte Familien-Statistiken abrufen
// @access Private
router.get('/stats', auth, async (req, res) => {
try {
const { period = '30' } = req.query; // Tage
const days = parseInt(period);
const startDate = new Date();
startDate.setDate(startDate.getDate() - days);
startDate.setHours(0, 0, 0, 0);
// Alle Kinder der Familie
const children = await Child.find({
parent: req.user.id,
isActive: true
});
const childIds = children.map(child => child._id);
// Aufgaben-Statistiken für den Zeitraum
const [tasksCreated, tasksCompleted, tasksApproved] = await Promise.all([
Task.countDocuments({
createdBy: req.user.id,
createdAt: { $gte: startDate },
isActive: true
}),
Task.countDocuments({
createdBy: req.user.id,
status: 'completed',
completedAt: { $gte: startDate },
isActive: true
}),
Task.countDocuments({
createdBy: req.user.id,
status: 'approved',
approvedAt: { $gte: startDate },
isActive: true
})
]);
// Punkte-Statistiken
const pointsEarned = await Task.aggregate([
{
$match: {
createdBy: req.user._id,
status: 'approved',
approvedAt: { $gte: startDate },
isActive: true
}
},
{
$group: {
_id: null,
totalPoints: { $sum: '$points' }
}
}
]);
const pointsSpent = await RewardRequest.aggregate([
{
$match: {
child: { $in: childIds },
status: { $in: ['approved', 'redeemed'] },
requestedAt: { $gte: startDate }
}
},
{
$group: {
_id: null,
totalSpent: { $sum: '$pointsSpent' }
}
}
]);
// Kategorie-Verteilung
const tasksByCategory = await Task.aggregate([
{
$match: {
createdBy: req.user._id,
status: 'approved',
approvedAt: { $gte: startDate },
isActive: true
}
},
{
$group: {
_id: '$category',
count: { $sum: 1 },
points: { $sum: '$points' }
}
},
{ $sort: { count: -1 } }
]);
// Tägliche Aktivität (letzte 7 Tage)
const last7Days = [];
for (let i = 6; i >= 0; i--) {
const date = new Date();
date.setDate(date.getDate() - i);
date.setHours(0, 0, 0, 0);
const nextDay = new Date(date);
nextDay.setDate(nextDay.getDate() + 1);
const dayTasks = await Task.countDocuments({
createdBy: req.user.id,
status: 'approved',
approvedAt: { $gte: date, $lt: nextDay },
isActive: true
});
last7Days.push({
date: date.toISOString().split('T')[0],
tasks: dayTasks
});
}
// Kind-spezifische Statistiken
const childStats = await Promise.all(
children.map(async (child) => {
const [childTasks, childPoints] = await Promise.all([
Task.countDocuments({
assignedTo: child._id,
status: 'approved',
approvedAt: { $gte: startDate }
}),
Task.aggregate([
{
$match: {
assignedTo: child._id,
status: 'approved',
approvedAt: { $gte: startDate }
}
},
{
$group: {
_id: null,
totalPoints: { $sum: '$points' }
}
}
])
]);
return {
id: child._id,
name: child.name,
avatar: child.avatar,
tasksCompleted: childTasks,
pointsEarned: childPoints[0]?.totalPoints || 0,
currentPoints: child.points.available,
level: child.level,
streak: child.stats.streak.current
};
})
);
const stats = {
period: `${days} Tage`,
overview: {
tasksCreated,
tasksCompleted,
tasksApproved,
completionRate: tasksCreated > 0 ? Math.round((tasksApproved / tasksCreated) * 100) : 0,
pointsEarned: pointsEarned[0]?.totalPoints || 0,
pointsSpent: pointsSpent[0]?.totalSpent || 0
},
categories: tasksByCategory,
dailyActivity: last7Days,
children: childStats,
family: {
totalChildren: children.length,
totalPoints: children.reduce((sum, child) => sum + child.points.total, 0),
averageLevel: children.length > 0
? Math.round(children.reduce((sum, child) => sum + child.level.current, 0) / children.length * 10) / 10
: 0
}
};
res.json({
success: true,
stats
});
} catch (error) {
console.error('Statistik-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Statistiken'
});
}
});
// @route GET /api/families/leaderboard
// @desc Familien-Bestenliste abrufen
// @access Private
router.get('/leaderboard', auth, async (req, res) => {
try {
const { period = 'week' } = req.query; // week, month, all
let startDate;
switch (period) {
case 'week':
startDate = new Date();
startDate.setDate(startDate.getDate() - 7);
break;
case 'month':
startDate = new Date();
startDate.setMonth(startDate.getMonth() - 1);
break;
default:
startDate = new Date(0); // Alle Zeit
}
startDate.setHours(0, 0, 0, 0);
// Alle Kinder der Familie
const children = await Child.find({
parent: req.user.id,
isActive: true
});
// Leaderboard-Daten für jedes Kind
const leaderboard = await Promise.all(
children.map(async (child) => {
const [tasksCompleted, pointsEarned] = await Promise.all([
Task.countDocuments({
assignedTo: child._id,
status: 'approved',
approvedAt: { $gte: startDate }
}),
Task.aggregate([
{
$match: {
assignedTo: child._id,
status: 'approved',
approvedAt: { $gte: startDate }
}
},
{
$group: {
_id: null,
totalPoints: { $sum: '$points' }
}
}
])
]);
return {
id: child._id,
name: child.name,
avatar: child.avatar,
age: child.age,
level: child.level,
tasksCompleted,
pointsEarned: pointsEarned[0]?.totalPoints || 0,
currentStreak: child.stats.streak.current,
longestStreak: child.stats.streak.longest,
totalPoints: child.points.total,
achievements: child.achievements.length
};
})
);
// Nach Punkten sortieren
leaderboard.sort((a, b) => b.pointsEarned - a.pointsEarned);
// Ränge zuweisen
leaderboard.forEach((child, index) => {
child.rank = index + 1;
child.medal = index === 0 ? '🥇' : index === 1 ? '🥈' : index === 2 ? '🥉' : null;
});
res.json({
success: true,
period,
leaderboard
});
} catch (error) {
console.error('Leaderboard-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Bestenliste'
});
}
});
// @route GET /api/families/activity-feed
// @desc Aktivitäts-Feed der Familie abrufen
// @access Private
router.get('/activity-feed', auth, async (req, res) => {
try {
const { limit = 20, offset = 0 } = req.query;
// Alle Kinder der Familie
const children = await Child.find({
parent: req.user.id,
isActive: true
});
const childIds = children.map(child => child._id);
// Aktivitäten sammeln
const activities = [];
// Genehmigte Aufgaben
const approvedTasks = await Task.find({
createdBy: req.user.id,
status: 'approved',
isActive: true
})
.populate('assignedTo', 'name avatar')
.sort({ approvedAt: -1 })
.limit(parseInt(limit))
.skip(parseInt(offset));
approvedTasks.forEach(task => {
activities.push({
id: task._id,
type: 'task_approved',
title: `${task.assignedTo.name} hat "${task.title}" erledigt`,
description: `+${task.points} Punkte erhalten`,
child: task.assignedTo,
points: task.points,
timestamp: task.approvedAt,
icon: '✅'
});
});
// Eingelöste Belohnungen
const redeemedRewards = await RewardRequest.find({
child: { $in: childIds },
status: { $in: ['approved', 'redeemed'] }
})
.populate('reward', 'title cost icon')
.populate('child', 'name avatar')
.sort({ processedAt: -1 })
.limit(parseInt(limit))
.skip(parseInt(offset));
redeemedRewards.forEach(request => {
activities.push({
id: request._id,
type: 'reward_redeemed',
title: `${request.child.name} hat "${request.reward.title}" eingelöst`,
description: `-${request.pointsSpent} Punkte ausgegeben`,
child: request.child,
points: -request.pointsSpent,
timestamp: request.processedAt || request.requestedAt,
icon: '🎁'
});
});
// Level-Ups (aus Achievements)
children.forEach(child => {
child.achievements.forEach(achievement => {
if (achievement.name.includes('Level')) {
activities.push({
id: `${child._id}_${achievement._id}`,
type: 'level_up',
title: `${child.name} hat ein neues Level erreicht!`,
description: achievement.name,
child: {
_id: child._id,
name: child.name,
avatar: child.avatar
},
timestamp: achievement.earnedAt,
icon: '🆙'
});
}
});
});
// Nach Zeitstempel sortieren
activities.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
// Limit anwenden
const paginatedActivities = activities.slice(
parseInt(offset),
parseInt(offset) + parseInt(limit)
);
res.json({
success: true,
count: paginatedActivities.length,
total: activities.length,
activities: paginatedActivities
});
} catch (error) {
console.error('Activity-Feed-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen des Aktivitäts-Feeds'
});
}
});
// @route PUT /api/families/settings
// @desc Familien-Einstellungen aktualisieren
// @access Private
router.put('/settings', [
auth,
body('familyName')
.optional()
.trim()
.isLength({ min: 2, max: 50 })
.withMessage('Familienname muss zwischen 2 und 50 Zeichen lang sein')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { familyName, preferences } = req.body;
const updateFields = {};
if (familyName) updateFields.familyName = familyName.trim();
if (preferences) updateFields.preferences = { ...req.user.preferences, ...preferences };
const user = await User.findByIdAndUpdate(
req.user.id,
{ $set: updateFields },
{ new: true, runValidators: true }
).select('-password');
res.json({
success: true,
message: 'Familien-Einstellungen wurden erfolgreich aktualisiert! ⚙️',
user
});
} catch (error) {
console.error('Einstellungs-Update-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Aktualisieren der Einstellungen'
});
}
});
module.exports = router;

215
routes/public.js Normal file
View File

@ -0,0 +1,215 @@
const express = require('express');
const router = express.Router();
const Child = require('../models/Child');
const Task = require('../models/Task');
const { Reward } = require('../models/Reward');
// @route GET /api/public/children
// @desc Alle Kinder für öffentliche Auswahl laden (ohne Authentifizierung)
// @access Public
router.get('/children', async (req, res) => {
try {
// Lade alle aktiven Kinder (in einer echten App würde man hier nach Familie filtern)
const children = await Child.find({ isActive: true })
.select('name age avatar level points')
.sort({ name: 1 });
res.json({
success: true,
children
});
} catch (error) {
console.error('Fehler beim Laden der Kinder:', error);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Laden der Kinder'
});
}
});
// @route GET /api/public/child/:id
// @desc Einzelnes Kind für öffentlichen Zugriff laden
// @access Public
router.get('/child/:id', async (req, res) => {
try {
const child = await Child.findById(req.params.id)
.select('name age avatar level points preferences stats');
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
res.json({
success: true,
child
});
} catch (error) {
console.error('Fehler beim Laden des Kindes:', error);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Laden des Kindes'
});
}
});
// @route GET /api/public/tasks/child/:childId
// @desc Aufgaben für ein Kind laden (öffentlich)
// @access Public
router.get('/tasks/child/:childId', async (req, res) => {
try {
const { childId } = req.params;
const { date } = req.query;
let dateFilter = {};
if (date === 'today') {
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
dateFilter = {
dueDate: {
$gte: today,
$lt: tomorrow
}
};
}
const tasks = await Task.find({
assignedTo: childId,
...dateFilter
}).sort({ dueDate: 1, priority: -1 });
res.json({
success: true,
tasks
});
} catch (error) {
console.error('Fehler beim Laden der Aufgaben:', error);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Laden der Aufgaben'
});
}
});
// @route GET /api/public/rewards/shop/:childId
// @desc Belohnungen für ein Kind laden (öffentlich)
// @access Public
router.get('/rewards/shop/:childId', async (req, res) => {
try {
const { childId } = req.params;
// Lade verfügbare Belohnungen für das Kind
const rewards = await Reward.find({
$or: [
{ targetChildren: childId },
{ targetChildren: { $size: 0 } } // Globale Belohnungen
],
isActive: true
}).sort({ cost: 1 });
res.json({
success: true,
rewards
});
} catch (error) {
console.error('Fehler beim Laden der Belohnungen:', error);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Laden der Belohnungen'
});
}
});
// @route POST /api/public/tasks/:taskId/complete
// @desc Aufgabe als erledigt markieren (öffentlich)
// @access Public
router.post('/tasks/:taskId/complete', async (req, res) => {
try {
const { taskId } = req.params;
const task = await Task.findById(taskId);
if (!task) {
return res.status(404).json({
success: false,
message: 'Aufgabe nicht gefunden'
});
}
// Aufgabe als erledigt markieren
task.status = 'completed';
task.completedAt = new Date();
await task.save();
res.json({
success: true,
message: `Aufgabe "${task.title}" wurde als erledigt markiert! 🎉`,
task
});
} catch (error) {
console.error('Fehler beim Markieren der Aufgabe:', error);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Markieren der Aufgabe'
});
}
});
// @route POST /api/public/rewards/:rewardId/redeem
// @desc Belohnung einlösen (öffentlich)
// @access Public
router.post('/rewards/:rewardId/redeem', async (req, res) => {
try {
const { rewardId } = req.params;
const { childId } = req.body;
const reward = await Reward.findById(rewardId);
if (!reward) {
return res.status(404).json({
success: false,
message: 'Belohnung nicht gefunden'
});
}
const child = await Child.findById(childId);
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
// Prüfen ob genug Punkte vorhanden
if (child.points.available < reward.cost) {
return res.status(400).json({
success: false,
message: 'Nicht genügend Punkte verfügbar'
});
}
// Punkte abziehen
child.points.available -= reward.cost;
child.points.spent += reward.cost;
await child.save();
res.json({
success: true,
message: `Belohnung "${reward.title}" wurde erfolgreich eingelöst! 🎁`,
child: {
points: child.points
}
});
} catch (error) {
console.error('Fehler beim Einlösen der Belohnung:', error);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Einlösen der Belohnung'
});
}
});
module.exports = router;

581
routes/rewards.js Normal file
View File

@ -0,0 +1,581 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { Reward, RewardRequest } = require('../models/Reward');
const Child = require('../models/Child');
const { auth } = require('../middleware/auth');
const router = express.Router();
// @route GET /api/rewards
// @desc Alle Belohnungen des angemeldeten Elternteils abrufen
// @access Private
router.get('/', auth, async (req, res) => {
try {
const { category, isActive = 'true' } = req.query;
const filter = { createdBy: req.user.id };
if (category) filter.category = category;
if (isActive !== 'all') filter.isActive = isActive === 'true';
const rewards = await Reward.find(filter)
.sort({ isPopular: -1, sortOrder: 1, cost: 1 });
res.json({
success: true,
count: rewards.length,
rewards
});
} catch (error) {
console.error('Belohnungen-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Belohnungen'
});
}
});
// @route GET /api/rewards/requests/all
// @desc Alle Belohnungsanfragen des angemeldeten Elternteils abrufen
// @access Private
router.get('/requests/all', auth, async (req, res) => {
try {
const { status, childId } = req.query;
// Alle Kinder des Elternteils abrufen
const children = await Child.find({ parent: req.user.id, isActive: true });
const childIds = children.map(child => child._id);
const filter = { child: { $in: childIds } };
if (status) filter.status = status;
if (childId) filter.child = childId;
const requests = await RewardRequest.find(filter)
.populate('child', 'name avatar')
.populate('reward', 'title cost category')
.sort({ createdAt: -1 });
res.json({
success: true,
count: requests.length,
requests
});
} catch (error) {
console.error('Belohnungsanfragen-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Belohnungsanfragen'
});
}
});
// @route GET /api/rewards/shop/:childId
// @desc Verfügbare Belohnungen für ein Kind abrufen (Kinder-Shop)
// @access Private
router.get('/shop/:childId', auth, async (req, res) => {
try {
const childId = req.params.childId;
const { category } = req.query;
// Prüfen ob Kind zu diesem Elternteil gehört
const child = await Child.findOne({
_id: childId,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
// Verfügbare Belohnungen für dieses Kind abrufen
let rewards = await Reward.findAvailableFor(child);
if (category) {
rewards = rewards.filter(reward => reward.category === category);
}
// Verfügbarkeit für jede Belohnung prüfen
const availableRewards = rewards.filter(reward => reward.isAvailableFor(child));
res.json({
success: true,
child: {
id: child._id,
name: child.name,
avatar: child.avatar,
points: child.points
},
count: availableRewards.length,
rewards: availableRewards
});
} catch (error) {
console.error('Shop-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen des Belohnungs-Shops'
});
}
});
// @route POST /api/rewards/requests/:id/approve
// @desc Belohnungs-Anfrage genehmigen
// @access Private
router.post('/requests/:id/approve', auth, async (req, res) => {
try {
const request = await RewardRequest.findOne({
_id: req.params.id,
status: 'pending'
}).populate('child', 'name parent').populate('reward', 'title cost');
if (!request) {
return res.status(404).json({
success: false,
message: 'Anfrage nicht gefunden oder bereits bearbeitet'
});
}
// Prüfen ob das Kind zu diesem Elternteil gehört
if (request.child.parent.toString() !== req.user.id) {
return res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Anfrage'
});
}
// Anfrage genehmigen
request.status = 'approved';
request.processedAt = new Date();
request.processedBy = req.user.id;
await request.save();
res.json({
success: true,
message: `Belohnungsanfrage für ${request.reward.title} wurde genehmigt`,
request
});
} catch (error) {
console.error('Anfrage-Genehmigung-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Genehmigen der Anfrage'
});
}
});
// @route POST /api/rewards/requests/:id/reject
// @desc Belohnungs-Anfrage ablehnen
// @access Private
router.post('/requests/:id/reject', [
auth,
body('reason')
.trim()
.isLength({ min: 3, max: 200 })
.withMessage('Ablehnungsgrund muss zwischen 3 und 200 Zeichen lang sein')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { reason } = req.body;
const request = await RewardRequest.findOne({
_id: req.params.id,
status: 'pending'
}).populate('child', 'name parent').populate('reward', 'title cost');
if (!request) {
return res.status(404).json({
success: false,
message: 'Anfrage nicht gefunden oder bereits bearbeitet'
});
}
// Prüfen ob das Kind zu diesem Elternteil gehört
if (request.child.parent.toString() !== req.user.id) {
return res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Anfrage'
});
}
// Anfrage ablehnen
request.status = 'rejected';
request.rejectionReason = reason;
request.processedAt = new Date();
request.processedBy = req.user.id;
await request.save();
res.json({
success: true,
message: `Belohnungsanfrage für ${request.reward.title} wurde abgelehnt`,
request
});
} catch (error) {
console.error('Anfrage-Ablehnung-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Ablehnen der Anfrage'
});
}
});
// @route POST /api/rewards/requests/:id/redeem
// @desc Genehmigte Belohnungsanfrage einlösen
// @access Private
router.post('/requests/:id/redeem', auth, async (req, res) => {
try {
const request = await RewardRequest.findOne({
_id: req.params.id,
status: 'approved'
}).populate('child', 'name parent points').populate('reward', 'title cost');
if (!request) {
return res.status(404).json({
success: false,
message: 'Genehmigte Anfrage nicht gefunden'
});
}
// Prüfen ob das Kind zu diesem Elternteil gehört
if (request.child.parent.toString() !== req.user.id) {
return res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Anfrage'
});
}
// Anfrage als eingelöst markieren
request.status = 'redeemed';
request.redeemedAt = new Date();
await request.save();
res.json({
success: true,
message: `Belohnung ${request.reward.title} wurde eingelöst`,
request
});
} catch (error) {
console.error('Anfrage-Einlösung-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Einlösen der Anfrage'
});
}
});
// @route GET /api/rewards/:id
// @desc Einzelne Belohnung abrufen
// @access Private
router.get('/:id', auth, async (req, res) => {
try {
const reward = await Reward.findOne({
_id: req.params.id,
createdBy: req.user.id
});
if (!reward) {
return res.status(404).json({
success: false,
message: 'Belohnung nicht gefunden'
});
}
res.json({
success: true,
reward
});
} catch (error) {
console.error('Belohnung-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Belohnung'
});
}
});
// @route POST /api/rewards
// @desc Neue Belohnung erstellen
// @access Private
router.post('/', [
auth,
body('title')
.trim()
.isLength({ min: 3, max: 100 })
.withMessage('Titel muss zwischen 3 und 100 Zeichen lang sein'),
body('cost')
.isInt({ min: 1, max: 10000 })
.withMessage('Kosten müssen zwischen 1 und 10000 Punkten liegen'),
body('category')
.optional()
.isIn([
'screen-time', 'treats', 'toys', 'activities', 'outings',
'privileges', 'money', 'special', 'electronics', 'books',
'sports', 'art', 'other'
])
.withMessage('Ungültige Kategorie')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const {
title, description, cost, category, icon, availableFor,
ageRestriction, availability, timeRestrictions,
requiresApproval, isInstant, tags, instructions
} = req.body;
// Neue Belohnung erstellen
const reward = new Reward({
title: title.trim(),
description: description?.trim(),
cost,
category: category || 'treats',
icon: icon || 'gift',
createdBy: req.user.id,
availableFor: availableFor || [],
ageRestriction: ageRestriction || {},
availability: availability || { isLimited: false },
timeRestrictions: timeRestrictions || {},
requiresApproval: requiresApproval !== false, // Standard: true
isInstant: isInstant || false,
tags: tags || [],
instructions: instructions?.trim()
});
await reward.save();
res.status(201).json({
success: true,
message: `Neue Belohnung "${title}" wurde erstellt! 🎁`,
reward
});
} catch (error) {
console.error('Belohnung-Erstellungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Erstellen der Belohnung'
});
}
});
// @route PUT /api/rewards/:id
// @desc Belohnung aktualisieren
// @access Private
router.put('/:id', [
auth,
body('title')
.optional()
.trim()
.isLength({ min: 3, max: 100 })
.withMessage('Titel muss zwischen 3 und 100 Zeichen lang sein'),
body('cost')
.optional()
.isInt({ min: 1, max: 10000 })
.withMessage('Kosten müssen zwischen 1 und 10000 Punkten liegen')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const updateFields = {};
const allowedFields = [
'title', 'description', 'cost', 'category', 'icon',
'availableFor', 'ageRestriction', 'availability',
'timeRestrictions', 'requiresApproval', 'isInstant',
'tags', 'instructions', 'isActive', 'isPopular', 'sortOrder'
];
// Nur erlaubte Felder aktualisieren
allowedFields.forEach(field => {
if (req.body[field] !== undefined) {
updateFields[field] = req.body[field];
}
});
const reward = await Reward.findOneAndUpdate(
{ _id: req.params.id, createdBy: req.user.id },
{ $set: updateFields },
{ new: true, runValidators: true }
);
if (!reward) {
return res.status(404).json({
success: false,
message: 'Belohnung nicht gefunden'
});
}
res.json({
success: true,
message: `Belohnung "${reward.title}" wurde erfolgreich aktualisiert! ✅`,
reward
});
} catch (error) {
console.error('Belohnung-Update-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Aktualisieren der Belohnung'
});
}
});
// @route POST /api/rewards/:id/redeem
// @desc Belohnung einlösen (von Kind)
// @access Private
router.post('/:id/redeem', [
auth,
body('childId')
.isMongoId()
.withMessage('Ungültige Kind-ID')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { childId } = req.body;
const rewardId = req.params.id;
// Prüfen ob Kind zu diesem Elternteil gehört
const child = await Child.findOne({
_id: childId,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
// Belohnung abrufen
const reward = await Reward.findOne({
_id: rewardId,
createdBy: req.user.id,
isActive: true
});
if (!reward) {
return res.status(404).json({
success: false,
message: 'Belohnung nicht gefunden'
});
}
// Verfügbarkeit prüfen
if (!reward.isAvailableFor(child)) {
return res.status(400).json({
success: false,
message: 'Diese Belohnung ist für dieses Kind nicht verfügbar'
});
}
// Punkte prüfen
if (child.points.available < reward.cost) {
return res.status(400).json({
success: false,
message: `Nicht genügend Punkte! Du brauchst ${reward.cost} Punkte, hast aber nur ${child.points.available}.`
});
}
// Belohnung einlösen
const request = await reward.redeem(childId);
const message = reward.requiresApproval
? `Super! ${child.name} möchte "${reward.title}" einlösen. Warte auf die Bestätigung! 🎁`
: `Glückwunsch! ${child.name} hat "${reward.title}" erfolgreich eingelöst! 🎉`;
res.json({
success: true,
message,
request: {
id: request._id,
status: request.status,
pointsSpent: request.pointsSpent,
requestedAt: request.requestedAt
},
child: {
id: child._id,
name: child.name,
points: {
available: child.points.available - reward.cost,
total: child.points.total,
spent: child.points.spent + reward.cost
}
}
});
} catch (error) {
console.error('Belohnung-Einlösungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: error.message || 'Server-Fehler beim Einlösen der Belohnung'
});
}
});
// @route DELETE /api/rewards/:id
// @desc Belohnung löschen (soft delete)
// @access Private
router.delete('/:id', auth, async (req, res) => {
try {
const reward = await Reward.findOneAndUpdate(
{ _id: req.params.id, createdBy: req.user.id },
{ $set: { isActive: false } },
{ new: true }
);
if (!reward) {
return res.status(404).json({
success: false,
message: 'Belohnung nicht gefunden'
});
}
res.json({
success: true,
message: `Belohnung "${reward.title}" wurde gelöscht`
});
} catch (error) {
console.error('Belohnung-Löschungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Löschen der Belohnung'
});
}
});
module.exports = router;

561
routes/tasks.js Normal file
View File

@ -0,0 +1,561 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const Task = require('../models/Task');
const Child = require('../models/Child');
const { auth } = require('../middleware/auth');
const router = express.Router();
// @route GET /api/tasks
// @desc Alle Aufgaben des angemeldeten Elternteils abrufen
// @access Private
router.get('/', auth, async (req, res) => {
try {
const { status, childId, date, category } = req.query;
// Filter aufbauen
const filter = { createdBy: req.user.id, isActive: true };
if (status) filter.status = status;
if (childId) filter.assignedTo = childId;
if (category) filter.category = category;
// Datum-Filter
if (date) {
const targetDate = new Date(date);
targetDate.setHours(0, 0, 0, 0);
const nextDay = new Date(targetDate);
nextDay.setDate(nextDay.getDate() + 1);
filter.dueDate = { $gte: targetDate, $lt: nextDay };
}
const tasks = await Task.find(filter)
.populate('assignedTo', 'name avatar age')
.populate('approvedBy', 'parentName')
.sort({ dueDate: 1, priority: -1, createdAt: -1 });
res.json({
success: true,
count: tasks.length,
tasks
});
} catch (error) {
console.error('Aufgaben-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Aufgaben'
});
}
});
// @route GET /api/tasks/child/:childId
// @desc Aufgaben für ein bestimmtes Kind abrufen (Kinder-Ansicht)
// @access Private
router.get('/child/:childId', auth, async (req, res) => {
try {
const { status = 'pending', date } = req.query;
const childId = req.params.childId;
// Prüfen ob Kind zu diesem Elternteil gehört
const child = await Child.findOne({
_id: childId,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden'
});
}
let tasks;
if (date === 'today') {
// Heutige Aufgaben
tasks = await Task.findTodayTasks(childId);
} else {
// Alle Aufgaben mit Filter
const filter = {
assignedTo: childId,
isActive: true
};
if (status !== 'all') {
filter.status = status;
}
tasks = await Task.find(filter)
.sort({ dueDate: 1, priority: -1, createdAt: -1 });
}
res.json({
success: true,
child: {
id: child._id,
name: child.name,
avatar: child.avatar,
points: child.points
},
count: tasks.length,
tasks
});
} catch (error) {
console.error('Kind-Aufgaben-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Aufgaben'
});
}
});
// @route GET /api/tasks/:id
// @desc Einzelne Aufgabe abrufen
// @access Private
router.get('/:id', auth, async (req, res) => {
try {
const task = await Task.findOne({
_id: req.params.id,
createdBy: req.user.id,
isActive: true
})
.populate('assignedTo', 'name avatar age')
.populate('approvedBy', 'parentName');
if (!task) {
return res.status(404).json({
success: false,
message: 'Aufgabe nicht gefunden'
});
}
res.json({
success: true,
task
});
} catch (error) {
console.error('Aufgabe-Abruf-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Aufgabe'
});
}
});
// @route POST /api/tasks
// @desc Neue Aufgabe erstellen
// @access Private
router.post('/', [
auth,
body('title')
.trim()
.isLength({ min: 3, max: 100 })
.withMessage('Titel muss zwischen 3 und 100 Zeichen lang sein'),
body('assignedTo')
.isMongoId()
.withMessage('Ungültige Kind-ID'),
body('dueDate')
.isISO8601()
.withMessage('Ungültiges Fälligkeitsdatum'),
body('points')
.isInt({ min: 1, max: 500 })
.withMessage('Punkte müssen zwischen 1 und 500 liegen'),
body('category')
.optional()
.isIn(['household', 'personal', 'homework', 'chores', 'hygiene', 'pets', 'garden', 'helping', 'learning', 'exercise', 'other'])
.withMessage('Ungültige Kategorie'),
body('difficulty')
.optional()
.isIn(['easy', 'medium', 'hard'])
.withMessage('Ungültige Schwierigkeit'),
body('priority')
.optional()
.isIn(['low', 'normal', 'high', 'urgent'])
.withMessage('Ungültige Priorität')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const {
title, description, assignedTo, dueDate, points,
category, difficulty, priority, icon, estimatedTime,
recurring, tags, notes
} = req.body;
// Prüfen ob Kind zu diesem Elternteil gehört
const child = await Child.findOne({
_id: assignedTo,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(404).json({
success: false,
message: 'Kind nicht gefunden oder gehört nicht zu diesem Account'
});
}
// Neue Aufgabe erstellen
const task = new Task({
title: title.trim(),
description: description?.trim(),
assignedTo,
createdBy: req.user.id,
dueDate: new Date(dueDate),
points,
category: category || 'household',
difficulty: difficulty || 'easy',
priority: priority || 'normal',
icon: icon || 'task',
estimatedTime,
recurring: recurring || { isRecurring: false },
tags: tags || [],
notes: notes?.trim()
});
await task.save();
// Aufgabe mit Kind-Daten laden
await task.populate('assignedTo', 'name avatar age');
res.status(201).json({
success: true,
message: `Neue Aufgabe "${title}" wurde für ${child.name} erstellt! 📝`,
task
});
} catch (error) {
console.error('Aufgabe-Erstellungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Erstellen der Aufgabe'
});
}
});
// @route PUT /api/tasks/:id
// @desc Aufgabe aktualisieren
// @access Private
router.put('/:id', [
auth,
body('title')
.optional()
.trim()
.isLength({ min: 3, max: 100 })
.withMessage('Titel muss zwischen 3 und 100 Zeichen lang sein'),
body('points')
.optional()
.isInt({ min: 1, max: 500 })
.withMessage('Punkte müssen zwischen 1 und 500 liegen')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const updateFields = {};
const allowedFields = [
'title', 'description', 'dueDate', 'points', 'category',
'difficulty', 'priority', 'icon', 'estimatedTime', 'tags', 'notes'
];
// Nur erlaubte Felder aktualisieren
allowedFields.forEach(field => {
if (req.body[field] !== undefined) {
updateFields[field] = req.body[field];
}
});
const task = await Task.findOneAndUpdate(
{
_id: req.params.id,
createdBy: req.user.id,
isActive: true,
status: { $in: ['pending', 'completed'] } // Nur nicht-genehmigte Aufgaben änderbar
},
{ $set: updateFields },
{ new: true, runValidators: true }
).populate('assignedTo', 'name avatar age');
if (!task) {
return res.status(404).json({
success: false,
message: 'Aufgabe nicht gefunden oder kann nicht bearbeitet werden'
});
}
res.json({
success: true,
message: `Aufgabe "${task.title}" wurde erfolgreich aktualisiert! ✅`,
task
});
} catch (error) {
console.error('Aufgabe-Update-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Aktualisieren der Aufgabe'
});
}
});
// @route POST /api/tasks/:id/complete
// @desc Aufgabe als erledigt markieren (von Kind)
// @access Private
router.post('/:id/complete', auth, async (req, res) => {
try {
const task = await Task.findOne({
_id: req.params.id,
isActive: true,
status: 'pending'
}).populate('assignedTo', 'name avatar');
if (!task) {
return res.status(404).json({
success: false,
message: 'Aufgabe nicht gefunden oder bereits erledigt'
});
}
// Prüfen ob Kind zu diesem Elternteil gehört
const child = await Child.findOne({
_id: task.assignedTo._id,
parent: req.user.id,
isActive: true
});
if (!child) {
return res.status(403).json({
success: false,
message: 'Keine Berechtigung für diese Aufgabe'
});
}
await task.markCompleted();
res.json({
success: true,
message: `Super! "${task.title}" wurde als erledigt markiert! 🎉 Warte auf die Bestätigung deiner Eltern.`,
task: {
id: task._id,
title: task.title,
status: task.status,
points: task.points,
completedAt: task.completedAt
}
});
} catch (error) {
console.error('Aufgabe-Abschluss-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Markieren der Aufgabe als erledigt'
});
}
});
// @route POST /api/tasks/:id/approve
// @desc Aufgabe genehmigen (von Eltern)
// @access Private
router.post('/:id/approve', auth, async (req, res) => {
try {
const task = await Task.findOne({
_id: req.params.id,
createdBy: req.user.id,
isActive: true,
status: 'completed'
}).populate('assignedTo', 'name avatar');
if (!task) {
return res.status(404).json({
success: false,
message: 'Aufgabe nicht gefunden oder nicht zur Genehmigung bereit'
});
}
await task.approve(req.user.id);
res.json({
success: true,
message: `Aufgabe "${task.title}" wurde genehmigt! ${task.assignedTo.name} erhält ${task.points} Punkte! 🌟`,
task: {
id: task._id,
title: task.title,
status: task.status,
points: task.points,
approvedAt: task.approvedAt,
child: task.assignedTo
}
});
} catch (error) {
console.error('Aufgabe-Genehmigungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Genehmigen der Aufgabe'
});
}
});
// @route POST /api/tasks/:id/reject
// @desc Aufgabe ablehnen (von Eltern)
// @access Private
router.post('/:id/reject', [
auth,
body('reason')
.trim()
.isLength({ min: 3, max: 200 })
.withMessage('Ablehnungsgrund muss zwischen 3 und 200 Zeichen lang sein')
], async (req, res) => {
try {
// Validierungsfehler prüfen
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
message: 'Validierungsfehler',
errors: errors.array()
});
}
const { reason } = req.body;
const task = await Task.findOne({
_id: req.params.id,
createdBy: req.user.id,
isActive: true,
status: 'completed'
}).populate('assignedTo', 'name avatar');
if (!task) {
return res.status(404).json({
success: false,
message: 'Aufgabe nicht gefunden oder nicht zur Ablehnung bereit'
});
}
await task.reject(reason.trim(), req.user.id);
res.json({
success: true,
message: `Aufgabe "${task.title}" wurde abgelehnt. ${task.assignedTo.name} kann es nochmal versuchen.`,
task: {
id: task._id,
title: task.title,
status: task.status,
rejectionReason: task.rejectionReason,
child: task.assignedTo
}
});
} catch (error) {
console.error('Aufgabe-Ablehnungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Ablehnen der Aufgabe'
});
}
});
// @route DELETE /api/tasks/:id
// @desc Aufgabe löschen (soft delete)
// @access Private
router.delete('/:id', auth, async (req, res) => {
try {
const task = await Task.findOneAndUpdate(
{
_id: req.params.id,
createdBy: req.user.id,
isActive: true,
status: { $in: ['pending', 'rejected'] } // Nur nicht-erledigte Aufgaben löschbar
},
{ $set: { isActive: false } },
{ new: true }
);
if (!task) {
return res.status(404).json({
success: false,
message: 'Aufgabe nicht gefunden oder kann nicht gelöscht werden'
});
}
res.json({
success: true,
message: `Aufgabe "${task.title}" wurde gelöscht`
});
} catch (error) {
console.error('Aufgabe-Löschungs-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Löschen der Aufgabe'
});
}
});
// @route GET /api/tasks/stats/overview
// @desc Aufgaben-Statistiken für Dashboard
// @access Private
router.get('/stats/overview', auth, async (req, res) => {
try {
const [totalTasks, pendingTasks, completedTasks, approvedTasks, overdueTasks] = await Promise.all([
Task.countDocuments({ createdBy: req.user.id, isActive: true }),
Task.countDocuments({ createdBy: req.user.id, status: 'pending', isActive: true }),
Task.countDocuments({ createdBy: req.user.id, status: 'completed', isActive: true }),
Task.countDocuments({ createdBy: req.user.id, status: 'approved', isActive: true }),
Task.countDocuments({
createdBy: req.user.id,
status: 'pending',
dueDate: { $lt: new Date() },
isActive: true
})
]);
// Heutige Aufgaben
const today = new Date();
today.setHours(0, 0, 0, 0);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const todayTasks = await Task.countDocuments({
createdBy: req.user.id,
dueDate: { $gte: today, $lt: tomorrow },
isActive: true
});
const stats = {
total: totalTasks,
pending: pendingTasks,
completed: completedTasks,
approved: approvedTasks,
overdue: overdueTasks,
today: todayTasks,
completionRate: totalTasks > 0 ? Math.round((approvedTasks / totalTasks) * 100) : 0
};
res.json({
success: true,
stats
});
} catch (error) {
console.error('Aufgaben-Statistik-Fehler:', error.message);
res.status(500).json({
success: false,
message: 'Server-Fehler beim Abrufen der Statistiken'
});
}
});
module.exports = router;

63
server.js Normal file
View File

@ -0,0 +1,63 @@
const express = require('express');
const mongoose = require('mongoose');
const cors = require('cors');
const path = require('path');
require('dotenv').config();
const app = express();
// Middleware
app.use(cors());
app.use(express.json());
// MongoDB Verbindung
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/familien-held';
mongoose.connect(MONGODB_URI, {
useNewUrlParser: true,
useUnifiedTopology: true,
})
.then(() => console.log('✅ MongoDB verbunden'))
.catch(err => console.error('❌ MongoDB Verbindungsfehler:', err));
// API Routes
app.use('/api/auth', require('./routes/auth'));
app.use('/api/child-auth', require('./routes/childAuth'));
app.use('/api/families', require('./routes/families'));
app.use('/api/children', require('./routes/children'));
app.use('/api/tasks', require('./routes/tasks'));
app.use('/api/rewards', require('./routes/rewards'));
app.use('/api/public', require('./routes/public'));
// Serve static files from React app in production
if (process.env.NODE_ENV === 'production') {
app.use(express.static(path.join(__dirname, 'client/build')));
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, 'client/build', 'index.html'));
});
}
// Basis Route für Entwicklung
app.get('/', (req, res) => {
res.json({
message: '🎉 Familien-Held API läuft!',
version: '1.0.0',
endpoints: [
'/api/auth - Eltern-Authentifizierung',
'/api/child-auth - Kinder-Authentifizierung',
'/api/families - Familien-Management',
'/api/children - Kinder-Profile',
'/api/tasks - Aufgaben-Verwaltung',
'/api/rewards - Belohnungs-System'
]
});
});
const PORT = process.env.PORT || 5000;
app.listen(PORT, () => {
console.log(`🚀 Server läuft auf Port ${PORT}`);
console.log(`📱 Familien-Held API bereit!`);
});
module.exports = app;