Compare commits

...

43 Commits

Author SHA1 Message Date
admin 1af18f1029 Merge pull request #17 from metacube2/claude/link-sharing-platform-01MxxjTZD3mi1H7nAVq16TYy
Build internal link sharing platform
2025-12-06 11:02:01 +01:00
admin 05295f4b2a Merge pull request #16 from metacube2/claude/bitcoin-trading-signals-01UzsK9jXmmNYJqv5mayUdNq
Bitcoin trading signals with market analysis
2025-12-06 10:21:28 +01:00
Claude bcc19577eb Add project backup ZIP archive
Created backup archive for download containing all project files
2025-12-06 09:18:27 +00:00
Claude ef7ea500a9 Add Bitcoin Trading Signal System with MACD and News Sentiment Analysis
- Implement comprehensive Bitcoin trading signal system
- Add MACD (Moving Average Convergence Divergence) indicator
- Integrate news sentiment analysis from CryptoCompare
- Combine technical analysis with market sentiment
- Generate trading recommendations (Strong Buy, Buy, Hold, Sell, Strong Sell)

Features:
- Real-time Bitcoin price data from Binance and CoinGecko APIs
- Historical data analysis with MACD indicator
- News sentiment analysis with keyword-based scoring
- Weighted signal combination (60% MACD, 40% Sentiment)
- Confidence scoring for each recommendation
- Detailed reasoning for trading signals
- CLI interface with verbose and quick modes

Components:
- data_fetcher.py: Bitcoin price and market data retrieval
- macd_indicator.py: MACD calculation and signal generation
- news_sentiment.py: News analysis and sentiment scoring
- signal_generator.py: Combined signal generation
- bitcoin_trader.py: Main CLI application

Usage:
  python bitcoin_trading/bitcoin_trader.py [--verbose] [--days N] [--quick]

Documentation in bitcoin_trading/README.md
2025-12-02 22:45:17 +00:00
admin d6694d97a3 Merge pull request #15 from metacube2/claude/add-band-email-feature-01F6etH4329FWCEMsSZpdvCj
Implement email functionality and improve booking system
2025-12-02 22:46:17 +01:00
admin 797afb4b17 Merge branch 'main' into claude/add-band-email-feature-01F6etH4329FWCEMsSZpdvCj 2025-12-02 22:46:11 +01:00
admin 29e836822f Merge pull request #14 from metacube2/claude/new-project-setup-018BzEVHSs5k9RShTHsPQsmE
Set up modern PHP MVC project structure for GetYourBand platform
2025-12-02 22:45:41 +01:00
admin 8f7be04301 Merge pull request #13 from metacube2/claude/organize-project-files-01C4TfggxYSQyiBmexbjTXun
Organize all project files into src subdirectory
2025-12-02 22:45:24 +01:00
admin e62c61e255 Merge pull request #12 from metacube2/claude/add-paypal-image-uploads-01XtWuh5yLHza7HPpMd7BKLq
Add PayPal integration and band image upload features
2025-12-02 22:45:06 +01:00
Claude 143fe3d488 Set up modern PHP MVC project structure for GetYourBand platform
- Implemented clean MVC architecture with Router, Controller, and Model base classes
- Created database migrations for users, bands, bookings, reviews, and availability
- Set up Tailwind CSS with yellow color scheme and modern design
- Added Alpine.js for reactive JavaScript components
- Configured Vite for asset building and hot module replacement
- Created authentication and role-based middleware
- Implemented helper functions and configuration system
- Added comprehensive README with setup instructions
- Configured Apache with proper rewrite rules and security headers
- Set up Composer and npm package management with modern dependencies
2025-12-02 21:31:08 +00:00
Claude fbbad82975 Organize all project files into src subdirectory
- Created new src/ directory
- Copied all project files and folders to src/
- Keeps project structure organized and consolidated
2025-12-02 21:13:51 +00:00
Claude 615866d215 Add PayPal integration and band image upload features
- Implemented complete PayPal payment integration with checkout flow
- Added payments table to database for transaction tracking
- Created image upload functionality for band profiles
- Added gallery management in band profiles
- Updated booking flow to support PayPal payments
- Added payment status display in user profiles
- Included comprehensive documentation for new features

New files:
- upload-handler.php: REST API for image uploads
- paypal-checkout.php: PayPal checkout page
- paypal-process.php: Payment processing backend
- PAYPAL_UPLOAD_FEATURES.md: Complete documentation
- storage/uploads/bands/: Upload directory

Modified files:
- database.sql: Added payments table
- profil.php: Added gallery and payment tracking
- anfrage.php: Integrated PayPal payment option
2025-12-02 21:11:04 +00:00
Claude acc50dbb5d Implement email functionality and improve booking system
Features:
- Add real email sending with PHP mail() function
- Create HTML email templates for bookings
- Send booking notifications to bands
- Send confirmation emails to customers
- Add email field to bands table and profile
- Enable guest bookings without login
- Improve form validation and UX
- Add migration script for database updates

This fixes the non-working email system and improves the reservation/booking process significantly.
2025-12-02 21:01:02 +00:00
admin 798a2785e7 Merge pull request #11 from metacube2/codex/create-advanced-mouse-synthesizer-in-synth-folder-235xid
Add extra modulation effects to mouse synth
2025-11-18 11:15:37 +01:00
admin 84c58e965c Merge branch 'main' into codex/create-advanced-mouse-synthesizer-in-synth-folder-235xid 2025-11-18 11:15:26 +01:00
admin c352851adb Add extra modulation effects to mouse synth 2025-11-18 11:13:33 +01:00
admin 2f4362e856 test 2025-11-18 10:41:04 +01:00
admin a0bca62a67 asd 2025-11-18 10:40:41 +01:00
admin e3c8c9717e sadf2 2025-11-18 10:27:54 +01:00
admin c1ed600d4e we 2025-11-18 10:23:49 +01:00
admin 4291ef6a05 Merge pull request #10 from metacube2/codex/create-advanced-mouse-synthesizer-in-synth-folder-grrihv
Expand synth with multi-source BTC modulation
2025-11-18 07:35:28 +01:00
admin 088ba9e8b1 Merge branch 'main' into codex/create-advanced-mouse-synthesizer-in-synth-folder-grrihv 2025-11-18 07:35:19 +01:00
admin bfb351d33e Expand synth with multi-source BTC modulation 2025-11-18 07:32:24 +01:00
admin 31fa44f6a7 Merge pull request #9 from metacube2/codex/projekt-fur-bandreservierung-erstellen-70yja1
Add band contact emails and contact form
2025-11-17 22:23:34 +01:00
admin f02f9e81a3 Merge branch 'main' into codex/projekt-fur-bandreservierung-erstellen-70yja1 2025-11-17 22:23:15 +01:00
admin 2c1cb35025 Add contact email support and band contact form 2025-11-17 22:17:59 +01:00
admin a3936c4c6f Merge pull request #8 from metacube2/codex/projekt-fur-bandreservierung-erstellen-tgjoyd
Integrate auroraalt mail transport
2025-11-17 21:50:16 +01:00
admin e04055103c Merge branch 'main' into codex/projekt-fur-bandreservierung-erstellen-tgjoyd 2025-11-17 21:50:05 +01:00
admin 619a1bf663 Wire up auroraalt mailer 2025-11-17 21:47:15 +01:00
admin a92ffc0ab9 Create auroraalt.php 2025-11-17 21:39:28 +01:00
admin 4e5a73a643 Create aurora.php 2025-11-17 21:29:08 +01:00
admin 1659940271 Merge pull request #7 from metacube2/codex/create-advanced-mouse-synthesizer-in-synth-folder
Add Bitcoin-reactive mouse synth playground
2025-11-17 16:49:40 +01:00
admin aa8ea8c6f2 Add Bitcoin-reactive mouse synth playground 2025-11-17 16:49:21 +01:00
admin 206125854a Merge pull request #6 from metacube2/codex/erstelle-seite-fur-top-10-altkos
Add MA200 signal overview for top altcoins
2025-11-17 16:17:14 +01:00
admin 98be743f35 Add MA200 signal overview for top altcoins 2025-11-17 16:16:50 +01:00
admin 5da0df88e0 Merge pull request #5 from metacube2/codex/projekt-fur-bandreservierung-erstellen-rx0gem
Add CLI functional test harness
2025-11-17 15:36:57 +01:00
admin 52c796d2db Add CLI functional test harness 2025-11-17 15:36:28 +01:00
admin bc9e3367b7 Merge pull request #4 from metacube2/codex/projekt-fur-bandreservierung-erstellen
Implement Bandreservierung Plattform
2025-11-17 15:05:10 +01:00
admin ab3a6e3711 Implement Bandreservierung Plattform 2025-11-17 15:02:58 +01:00
admin a7a619079a Create requirement.md 2025-11-17 14:53:12 +01:00
admin 14cd4c210f Merge pull request #3 from metacube2/codex/create-php-page-for-modern-date-app
Add hypermodern date dashboard
2025-11-17 14:51:09 +01:00
admin 1864a6a812 Add hypermodern date dashboard 2025-11-17 14:50:48 +01:00
admin f8eb38e3bf Merge pull request #1 from metacube2/codex/create-php-website-with-mouse-synthesizer
Add MausSynth Web Audio playground
2025-11-10 20:27:32 +01:00
109 changed files with 30291 additions and 314 deletions
+45
View File
@@ -0,0 +1,45 @@
# Application
APP_NAME="GetYourBand"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://localhost
# Database
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=getyourband
DB_USERNAME=root
DB_PASSWORD=
# Mail (SMTP)
MAIL_MAILER=smtp
MAIL_HOST=smtp.mailtrap.io
MAIL_PORT=2525
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=tls
MAIL_FROM_ADDRESS=noreply@getyourband.ch
MAIL_FROM_NAME="${APP_NAME}"
# Payment
PAYPAL_MODE=sandbox
PAYPAL_CLIENT_ID=
PAYPAL_SECRET=
PAYMENT_ENABLED=false
COMMISSION_RATE=0.10
# Upload Settings
MAX_UPLOAD_SIZE=5242880
ALLOWED_IMAGE_TYPES=jpg,jpeg,png,webp
ALLOWED_VIDEO_TYPES=mp4,webm
# Security
SESSION_LIFETIME=120
SESSION_DRIVER=file
HASH_ALGO=bcrypt
# Features
REQUIRE_EMAIL_VERIFICATION=true
REQUIRE_BAND_APPROVAL=true
ENABLE_REVIEWS=true
+41
View File
@@ -0,0 +1,41 @@
# Environment
.env
.env.local
# Dependencies
/vendor/
/node_modules/
# Build assets
/public/dist/
/public/hot
# Storage
storage/*
!storage/.gitkeep
storage/cache/*
storage/logs/*
storage/sessions/*
storage/uploads/*
# IDE
.vscode/
.idea/
*.sublime-*
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Composer
composer.lock
# NPM
package-lock.json
yarn.lock
# Testing
.phpunit.result.cache
+10
View File
@@ -0,0 +1,10 @@
Options -Indexes
AddDefaultCharset UTF-8
<IfModule mod_rewrite.c>
RewriteEngine On
# Redirect to public directory
RewriteCond %{REQUEST_URI} !^/public/
RewriteRule ^(.*)$ /public/$1 [L,QSA]
</IfModule>
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Mein Webserver",
"host": "192.168.178.88",
"protocol": "sftp",
"port": 22,
"username": "root",
"password": "934290",
"remotePath": "/var/www/html/",
"uploadOnSave": true,
"useTempFile": false,
"openSsh": false
}
+163
View File
@@ -0,0 +1,163 @@
# Email & Buchungssystem Updates
## Änderungen vom 2. Dezember 2025
### ✅ Implementierte Features:
#### 1. **Echte Email-Funktionalität** (`includes/email.php`)
- ✨ PHP `mail()` Funktion implementiert statt nur Logging
- ✅ HTML-Email-Support mit professionellen Templates
- 📧 Automatische Headers (From, Reply-To, Content-Type)
- 📝 Logging bleibt erhalten für Debugging
#### 2. **Email-Template-System**
- 🎨 Professionelle HTML-Email-Templates mit Styling
- 🎸 "booking_request" - Email an die Band
- ✅ "booking_confirmation" - Bestätigung an den Kunden
- 🎨 Gelbes Branding (#f4b807) passend zur Plattform
#### 3. **Verbesserte Buchungsanfragen** (`anfrage.php`)
- 📧 Automatische Email an Band bei neuer Anfrage
- ✅ Bestätigungs-Email an Kunden
- 👥 **Gäste-Buchungen** ohne Login möglich
- ✔️ Bessere Formular-Validierung
- 📅 Datum-Mindestauswahl (nur zukünftige Daten)
#### 4. **Band-Email-Verwaltung** (`profil.php`)
- 📧 Bands können eigene Email-Adresse hinterlegen
- 📝 Klare Beschriftung: "Email für Buchungsanfragen"
- 💾 Email wird in der Datenbank gespeichert
#### 5. **Datenbank-Updates** (`database.sql`)
- 🗄️ Neue Spalte `email` in `bands` Tabelle
- 📜 Migration-Script: `migrate_add_band_email.php`
---
## 📋 Installations-Anleitung
### Schritt 1: Migration ausführen
```bash
php migrate_add_band_email.php
```
### Schritt 2: Mail-Server konfigurieren
Stelle sicher, dass PHP's `mail()` Funktion auf dem Server konfiguriert ist:
- Ubuntu/Debian: `sudo apt-get install sendmail`
- Oder verwende einen SMTP-Relay wie Postfix
### Schritt 3: Testen
1. Als Band einloggen
2. Profil bearbeiten und Email-Adresse hinzufügen
3. Als Gast oder Kunde eine Buchungsanfrage senden
4. Prüfe die Emails (und `storage/logs/mail.log`)
---
## 🎯 Neue Funktionen im Detail
### Email an Band (booking_request)
```
Enthält:
- Event-Datum, Ort, Typ
- Budget
- Nachricht des Kunden
- Kontaktdaten (Name, Email)
- Professionelles Layout
```
### Email an Kunde (booking_confirmation)
```
Enthält:
- Bestätigung der Anfrage
- Event-Details
- Hinweis auf Rückmeldung der Band
- Support-Kontakt
```
### Gäste-Buchungen
```
- Keine Registrierung nötig
- Name + Email Pflichtfelder
- Email-Validierung
- Gleiche Funktionalität wie eingeloggte User
```
---
## 🔧 Konfiguration
### Email-Absender
In `includes/config.php`:
```php
const SITE_NAME = 'GetYourBand';
const SUPPORT_EMAIL = 'support@getyourband.ch';
```
### Band-Email Fallback
Falls Band keine Email hinterlegt hat:
```php
info@[bandname].ch
```
(Leerzeichen werden entfernt, lowercase)
---
## 📝 Nächste Schritte (Optional)
### Empfohlene Erweiterungen:
1. **PHPMailer Integration** für SMTP-Support
2. **Email-Queue** für große Mengen
3. **Email-Templates per Datenbank** konfigurierbar
4. **Email-Benachrichtigungen** für:
- Status-Änderungen von Anfragen
- Neue Bewertungen
- Profil-Freigaben
### SMTP mit PHPMailer (Beispiel):
```bash
composer require phpmailer/phpmailer
```
Dann in `includes/email.php` ersetzen:
```php
use PHPMailer\PHPMailer\PHPMailer;
// ... SMTP Konfiguration
```
---
## 🐛 Debugging
### Email kommt nicht an?
1. Prüfe `storage/logs/mail.log` - werden Emails geloggt?
2. Prüfe Server Mail-Logs: `tail -f /var/log/mail.log`
3. Teste PHP mail(): `php -r "mail('test@example.com', 'Test', 'Test');"`
4. Prüfe Spam-Ordner
### Häufige Probleme:
- **sendmail nicht installiert**: `sudo apt-get install sendmail`
- **Port 25 blockiert**: Verwende SMTP-Relay
- **SPF/DKIM fehlt**: Emails landen im Spam
---
## ✨ Zusammenfassung
**Vorher:**
- ❌ Emails wurden nur geloggt
- ❌ Keine echten Email-Benachrichtigungen
- ❌ Gäste konnten nicht buchen
- ❌ Bands hatten keine Email-Verwaltung
**Nachher:**
- ✅ Echte Email-Versand mit HTML-Templates
- ✅ Automatische Benachrichtigungen an Band & Kunde
- ✅ Gäste-Buchungen möglich
- ✅ Bands verwalten ihre Email-Adresse
- ✅ Professionelles Design
- ✅ Bessere Validierung
---
**Viel Erfolg! 🎸🎵**
+180
View File
@@ -0,0 +1,180 @@
# Neue Features: PayPal-Integration & Bild-Upload
Dieses Dokument beschreibt die neu hinzugefügten Features für die GetYourBand-Plattform.
## 🖼️ Bild-Upload für Bands
### Features
- **Upload-Funktionalität**: Bands können eigene Bilder hochladen
- **Galerie-Verwaltung**: Anzeige und Verwaltung aller hochgeladenen Bilder
- **Löschen**: Bilder können jederzeit gelöscht werden
- **Validierung**:
- Erlaubte Formate: JPG, PNG, GIF, WEBP
- Maximale Dateigröße: 5MB
- Automatische Dateinamens-Generierung
### Technische Details
- **Upload-Verzeichnis**: `/storage/uploads/bands/`
- **Handler**: `upload-handler.php`
- **Frontend**: AJAX-basierter Upload mit Fetch API
- **Dateinamensschema**: `band_{band_id}_{unique_id}.{extension}`
### Verwendung
1. Als Band-User einloggen
2. Zum Profil navigieren (`profil.php`)
3. Sektion "Band-Galerie" finden
4. Auf "+ Bild hochladen" klicken
5. Bild auswählen (wird automatisch hochgeladen)
### Sicherheit
- Nur authentifizierte Band-User können uploaden
- Strenge Dateitypprüfung (MIME-Type + Extension)
- Größenlimit verhindert DoS
- Sichere Dateinamen ohne User-Input
---
## 💳 PayPal-Integration
### Features
- **Zahlungsabwicklung**: Kunden können Buchungen direkt mit PayPal bezahlen
- **Service Fee**: Konfigurierbare Servicegebühr (in Admin-Settings)
- **Zahlungs-Tracking**: Alle Zahlungen werden in der Datenbank gespeichert
- **Status-Updates**: Anfragen werden automatisch auf "bestätigt" gesetzt
- **Email-Benachrichtigungen**: Kunde und Band erhalten Bestätigungen
### Komponenten
#### 1. Datenbank
Neue Tabelle `payments`:
```sql
CREATE TABLE payments (
id INTEGER PRIMARY KEY,
request_id INTEGER NOT NULL,
amount REAL NOT NULL,
service_fee REAL NOT NULL,
total_amount REAL NOT NULL,
paypal_order_id TEXT,
paypal_payer_id TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT,
completed_at TEXT
);
```
#### 2. Checkout-Seite
**Datei**: `paypal-checkout.php`
- Zeigt Buchungsdetails und Zahlungsübersicht
- Integriert PayPal JavaScript SDK
- Berechnet Gesamtbetrag (Band-Gage + Service Fee)
#### 3. Payment Processing
**Datei**: `paypal-process.php`
- Speichert erfolgreiche Zahlungen
- Aktualisiert Request-Status
- Sendet Bestätigungs-Emails
#### 4. Integration in Buchungsflow
**Änderungen in `anfrage.php`**:
- Nach erfolgreicher Anfrage wird PayPal-Button angezeigt (wenn aktiviert)
- Direkter Link zum Checkout
**Änderungen in `profil.php`**:
- Zahlungsstatus für jede Anfrage angezeigt
- "Jetzt bezahlen"-Button für ausstehende Zahlungen
### PayPal-Konfiguration
#### Admin-Einstellungen
Im Admin-Panel (`admin/settings.php`):
- `paypal_enabled`: 0/1 (aktiviert/deaktiviert)
- `service_fee`: Prozentsatz (z.B. 8 für 8%)
#### PayPal API Credentials
In `paypal-checkout.php` Zeile 80:
```javascript
<script src="https://www.paypal.com/sdk/js?client-id=YOUR_PAYPAL_CLIENT_ID&currency=CHF"></script>
```
**Wichtig**: `YOUR_PAYPAL_CLIENT_ID` durch echte Client-ID ersetzen!
#### PayPal Developer Setup
1. Gehen Sie zu https://developer.paypal.com
2. Erstellen Sie eine App in "My Apps & Credentials"
3. Kopieren Sie die Client-ID
4. Für Produktion: Aktivieren Sie Live-Modus und verwenden Sie Live-Credentials
### Zahlungsablauf
1. **Kunde erstellt Anfrage** → Request wird in DB gespeichert
2. **PayPal-Link erscheint** → Kunde klickt auf "Mit PayPal bezahlen"
3. **Checkout-Seite** → Übersicht und PayPal-Button
4. **PayPal-Zahlung** → Kunde loggt sich in PayPal ein und zahlt
5. **Payment Processing** → Zahlung wird in DB gespeichert
6. **Status-Update** → Request → "bestätigt", Emails versandt
7. **Rückkehr zum Profil** → Erfolgsmeldung
### Testmodus
Die aktuelle Implementation läuft im **Sandbox-Modus**:
- Verwenden Sie PayPal Sandbox-Accounts zum Testen
- Keine echten Transaktionen werden durchgeführt
- Für Produktion: Client-ID auf Live-Credentials umstellen
### Sicherheit
- Zahlung nur für eigene Requests möglich
- Doppelzahlungen werden verhindert
- Transaktions-IDs werden gespeichert
- Server-seitige Validierung aller Zahlungsdaten
---
## 📂 Neue Dateien
| Datei | Beschreibung |
|-------|--------------|
| `upload-handler.php` | REST-API für Bild-Uploads (POST/DELETE) |
| `paypal-checkout.php` | PayPal Checkout-Seite |
| `paypal-process.php` | PayPal Payment Processing Backend |
| `storage/uploads/bands/` | Upload-Verzeichnis für Band-Bilder |
| `PAYPAL_UPLOAD_FEATURES.md` | Diese Dokumentation |
## 🔄 Geänderte Dateien
| Datei | Änderungen |
|-------|------------|
| `database.sql` | + `payments` Tabelle |
| `profil.php` | + Galerie-Sektion, + Zahlungsstatus in Anfragen |
| `anfrage.php` | + PayPal-Button nach erfolgreicher Anfrage |
## 🚀 Deployment-Checklist
- [ ] `storage/uploads/` Verzeichnis erstellen mit Schreibrechten
- [ ] PayPal Developer Account erstellen
- [ ] Client-ID in `paypal-checkout.php` eintragen
- [ ] Admin-Panel: PayPal aktivieren und Service Fee setzen
- [ ] Für Produktion: Auf Live-Credentials umstellen
- [ ] SSL-Zertifikat für HTTPS (PayPal requirement)
## 🐛 Bekannte Einschränkungen
1. **PayPal Client-ID**: Muss manuell konfiguriert werden
2. **Keine Rückerstattungen**: Keine Admin-UI für Refunds
3. **Email-System**: Aktuell nur Logging, kein echtes SMTP
4. **Sandbox-Modus**: Standardmäßig aktiviert
## 📝 Nächste Schritte (Optional)
- Webhook-Integration für PayPal IPN (Instant Payment Notification)
- Admin-Dashboard für Zahlungsübersicht
- Automatische Rechnungserstellung (PDF)
- Stripe als alternative Zahlungsmethode
- Bulk-Upload für mehrere Bilder
- Bildkompression/Optimierung
- Thumbnail-Generierung
---
**Entwickelt für**: GetYourBand Platform
**Datum**: 2025-12-02
**Version**: 1.0
+356
View File
@@ -0,0 +1,356 @@
# 🎸 GetYourBand - Bandvermittlungsplattform
Eine moderne, professionelle Plattform für die Vermittlung von Live-Bands in der Schweiz.
## 🚀 Features
-**Moderne MVC-Architektur** - Saubere Trennung von Logik, Daten und Präsentation
- 🎨 **Tailwind CSS** - Modernes, responsives Design mit gelben Farbtönen
-**Alpine.js** - Leichtgewichtige JavaScript-Interaktivität
- 🔐 **Authentifizierung** - Login, Registrierung, E-Mail-Verifizierung
- 👥 **Mehrere Rollen** - Admin, Band, Kunde
- 🔍 **Erweiterte Suche** - Nach Genre, Ort, Preis filtern
-**Bewertungssystem** - Nur nach Buchung möglich
- 📅 **Verfügbarkeitskalender** - Bands können Verfügbarkeit verwalten
- 💳 **PayPal-Integration** - Optional aktivierbare Zahlungen
- 📧 **E-Mail-Benachrichtigungen** - Automatische Updates
- 🛡️ **DSGVO-konform** - Cookie-Banner, Datenschutz
- 📱 **Mobile-First** - Optimiert für alle Geräte
## 📋 Voraussetzungen
- PHP 8.3 oder höher
- MySQL 5.7+ oder MariaDB 10.3+
- Apache mit mod_rewrite
- Composer
- Node.js & npm (für Frontend-Build)
## 🔧 Installation
### 1. Repository klonen
```bash
git clone <repository-url>
cd ai_playgroud
```
### 2. PHP-Abhängigkeiten installieren
```bash
composer install
```
### 3. Frontend-Abhängigkeiten installieren
```bash
npm install
```
### 4. Umgebungskonfiguration
```bash
cp .env.example .env
```
Passe die `.env`-Datei an:
```env
# Datenbank
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=getyourband
DB_USERNAME=root
DB_PASSWORD=dein_passwort
# Mail (SMTP)
MAIL_HOST=smtp.gmail.com
MAIL_PORT=587
MAIL_USERNAME=deine@email.ch
MAIL_PASSWORD=dein_passwort
# Optional: PayPal
PAYPAL_CLIENT_ID=deine_client_id
PAYPAL_SECRET=dein_secret
PAYMENT_ENABLED=true
```
### 5. Datenbank erstellen
```bash
mysql -u root -p -e "CREATE DATABASE getyourband CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"
```
### 6. Migrationen ausführen
```bash
php migrate.php
```
### 7. Frontend-Assets kompilieren
**Entwicklung:**
```bash
npm run dev
```
**Produktion:**
```bash
npm run build
```
### 8. Berechtigungen setzen
```bash
chmod -R 755 storage
chmod -R 755 public/uploads
```
## 🌐 Entwicklungsserver
### Option 1: PHP Built-in Server
```bash
cd public
php -S localhost:8000
```
Öffne: http://localhost:8000
### Option 2: Apache/XAMPP
1. Erstelle einen Virtual Host oder nutze htdocs
2. Stelle sicher, dass `mod_rewrite` aktiviert ist
3. DocumentRoot sollte auf das Hauptverzeichnis zeigen (nicht /public!)
## 📁 Projektstruktur
```
.
├── app/
│ ├── Controllers/ # Controller-Klassen
│ ├── Models/ # Datenmodelle
│ ├── Views/ # View-Templates
│ ├── Middleware/ # Middleware (Auth, etc.)
│ ├── Core/ # Kern-Framework (Router, Controller, Model)
│ └── helpers.php # Helper-Funktionen
├── config/ # Konfigurationsdateien
├── database/
│ ├── migrations/ # SQL-Migrationen
│ └── Database.php # Datenbankverbindung
├── public/ # Öffentliches Verzeichnis (DocumentRoot)
│ ├── index.php # Entry Point
│ ├── .htaccess # Apache-Konfiguration
│ ├── css/ # Kompilierte CSS
│ ├── js/ # Kompilierte JS
│ └── uploads/ # User-Uploads
├── resources/
│ ├── css/ # Quell-CSS (Tailwind)
│ └── js/ # Quell-JavaScript
├── routes/
│ └── web.php # Route-Definitionen
├── storage/ # Temporäre Dateien, Logs, Cache
├── .env # Umgebungsvariablen (nicht committen!)
├── composer.json # PHP-Abhängigkeiten
├── package.json # Frontend-Abhängigkeiten
├── tailwind.config.js # Tailwind-Konfiguration
└── vite.config.js # Vite-Build-Konfiguration
```
## 🎨 Design & Farben
Das Projekt nutzt ein modernes gelbes Farbschema:
- **Primary**: Gelb-Orange-Töne (#fbbf24 - #f59e0b)
- **Accent**: Helles Gelb (#eab308 - #facc15)
- **Schrift**: Inter (Body), Poppins (Headlines)
## 🔐 Standard-Admin erstellen
Nach der Migration kannst du einen Admin-Account manuell in der Datenbank erstellen:
```sql
INSERT INTO users (email, password, name, role, email_verified_at, is_active)
VALUES (
'admin@getyourband.ch',
'$2y$10$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2.uheWG/igi', -- "password"
'Admin',
'admin',
NOW(),
1
);
```
**Login:** admin@getyourband.ch
**Passwort:** password
⚠️ **Wichtig:** Ändere das Passwort nach dem ersten Login!
## 📝 Routen-Übersicht
### Öffentlich
- `GET /` - Homepage
- `GET /bands` - Band-Liste
- `GET /bands/{slug}` - Band-Detail
- `GET /login` - Login-Formular
- `POST /login` - Login-Verarbeitung
- `GET /register` - Registrierungs-Formular
- `POST /register` - Registrierung
### Geschützt (Authentifiziert)
- `GET /profile` - User-Profil
- `POST /profile/update` - Profil aktualisieren
- `POST /bookings/create` - Buchung erstellen
- `GET /my-bookings` - Meine Buchungen
### Band-Bereich
- `GET /band/manage` - Band-Verwaltung
- `POST /band/update` - Band aktualisieren
- `GET /band/bookings` - Eingehende Buchungsanfragen
### Admin-Bereich
- `GET /admin` - Admin-Dashboard
- `GET /admin/bands` - Band-Verwaltung
- `POST /admin/bands/{id}/approve` - Band freischalten
- `GET /admin/reviews` - Bewertungen moderieren
## 🧪 Entwicklung
### Tailwind-Klassen neu kompilieren
```bash
npm run watch
```
Dies startet einen Watch-Modus, der bei Änderungen automatisch neu kompiliert.
### Neue Migration erstellen
Erstelle eine neue SQL-Datei in `database/migrations/`:
```bash
touch database/migrations/007_create_new_table.sql
```
Führe sie aus:
```bash
php migrate.php
```
### Neuen Controller erstellen
```php
<?php
namespace App\Controllers;
use App\Core\Controller;
class MyController extends Controller
{
public function index(): void
{
$this->view('my-view', [
'data' => 'value'
]);
}
}
```
### Neues Model erstellen
```php
<?php
namespace App\Models;
use App\Core\Model;
class MyModel extends Model
{
protected string $table = 'my_table';
protected array $fillable = [
'column1',
'column2',
];
}
```
## 🐛 Debugging
Debug-Modus aktivieren in `.env`:
```env
APP_DEBUG=true
```
Im Debug-Modus werden ausführliche Fehler angezeigt.
### Nützliche Helper-Funktionen
```php
dd($variable); // Dump & Die
config('app.name'); // Konfiguration abrufen
env('DB_HOST'); // Umgebungsvariable
old('field_name'); // Vorheriger Formular-Wert
error('field_name'); // Validierungsfehler
```
## 📦 Deployment
### Produktion vorbereiten
1. **Assets kompilieren:**
```bash
npm run build
```
2. **Composer optimieren:**
```bash
composer install --optimize-autoloader --no-dev
```
3. **Environment:**
```env
APP_ENV=production
APP_DEBUG=false
```
4. **Berechtigungen:**
```bash
chmod -R 755 storage
chmod -R 755 public/uploads
```
5. **Apache-Konfiguration:**
- DocumentRoot auf Hauptverzeichnis setzen (nicht /public!)
- `mod_rewrite` aktivieren
- `.htaccess` ermöglichen
## 🤝 Contributing
1. Fork das Projekt
2. Feature-Branch erstellen (`git checkout -b feature/AmazingFeature`)
3. Änderungen committen (`git commit -m 'Add some AmazingFeature'`)
4. Branch pushen (`git push origin feature/AmazingFeature`)
5. Pull Request öffnen
## 📄 Lizenz
Proprietary - Alle Rechte vorbehalten
## 👤 Kontakt
GetYourBand - info@getyourband.ch
## 🙏 Credits
- **Tailwind CSS** - https://tailwindcss.com
- **Alpine.js** - https://alpinejs.dev
- **Vite** - https://vitejs.dev
- **PHP** - https://php.net
---
Made with ❤️ and 🎸 in Switzerland
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/auth.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
changeBandStatus((int) $_POST['band_id'], $_POST['status']);
}
$bands = moderationItems('bands');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bands moderieren <?= SITE_NAME ?></title>
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header><div class="admin-nav"><a href="dashboard.php">Dashboard</a><a href="bands.php">Bands</a><a href="bewertungen.php">Bewertungen</a><a href="settings.php">Settings</a></div></header>
<main>
<h1>Bandfreigaben</h1>
<table class="table">
<thead><tr><th>Name</th><th>Ort</th><th>Status</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($bands as $band): ?>
<tr>
<td><?= htmlspecialchars($band['name']) ?></td>
<td><?= htmlspecialchars($band['city']) ?></td>
<td><?= htmlspecialchars($band['status']) ?></td>
<td>
<form method="post" style="display:inline-flex; gap:4px;">
<input type="hidden" name="band_id" value="<?= $band['id'] ?>">
<select name="status" class="form-control" style="width:auto;">
<option value="aktiv">Freigeben</option>
<option value="archiv">Archivieren</option>
<option value="prüfung">Zur Prüfung</option>
</select>
<button class="btn-primary">Speichern</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (!$bands): ?><p>Keine Bands warten auf Moderation.</p><?php endif; ?>
</main>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/auth.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
changeReviewStatus((int) $_POST['review_id'], $_POST['status']);
}
$reviews = moderationItems('reviews');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bewertungen prüfen <?= SITE_NAME ?></title>
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header><div class="admin-nav"><a href="dashboard.php">Dashboard</a><a href="bands.php">Bands</a><a href="bewertungen.php">Bewertungen</a><a href="settings.php">Settings</a></div></header>
<main>
<h1>Bewertungen moderieren</h1>
<table class="table">
<thead><tr><th>Band</th><th>Autor</th><th>Bewertung</th><th>Kommentar</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($reviews as $review): ?>
<tr>
<td><?= htmlspecialchars($review['band_name']) ?></td>
<td><?= htmlspecialchars($review['author']) ?></td>
<td><?= (int) $review['rating'] ?> ★</td>
<td><?= htmlspecialchars($review['comment']) ?></td>
<td>
<form method="post" style="display:inline-flex; gap:4px;">
<input type="hidden" name="review_id" value="<?= $review['id'] ?>">
<select name="status" class="form-control" style="width:auto;">
<option value="freigegeben">Freigeben</option>
<option value="abgelehnt">Ablehnen</option>
</select>
<button class="btn-primary">Speichern</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (!$reviews): ?><p>Keine Bewertungen in Moderation.</p><?php endif; ?>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/auth.php';
requireAdmin();
$pdo = db();
$stats = [
'bands' => (int) $pdo->query("SELECT COUNT(*) FROM bands")->fetchColumn(),
'requests' => (int) $pdo->query("SELECT COUNT(*) FROM requests")->fetchColumn(),
'reviews' => (int) $pdo->query("SELECT COUNT(*) FROM reviews")->fetchColumn(),
];
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Admin Dashboard <?= SITE_NAME ?></title>
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header>
<div class="admin-nav">
<a href="dashboard.php">Dashboard</a>
<a href="bands.php">Bandfreigaben</a>
<a href="bewertungen.php">Bewertungen</a>
<a href="settings.php">Settings</a>
</div>
</header>
<main>
<section class="band-grid">
<article class="band-card"><h3>Bands</h3><p><?= $stats['bands'] ?></p></article>
<article class="band-card"><h3>Anfragen</h3><p><?= $stats['requests'] ?></p></article>
<article class="band-card"><h3>Bewertungen</h3><p><?= $stats['reviews'] ?></p></article>
</section>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/auth.php';
requireAdmin();
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
updateSetting('paypal_enabled', isset($_POST['paypal_enabled']) ? '1' : '0');
updateSetting('service_fee', (string) (int) $_POST['service_fee']);
$message = 'Einstellungen gespeichert.';
}
$config = settings();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Settings <?= SITE_NAME ?></title>
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header><div class="admin-nav"><a href="dashboard.php">Dashboard</a><a href="bands.php">Bands</a><a href="bewertungen.php">Bewertungen</a><a href="settings.php">Settings</a></div></header>
<main>
<h1>Vermittlungsgebühr & PayPal</h1>
<?php if ($message): ?><div class="alert alert-success"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<form method="post">
<label>
<input type="checkbox" name="paypal_enabled" <?= $config['paypal_enabled'] === '1' ? 'checked' : '' ?>> PayPal aktivieren
</label>
<label>Service Fee (%)
<input type="number" class="form-control" name="service_fee" value="<?= htmlspecialchars($config['service_fee']) ?>">
</label>
<button class="btn-primary">Speichern</button>
</form>
</main>
</body>
</html>
Binary file not shown.
+342
View File
@@ -0,0 +1,342 @@
<?php
$altcoins = [
[
'name' => 'Ethereum (ETH)',
'symbol' => 'ETH',
'price' => 3725.42,
'ma200' => 3450.15,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'BNB',
'symbol' => 'BNB',
'price' => 598.12,
'ma200' => 612.77,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Solana (SOL)',
'symbol' => 'SOL',
'price' => 158.34,
'ma200' => 143.05,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'XRP',
'symbol' => 'XRP',
'price' => 0.57,
'ma200' => 0.63,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Dogecoin (DOGE)',
'symbol' => 'DOGE',
'price' => 0.19,
'ma200' => 0.15,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Cardano (ADA)',
'symbol' => 'ADA',
'price' => 0.48,
'ma200' => 0.62,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Avalanche (AVAX)',
'symbol' => 'AVAX',
'price' => 47.22,
'ma200' => 44.61,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Polkadot (DOT)',
'symbol' => 'DOT',
'price' => 8.81,
'ma200' => 7.29,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Chainlink (LINK)',
'symbol' => 'LINK',
'price' => 17.02,
'ma200' => 18.40,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Polygon (MATIC)',
'symbol' => 'MATIC',
'price' => 0.92,
'ma200' => 0.98,
'last_update' => '2024-04-21 14:00 UTC',
],
];
function determineSignal(float $price, float $ma200): array
{
if ($price >= $ma200) {
return ['LONG', 'Preis notiert über dem 200-Tage-Durchschnitt.'];
}
return ['SHORT', 'Preis notiert unter dem 200-Tage-Durchschnitt.'];
}
function formatNumber(float $value, int $decimals = 2): string
{
return number_format($value, $decimals, ',', '.');
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Top 10 Altcoins MA200 Signale</title>
<style>
:root {
color-scheme: light dark;
--bg: #0f172a;
--fg: #f8fafc;
--card: #1e293b;
--accent: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--muted: #94a3b8;
}
* {
box-sizing: border-box;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, rgba(16,185,129,0.25), transparent 55%),
var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
main {
width: min(1100px, 100%);
background: rgba(15,23,42,0.85);
border: 1px solid rgba(148,163,184,0.2);
border-radius: 24px;
box-shadow: 0 15px 60px rgba(0,0,0,0.45);
padding: 2rem 2.5rem 2.5rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
header h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.2;
}
header p {
margin: 0;
color: var(--muted);
}
table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
thead th {
text-align: left;
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid rgba(148,163,184,0.2);
padding-bottom: 0.75rem;
}
tbody td {
padding: 1rem 0;
border-bottom: 1px solid rgba(148,163,184,0.1);
}
tbody tr:last-child td {
border-bottom: none;
}
.symbol {
color: var(--muted);
font-size: 0.9rem;
}
.price {
font-variant-numeric: tabular-nums;
}
.signal {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.signal-long {
background: rgba(16,185,129,0.15);
border: 1px solid rgba(16,185,129,0.5);
color: var(--accent);
}
.signal-short {
background: rgba(239,68,68,0.15);
border: 1px solid rgba(239,68,68,0.5);
color: var(--danger);
}
.telegram-card {
margin-top: 2rem;
background: rgba(30,41,59,0.9);
border: 1px solid rgba(59,130,246,0.4);
border-radius: 18px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.telegram-card h2 {
margin: 0;
font-size: 1.2rem;
}
.telegram-card p {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.telegram-card pre {
margin: 0;
padding: 1rem;
background: rgba(15,23,42,0.8);
border-radius: 12px;
border: 1px solid rgba(148,163,184,0.15);
font-size: 0.9rem;
white-space: pre-wrap;
}
@media (max-width: 720px) {
main {
padding: 1.5rem;
}
table, thead, tbody, tr, td, th {
display: block;
}
thead {
display: none;
}
tbody tr {
border: 1px solid rgba(148,163,184,0.2);
border-radius: 18px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
tbody td {
border: none;
padding: 0.35rem 0;
}
tbody td::before {
content: attr(data-label);
display: block;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.2rem;
}
}
</style>
</head>
<body>
<main>
<header>
<h1>Top 10 Altcoins &amp; MA200 Signale</h1>
<p>Überblick über mögliche Einstiegszonen basierend auf dem 200-Tage-Durchschnitt. Die Signale (Long oder Short)
können direkt an den Telegram-Channel weitergeleitet werden.</p>
</header>
<table>
<thead>
<tr>
<th>Asset</th>
<th>Preis (USD)</th>
<th>MA200 (USD)</th>
<th>Abweichung</th>
<th>Signal</th>
<th>Zuletzt aktualisiert</th>
</tr>
</thead>
<tbody>
<?php foreach ($altcoins as $coin):
[$signal, $reason] = determineSignal($coin['price'], $coin['ma200']);
$diff = $coin['price'] - $coin['ma200'];
$diffPercent = ($coin['ma200'] > 0)
? ($diff / $coin['ma200']) * 100
: 0;
?>
<tr>
<td data-label="Asset">
<strong><?php echo htmlspecialchars($coin['name']); ?></strong>
<div class="symbol"><?php echo htmlspecialchars($coin['symbol']); ?></div>
</td>
<td data-label="Preis" class="price">$ <?php echo formatNumber($coin['price']); ?></td>
<td data-label="MA200" class="price">$ <?php echo formatNumber($coin['ma200']); ?></td>
<td data-label="Abweichung">
<?php echo ($diff >= 0 ? '+' : '-') . formatNumber(abs($diff)); ?>
<span class="symbol">(<?php echo ($diffPercent >= 0 ? '+' : '-') . formatNumber(abs($diffPercent)); ?> %)</span>
</td>
<td data-label="Signal">
<span class="signal signal-<?php echo strtolower($signal); ?>">
<?php echo $signal; ?>
</span>
<div class="symbol"><?php echo $reason; ?></div>
</td>
<td data-label="Update" class="symbol"><?php echo htmlspecialchars($coin['last_update']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
$signals = array_map(function ($coin) {
[$signal, $reason] = determineSignal($coin['price'], $coin['ma200']);
return sprintf('%s (%s): %s %s', $coin['name'], $coin['symbol'], $signal, $reason);
}, $altcoins);
?>
<section class="telegram-card">
<h2>Telegram Broadcast</h2>
<p>Kopiere die Zusammenfassung, um sie in deinem Signal-Channel zu posten, sobald eine MA200-Überschreitung
stattfindet.</p>
<pre><?php echo htmlspecialchars(implode("\n", $signals)); ?></pre>
</section>
</main>
</body>
</html>
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/email.php';
$bandId = isset($_GET['band_id']) ? (int) $_GET['band_id'] : 0;
$band = $bandId ? findBand($bandId) : null;
if (!$band) {
http_response_code(404);
echo 'Band nicht gefunden';
exit;
}
$user = currentUser();
$message = '';
$error = '';
$requestId = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = [
'band_id' => $bandId,
'user_id' => $user['id'] ?? null,
'event_date' => $_POST['event_date'] ?? '',
'location' => trim((string) $_POST['location'] ?? ''),
'budget' => (int) ($_POST['budget'] ?? 0),
'event_type' => trim((string) $_POST['event_type'] ?? ''),
'message' => trim((string) $_POST['message'] ?? ''),
];
$guestName = trim($_POST['guest_name'] ?? '');
$guestEmail = trim($_POST['guest_email'] ?? '');
if (!$data['event_date'] || !$data['location']) {
$error = 'Bitte Datum und Ort ausfüllen.';
} elseif (!$user && (!$guestName || !$guestEmail)) {
$error = 'Bitte geben Sie Ihren Namen und Email-Adresse an.';
} elseif (!$user && !filter_var($guestEmail, FILTER_VALIDATE_EMAIL)) {
$error = 'Bitte geben Sie eine gültige Email-Adresse an.';
} else {
createRequest($data);
$requestId = (int) db()->lastInsertId();
$message = 'Anfrage gespeichert und an die Band gemeldet.';
sendEmail('info@' . preg_replace('/\s+/', '', strtolower($band['name'])) . '.ch', 'Neue Anfrage', 'Neue Anfrage für ' . $band['name']);
}
}
$settings = settings();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anfrage <?= htmlspecialchars($band['name']) ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="band-detail.php?id=<?= $bandId ?>">← Zurück</a>
<h1>Anfrage an <?= htmlspecialchars($band['name']) ?></h1>
<p>PayPal Zahlungsabwicklung ist <?= $settings['paypal_enabled'] === '1' ? 'aktiviert' : 'optional' ?>, Service Fee: <?= htmlspecialchars($settings['service_fee']) ?>%.</p>
</header>
<main>
<?php if ($message): ?>
<div class="alert alert-success">
<?= htmlspecialchars($message) ?>
<?php if ($requestId && $settings['paypal_enabled'] === '1'): ?>
<div style="margin-top: 1rem;">
<a href="paypal-checkout.php?request_id=<?= $requestId ?>" class="btn-primary" style="display: inline-block; padding: 0.75rem 1.5rem; text-decoration: none;">
Jetzt mit PayPal bezahlen
</a>
</div>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php if (!$message): ?>
<form method="post">
<?php if (!$user): ?>
<div style="background: #fff3cd; padding: 15px; margin-bottom: 20px; border-radius: 4px;">
<strong>Gast-Buchung</strong>
<p style="margin: 5px 0 0 0; font-size: 14px;">Sie sind nicht eingeloggt. Bitte geben Sie Ihre Kontaktdaten an.</p>
</div>
<label>Ihr Name *
<input type="text" class="form-control" name="guest_name" required>
</label>
<label>Ihre Email *
<input type="email" class="form-control" name="guest_email" required>
</label>
<hr style="margin: 20px 0;">
<?php endif; ?>
<label>Event-Datum *
<input type="date" class="form-control" name="event_date" min="<?= date('Y-m-d') ?>" required>
</label>
<label>Ort / Location *
<input type="text" class="form-control" name="location" placeholder="Zürich, Kaufleuten" required>
</label>
<label>Event-Typ
<input type="text" class="form-control" name="event_type" placeholder="Hochzeit, Firmenfeier, Geburtstag">
</label>
<label>Budget (CHF)
<input type="number" class="form-control" name="budget" placeholder="4500" min="0">
</label>
<label>Nachricht / Besondere Wünsche
<textarea class="form-control" name="message" rows="4" placeholder="Erzählen Sie uns mehr über Ihr Event..."></textarea>
</label>
<button class="btn-primary">Anfrage senden</button>
</form>
<?php endif; ?>
</main>
</body>
</html>
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Controllers;
use App\Core\Controller;
use App\Models\Band;
class HomeController extends Controller
{
public function index(): void
{
$bandModel = new Band();
// Get top-rated bands
$featuredBands = $bandModel->query(
"SELECT * FROM bands
WHERE is_approved = 1 AND is_active = 1
ORDER BY average_rating DESC, total_reviews DESC
LIMIT 6"
);
$this->view('home', [
'featuredBands' => $featuredBands,
]);
}
}
+99
View File
@@ -0,0 +1,99 @@
<?php
namespace App\Core;
class Controller
{
protected function view(string $view, array $data = []): void
{
extract($data);
$viewPath = __DIR__ . '/../Views/' . str_replace('.', '/', $view) . '.php';
if (!file_exists($viewPath)) {
throw new \RuntimeException("View not found: {$view}");
}
require_once $viewPath;
}
protected function json($data, int $status = 200): void
{
http_response_code($status);
header('Content-Type: application/json');
echo json_encode($data);
exit;
}
protected function redirect(string $path): void
{
header("Location: {$path}");
exit;
}
protected function back(): void
{
$referer = $_SERVER['HTTP_REFERER'] ?? '/';
$this->redirect($referer);
}
protected function input(string $key, $default = null)
{
return $_POST[$key] ?? $_GET[$key] ?? $default;
}
protected function validate(array $rules): array
{
$errors = [];
$data = [];
foreach ($rules as $field => $fieldRules) {
$value = $this->input($field);
$fieldRules = explode('|', $fieldRules);
foreach ($fieldRules as $rule) {
if ($rule === 'required' && empty($value)) {
$errors[$field][] = ucfirst($field) . ' is required';
}
if (str_starts_with($rule, 'min:')) {
$min = (int) substr($rule, 4);
if (strlen($value) < $min) {
$errors[$field][] = ucfirst($field) . " must be at least {$min} characters";
}
}
if (str_starts_with($rule, 'max:')) {
$max = (int) substr($rule, 4);
if (strlen($value) > $max) {
$errors[$field][] = ucfirst($field) . " must not exceed {$max} characters";
}
}
if ($rule === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
$errors[$field][] = ucfirst($field) . ' must be a valid email';
}
}
$data[$field] = $value;
}
if (!empty($errors)) {
$_SESSION['errors'] = $errors;
$_SESSION['old'] = $data;
$this->back();
}
return $data;
}
protected function auth()
{
return $_SESSION['user'] ?? null;
}
protected function isAuthenticated(): bool
{
return isset($_SESSION['user']);
}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
namespace App\Core;
use Database\Database;
use PDO;
abstract class Model
{
protected PDO $db;
protected string $table;
protected string $primaryKey = 'id';
protected array $fillable = [];
public function __construct()
{
$this->db = Database::connect();
}
public function all(): array
{
$stmt = $this->db->query("SELECT * FROM {$this->table}");
return $stmt->fetchAll();
}
public function find(int $id): ?array
{
$stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE {$this->primaryKey} = ? LIMIT 1");
$stmt->execute([$id]);
$result = $stmt->fetch();
return $result ?: null;
}
public function where(string $column, $value): array
{
$stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE {$column} = ?");
$stmt->execute([$value]);
return $stmt->fetchAll();
}
public function first(string $column, $value): ?array
{
$stmt = $this->db->prepare("SELECT * FROM {$this->table} WHERE {$column} = ? LIMIT 1");
$stmt->execute([$value]);
$result = $stmt->fetch();
return $result ?: null;
}
public function create(array $data): int
{
$data = $this->filterFillable($data);
$columns = implode(', ', array_keys($data));
$placeholders = implode(', ', array_fill(0, count($data), '?'));
$sql = "INSERT INTO {$this->table} ({$columns}) VALUES ({$placeholders})";
$stmt = $this->db->prepare($sql);
$stmt->execute(array_values($data));
return (int) $this->db->lastInsertId();
}
public function update(int $id, array $data): bool
{
$data = $this->filterFillable($data);
$set = implode(' = ?, ', array_keys($data)) . ' = ?';
$sql = "UPDATE {$this->table} SET {$set} WHERE {$this->primaryKey} = ?";
$stmt = $this->db->prepare($sql);
return $stmt->execute([...array_values($data), $id]);
}
public function delete(int $id): bool
{
$stmt = $this->db->prepare("DELETE FROM {$this->table} WHERE {$this->primaryKey} = ?");
return $stmt->execute([$id]);
}
public function query(string $sql, array $params = []): array
{
$stmt = $this->db->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll();
}
public function execute(string $sql, array $params = []): bool
{
$stmt = $this->db->prepare($sql);
return $stmt->execute($params);
}
protected function filterFillable(array $data): array
{
if (empty($this->fillable)) {
return $data;
}
return array_intersect_key($data, array_flip($this->fillable));
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace App\Core;
class Router
{
private array $routes = [];
private array $middlewareStack = [];
public function get(string $path, $handler): void
{
$this->addRoute('GET', $path, $handler);
}
public function post(string $path, $handler): void
{
$this->addRoute('POST', $path, $handler);
}
public function put(string $path, $handler): void
{
$this->addRoute('PUT', $path, $handler);
}
public function delete(string $path, $handler): void
{
$this->addRoute('DELETE', $path, $handler);
}
public function group(array $attributes, callable $callback): void
{
$previousMiddleware = $this->middlewareStack;
if (isset($attributes['middleware'])) {
$this->middlewareStack = array_merge(
$this->middlewareStack,
(array) $attributes['middleware']
);
}
$callback($this);
$this->middlewareStack = $previousMiddleware;
}
private function addRoute(string $method, string $path, $handler): void
{
$this->routes[] = [
'method' => $method,
'path' => $path,
'handler' => $handler,
'middleware' => $this->middlewareStack,
];
}
public function dispatch(string $requestMethod, string $requestUri): void
{
$requestUri = parse_url($requestUri, PHP_URL_PATH);
foreach ($this->routes as $route) {
if ($route['method'] !== $requestMethod) {
continue;
}
$pattern = $this->convertToPattern($route['path']);
if (preg_match($pattern, $requestUri, $matches)) {
array_shift($matches); // Remove full match
// Execute middleware
foreach ($route['middleware'] as $middleware) {
$this->executeMiddleware($middleware);
}
// Execute handler
$this->executeHandler($route['handler'], $matches);
return;
}
}
// 404 Not Found
http_response_code(404);
echo "404 - Page Not Found";
}
private function convertToPattern(string $path): string
{
$pattern = preg_replace('/\{([a-zA-Z0-9_]+)\}/', '([^/]+)', $path);
return '#^' . $pattern . '$#';
}
private function executeMiddleware(string $middleware): void
{
$parts = explode(':', $middleware);
$name = $parts[0];
$params = $parts[1] ?? null;
$middlewareClass = "App\\Middleware\\" . ucfirst($name) . "Middleware";
if (!class_exists($middlewareClass)) {
throw new \RuntimeException("Middleware not found: {$middlewareClass}");
}
$instance = new $middlewareClass();
$instance->handle($params);
}
private function executeHandler($handler, array $params): void
{
if (is_array($handler)) {
[$class, $method] = $handler;
$controller = new $class();
call_user_func_array([$controller, $method], $params);
} elseif (is_callable($handler)) {
call_user_func_array($handler, $params);
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace App\Middleware;
class AuthMiddleware
{
public function handle($params = null): void
{
if (!isset($_SESSION['user'])) {
header('Location: /login');
exit;
}
}
}
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace App\Middleware;
class RoleMiddleware
{
public function handle($role = null): void
{
if (!isset($_SESSION['user'])) {
header('Location: /login');
exit;
}
if ($role && $_SESSION['user']['role'] !== $role) {
http_response_code(403);
die('403 - Forbidden');
}
}
}
+96
View File
@@ -0,0 +1,96 @@
<?php
namespace App\Models;
use App\Core\Model;
class Band extends Model
{
protected string $table = 'bands';
protected array $fillable = [
'user_id',
'name',
'slug',
'description',
'genre',
'location',
'postal_code',
'price_min',
'price_max',
'member_count',
'phone',
'website',
'facebook',
'instagram',
'youtube',
'profile_image',
'cover_image',
'is_approved',
'is_active',
];
public function findBySlug(string $slug): ?array
{
return $this->first('slug', $slug);
}
public function search(array $filters): array
{
$sql = "SELECT * FROM {$this->table} WHERE is_approved = 1 AND is_active = 1";
$params = [];
if (!empty($filters['genre'])) {
$sql .= " AND genre = ?";
$params[] = $filters['genre'];
}
if (!empty($filters['location'])) {
$sql .= " AND (location LIKE ? OR postal_code LIKE ?)";
$params[] = "%{$filters['location']}%";
$params[] = "%{$filters['location']}%";
}
if (!empty($filters['price_max'])) {
$sql .= " AND price_min <= ?";
$params[] = $filters['price_max'];
}
if (!empty($filters['q'])) {
$sql .= " AND MATCH(name, description, genre) AGAINST (? IN NATURAL LANGUAGE MODE)";
$params[] = $filters['q'];
}
$sql .= " ORDER BY average_rating DESC, total_reviews DESC";
return $this->query($sql, $params);
}
public function incrementViews(int $id): bool
{
return $this->execute(
"UPDATE {$this->table} SET view_count = view_count + 1 WHERE id = ?",
[$id]
);
}
public function updateRating(int $bandId): void
{
$sql = "
UPDATE bands
SET average_rating = (
SELECT AVG(rating)
FROM reviews
WHERE band_id = ? AND is_approved = 1
),
total_reviews = (
SELECT COUNT(*)
FROM reviews
WHERE band_id = ? AND is_approved = 1
)
WHERE id = ?
";
$this->execute($sql, [$bandId, $bandId, $bandId]);
}
}
+49
View File
@@ -0,0 +1,49 @@
<?php
namespace App\Models;
use App\Core\Model;
class User extends Model
{
protected string $table = 'users';
protected array $fillable = [
'email',
'password',
'name',
'role',
'verification_token',
'email_verified_at',
'is_active',
];
public function findByEmail(string $email): ?array
{
return $this->first('email', $email);
}
public function verifyEmail(string $token): bool
{
$user = $this->first('verification_token', $token);
if (!$user) {
return false;
}
return $this->update($user['id'], [
'email_verified_at' => date('Y-m-d H:i:s'),
'verification_token' => null,
]);
}
public static function hashPassword(string $password): string
{
return password_hash($password, PASSWORD_BCRYPT);
}
public static function verifyPassword(string $password, string $hash): bool
{
return password_verify($password, $hash);
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php ob_start(); ?>
<!-- Hero Section -->
<section class="bg-gradient-to-br from-primary-500 via-accent-500 to-primary-600 text-white py-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<h1 class="text-5xl md:text-6xl font-display font-bold mb-6 text-balance">
Finde die perfekte Band für dein Event
</h1>
<p class="text-xl md:text-2xl mb-8 text-primary-50 max-w-3xl mx-auto text-balance">
Professionelle Live-Bands in der ganzen Schweiz. Einfach buchen, perfekt performen.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a href="/bands" class="btn bg-white text-primary-600 hover:bg-gray-100 text-lg px-8 py-3">
Bands entdecken
</a>
<a href="/register" class="btn bg-primary-700 text-white hover:bg-primary-800 text-lg px-8 py-3">
Als Band registrieren
</a>
</div>
</div>
</section>
<!-- Search Section -->
<section class="py-16 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-gray-50 rounded-2xl shadow-lg p-8" x-data="searchBands">
<h2 class="text-3xl font-display font-bold text-center mb-8">Suche deine Band</h2>
<form @submit.prevent="search" class="grid grid-cols-1 md:grid-cols-4 gap-4">
<input
type="text"
x-model="query"
placeholder="Band, Genre, Stil..."
class="input-field"
>
<input
type="text"
x-model="filters.location"
placeholder="Ort oder PLZ"
class="input-field"
>
<select x-model="filters.genre" class="input-field">
<option value="">Alle Genres</option>
<option value="Rock">Rock</option>
<option value="Pop">Pop</option>
<option value="Jazz">Jazz</option>
<option value="Blues">Blues</option>
<option value="Funk">Funk</option>
<option value="Cover">Cover</option>
</select>
<button type="submit" class="btn btn-primary">
Suchen
</button>
</form>
</div>
</div>
</section>
<!-- Featured Bands -->
<section class="py-16 bg-gray-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-4xl font-display font-bold text-center mb-12">Top bewertete Bands</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<?php foreach ($featuredBands ?? [] as $band): ?>
<div class="card group hover:scale-105 transition-transform">
<div class="aspect-video bg-gray-200 rounded-lg mb-4 overflow-hidden">
<?php if ($band['cover_image']): ?>
<img src="<?= $band['cover_image'] ?>" alt="<?= $band['name'] ?>" class="w-full h-full object-cover">
<?php endif; ?>
</div>
<div class="flex items-start justify-between mb-2">
<h3 class="text-xl font-bold text-gray-900"><?= htmlspecialchars($band['name']) ?></h3>
<span class="badge badge-yellow"><?= htmlspecialchars($band['genre']) ?></span>
</div>
<p class="text-gray-600 mb-4 line-clamp-2"><?= htmlspecialchars($band['description']) ?></p>
<div class="flex items-center justify-between">
<div class="flex items-center">
<span class="text-yellow-500 mr-1">⭐</span>
<span class="font-semibold"><?= number_format($band['average_rating'], 1) ?></span>
<span class="text-gray-500 text-sm ml-1">(<?= $band['total_reviews'] ?>)</span>
</div>
<a href="/bands/<?= $band['slug'] ?>" class="text-primary-600 hover:text-primary-700 font-medium">
Details →
</a>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
</section>
<!-- How it Works -->
<section class="py-16 bg-white">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<h2 class="text-4xl font-display font-bold text-center mb-12">So funktioniert's</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-12">
<div class="text-center">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-3xl">🔍</span>
</div>
<h3 class="text-xl font-bold mb-2">1. Suchen</h3>
<p class="text-gray-600">Finde die perfekte Band für dein Event mit unseren Suchfiltern.</p>
</div>
<div class="text-center">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-3xl">📧</span>
</div>
<h3 class="text-xl font-bold mb-2">2. Anfragen</h3>
<p class="text-gray-600">Sende eine unverbindliche Anfrage mit deinen Event-Details.</p>
</div>
<div class="text-center">
<div class="w-16 h-16 bg-primary-100 rounded-full flex items-center justify-center mx-auto mb-4">
<span class="text-3xl">🎉</span>
</div>
<h3 class="text-xl font-bold mb-2">3. Buchen</h3>
<p class="text-gray-600">Bestätige die Buchung und freue dich auf ein unvergessliches Event!</p>
</div>
</div>
</div>
</section>
<?php $content = ob_get_clean(); ?>
<?php $title = 'Home'; ?>
<?php include __DIR__ . '/layouts/app.php'; ?>
+104
View File
@@ -0,0 +1,104 @@
<!DOCTYPE html>
<html lang="de" class="h-full">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title><?= $title ?? 'GetYourBand' ?> - Bandvermittlung Schweiz</title>
<!-- Fonts -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@600;700;800&display=swap" rel="stylesheet">
<!-- Styles -->
<link rel="stylesheet" href="/dist/css/app.css">
<!-- Alpine.js -->
<script defer src="/dist/js/app.js"></script>
</head>
<body class="h-full">
<!-- Navigation -->
<nav class="bg-white shadow-sm sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<a href="/" class="text-2xl font-display font-bold text-primary-600">
🎸 GetYourBand
</a>
</div>
<div class="hidden md:flex items-center space-x-8">
<a href="/" class="text-gray-700 hover:text-primary-600 transition">Home</a>
<a href="/bands" class="text-gray-700 hover:text-primary-600 transition">Bands</a>
<?php if (isset($_SESSION['user'])): ?>
<a href="/profile" class="text-gray-700 hover:text-primary-600 transition">Profil</a>
<form action="/logout" method="POST" class="inline">
<?= csrf_field() ?>
<button type="submit" class="btn btn-secondary">Logout</button>
</form>
<?php else: ?>
<a href="/login" class="text-gray-700 hover:text-primary-600 transition">Login</a>
<a href="/register" class="btn btn-primary">Registrieren</a>
<?php endif; ?>
</div>
</div>
</div>
</nav>
<!-- Main Content -->
<main>
<?php if (isset($_SESSION['success'])): ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded">
<?= $_SESSION['success'] ?>
<?php unset($_SESSION['success']); ?>
</div>
</div>
<?php endif; ?>
<?php if (isset($_SESSION['error'])): ?>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mt-4">
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
<?= $_SESSION['error'] ?>
<?php unset($_SESSION['error']); ?>
</div>
</div>
<?php endif; ?>
<?= $content ?? '' ?>
</main>
<!-- Footer -->
<footer class="bg-gray-900 text-white mt-20">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h3 class="text-xl font-display font-bold text-primary-400 mb-4">GetYourBand</h3>
<p class="text-gray-400">Die Plattform für professionelle Bandvermittlung in der Schweiz.</p>
</div>
<div>
<h4 class="font-semibold mb-4">Links</h4>
<ul class="space-y-2">
<li><a href="/" class="text-gray-400 hover:text-white transition">Home</a></li>
<li><a href="/bands" class="text-gray-400 hover:text-white transition">Bands</a></li>
<li><a href="/register" class="text-gray-400 hover:text-white transition">Als Band registrieren</a></li>
</ul>
</div>
<div>
<h4 class="font-semibold mb-4">Rechtliches</h4>
<ul class="space-y-2">
<li><a href="/impressum" class="text-gray-400 hover:text-white transition">Impressum</a></li>
<li><a href="/datenschutz" class="text-gray-400 hover:text-white transition">Datenschutz</a></li>
<li><a href="/agb" class="text-gray-400 hover:text-white transition">AGB</a></li>
</ul>
</div>
</div>
<div class="border-t border-gray-800 mt-8 pt-8 text-center text-gray-400">
<p>&copy; <?= date('Y') ?> GetYourBand. Alle Rechte vorbehalten.</p>
</div>
</div>
</footer>
</body>
</html>
+100
View File
@@ -0,0 +1,100 @@
<?php
/**
* Helper functions available globally
*/
if (!function_exists('env')) {
function env(string $key, $default = null)
{
return $_ENV[$key] ?? $default;
}
}
if (!function_exists('asset')) {
function asset(string $path): string
{
return '/' . ltrim($path, '/');
}
}
if (!function_exists('url')) {
function url(string $path = ''): string
{
$baseUrl = env('APP_URL', 'http://localhost');
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
}
}
if (!function_exists('redirect')) {
function redirect(string $path): void
{
header("Location: {$path}");
exit;
}
}
if (!function_exists('old')) {
function old(string $key, $default = '')
{
return $_SESSION['old'][$key] ?? $default;
}
}
if (!function_exists('error')) {
function error(string $key): ?string
{
return $_SESSION['errors'][$key][0] ?? null;
}
}
if (!function_exists('csrf_token')) {
function csrf_token(): string
{
if (!isset($_SESSION['csrf_token'])) {
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}
return $_SESSION['csrf_token'];
}
}
if (!function_exists('csrf_field')) {
function csrf_field(): string
{
return '<input type="hidden" name="csrf_token" value="' . csrf_token() . '">';
}
}
if (!function_exists('dd')) {
function dd(...$vars): void
{
foreach ($vars as $var) {
var_dump($var);
}
die();
}
}
if (!function_exists('formatPrice')) {
function formatPrice($price): string
{
return 'CHF ' . number_format($price, 2, '.', '\'');
}
}
if (!function_exists('formatDate')) {
function formatDate($date): string
{
return date('d.m.Y', strtotime($date));
}
}
if (!function_exists('generateSlug')) {
function generateSlug(string $text): string
{
$text = mb_strtolower($text, 'UTF-8');
$text = preg_replace('/[^a-z0-9\s-]/', '', $text);
$text = preg_replace('/[\s-]+/', '-', $text);
return trim($text, '-');
}
}
+237
View File
@@ -0,0 +1,237 @@
:root {
--primary: #ffb703;
--secondary: #fb8500;
--dark: #0b0d17;
--darker: #090b13;
--light: #fefae0;
--gray: #8d99ae;
--gradient: linear-gradient(120deg, #ffb703, #fb5607, #ff006e);
}
* {
box-sizing: border-box;
}
body {
font-family: 'Space Grotesk', 'Segoe UI', system-ui, sans-serif;
margin: 0;
background: radial-gradient(circle at 10% 20%, rgba(255, 183, 3, 0.25), rgba(9, 11, 19, 0.95)), var(--dark);
color: var(--light);
min-height: 100vh;
}
header {
padding: 40px 5vw 20px;
}
.hero {
background: var(--darker);
border-radius: 24px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
align-items: center;
}
.hero h1 {
font-size: clamp(2.2rem, 5vw, 3.6rem);
margin-bottom: 10px;
}
.hero p {
color: rgba(255, 255, 255, 0.8);
}
.badge-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.badge {
background: rgba(255, 255, 255, 0.08);
padding: 6px 18px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.9rem;
}
.search-panel {
margin-top: 30px;
background: rgba(0, 0, 0, 0.35);
border-radius: 18px;
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 15px;
}
.search-panel input,
.search-panel select {
width: 100%;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: var(--light);
}
.btn-primary {
padding: 14px 24px;
border-radius: 16px;
border: none;
font-weight: bold;
background: var(--gradient);
color: var(--dark);
cursor: pointer;
transition: transform 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
}
main {
padding: 30px 5vw 80px;
}
.band-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.band-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 20px;
position: relative;
overflow: hidden;
transition: transform 0.2s ease, border-color 0.3s ease;
}
.band-card:hover {
transform: translateY(-4px);
border-color: var(--primary);
}
.band-card h3 {
margin-top: 0;
margin-bottom: 8px;
}
.price-tag {
font-size: 1.1rem;
color: var(--primary);
font-weight: bold;
}
.card-meta {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
}
footer {
background: rgba(0, 0, 0, 0.4);
padding: 30px 5vw;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 15px;
}
.cookie-banner {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--darker);
border-radius: 16px;
padding: 20px;
width: min(360px, calc(100% - 40px));
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.5);
display: none;
}
.cookie-banner.active {
display: block;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-align: left;
}
.form-control {
width: 100%;
padding: 12px 16px;
margin-bottom: 15px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: var(--light);
}
.alert {
padding: 15px 18px;
border-radius: 12px;
margin-bottom: 15px;
}
.alert-success {
background: rgba(56, 142, 60, 0.2);
border: 1px solid rgba(56, 142, 60, 0.4);
}
.alert-error {
background: rgba(213, 0, 0, 0.2);
border: 1px solid rgba(213, 0, 0, 0.4);
}
.band-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
margin-top: 30px;
}
.gallery img {
width: 100%;
border-radius: 16px;
margin-bottom: 16px;
}
.badge-rating {
background: rgba(255, 183, 3, 0.2);
border-color: rgba(255, 183, 3, 0.5);
}
.admin-nav {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.admin-nav a {
color: var(--light);
text-decoration: none;
padding: 10px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
}
@media (max-width: 768px) {
.hero,
footer {
padding: 24px;
}
}
+20
View File
@@ -0,0 +1,20 @@
const cookieBanner = document.querySelector('.cookie-banner');
const cookieAccept = document.querySelector('[data-cookie-accept]');
if (cookieBanner && cookieAccept) {
const consent = localStorage.getItem('gyb-cookie');
if (!consent) {
cookieBanner.classList.add('active');
}
cookieAccept.addEventListener('click', () => {
localStorage.setItem('gyb-cookie', 'accepted');
cookieBanner.classList.remove('active');
});
}
const filterForm = document.querySelector('[data-filter-form]');
if (filterForm) {
filterForm.addEventListener('input', () => {
filterForm.submit();
});
}
+1720
View File
File diff suppressed because it is too large Load Diff
+4045
View File
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
$bandId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
$band = $bandId ? findBand($bandId) : null;
if (!$band) {
http_response_code(404);
echo 'Band nicht gefunden';
exit;
}
$media = bandMedia($bandId);
$availability = bandAvailability($bandId);
$reviews = bandReviews($bandId);
$user = currentUser();
$message = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $user) {
if (isset($_POST['review'])) {
if (!eligibleForReview($bandId, (int) $user['id'])) {
$error = 'Für Bewertungen ist eine bestätigte Buchung nötig.';
} else {
$comment = trim((string) ($_POST['comment'] ?? ''));
if (mb_strlen($comment) > 200) {
$error = 'Maximal 200 Zeichen erlaubt.';
} else {
storeReview([
'band_id' => $bandId,
'user_id' => (int) $user['id'],
'rating' => (int) $_POST['rating'],
'comment' => $comment,
]);
$message = 'Danke! Deine Bewertung wartet auf Freigabe.';
}
}
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($band['name']) ?> <?= SITE_NAME ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="index.php">← Zurück</a>
<div class="hero" style="margin-top: 20px;">
<div>
<p class="badge"><?= htmlspecialchars($band['genre']) ?></p>
<h1><?= htmlspecialchars($band['name']) ?></h1>
<p><?= nl2br(htmlspecialchars($band['description'])) ?></p>
<div class="badge-list">
<span class="badge">Ort: <?= htmlspecialchars($band['city'] ?? '') ?></span>
<span class="badge">ab <?= formatPrice((int) $band['price']) ?></span>
</div>
<div class="badge-list">
<?php foreach (array_filter(array_map('trim', explode(',', (string) $band['style_tags']))) as $tag): ?>
<span class="badge">#<?= htmlspecialchars($tag) ?></span>
<?php endforeach; ?>
</div>
<p>
<a class="btn-primary" href="anfrage.php?band_id=<?= $bandId ?>">Verfügbarkeit anfragen</a>
</p>
</div>
<div>
<?php if (!empty($band['video_url'])): ?>
<iframe width="100%" height="280" src="<?= htmlspecialchars($band['video_url']) ?>" title="Bandvideo" allowfullscreen></iframe>
<?php endif; ?>
</div>
</div>
</header>
<main>
<section class="band-detail-grid">
<div class="gallery">
<h3>Galerie</h3>
<?php foreach ($media as $item): ?>
<?php if ($item['type'] === 'image'): ?>
<img src="<?= htmlspecialchars($item['url']) ?>" alt="Bandbild">
<?php endif; ?>
<?php endforeach; ?>
</div>
<div>
<h3>Verfügbarkeit</h3>
<table class="table">
<thead>
<tr><th>Datum</th><th>Status</th></tr>
</thead>
<tbody>
<?php foreach ($availability as $slot): ?>
<tr>
<td><?= htmlspecialchars((new DateTimeImmutable($slot['event_date']))->format('d.m.Y')) ?></td>
<td><?= htmlspecialchars(ucfirst($slot['status'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section style="margin-top: 40px;">
<h3>Bewertungen</h3>
<?php if ($message): ?><div class="alert alert-success"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php foreach ($reviews as $review): ?>
<article class="band-card">
<p><strong><?= htmlspecialchars($review['author']) ?></strong> <?= (int) $review['rating'] ?> ★</p>
<p><?= htmlspecialchars($review['comment']) ?></p>
<p class="card-meta"><?= (new DateTimeImmutable($review['created_at']))->format('d.m.Y') ?></p>
</article>
<?php endforeach; ?>
<?php if (!$reviews): ?>
<p>Noch keine freigegebenen Bewertungen.</p>
<?php endif; ?>
</section>
<?php if ($user && $user['role'] === 'kunde'): ?>
<section style="margin-top: 40px;">
<h3>Eigene Bewertung</h3>
<form method="post">
<input type="hidden" name="review" value="1">
<label>Sterne
<select class="form-control" name="rating">
<?php for ($i = 5; $i >= 1; $i--): ?>
<option value="<?= $i ?>"><?= $i ?></option>
<?php endfor; ?>
</select>
</label>
<label>Kommentar (max. 200 Zeichen)
<textarea class="form-control" name="comment" maxlength="200"></textarea>
</label>
<button class="btn-primary">Bewertung senden</button>
</form>
</section>
<?php endif; ?>
</main>
</body>
</html>
+39
View File
@@ -0,0 +1,39 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Environment
.env
.env.local
# OS
.DS_Store
Thumbs.db
+274
View File
@@ -0,0 +1,274 @@
# 🪙 Bitcoin Trading Signal System
Ein intelligentes Trading-Signal-System für Bitcoin, das **technische Analyse (MACD)** mit **News-Sentiment-Analyse** kombiniert, um fundierte Kauf- und Verkaufsempfehlungen zu generieren.
## 📋 Features
### 🔍 Technische Analyse
- **MACD-Indikator** (Moving Average Convergence Divergence)
- Erkennung von Bullish/Bearish Crossovers
- Histogramm-Analyse für Momentum-Erkennung
- Trend-Analyse über multiple Zeitperioden
### 📰 Sentiment-Analyse
- Echtzeit-Analyse von Bitcoin-News
- Keyword-basierte Sentiment-Bewertung
- Multiple News-Quellen (CryptoCompare, optional NewsAPI)
- Aggregierung von positiven/negativen Marktsignalen
### 🎯 Kombinierte Signale
- Gewichtete Kombination aus MACD + Sentiment
- 5 Signal-Stufen: Starker Kauf, Kauf, Halten, Verkauf, Starker Verkauf
- Konfidenz-Bewertung für jedes Signal
- Detaillierte Begründungen für Empfehlungen
## 🚀 Installation
### Voraussetzungen
- Python 3.8 oder höher
- pip (Python Package Manager)
### 1. Dependencies installieren
```bash
cd bitcoin_trading
pip install -r requirements.txt
```
### 2. Optional: NewsAPI-Schlüssel
Für erweiterte News-Analyse kannst du einen kostenlosen NewsAPI-Schlüssel erhalten:
1. Registriere dich auf [NewsAPI.org](https://newsapi.org)
2. Hole dir deinen API-Schlüssel
3. Verwende ihn mit `--newsapi-key` Parameter
## 💻 Verwendung
### Basis-Analyse (empfohlen)
```bash
python bitcoin_trader.py
```
Dies führt eine vollständige Analyse durch mit:
- Aktuellen Bitcoin-Preisdaten
- 30 Tage historische MACD-Daten
- Aktuelle News-Sentiment-Analyse
- Kombinierter Trading-Empfehlung
### Erweiterte Optionen
**Ausführliche Ausgabe mit Ladestatus:**
```bash
python bitcoin_trader.py --verbose
```
**Mehr historische Daten (z.B. 60 Tage):**
```bash
python bitcoin_trader.py --days 60
```
**Schnelles Signal (nur Empfehlung):**
```bash
python bitcoin_trader.py --quick
```
**Mit NewsAPI-Schlüssel:**
```bash
python bitcoin_trader.py --newsapi-key YOUR_API_KEY
```
**Alle Optionen kombiniert:**
```bash
python bitcoin_trader.py --verbose --days 90 --newsapi-key YOUR_KEY
```
### Als Python-Modul verwenden
```python
from bitcoin_trading import BitcoinTrader
# Initialisiere Trader
trader = BitcoinTrader(verbose=True)
# Führe Analyse durch
trader.run_analysis(days=30)
# Oder hole schnelles Signal
signal = trader.get_quick_signal()
print(signal)
```
## 📊 Output-Beispiel
```
╔══════════════════════════════════════════════════════════════════╗
║ BITCOIN TRADING SIGNAL - 2024-12-02 15:30 ║
╚══════════════════════════════════════════════════════════════════╝
📊 EMPFEHLUNG: 🟢 KAUF
💯 KONFIDENZ: 72%
💰 AKTUELLER PREIS: $42,583.50
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📈 TECHNISCHE ANALYSE (MACD)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Signal: KAUF
MACD: 125.34
Signal Line: 98.21
Histogram: 27.13
Preis-Änderung (10 Tage): +5.67%
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📰 SENTIMENT-ANALYSE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Markt-Sentiment: POSITIV
Sentiment-Score: 0.425
Analysierte Artikel: 28
├─ Positiv: 16
├─ Neutral: 8
└─ Negativ: 4
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 BEGRÜNDUNG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. MACD-Signal: KAUF (Konfidenz: 70%)
2. Bullish Crossover: MACD kreuzt Signal-Linie von unten
3. Positives Momentum: Histogramm steigt
4. Markt-Sentiment: POSITIV (Konfidenz: 75%, Score: 0.425)
5. ✅ MACD und Sentiment stimmen überein → Starkes Signal
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
💡 HANDLUNGSEMPFEHLUNG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🟢 KAUFGELEGENHEIT
→ Erwäge einen Einstieg mit kleiner Position
→ Warte ggf. auf Bestätigung durch weitere Signale
⚠️ Risiko-Hinweis: Diese Analyse hat eine Konfidenz von 72%
⚠️ Keine Anlageberatung - Trading auf eigenes Risiko!
╚══════════════════════════════════════════════════════════════════╝
```
## 🔧 Technische Details
### MACD-Parameter
- **Fast EMA**: 12 Perioden
- **Slow EMA**: 26 Perioden
- **Signal Line**: 9 Perioden
### Signal-Logik
- **Starker Kauf**: Bullish Crossover im negativen Bereich
- **Kauf**: Bullish Crossover oder positives Momentum
- **Halten**: Keine klare Richtung
- **Verkauf**: Bearish Crossover oder negatives Momentum
- **Starker Verkauf**: Bearish Crossover im positiven Bereich
### Gewichtung
- MACD (Technische Analyse): **60%**
- News-Sentiment: **40%**
- Bonus bei übereinstimmenden Signalen: **+10%**
## 📁 Projektstruktur
```
bitcoin_trading/
├── __init__.py # Package Initialisierung
├── bitcoin_trader.py # Hauptprogramm (CLI)
├── data_fetcher.py # Bitcoin-Preisdaten-Abruf
├── macd_indicator.py # MACD-Indikator-Berechnung
├── news_sentiment.py # News-Sentiment-Analyse
├── signal_generator.py # Signal-Kombination & Empfehlung
├── requirements.txt # Python-Dependencies
└── README.md # Diese Datei
```
## 🔌 API-Quellen
### Preisdaten
- **Binance API** (primär) - Schnelle, zuverlässige Preisdaten
- **CoinGecko API** (fallback) - Backup-Datenquelle
### News
- **CryptoCompare News API** (kostenlos) - Crypto-spezifische News
- **NewsAPI** (optional) - Erweiterte News-Abdeckung
## ⚠️ Wichtige Hinweise
### Disclaimer
- **Dies ist KEINE Anlageberatung**
- Trading mit Kryptowährungen ist hochriskant
- Vergangene Performance garantiert keine zukünftigen Ergebnisse
- Investiere nur Geld, das du dir leisten kannst zu verlieren
- Führe deine eigene Due Diligence durch
### Risiken
- Marktvolatilität kann Signale schnell ungültig machen
- Technische Indikatoren sind nicht 100% zuverlässig
- News-Sentiment kann manipuliert sein
- API-Ausfälle können Daten beeinträchtigen
### Best Practices
- Verwende Signale als einen von mehreren Faktoren
- Setze immer Stop-Loss-Orders
- Diversifiziere dein Portfolio
- Handel nur mit klarem Kopf
- Dokumentiere deine Trades
## 🐛 Troubleshooting
### Fehler: "Konnte Bitcoin-Preis nicht abrufen"
- Überprüfe Internetverbindung
- APIs könnten temporär down sein
- Warte kurz und versuche es erneut
### Fehler: "Nicht genug Daten für MACD"
- Erhöhe `--days` Parameter (mindestens 30 Tage empfohlen)
- Stelle sicher, dass historische Daten geladen werden
### Sentiment zeigt immer "NEUTRAL"
- Möglicherweise keine aktuellen News verfügbar
- Verwende `--newsapi-key` für mehr News-Quellen
- News-APIs könnten Rate-Limits haben
## 🔄 Updates & Erweiterungen
### Geplante Features
- [ ] RSI (Relative Strength Index) Integration
- [ ] Bollinger Bands Analyse
- [ ] Machine Learning Modelle
- [ ] Email/Telegram Benachrichtigungen
- [ ] Backtesting-Funktionalität
- [ ] WebSocket Real-time Updates
- [ ] Multi-Coin Support (ETH, etc.)
### Erweiterungsmöglichkeiten
- Integration weiterer technischer Indikatoren
- Social Media Sentiment (Twitter/Reddit)
- On-Chain-Metriken (Wallet-Bewegungen)
- Advanced ML/AI Modelle
- Portfolio-Management-Features
## 📝 Lizenz
Dieses Projekt ist Teil des GetYourBand-Projekts.
## 🤝 Beitragen
Contributions sind willkommen! Bitte öffne ein Issue oder Pull Request.
## 📧 Support
Bei Fragen oder Problemen erstelle ein Issue im Repository.
---
**Made with 📊 and ₿ for informed trading decisions**
⚠️ **Remember: Don't invest more than you can afford to lose!**
+26
View File
@@ -0,0 +1,26 @@
"""
Bitcoin Trading Signal System
Ein System zur Generierung von Kauf-/Verkaufsempfehlungen für Bitcoin
basierend auf MACD-Indikatoren und News-Sentiment-Analyse
"""
__version__ = "1.0.0"
__author__ = "Bitcoin Trading Signal System"
from .data_fetcher import BitcoinDataFetcher
from .macd_indicator import MACDIndicator, MACDSignal
from .news_sentiment import NewsSentimentAnalyzer, SentimentScore
from .signal_generator import SignalGenerator, TradingAction, TradingSignal
from .bitcoin_trader import BitcoinTrader
__all__ = [
'BitcoinDataFetcher',
'MACDIndicator',
'MACDSignal',
'NewsSentimentAnalyzer',
'SentimentScore',
'SignalGenerator',
'TradingAction',
'TradingSignal',
'BitcoinTrader',
]
+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
"""
Bitcoin Trading Signal System
Hauptprogramm für Bitcoin Trading-Empfehlungen basierend auf MACD und News-Sentiment
"""
import sys
import argparse
from datetime import datetime
from typing import Optional
from data_fetcher import BitcoinDataFetcher
from signal_generator import SignalGenerator
class BitcoinTrader:
"""
Haupt-Klasse für das Bitcoin Trading Signal System
"""
def __init__(self, newsapi_key: Optional[str] = None, verbose: bool = False):
"""
Args:
newsapi_key: Optional NewsAPI-Schlüssel
verbose: Ausführliche Ausgabe
"""
self.data_fetcher = BitcoinDataFetcher()
self.signal_generator = SignalGenerator(newsapi_key=newsapi_key)
self.verbose = verbose
def run_analysis(self, days: int = 30) -> None:
"""
Führt komplette Trading-Analyse durch
Args:
days: Anzahl Tage für historische Daten
"""
print("╔══════════════════════════════════════════════════════════════════╗")
print("║ BITCOIN TRADING SIGNAL SYSTEM v1.0 ║")
print("╚══════════════════════════════════════════════════════════════════╝")
print()
# 1. Lade aktuelle Preisdaten
if self.verbose:
print("📊 Lade aktuelle Bitcoin-Preisdaten...")
current_price = self.data_fetcher.get_current_price()
if not current_price:
print("❌ Fehler: Konnte aktuellen Bitcoin-Preis nicht abrufen!")
sys.exit(1)
if self.verbose:
print(f"✓ Aktueller BTC-Preis: ${current_price:,.2f}")
# 2. Lade historische Daten
if self.verbose:
print(f"📈 Lade historische Daten ({days} Tage)...")
price_df = self.data_fetcher.get_historical_data(days=days)
if price_df.empty:
print("❌ Fehler: Konnte historische Daten nicht abrufen!")
sys.exit(1)
if self.verbose:
print(f"{len(price_df)} Datenpunkte geladen")
# 3. Lade Marktdaten (optional)
if self.verbose:
print("💹 Lade erweiterte Marktdaten...")
market_data = self.data_fetcher.get_market_data()
if market_data and self.verbose:
print(f"✓ Marktdaten geladen")
if market_data.get('price_change_24h'):
change_24h = market_data['price_change_24h']
emoji = "📈" if change_24h > 0 else "📉"
print(f" {emoji} 24h Veränderung: {change_24h:+.2f}%")
# 4. Generiere Trading-Signal
if self.verbose:
print("\n🔍 Analysiere MACD-Indikatoren...")
print("📰 Analysiere News-Sentiment...")
print("🎯 Generiere Trading-Signal...\n")
signal = self.signal_generator.generate_signal(price_df, current_price)
# 5. Zeige Empfehlung
recommendation = self.signal_generator.get_recommendation_text(signal)
print(recommendation)
# 6. Zusätzliche Marktinformationen
if market_data and self.verbose:
print("\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")
print("📊 ZUSÄTZLICHE MARKTINFORMATIONEN")
print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n")
if market_data.get('market_cap'):
print(f" Marktkapitalisierung: ${market_data['market_cap']:,.0f}")
if market_data.get('total_volume'):
print(f" 24h Handelsvolumen: ${market_data['total_volume']:,.0f}")
if market_data.get('high_24h') and market_data.get('low_24h'):
print(f" 24h Hoch: ${market_data['high_24h']:,.2f}")
print(f" 24h Tief: ${market_data['low_24h']:,.2f}")
if market_data.get('price_change_7d'):
print(f" 7-Tage Veränderung: {market_data['price_change_7d']:+.2f}%")
if market_data.get('price_change_30d'):
print(f" 30-Tage Veränderung: {market_data['price_change_30d']:+.2f}%")
print()
def get_quick_signal(self) -> str:
"""
Gibt schnelles Trading-Signal zurück (nur Empfehlung)
Returns:
Signal-String
"""
current_price = self.data_fetcher.get_current_price()
if not current_price:
return "❌ Fehler beim Abrufen der Daten"
price_df = self.data_fetcher.get_historical_data(days=30)
if price_df.empty:
return "❌ Fehler beim Abrufen der Daten"
signal = self.signal_generator.generate_signal(price_df, current_price)
return f"{signal.action.value} (Konfidenz: {signal.confidence}%) @ ${signal.price:,.2f}"
def main():
"""Hauptfunktion"""
parser = argparse.ArgumentParser(
description='Bitcoin Trading Signal System - MACD + News Sentiment Analyse',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Beispiele:
%(prog)s # Standard-Analyse
%(prog)s --verbose # Ausführliche Ausgabe
%(prog)s --days 60 # 60 Tage historische Daten
%(prog)s --quick # Schnelles Signal
%(prog)s --newsapi-key YOUR_KEY # Mit NewsAPI-Schlüssel
Hinweis:
- NewsAPI-Schlüssel optional (erhöht News-Quellen)
- Kostenlos bei https://newsapi.org
"""
)
parser.add_argument(
'-v', '--verbose',
action='store_true',
help='Ausführliche Ausgabe mit Ladestatus'
)
parser.add_argument(
'-d', '--days',
type=int,
default=30,
help='Anzahl Tage für historische Daten (Standard: 30)'
)
parser.add_argument(
'-q', '--quick',
action='store_true',
help='Schnelles Signal ohne Details'
)
parser.add_argument(
'--newsapi-key',
type=str,
default=None,
help='NewsAPI-Schlüssel für erweiterte News-Analyse'
)
args = parser.parse_args()
# Initialisiere Trader
trader = BitcoinTrader(
newsapi_key=args.newsapi_key,
verbose=args.verbose
)
try:
if args.quick:
# Schnelles Signal
signal = trader.get_quick_signal()
print(signal)
else:
# Vollständige Analyse
trader.run_analysis(days=args.days)
except KeyboardInterrupt:
print("\n\n⚠️ Analyse abgebrochen durch Benutzer")
sys.exit(0)
except Exception as e:
print(f"\n❌ Fehler: {e}")
if args.verbose:
import traceback
traceback.print_exc()
sys.exit(1)
if __name__ == "__main__":
main()
+175
View File
@@ -0,0 +1,175 @@
"""
Bitcoin Price Data Fetcher
Ruft aktuelle und historische Bitcoin-Preisdaten von verschiedenen APIs ab
"""
import requests
from datetime import datetime, timedelta
import pandas as pd
from typing import Dict, List, Optional
import time
class BitcoinDataFetcher:
"""Fetches Bitcoin price data from various sources"""
def __init__(self):
self.base_url_coingecko = "https://api.coingecko.com/api/v3"
self.base_url_binance = "https://api.binance.com/api/v3"
def get_current_price(self) -> Optional[float]:
"""
Holt den aktuellen Bitcoin-Preis in USD
Returns:
float: Aktueller BTC/USD Preis oder None bei Fehler
"""
try:
# Versuche zuerst Binance (schneller und zuverlässiger)
url = f"{self.base_url_binance}/ticker/price"
params = {"symbol": "BTCUSDT"}
response = requests.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
return float(data['price'])
except Exception as e:
print(f"Fehler beim Abrufen von Binance: {e}")
try:
# Fallback zu CoinGecko
url = f"{self.base_url_coingecko}/simple/price"
params = {
"ids": "bitcoin",
"vs_currencies": "usd"
}
response = requests.get(url, params=params, timeout=10)
if response.status_code == 200:
data = response.json()
return float(data['bitcoin']['usd'])
except Exception as e:
print(f"Fehler beim Abrufen von CoinGecko: {e}")
return None
def get_historical_data(self, days: int = 30) -> pd.DataFrame:
"""
Holt historische Bitcoin-Preisdaten
Args:
days: Anzahl der Tage zurück
Returns:
DataFrame mit Spalten: timestamp, price, volume
"""
try:
# Binance Klines (Candlestick-Daten)
url = f"{self.base_url_binance}/klines"
# Berechne Zeitstempel
end_time = int(datetime.now().timestamp() * 1000)
start_time = int((datetime.now() - timedelta(days=days)).timestamp() * 1000)
params = {
"symbol": "BTCUSDT",
"interval": "1h", # Stündliche Daten
"startTime": start_time,
"endTime": end_time,
"limit": 1000
}
response = requests.get(url, params=params, timeout=30)
if response.status_code == 200:
data = response.json()
# Konvertiere zu DataFrame
df = pd.DataFrame(data, columns=[
'timestamp', 'open', 'high', 'low', 'close',
'volume', 'close_time', 'quote_volume', 'trades',
'taker_buy_base', 'taker_buy_quote', 'ignore'
])
# Behalte nur relevante Spalten
df = df[['timestamp', 'close', 'volume']]
df.columns = ['timestamp', 'price', 'volume']
# Konvertiere Datentypen
df['timestamp'] = pd.to_datetime(df['timestamp'], unit='ms')
df['price'] = df['price'].astype(float)
df['volume'] = df['volume'].astype(float)
return df
except Exception as e:
print(f"Fehler beim Abrufen historischer Daten: {e}")
# Fallback: Leeres DataFrame
return pd.DataFrame(columns=['timestamp', 'price', 'volume'])
def get_market_data(self) -> Dict:
"""
Holt erweiterte Marktdaten für Bitcoin
Returns:
Dict mit Marktdaten (Volumen, Marktkapitalisierung, etc.)
"""
try:
url = f"{self.base_url_coingecko}/coins/bitcoin"
params = {
"localization": "false",
"tickers": "false",
"market_data": "true",
"community_data": "false",
"developer_data": "false"
}
response = requests.get(url, params=params, timeout=15)
if response.status_code == 200:
data = response.json()
market_data = data.get('market_data', {})
return {
'current_price': market_data.get('current_price', {}).get('usd'),
'market_cap': market_data.get('market_cap', {}).get('usd'),
'total_volume': market_data.get('total_volume', {}).get('usd'),
'price_change_24h': market_data.get('price_change_percentage_24h'),
'price_change_7d': market_data.get('price_change_percentage_7d'),
'price_change_30d': market_data.get('price_change_percentage_30d'),
'high_24h': market_data.get('high_24h', {}).get('usd'),
'low_24h': market_data.get('low_24h', {}).get('usd'),
}
except Exception as e:
print(f"Fehler beim Abrufen von Marktdaten: {e}")
return {}
if __name__ == "__main__":
# Test
fetcher = BitcoinDataFetcher()
print("=== Bitcoin Preis ===")
price = fetcher.get_current_price()
if price:
print(f"Aktueller BTC/USD Preis: ${price:,.2f}")
print("\n=== Marktdaten ===")
market = fetcher.get_market_data()
for key, value in market.items():
if value is not None:
if 'price' in key or 'cap' in key or 'volume' in key or 'high' in key or 'low' in key:
print(f"{key}: ${value:,.2f}")
else:
print(f"{key}: {value:.2f}%")
print("\n=== Historische Daten (letzte 7 Tage) ===")
df = fetcher.get_historical_data(days=7)
if not df.empty:
print(f"Anzahl Datenpunkte: {len(df)}")
print(df.tail())
+254
View File
@@ -0,0 +1,254 @@
"""
MACD (Moving Average Convergence Divergence) Indicator
Berechnet MACD-Signale für Bitcoin Trading
"""
import pandas as pd
import numpy as np
from typing import Dict, Tuple, Optional
from enum import Enum
class MACDSignal(Enum):
"""MACD Signal-Typen"""
STRONG_BUY = "STARKER KAUF"
BUY = "KAUF"
NEUTRAL = "NEUTRAL"
SELL = "VERKAUF"
STRONG_SELL = "STARKER VERKAUF"
class MACDIndicator:
"""
MACD-Indikator für technische Analyse
Standard-Parameter:
- Fast EMA: 12 Perioden
- Slow EMA: 26 Perioden
- Signal: 9 Perioden
"""
def __init__(self, fast_period: int = 12, slow_period: int = 26, signal_period: int = 9):
"""
Args:
fast_period: Schnelle EMA-Periode (Standard: 12)
slow_period: Langsame EMA-Periode (Standard: 26)
signal_period: Signal-Linie-Periode (Standard: 9)
"""
self.fast_period = fast_period
self.slow_period = slow_period
self.signal_period = signal_period
def calculate_ema(self, data: pd.Series, period: int) -> pd.Series:
"""
Berechnet Exponential Moving Average (EMA)
Args:
data: Preisdaten
period: EMA-Periode
Returns:
EMA-Serie
"""
return data.ewm(span=period, adjust=False).mean()
def calculate_macd(self, prices: pd.Series) -> pd.DataFrame:
"""
Berechnet MACD-Indikator
Args:
prices: Preis-Serie (normalerweise Close-Preise)
Returns:
DataFrame mit MACD, Signal und Histogram
"""
# Berechne EMAs
ema_fast = self.calculate_ema(prices, self.fast_period)
ema_slow = self.calculate_ema(prices, self.slow_period)
# MACD-Linie
macd_line = ema_fast - ema_slow
# Signal-Linie (9-Tage EMA der MACD-Linie)
signal_line = self.calculate_ema(macd_line, self.signal_period)
# MACD-Histogramm (Differenz zwischen MACD und Signal)
histogram = macd_line - signal_line
# Erstelle DataFrame
df = pd.DataFrame({
'macd': macd_line,
'signal': signal_line,
'histogram': histogram
})
return df
def get_signal(self, df: pd.DataFrame) -> Tuple[MACDSignal, Dict]:
"""
Generiert Trading-Signal basierend auf MACD
Args:
df: DataFrame mit price-Spalte
Returns:
Tuple aus (Signal, Details-Dict)
"""
if df.empty or len(df) < self.slow_period + self.signal_period:
return MACDSignal.NEUTRAL, {
'reason': 'Nicht genug Daten für MACD-Berechnung',
'confidence': 0
}
# Berechne MACD
macd_df = self.calculate_macd(df['price'])
# Hole aktuelle und vorherige Werte
current_macd = macd_df['macd'].iloc[-1]
current_signal = macd_df['signal'].iloc[-1]
current_histogram = macd_df['histogram'].iloc[-1]
prev_macd = macd_df['macd'].iloc[-2]
prev_signal = macd_df['signal'].iloc[-2]
prev_histogram = macd_df['histogram'].iloc[-2]
# Bestimme Signal-Typ
signal = MACDSignal.NEUTRAL
confidence = 0
reasons = []
# 1. Bullish Crossover (MACD kreuzt Signal von unten)
if prev_macd <= prev_signal and current_macd > current_signal:
signal = MACDSignal.BUY
confidence = 70
reasons.append("Bullish Crossover: MACD kreuzt Signal-Linie von unten")
# Starkes Kaufsignal, wenn zusätzlich im negativen Bereich
if current_macd < 0:
signal = MACDSignal.STRONG_BUY
confidence = 85
reasons.append("MACD im negativen Bereich → Überkauft")
# 2. Bearish Crossover (MACD kreuzt Signal von oben)
elif prev_macd >= prev_signal and current_macd < current_signal:
signal = MACDSignal.SELL
confidence = 70
reasons.append("Bearish Crossover: MACD kreuzt Signal-Linie von oben")
# Starkes Verkaufssignal, wenn zusätzlich im positiven Bereich
if current_macd > 0:
signal = MACDSignal.STRONG_SELL
confidence = 85
reasons.append("MACD im positiven Bereich → Überverkauft")
# 3. Divergenz-Analyse (Histogramm)
else:
histogram_trend = current_histogram - prev_histogram
if histogram_trend > 0 and current_histogram > 0:
signal = MACDSignal.BUY
confidence = 55
reasons.append("Positives Momentum: Histogramm steigt")
elif histogram_trend < 0 and current_histogram < 0:
signal = MACDSignal.SELL
confidence = 55
reasons.append("Negatives Momentum: Histogramm fällt")
else:
signal = MACDSignal.NEUTRAL
confidence = 30
reasons.append("Kein klarer Trend erkennbar")
# Zusätzliche Analyse: Preis-Trend
recent_prices = df['price'].tail(10)
price_change = ((recent_prices.iloc[-1] - recent_prices.iloc[0]) / recent_prices.iloc[0]) * 100
if abs(price_change) > 5:
if price_change > 0:
reasons.append(f"Preis-Trend: +{price_change:.2f}% (aufwärts)")
else:
reasons.append(f"Preis-Trend: {price_change:.2f}% (abwärts)")
details = {
'macd': float(current_macd),
'signal': float(current_signal),
'histogram': float(current_histogram),
'prev_histogram': float(prev_histogram),
'crossover': current_macd > current_signal,
'confidence': confidence,
'reasons': reasons,
'price_change_10d': float(price_change)
}
return signal, details
def analyze_trend(self, df: pd.DataFrame, periods: int = 20) -> str:
"""
Analysiert den Trend der letzten Perioden
Args:
df: DataFrame mit MACD-Daten
periods: Anzahl Perioden zur Analyse
Returns:
Trend-Beschreibung
"""
macd_df = self.calculate_macd(df['price'])
if len(macd_df) < periods:
return "Nicht genug Daten"
recent_histogram = macd_df['histogram'].tail(periods)
# Zähle positive/negative Balken
positive_bars = (recent_histogram > 0).sum()
negative_bars = (recent_histogram < 0).sum()
if positive_bars > negative_bars * 1.5:
return "Starker Aufwärtstrend"
elif positive_bars > negative_bars:
return "Leichter Aufwärtstrend"
elif negative_bars > positive_bars * 1.5:
return "Starker Abwärtstrend"
elif negative_bars > positive_bars:
return "Leichter Abwärtstrend"
else:
return "Seitwärtstrend"
if __name__ == "__main__":
# Test mit simulierten Daten
print("=== MACD Indicator Test ===\n")
# Erstelle Test-Daten
dates = pd.date_range(start='2024-01-01', periods=100, freq='D')
prices = 40000 + np.cumsum(np.random.randn(100) * 500) # Random Walk
df = pd.DataFrame({
'timestamp': dates,
'price': prices
})
# Initialisiere MACD
macd = MACDIndicator()
# Berechne MACD
macd_df = macd.calculate_macd(df['price'])
print("Letzte 5 MACD-Werte:")
print(macd_df.tail())
print("\n=== Trading Signal ===")
signal, details = macd.get_signal(df)
print(f"Signal: {signal.value}")
print(f"Konfidenz: {details['confidence']}%")
print(f"\nMACD: {details['macd']:.2f}")
print(f"Signal: {details['signal']:.2f}")
print(f"Histogram: {details['histogram']:.2f}")
print(f"\nGründe:")
for reason in details['reasons']:
print(f" - {reason}")
print(f"\nTrend-Analyse: {macd.analyze_trend(df)}")
+300
View File
@@ -0,0 +1,300 @@
"""
News Sentiment Analyzer für Bitcoin
Analysiert Nachrichten und bestimmt das Markt-Sentiment
"""
import requests
from datetime import datetime, timedelta
from typing import List, Dict, Optional, Tuple
from enum import Enum
import re
class SentimentScore(Enum):
"""Sentiment-Score-Typen"""
VERY_POSITIVE = "SEHR POSITIV"
POSITIVE = "POSITIV"
NEUTRAL = "NEUTRAL"
NEGATIVE = "NEGATIV"
VERY_NEGATIVE = "SEHR NEGATIV"
class NewsSentimentAnalyzer:
"""
Analysiert Bitcoin-bezogene Nachrichten und bestimmt das Sentiment
"""
def __init__(self, api_key: Optional[str] = None):
"""
Args:
api_key: NewsAPI-Schlüssel (optional, verwendet Free Tier wenn None)
"""
self.api_key = api_key
self.newsapi_url = "https://newsapi.org/v2/everything"
# Sentiment-Keyword-Listen
self.positive_keywords = [
'bullish', 'surge', 'rally', 'gain', 'rise', 'soar', 'jump',
'breakthrough', 'adoption', 'institutional', 'buy', 'investment',
'growth', 'profit', 'support', 'upgrade', 'positive', 'optimistic',
'breakout', 'moon', 'accumulation', 'bull run', 'all-time high',
'ath', 'recovery', 'uptrend', 'momentum', 'strong'
]
self.negative_keywords = [
'bearish', 'crash', 'plunge', 'fall', 'drop', 'decline', 'sell',
'regulation', 'ban', 'warning', 'risk', 'fear', 'panic', 'loss',
'hack', 'fraud', 'scam', 'bubble', 'concern', 'volatile',
'downtrend', 'resistance', 'bear market', 'dump', 'correction',
'selling pressure', 'oversold', 'weak', 'uncertainty'
]
# Crypto News Sources (kostenlos zugänglich)
self.crypto_news_sources = [
'https://cryptopanic.com/api/v1/posts/',
'https://min-api.cryptocompare.com/data/v2/news/',
]
def fetch_news_newsapi(self, days: int = 1) -> List[Dict]:
"""
Holt Bitcoin-News von NewsAPI
Args:
days: Anzahl Tage zurück
Returns:
Liste von News-Artikeln
"""
if not self.api_key:
return []
try:
from_date = (datetime.now() - timedelta(days=days)).strftime('%Y-%m-%d')
params = {
'q': 'bitcoin OR BTC OR cryptocurrency',
'from': from_date,
'sortBy': 'publishedAt',
'language': 'en',
'apiKey': self.api_key
}
response = requests.get(self.newsapi_url, params=params, timeout=15)
if response.status_code == 200:
data = response.json()
return data.get('articles', [])
except Exception as e:
print(f"Fehler beim Abrufen von NewsAPI: {e}")
return []
def fetch_news_cryptocompare(self, limit: int = 50) -> List[Dict]:
"""
Holt Bitcoin-News von CryptoCompare (kostenlos)
Args:
limit: Anzahl News-Artikel
Returns:
Liste von News-Artikeln
"""
try:
url = "https://min-api.cryptocompare.com/data/v2/news/"
params = {
'categories': 'BTC',
'lang': 'EN'
}
response = requests.get(url, params=params, timeout=15)
if response.status_code == 200:
data = response.json()
news_list = data.get('Data', [])
# Formatiere zu einheitlichem Format
formatted_news = []
for item in news_list[:limit]:
formatted_news.append({
'title': item.get('title', ''),
'description': item.get('body', ''),
'publishedAt': datetime.fromtimestamp(item.get('published_on', 0)),
'source': item.get('source', ''),
'url': item.get('url', '')
})
return formatted_news
except Exception as e:
print(f"Fehler beim Abrufen von CryptoCompare: {e}")
return []
def analyze_text_sentiment(self, text: str) -> Tuple[float, Dict]:
"""
Analysiert Sentiment eines Textes
Args:
text: Zu analysierender Text
Returns:
Tuple aus (Score -1 bis +1, Details)
"""
if not text:
return 0.0, {'positive': 0, 'negative': 0}
text_lower = text.lower()
# Zähle positive und negative Keywords
positive_count = sum(1 for keyword in self.positive_keywords if keyword in text_lower)
negative_count = sum(1 for keyword in self.negative_keywords if keyword in text_lower)
# Berechne Score (-1 bis +1)
total_keywords = positive_count + negative_count
if total_keywords == 0:
score = 0.0
else:
score = (positive_count - negative_count) / total_keywords
details = {
'positive': positive_count,
'negative': negative_count,
'total': total_keywords
}
return score, details
def analyze_news_sentiment(self, days: int = 1, limit: int = 50) -> Tuple[SentimentScore, Dict]:
"""
Analysiert Gesamt-Sentiment der aktuellen Bitcoin-Nachrichten
Args:
days: Anzahl Tage zurück
limit: Max. Anzahl Artikel
Returns:
Tuple aus (Sentiment, Details)
"""
# Hole News von verschiedenen Quellen
news_articles = []
# Versuche NewsAPI (falls API-Key vorhanden)
if self.api_key:
news_articles.extend(self.fetch_news_newsapi(days))
# Hole von CryptoCompare (kostenlos)
crypto_news = self.fetch_news_cryptocompare(limit)
news_articles.extend(crypto_news)
if not news_articles:
return SentimentScore.NEUTRAL, {
'reason': 'Keine News gefunden',
'confidence': 0,
'articles_analyzed': 0
}
# Analysiere jeden Artikel
sentiment_scores = []
positive_articles = 0
negative_articles = 0
neutral_articles = 0
for article in news_articles[:limit]:
title = article.get('title', '')
description = article.get('description', '')
combined_text = f"{title} {description}"
score, details = self.analyze_text_sentiment(combined_text)
sentiment_scores.append(score)
if score > 0.2:
positive_articles += 1
elif score < -0.2:
negative_articles += 1
else:
neutral_articles += 1
# Berechne Durchschnitts-Sentiment
avg_sentiment = sum(sentiment_scores) / len(sentiment_scores) if sentiment_scores else 0
# Bestimme Gesamt-Sentiment
if avg_sentiment > 0.4:
sentiment = SentimentScore.VERY_POSITIVE
confidence = min(95, int(70 + abs(avg_sentiment) * 50))
elif avg_sentiment > 0.15:
sentiment = SentimentScore.POSITIVE
confidence = min(85, int(60 + abs(avg_sentiment) * 50))
elif avg_sentiment < -0.4:
sentiment = SentimentScore.VERY_NEGATIVE
confidence = min(95, int(70 + abs(avg_sentiment) * 50))
elif avg_sentiment < -0.15:
sentiment = SentimentScore.NEGATIVE
confidence = min(85, int(60 + abs(avg_sentiment) * 50))
else:
sentiment = SentimentScore.NEUTRAL
confidence = 50
details = {
'average_sentiment': float(avg_sentiment),
'confidence': confidence,
'articles_analyzed': len(news_articles),
'positive_articles': positive_articles,
'negative_articles': negative_articles,
'neutral_articles': neutral_articles,
'top_articles': news_articles[:5] # Top 5 für Details
}
return sentiment, details
def get_trending_topics(self, news_articles: List[Dict]) -> List[str]:
"""
Extrahiert Trend-Topics aus News
Args:
news_articles: Liste von News-Artikeln
Returns:
Liste der häufigsten Topics
"""
topics = []
keywords = [
'regulation', 'etf', 'institutional', 'mining',
'halving', 'adoption', 'sec', 'fed', 'inflation',
'blockchain', 'defi', 'nft', 'altcoin'
]
for article in news_articles:
text = f"{article.get('title', '')} {article.get('description', '')}".lower()
for keyword in keywords:
if keyword in text:
topics.append(keyword)
# Zähle Häufigkeit
from collections import Counter
topic_counts = Counter(topics)
return [topic for topic, count in topic_counts.most_common(5)]
if __name__ == "__main__":
# Test
print("=== Bitcoin News Sentiment Analyzer ===\n")
analyzer = NewsSentimentAnalyzer()
print("Lade Bitcoin-Nachrichten...")
sentiment, details = analyzer.analyze_news_sentiment(days=1, limit=30)
print(f"\n=== Sentiment-Analyse ===")
print(f"Gesamt-Sentiment: {sentiment.value}")
print(f"Konfidenz: {details['confidence']}%")
print(f"Durchschnitts-Score: {details['average_sentiment']:.3f}")
print(f"\nAnalysierte Artikel: {details['articles_analyzed']}")
print(f" Positiv: {details['positive_articles']}")
print(f" Neutral: {details['neutral_articles']}")
print(f" Negativ: {details['negative_articles']}")
if details.get('top_articles'):
print(f"\n=== Top 3 Schlagzeilen ===")
for i, article in enumerate(details['top_articles'][:3], 1):
print(f"{i}. {article.get('title', 'N/A')}")
+20
View File
@@ -0,0 +1,20 @@
# Bitcoin Trading Signal System - Python Dependencies
# Data Processing
pandas>=2.0.0
numpy>=1.24.0
# HTTP Requests
requests>=2.31.0
# Optional: Enhanced Sentiment Analysis
# textblob>=0.17.0
# vaderSentiment>=3.3.2
# Optional: Advanced Technical Analysis
# ta>=0.11.0
# pandas-ta>=0.3.14b0
# Development/Testing (optional)
# pytest>=7.4.0
# pytest-cov>=4.1.0
+295
View File
@@ -0,0 +1,295 @@
"""
Trading Signal Generator
Kombiniert MACD-Indikatoren und News-Sentiment für Trading-Empfehlungen
"""
from typing import Dict, Tuple
from enum import Enum
from dataclasses import dataclass
from datetime import datetime
from macd_indicator import MACDIndicator, MACDSignal
from news_sentiment import NewsSentimentAnalyzer, SentimentScore
class TradingAction(Enum):
"""Trading-Empfehlungs-Typen"""
STRONG_BUY = "🟢 STARKER KAUF"
BUY = "🟢 KAUF"
HOLD = "🟡 HALTEN"
SELL = "🔴 VERKAUF"
STRONG_SELL = "🔴 STARKER VERKAUF"
@dataclass
class TradingSignal:
"""Datenklasse für Trading-Signale"""
action: TradingAction
confidence: int # 0-100
price: float
timestamp: datetime
macd_signal: MACDSignal
sentiment: SentimentScore
reasons: list
technical_details: dict
sentiment_details: dict
def __str__(self):
return f"""
╔══════════════════════════════════════════════════════════════════╗
║ BITCOIN TRADING SIGNAL - {self.timestamp.strftime('%Y-%m-%d %H:%M')}
╚══════════════════════════════════════════════════════════════════╝
📊 EMPFEHLUNG: {self.action.value}
💯 KONFIDENZ: {self.confidence}%
💰 AKTUELLER PREIS: ${self.price:,.2f}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📈 TECHNISCHE ANALYSE (MACD)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Signal: {self.macd_signal.value}
MACD: {self.technical_details.get('macd', 0):.2f}
Signal Line: {self.technical_details.get('signal', 0):.2f}
Histogram: {self.technical_details.get('histogram', 0):.2f}
Preis-Änderung (10 Tage): {self.technical_details.get('price_change_10d', 0):.2f}%
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📰 SENTIMENT-ANALYSE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Markt-Sentiment: {self.sentiment.value}
Sentiment-Score: {self.sentiment_details.get('average_sentiment', 0):.3f}
Analysierte Artikel: {self.sentiment_details.get('articles_analyzed', 0)}
├─ Positiv: {self.sentiment_details.get('positive_articles', 0)}
├─ Neutral: {self.sentiment_details.get('neutral_articles', 0)}
└─ Negativ: {self.sentiment_details.get('negative_articles', 0)}
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
📋 BEGRÜNDUNG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
"""
def format_reasons(self):
"""Formatiert die Gründe"""
output = ""
for i, reason in enumerate(self.reasons, 1):
output += f" {i}. {reason}\n"
return output
class SignalGenerator:
"""
Generiert Trading-Signale durch Kombination von MACD und Sentiment
"""
def __init__(self, newsapi_key: str = None):
"""
Args:
newsapi_key: Optional NewsAPI-Schlüssel für erweiterte News
"""
self.macd_indicator = MACDIndicator()
self.sentiment_analyzer = NewsSentimentAnalyzer(api_key=newsapi_key)
# Gewichtungen für Signal-Berechnung
self.macd_weight = 0.6 # 60% Gewichtung für technische Analyse
self.sentiment_weight = 0.4 # 40% Gewichtung für Sentiment
def calculate_combined_score(self, macd_signal: MACDSignal,
sentiment: SentimentScore,
macd_confidence: int,
sentiment_confidence: int) -> Tuple[float, int]:
"""
Berechnet kombinierten Score aus MACD und Sentiment
Returns:
Tuple aus (Score -1 bis +1, Gesamt-Konfidenz)
"""
# Konvertiere Signale zu Scores (-1 bis +1)
macd_score_map = {
MACDSignal.STRONG_BUY: 1.0,
MACDSignal.BUY: 0.5,
MACDSignal.NEUTRAL: 0.0,
MACDSignal.SELL: -0.5,
MACDSignal.STRONG_SELL: -1.0
}
sentiment_score_map = {
SentimentScore.VERY_POSITIVE: 1.0,
SentimentScore.POSITIVE: 0.5,
SentimentScore.NEUTRAL: 0.0,
SentimentScore.NEGATIVE: -0.5,
SentimentScore.VERY_NEGATIVE: -1.0
}
macd_score = macd_score_map.get(macd_signal, 0)
sentiment_score = sentiment_score_map.get(sentiment, 0)
# Gewichteter kombinierter Score
combined_score = (macd_score * self.macd_weight +
sentiment_score * self.sentiment_weight)
# Gewichtete kombinierte Konfidenz
combined_confidence = int(
macd_confidence * self.macd_weight +
sentiment_confidence * self.sentiment_weight
)
# Bonus für übereinstimmende Signale
if (macd_score > 0 and sentiment_score > 0) or \
(macd_score < 0 and sentiment_score < 0):
combined_confidence = min(100, combined_confidence + 10)
return combined_score, combined_confidence
def generate_signal(self, price_df, current_price: float) -> TradingSignal:
"""
Generiert Trading-Signal
Args:
price_df: DataFrame mit historischen Preisen
current_price: Aktueller Bitcoin-Preis
Returns:
TradingSignal mit Empfehlung
"""
# Hole MACD-Signal
macd_signal, macd_details = self.macd_indicator.get_signal(price_df)
# Hole Sentiment
sentiment, sentiment_details = self.sentiment_analyzer.analyze_news_sentiment(
days=1, limit=30
)
# Berechne kombinierten Score
combined_score, confidence = self.calculate_combined_score(
macd_signal,
sentiment,
macd_details['confidence'],
sentiment_details['confidence']
)
# Bestimme Trading-Action
if combined_score >= 0.6:
action = TradingAction.STRONG_BUY
elif combined_score >= 0.2:
action = TradingAction.BUY
elif combined_score <= -0.6:
action = TradingAction.STRONG_SELL
elif combined_score <= -0.2:
action = TradingAction.SELL
else:
action = TradingAction.HOLD
# Sammle Gründe
reasons = []
# MACD-Gründe
reasons.append(f"MACD-Signal: {macd_signal.value} (Konfidenz: {macd_details['confidence']}%)")
reasons.extend(macd_details.get('reasons', []))
# Sentiment-Gründe
reasons.append(
f"Markt-Sentiment: {sentiment.value} "
f"(Konfidenz: {sentiment_details['confidence']}%, "
f"Score: {sentiment_details['average_sentiment']:.3f})"
)
# Zusätzliche Hinweise
if macd_signal in [MACDSignal.STRONG_BUY, MACDSignal.BUY] and \
sentiment in [SentimentScore.VERY_POSITIVE, SentimentScore.POSITIVE]:
reasons.append("✅ MACD und Sentiment stimmen überein → Starkes Signal")
elif macd_signal in [MACDSignal.STRONG_SELL, MACDSignal.SELL] and \
sentiment in [SentimentScore.VERY_NEGATIVE, SentimentScore.NEGATIVE]:
reasons.append("✅ MACD und Sentiment stimmen überein → Starkes Signal")
elif (macd_signal in [MACDSignal.STRONG_BUY, MACDSignal.BUY] and
sentiment in [SentimentScore.NEGATIVE, SentimentScore.VERY_NEGATIVE]) or \
(macd_signal in [MACDSignal.STRONG_SELL, MACDSignal.SELL] and
sentiment in [SentimentScore.POSITIVE, SentimentScore.VERY_POSITIVE]):
reasons.append("⚠️ MACD und Sentiment widersprechen sich → Vorsicht geboten")
confidence = max(30, confidence - 20) # Reduziere Konfidenz
# Erstelle Trading-Signal
signal = TradingSignal(
action=action,
confidence=confidence,
price=current_price,
timestamp=datetime.now(),
macd_signal=macd_signal,
sentiment=sentiment,
reasons=reasons,
technical_details=macd_details,
sentiment_details=sentiment_details
)
return signal
def get_recommendation_text(self, signal: TradingSignal) -> str:
"""
Generiert Empfehlungstext
Args:
signal: Trading-Signal
Returns:
Formatierter Empfehlungstext
"""
recommendation = str(signal)
recommendation += signal.format_reasons()
# Füge Handlungsempfehlung hinzu
recommendation += "\n━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n"
recommendation += "💡 HANDLUNGSEMPFEHLUNG\n"
recommendation += "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\n"
if signal.action == TradingAction.STRONG_BUY:
recommendation += " 🟢 STARKE KAUFGELEGENHEIT\n"
recommendation += " → Erwäge einen Einstieg oder Aufstockung der Position\n"
recommendation += " → Setze Stop-Loss ca. 5-7% unter Einstiegspreis\n"
elif signal.action == TradingAction.BUY:
recommendation += " 🟢 KAUFGELEGENHEIT\n"
recommendation += " → Erwäge einen Einstieg mit kleiner Position\n"
recommendation += " → Warte ggf. auf Bestätigung durch weitere Signale\n"
elif signal.action == TradingAction.HOLD:
recommendation += " 🟡 ABWARTEN\n"
recommendation += " → Keine klare Richtung erkennbar\n"
recommendation += " → Behalte den Markt im Auge für klarere Signale\n"
elif signal.action == TradingAction.SELL:
recommendation += " 🔴 VERKAUFSSIGNAL\n"
recommendation += " → Erwäge Teilverkauf oder Gewinnmitnahme\n"
recommendation += " → Ziehe Stop-Loss nach, um Gewinne zu sichern\n"
elif signal.action == TradingAction.STRONG_SELL:
recommendation += " 🔴 STARKES VERKAUFSSIGNAL\n"
recommendation += " → Erwäge Ausstieg aus Position\n"
recommendation += " → Sichere Gewinne oder begrenze Verluste\n"
recommendation += f"\n ⚠️ Risiko-Hinweis: Diese Analyse hat eine Konfidenz von {signal.confidence}%\n"
recommendation += " ⚠️ Keine Anlageberatung - Trading auf eigenes Risiko!\n"
recommendation += "\n╚══════════════════════════════════════════════════════════════════╝\n"
return recommendation
if __name__ == "__main__":
# Test
from data_fetcher import BitcoinDataFetcher
print("=== Bitcoin Trading Signal Generator ===\n")
print("Lade Daten...\n")
# Hole Preisdaten
fetcher = BitcoinDataFetcher()
price_df = fetcher.get_historical_data(days=30)
current_price = fetcher.get_current_price()
if price_df.empty or not current_price:
print("Fehler beim Laden der Daten!")
else:
# Generiere Signal
generator = SignalGenerator()
signal = generator.generate_signal(price_df, current_price)
# Zeige Empfehlung
recommendation = generator.get_recommendation_text(signal)
print(recommendation)
+50
View File
@@ -0,0 +1,50 @@
<?php
// Start session
session_start();
// Load Composer autoloader
require_once __DIR__ . '/vendor/autoload.php';
// Load environment variables
$dotenv = Dotenv\Dotenv::createImmutable(__DIR__);
$dotenv->load();
// Set timezone
date_default_timezone_set('Europe/Zurich');
// Error reporting based on environment
if (env('APP_DEBUG', false)) {
error_reporting(E_ALL);
ini_set('display_errors', 1);
} else {
error_reporting(0);
ini_set('display_errors', 0);
}
// Load configuration
$config = [];
$configFiles = glob(__DIR__ . '/config/*.php');
foreach ($configFiles as $file) {
$key = basename($file, '.php');
$config[$key] = require $file;
}
// Make config globally accessible
define('CONFIG', $config);
// Helper function to access config
function config(string $key, $default = null)
{
$keys = explode('.', $key);
$value = CONFIG;
foreach ($keys as $k) {
if (!isset($value[$k])) {
return $default;
}
$value = $value[$k];
}
return $value;
}
+40
View File
@@ -0,0 +1,40 @@
{
"name": "getyourband/platform",
"description": "Modern band booking platform",
"type": "project",
"license": "proprietary",
"require": {
"php": ">=8.3",
"ext-pdo": "*",
"ext-mbstring": "*",
"ext-json": "*",
"vlucas/phpdotenv": "^5.6",
"twig/twig": "^3.8",
"phpmailer/phpmailer": "^6.9",
"respect/validation": "^2.3"
},
"require-dev": {
"phpunit/phpunit": "^10.5"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\": "database/"
},
"files": [
"app/helpers.php"
]
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true
},
"minimum-stability": "stable",
"prefer-stable": true,
"scripts": {
"post-autoload-dump": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
]
}
}
+28
View File
@@ -0,0 +1,28 @@
<?php
return [
'name' => env('APP_NAME', 'GetYourBand'),
'env' => env('APP_ENV', 'production'),
'debug' => env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'timezone' => 'Europe/Zurich',
'locale' => 'de_CH',
'features' => [
'email_verification' => env('REQUIRE_EMAIL_VERIFICATION', true),
'band_approval' => env('REQUIRE_BAND_APPROVAL', true),
'reviews' => env('ENABLE_REVIEWS', true),
'payment' => env('PAYMENT_ENABLED', false),
],
'upload' => [
'max_size' => env('MAX_UPLOAD_SIZE', 5242880), // 5MB
'allowed_images' => explode(',', env('ALLOWED_IMAGE_TYPES', 'jpg,jpeg,png,webp')),
'allowed_videos' => explode(',', env('ALLOWED_VIDEO_TYPES', 'mp4,webm')),
],
'pagination' => [
'per_page' => 12,
],
];
+17
View File
@@ -0,0 +1,17 @@
<?php
return [
'connection' => env('DB_CONNECTION', 'mysql'),
'connections' => [
'mysql' => [
'host' => env('DB_HOST', '127.0.0.1'),
'port' => env('DB_PORT', '3306'),
'database' => env('DB_DATABASE', 'getyourband'),
'username' => env('DB_USERNAME', 'root'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
],
],
];
+91
View File
@@ -0,0 +1,91 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'kunde',
city TEXT,
verified INTEGER NOT NULL DEFAULT 0,
verification_token TEXT,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS bands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
name TEXT NOT NULL,
email TEXT,
city TEXT,
genre TEXT,
price INTEGER DEFAULT 0,
description TEXT,
status TEXT NOT NULL DEFAULT 'prüfung',
style_tags TEXT,
video_url TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS band_media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
band_id INTEGER NOT NULL,
type TEXT NOT NULL,
url TEXT NOT NULL,
FOREIGN KEY(band_id) REFERENCES bands(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS band_availability (
id INTEGER PRIMARY KEY AUTOINCREMENT,
band_id INTEGER NOT NULL,
event_date TEXT NOT NULL,
status TEXT NOT NULL,
FOREIGN KEY(band_id) REFERENCES bands(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
band_id INTEGER NOT NULL,
user_id INTEGER,
event_date TEXT,
location TEXT,
budget INTEGER,
event_type TEXT,
message TEXT,
status TEXT NOT NULL DEFAULT 'neu',
created_at TEXT,
FOREIGN KEY(band_id) REFERENCES bands(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
band_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
rating INTEGER NOT NULL,
comment TEXT,
status TEXT NOT NULL DEFAULT 'wartend',
created_at TEXT,
FOREIGN KEY(band_id) REFERENCES bands(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
request_id INTEGER NOT NULL,
amount REAL NOT NULL,
service_fee REAL NOT NULL,
total_amount REAL NOT NULL,
paypal_order_id TEXT,
paypal_payer_id TEXT,
status TEXT NOT NULL DEFAULT 'pending',
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT,
FOREIGN KEY(request_id) REFERENCES requests(id) ON DELETE CASCADE
);
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace Database;
use PDO;
use PDOException;
class Database
{
private static ?PDO $instance = null;
public static function connect(): PDO
{
if (self::$instance === null) {
try {
$host = $_ENV['DB_HOST'] ?? '127.0.0.1';
$port = $_ENV['DB_PORT'] ?? '3306';
$dbname = $_ENV['DB_DATABASE'] ?? 'getyourband';
$username = $_ENV['DB_USERNAME'] ?? 'root';
$password = $_ENV['DB_PASSWORD'] ?? '';
$dsn = "mysql:host={$host};port={$port};dbname={$dbname};charset=utf8mb4";
self::$instance = new PDO($dsn, $username, $password, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
} catch (PDOException $e) {
throw new \RuntimeException("Database connection failed: " . $e->getMessage());
}
}
return self::$instance;
}
public static function disconnect(): void
{
self::$instance = null;
}
public static function runMigrations(string $migrationsPath): void
{
$db = self::connect();
$files = glob($migrationsPath . '/*.sql');
sort($files);
foreach ($files as $file) {
echo "Running migration: " . basename($file) . "\n";
$sql = file_get_contents($file);
try {
$db->exec($sql);
echo "✓ Migration completed successfully\n";
} catch (PDOException $e) {
echo "✗ Migration failed: " . $e->getMessage() . "\n";
throw $e;
}
}
echo "\nAll migrations completed!\n";
}
}
@@ -0,0 +1,21 @@
-- Migration: Create users table
-- Created: 2025-12-02
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
role ENUM('admin', 'band', 'customer') NOT NULL DEFAULT 'customer',
email_verified_at TIMESTAMP NULL,
verification_token VARCHAR(64) NULL,
reset_token VARCHAR(64) NULL,
reset_token_expires TIMESTAMP NULL,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_role (role),
INDEX idx_verification_token (verification_token),
INDEX idx_reset_token (reset_token)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,38 @@
-- Migration: Create bands table
-- Created: 2025-12-02
CREATE TABLE IF NOT EXISTS bands (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
name VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL UNIQUE,
description TEXT,
genre VARCHAR(100),
location VARCHAR(255),
postal_code VARCHAR(10),
price_min DECIMAL(10, 2),
price_max DECIMAL(10, 2),
member_count INT,
phone VARCHAR(50),
website VARCHAR(255),
facebook VARCHAR(255),
instagram VARCHAR(255),
youtube VARCHAR(255),
profile_image VARCHAR(255),
cover_image VARCHAR(255),
is_approved BOOLEAN DEFAULT FALSE,
is_active BOOLEAN DEFAULT TRUE,
view_count INT DEFAULT 0,
average_rating DECIMAL(3, 2) DEFAULT 0.00,
total_reviews INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_slug (slug),
INDEX idx_genre (genre),
INDEX idx_location (location),
INDEX idx_postal_code (postal_code),
INDEX idx_is_approved (is_approved),
INDEX idx_average_rating (average_rating),
FULLTEXT idx_search (name, description, genre)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,17 @@
-- Migration: Create band_media table
-- Created: 2025-12-02
CREATE TABLE IF NOT EXISTS band_media (
id INT AUTO_INCREMENT PRIMARY KEY,
band_id INT NOT NULL,
type ENUM('image', 'video') NOT NULL,
url VARCHAR(500) NOT NULL,
title VARCHAR(255),
is_featured BOOLEAN DEFAULT FALSE,
sort_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (band_id) REFERENCES bands(id) ON DELETE CASCADE,
INDEX idx_band_id (band_id),
INDEX idx_type (type),
INDEX idx_sort_order (sort_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,26 @@
-- Migration: Create bookings table
-- Created: 2025-12-02
CREATE TABLE IF NOT EXISTS bookings (
id INT AUTO_INCREMENT PRIMARY KEY,
band_id INT NOT NULL,
customer_id INT NOT NULL,
event_date DATE NOT NULL,
event_time TIME,
event_location VARCHAR(255) NOT NULL,
event_type VARCHAR(100),
budget DECIMAL(10, 2),
guest_count INT,
message TEXT,
status ENUM('pending', 'accepted', 'rejected', 'completed', 'cancelled') DEFAULT 'pending',
band_response TEXT,
responded_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (band_id) REFERENCES bands(id) ON DELETE CASCADE,
FOREIGN KEY (customer_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_band_id (band_id),
INDEX idx_customer_id (customer_id),
INDEX idx_status (status),
INDEX idx_event_date (event_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,23 @@
-- Migration: Create reviews table
-- Created: 2025-12-02
CREATE TABLE IF NOT EXISTS reviews (
id INT AUTO_INCREMENT PRIMARY KEY,
band_id INT NOT NULL,
booking_id INT NOT NULL,
customer_id INT NOT NULL,
rating INT NOT NULL CHECK (rating BETWEEN 1 AND 5),
comment TEXT,
is_approved BOOLEAN DEFAULT FALSE,
is_visible BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (band_id) REFERENCES bands(id) ON DELETE CASCADE,
FOREIGN KEY (booking_id) REFERENCES bookings(id) ON DELETE CASCADE,
FOREIGN KEY (customer_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY unique_booking_review (booking_id),
INDEX idx_band_id (band_id),
INDEX idx_customer_id (customer_id),
INDEX idx_rating (rating),
INDEX idx_is_approved (is_approved)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
@@ -0,0 +1,16 @@
-- Migration: Create band_availability table
-- Created: 2025-12-02
CREATE TABLE IF NOT EXISTS band_availability (
id INT AUTO_INCREMENT PRIMARY KEY,
band_id INT NOT NULL,
date DATE NOT NULL,
is_available BOOLEAN DEFAULT TRUE,
notes VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (band_id) REFERENCES bands(id) ON DELETE CASCADE,
UNIQUE KEY unique_band_date (band_id, date),
INDEX idx_band_id (band_id),
INDEX idx_date (date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/config.php';
$options = array_slice($argv, 1);
if (in_array('--fresh', $options, true) && file_exists(DB_PATH)) {
unlink(DB_PATH);
echo "Bestehende Datenbank entfernt wird neu initialisiert.\n";
}
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/email.php';
if (!isset($_SERVER['REQUEST_METHOD'])) {
$_SERVER['REQUEST_METHOD'] = 'GET';
}
final class FunctionalTestRunner
{
private PDO $pdo;
private array $results = [];
private int $passed = 0;
private int $failed = 0;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function run(string $title, callable $test, bool $transactional = false): void
{
try {
if ($transactional) {
$this->pdo->beginTransaction();
}
$details = $test($this->pdo);
if ($transactional && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->record('PASS', $title, $details);
} catch (Throwable $e) {
if ($transactional && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->record('FAIL', $title, $e->getMessage());
}
}
private function record(string $status, string $title, ?string $details): void
{
$status === 'PASS' ? $this->passed++ : $this->failed++;
$this->results[] = [
'status' => $status,
'title' => $title,
'details' => $details ?? '',
];
}
public function summary(): void
{
foreach ($this->results as $result) {
$symbol = $result['status'] === 'PASS' ? '\u{2705}' : '\u{274C}';
echo sprintf("%s %s\n", $symbol, $result['title']);
if ($result['details'] !== '') {
echo sprintf(" %s\n", $result['details']);
}
}
echo str_repeat('-', 50) . "\n";
echo sprintf("Ergebnis: %d bestanden, %d fehlgeschlagen\n", $this->passed, $this->failed);
exit($this->failed === 0 ? 0 : 1);
}
}
function renderPage(string $file, array $get = [], array $post = []): string
{
$previousGet = $_GET ?? [];
$previousPost = $_POST ?? [];
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$_GET = $get;
$_POST = $post;
$_SERVER['REQUEST_METHOD'] = empty($post) ? 'GET' : 'POST';
ob_start();
include __DIR__ . '/' . ltrim($file, '/');
$output = ob_get_clean();
$_GET = $previousGet;
$_POST = $previousPost;
$_SERVER['REQUEST_METHOD'] = $previousMethod;
return $output;
}
function restartSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
$pdo = db();
$runner = new FunctionalTestRunner($pdo);
$runner->run('Storage-Verzeichnis vorhanden', function () {
if (!is_dir(__DIR__ . '/storage')) {
throw new RuntimeException('Ordner storage fehlt.');
}
return 'Pfad: ' . realpath(__DIR__ . '/storage');
});
$runner->run('Datenbank initialisiert', function (PDO $pdo) {
if (!file_exists(DB_PATH)) {
throw new RuntimeException('database.sqlite wurde nicht erstellt.');
}
$tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")
->fetchAll(PDO::FETCH_COLUMN);
$required = ['users', 'bands', 'requests', 'reviews', 'settings'];
foreach ($required as $table) {
if (!in_array($table, $tables, true)) {
throw new RuntimeException('Tabelle ' . $table . ' fehlt.');
}
}
return 'Tabellen gefunden: ' . implode(', ', $required);
});
$runner->run('Seed-Daten verfügbar', function (PDO $pdo) {
$users = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
$bands = (int) $pdo->query('SELECT COUNT(*) FROM bands')->fetchColumn();
if ($users < 3 || $bands < 2) {
throw new RuntimeException('Seed-Daten unvollständig.');
}
return sprintf('Users: %d, Bands: %d', $users, $bands);
});
$runner->run('Login / Logout Workflow', function () {
if (!login('david@example.com', 'secret123')) {
throw new RuntimeException('Login schlug fehl.');
}
$user = currentUser();
if (!$user || $user['role'] !== 'kunde') {
throw new RuntimeException('Session liefert keinen Kunden.');
}
logout();
restartSession();
if (currentUser()) {
throw new RuntimeException('Logout hat Session nicht geleert.');
}
return 'Login erfolgreich für ' . $user['name'];
});
$runner->run('Band-Filter & Durchschnitt', function () {
$bands = allBands(['genre' => 'Funk']);
if (!$bands) {
throw new RuntimeException('Filter lieferte keine Band.');
}
$rating = averageRating((int) $bands[0]['id']);
if ($rating === null) {
throw new RuntimeException('Keine Bewertung vorhanden.');
}
return sprintf('%d Bands, Ø Bewertung %.1f★', count($bands), $rating);
});
$runner->run('Medien & Verfügbarkeiten geladen', function () {
$media = bandMedia(1);
$availability = bandAvailability(1);
$reviews = bandReviews(1);
if (!$media || !$availability || !$reviews) {
throw new RuntimeException('Band 1 hat unvollständige Daten.');
}
return sprintf('Medien: %d, Slots: %d, Reviews: %d', count($media), count($availability), count($reviews));
});
$runner->run('Anfrage speichern (Transaktion)', function (PDO $pdo) {
$before = (int) $pdo->query('SELECT COUNT(*) FROM requests')->fetchColumn();
createRequest([
'band_id' => 1,
'user_id' => 3,
'event_date' => (new DateTimeImmutable('+60 days'))->format('Y-m-d'),
'location' => 'Teststadt',
'budget' => 4500,
'event_type' => 'Testevent',
'message' => 'Funktionstest Anfrage',
]);
$after = (int) $pdo->query('SELECT COUNT(*) FROM requests')->fetchColumn();
if ($after !== $before + 1) {
throw new RuntimeException('Anfrage wurde nicht gespeichert.');
}
return 'Requests gesamt (temporär): ' . $after;
}, true);
$runner->run('Bewertungen speichern & Eligibility', function (PDO $pdo) {
if (!eligibleForReview(1, 3)) {
throw new RuntimeException('User 3 sollte berechtigt sein.');
}
$before = (int) $pdo->query('SELECT COUNT(*) FROM reviews')->fetchColumn();
storeReview([
'band_id' => 1,
'user_id' => 3,
'rating' => 4,
'comment' => 'Testkommentar',
]);
$after = (int) $pdo->query('SELECT COUNT(*) FROM reviews')->fetchColumn();
if ($after !== $before + 1) {
throw new RuntimeException('Review wurde nicht gespeichert.');
}
return 'Reviews gesamt (temporär): ' . $after;
}, true);
$runner->run('Einstellungen lesen & aktualisieren', function () {
$current = settings();
$originalFee = $current['service_fee'] ?? '0';
updateSetting('service_fee', '12');
$updated = settings();
if (($updated['service_fee'] ?? null) !== '12') {
throw new RuntimeException('Service Fee konnte nicht aktualisiert werden.');
}
updateSetting('service_fee', $originalFee);
return 'Service Fee temporär auf 12 gesetzt.';
}, true);
$runner->run('Moderations-Aktionen', function (PDO $pdo) {
changeBandStatus(1, 'prüfung');
$status = $pdo->query('SELECT status FROM bands WHERE id = 1')->fetchColumn();
if ($status !== 'prüfung') {
throw new RuntimeException('Bandstatus änderte sich nicht.');
}
changeReviewStatus(1, 'gesperrt');
$reviewStatus = $pdo->query('SELECT status FROM reviews WHERE id = 1')->fetchColumn();
if ($reviewStatus !== 'gesperrt') {
throw new RuntimeException('Reviewstatus änderte sich nicht.');
}
return 'Statusänderungen durchgeführt.';
}, true);
$runner->run('Registrierung legt Band an', function (PDO $pdo) {
$email = 'tester+' . uniqid('', true) . '@example.com';
$result = register([
'name' => 'Functional Tester',
'email' => $email,
'password' => 'secret123',
'role' => 'band',
'city' => 'Testingen',
'band_name' => 'QA Ensemble',
'genre' => 'QA Funk',
]);
if (empty($result['token']) || strlen($result['token']) < 20) {
throw new RuntimeException('Verifikationstoken fehlt.');
}
$user = $pdo->prepare('SELECT id, role FROM users WHERE email = :email');
$user->execute([':email' => $email]);
$userRow = $user->fetch(PDO::FETCH_ASSOC);
if (!$userRow || $userRow['role'] !== 'band') {
throw new RuntimeException('User wurde nicht gespeichert.');
}
$band = $pdo->prepare('SELECT status FROM bands WHERE user_id = :id');
$band->execute([':id' => $userRow['id']]);
$bandRow = $band->fetch(PDO::FETCH_ASSOC);
if (!$bandRow || $bandRow['status'] !== 'prüfung') {
throw new RuntimeException('Bandprofil wurde nicht angelegt.');
}
return 'Token erstellt und Bandstatus "prüfung" bestätigt.';
}, true);
$runner->run('Startseite rendert fehlerfrei', function () {
$html = renderPage('index.php');
if (strpos($html, 'Aktive Bands') === false) {
throw new RuntimeException('Indexseite liefert keinen Inhalt.');
}
return 'HTML-Länge: ' . strlen($html) . ' Zeichen';
});
$runner->run('Band-Detailseite rendert', function () {
$html = renderPage('band-detail.php', ['id' => 1]);
if (strpos($html, 'Verfügbarkeit') === false) {
throw new RuntimeException('Band-Detailseite unvollständig.');
}
return 'HTML-Länge: ' . strlen($html) . ' Zeichen';
});
$runner->run('Anfrageformular rendert', function () {
$html = renderPage('anfrage.php', ['band_id' => 1]);
if (strpos($html, 'Anfrage an') === false) {
throw new RuntimeException('Anfrageformular fehlgeschlagen.');
}
return 'HTML-Länge: ' . strlen($html) . ' Zeichen';
});
$runner->run('E-Mail Logging (kein Versand)', function () {
$logDir = __DIR__ . '/storage/logs';
if (!is_dir($logDir)) {
mkdir($logDir, 0775, true);
}
$logFile = $logDir . '/mail.log';
$before = file_exists($logFile) ? filesize($logFile) : 0;
sendEmail('qa@example.com', 'Functional Test', 'Nur Logeintrag kein Versand.');
$after = filesize($logFile);
if ($after <= $before) {
throw new RuntimeException('Mail-Log wurde nicht aktualisiert.');
}
return 'Logeintrag ergänzt, Versand erfolgt nur als Datei.';
});
$runner->summary();
+93
View File
@@ -0,0 +1,93 @@
<?php
require_once __DIR__ . '/functions.php';
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
function currentUser(): ?array
{
if (empty($_SESSION['user_id'])) {
return null;
}
static $user;
if ($user) {
return $user;
}
$stmt = db()->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $_SESSION['user_id']]);
$user = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
return $user;
}
function login(string $email, string $password): bool
{
$stmt = db()->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($password, $user['password'])) {
return false;
}
if ((int) $user['verified'] !== 1) {
throw new RuntimeException('Bitte verifiziere zuerst deine E-Mail.');
}
$_SESSION['user_id'] = $user['id'];
return true;
}
function logout(): void
{
session_destroy();
}
function register(array $data): array
{
$token = bin2hex(random_bytes(16));
$stmt = db()->prepare('INSERT INTO users (name, email, password, role, city, verified, verification_token, created_at)
VALUES (:name, :email, :password, :role, :city, 0, :token, :created)');
$stmt->execute([
':name' => $data['name'],
':email' => $data['email'],
':password' => password_hash($data['password'], PASSWORD_DEFAULT),
':role' => $data['role'],
':city' => $data['city'] ?? null,
':token' => $token,
':created' => (new DateTimeImmutable())->format('c'),
]);
if ($data['role'] === 'band') {
$band = db()->prepare('INSERT INTO bands (user_id, name, city, genre, price, description, status)
VALUES (:user_id, :name, :city, :genre, :price, :description, :status)');
$band->execute([
':user_id' => (int) db()->lastInsertId(),
':name' => $data['band_name'] ?? 'Neue Band',
':city' => $data['city'] ?? '',
':genre' => $data['genre'] ?? '',
':price' => 0,
':description' => 'Bitte Profil ergänzen.',
':status' => 'prüfung',
]);
}
return ['token' => $token];
}
function requireLogin(): void
{
if (!currentUser()) {
header('Location: login.php');
exit;
}
}
function requireAdmin(): void
{
$user = currentUser();
if (!$user || $user['role'] !== 'admin') {
http_response_code(403);
echo 'Keine Berechtigung';
exit;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
const SITE_NAME = 'GetYourBand';
const DB_PATH = __DIR__ . '/../storage/database.sqlite';
const SUPPORT_EMAIL = 'support@getyourband.ch';
const BASE_URL = '';
const COOKIE_NAME = 'gyb_consent';
if (!is_dir(__DIR__ . '/../storage')) {
mkdir(__DIR__ . '/../storage', 0775, true);
}
+144
View File
@@ -0,0 +1,144 @@
<?php
require_once __DIR__ . '/config.php';
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'sqlite:' . DB_PATH;
$pdo = new PDO($dsn);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
initializeDatabase($pdo);
return $pdo;
}
function initializeDatabase(PDO $pdo): void
{
$schema = file_get_contents(__DIR__ . '/../database.sql');
$pdo->exec($schema);
seedData($pdo);
}
function seedData(PDO $pdo): void
{
$count = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
if ($count > 0) {
return;
}
$now = (new DateTimeImmutable())->format('c');
$password = password_hash('secret123', PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (name, email, password, role, verified, verification_token, created_at)
VALUES (:name, :email, :password, :role, :verified, :token, :created)');
$stmt->execute([
':name' => 'Admin',
':email' => 'admin@getyourband.ch',
':password' => $password,
':role' => 'admin',
':verified' => 1,
':token' => null,
':created' => $now,
]);
$stmt->execute([
':name' => 'Maya Keller',
':email' => 'maya@getyourband.ch',
':password' => $password,
':role' => 'band',
':verified' => 1,
':token' => null,
':created' => $now,
]);
$stmt->execute([
':name' => 'David Graf',
':email' => 'david@example.com',
':password' => $password,
':role' => 'kunde',
':verified' => 1,
':token' => null,
':created' => $now,
]);
$bands = [
[
'user_id' => 2,
'name' => 'Neon Groove Kollektiv',
'city' => 'Zürich',
'genre' => 'Funk / Soul',
'price' => 4200,
'description' => '7-köpfige Funk- und Soulband mit knalligem Brass-Sound und interaktiver Show.',
'status' => 'aktiv',
'style_tags' => 'Funk,Retro,Showband',
'video_url' => 'https://www.youtube.com/embed/dQw4w9WgXcQ',
],
[
'user_id' => null,
'name' => 'Sonnenblitz Orchester',
'city' => 'Bern',
'genre' => 'Pop / Party',
'price' => 3700,
'description' => 'Party-Coverband mit LED-Lichtshow und zweistimmigem Gesang.',
'status' => 'aktiv',
'style_tags' => 'Pop,Party,LED',
'video_url' => 'https://www.youtube.com/embed/5NV6Rdv1a3I',
],
];
$bandStmt = $pdo->prepare('INSERT INTO bands (user_id, name, city, genre, price, description, status, style_tags, video_url)
VALUES (:user_id, :name, :city, :genre, :price, :description, :status, :style_tags, :video_url)');
foreach ($bands as $band) {
$bandStmt->execute([
':user_id' => $band['user_id'],
':name' => $band['name'],
':city' => $band['city'],
':genre' => $band['genre'],
':price' => $band['price'],
':description' => $band['description'],
':status' => $band['status'],
':style_tags' => $band['style_tags'],
':video_url' => $band['video_url'],
]);
$bandId = (int) $pdo->lastInsertId();
$mediaStmt = $pdo->prepare('INSERT INTO band_media (band_id, type, url) VALUES (:band_id, :type, :url)');
$mediaStmt->execute([':band_id' => $bandId, ':type' => 'image', ':url' => 'https://images.unsplash.com/photo-1507878866276-a947ef722fee']);
$mediaStmt->execute([':band_id' => $bandId, ':type' => 'image', ':url' => 'https://images.unsplash.com/photo-1489515217757-5fd1be406fef']);
$availStmt = $pdo->prepare('INSERT INTO band_availability (band_id, event_date, status) VALUES (:band_id, :event_date, :status)');
for ($i = 0; $i < 4; $i++) {
$availStmt->execute([
':band_id' => $bandId,
':event_date' => (new DateTimeImmutable('+' . ($i + 1) * 7 . ' days'))->format('Y-m-d'),
':status' => $i % 2 === 0 ? 'frei' : 'option',
]);
}
}
$pdo->exec("INSERT INTO settings (key, value) VALUES ('paypal_enabled', '0'), ('service_fee', '8')");
$requestStmt = $pdo->prepare('INSERT INTO requests (band_id, user_id, event_date, location, budget, event_type, message, status, created_at)
VALUES (:band_id, :user_id, :event_date, :location, :budget, :event_type, :message, :status, :created)');
$requestStmt->execute([
':band_id' => 1,
':user_id' => 3,
':event_date' => (new DateTimeImmutable('+30 days'))->format('Y-m-d'),
':location' => 'Basel',
':budget' => 5000,
':event_type' => 'Firmenfeier',
':message' => 'Wir suchen einen funky Act für die Sommerparty.',
':status' => 'bestätigt',
':created' => $now,
]);
$pdo->exec("INSERT INTO reviews (band_id, user_id, rating, comment, status, created_at) VALUES (1, 3, 5, 'Mega Stimmung und super Show!', 'freigegeben', datetime('now'))");
}
+181
View File
@@ -0,0 +1,181 @@
<?php
require_once __DIR__ . '/config.php';
function sendEmail(string $to, string $subject, string $message, bool $isHtml = true): bool
{
$logDir = __DIR__ . '/../storage/logs';
if (!is_dir($logDir)) {
mkdir($logDir, 0775, true);
}
$entry = sprintf("%s\nTo: %s\nSubject: %s\n%s\n---\n", date('c'), $to, $subject, $message);
file_put_contents($logDir . '/mail.log', $entry, FILE_APPEND);
$headers = [
'From: ' . SITE_NAME . ' <' . SUPPORT_EMAIL . '>',
'Reply-To: ' . SUPPORT_EMAIL,
'X-Mailer: PHP/' . phpversion(),
'MIME-Version: 1.0'
];
if ($isHtml) {
$headers[] = 'Content-Type: text/html; charset=UTF-8';
} else {
$headers[] = 'Content-Type: text/plain; charset=UTF-8';
}
return mail($to, $subject, $message, implode("\r\n", $headers));
}
function sendBookingRequestEmail(array $band, array $requestData, ?array $customer = null): bool
{
$bandEmail = $band['email'] ?? 'info@' . preg_replace('/\s+/', '', strtolower($band['name'])) . '.ch';
$subject = 'Neue Buchungsanfrage für ' . $band['name'];
$message = emailTemplate('booking_request', [
'band_name' => $band['name'],
'event_date' => date('d.m.Y', strtotime($requestData['event_date'])),
'location' => $requestData['location'],
'event_type' => $requestData['event_type'] ?: 'Nicht angegeben',
'budget' => $requestData['budget'] ? formatPrice($requestData['budget']) : 'Nicht angegeben',
'message' => $requestData['message'] ?: 'Keine Nachricht',
'customer_name' => $customer['name'] ?? 'Gast',
'customer_email' => $customer['email'] ?? 'Keine Email angegeben',
]);
return sendEmail($bandEmail, $subject, $message);
}
function sendBookingConfirmationEmail(string $customerEmail, array $band, array $requestData): bool
{
$subject = 'Ihre Anfrage an ' . $band['name'] . ' wurde gesendet';
$message = emailTemplate('booking_confirmation', [
'band_name' => $band['name'],
'event_date' => date('d.m.Y', strtotime($requestData['event_date'])),
'location' => $requestData['location'],
'site_name' => SITE_NAME,
]);
return sendEmail($customerEmail, $subject, $message);
}
function emailTemplate(string $templateName, array $data): string
{
$templates = [
'booking_request' => '
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #f4b807; padding: 20px; text-align: center; }
.content { background: #fff; padding: 20px; border: 1px solid #ddd; }
.info-row { margin: 10px 0; padding: 10px; background: #f9f9f9; }
.label { font-weight: bold; color: #666; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 style="margin:0; color: #fff;">🎸 Neue Buchungsanfrage</h1>
</div>
<div class="content">
<p>Hallo <strong>' . htmlspecialchars($data['band_name']) . '</strong>,</p>
<p>Sie haben eine neue Buchungsanfrage erhalten:</p>
<div class="info-row">
<span class="label">Event-Datum:</span> ' . htmlspecialchars($data['event_date']) . '
</div>
<div class="info-row">
<span class="label">Ort:</span> ' . htmlspecialchars($data['location']) . '
</div>
<div class="info-row">
<span class="label">Event-Typ:</span> ' . htmlspecialchars($data['event_type']) . '
</div>
<div class="info-row">
<span class="label">Budget:</span> ' . htmlspecialchars($data['budget']) . '
</div>
<div class="info-row">
<span class="label">Nachricht:</span><br>' . nl2br(htmlspecialchars($data['message'])) . '
</div>
<h3>Kontaktdaten:</h3>
<div class="info-row">
<span class="label">Name:</span> ' . htmlspecialchars($data['customer_name']) . '
</div>
<div class="info-row">
<span class="label">Email:</span> <a href="mailto:' . htmlspecialchars($data['customer_email']) . '">' . htmlspecialchars($data['customer_email']) . '</a>
</div>
<p style="margin-top: 20px;">
Bitte kontaktieren Sie den Kunden direkt, um die Details zu besprechen.
</p>
</div>
<div class="footer">
Gesendet von ' . SITE_NAME . ' - Ihre Band-Vermittlungsplattform
</div>
</div>
</body>
</html>
',
'booking_confirmation' => '
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background: #f4b807; padding: 20px; text-align: center; }
.content { background: #fff; padding: 20px; border: 1px solid #ddd; }
.info-row { margin: 10px 0; padding: 10px; background: #f9f9f9; }
.label { font-weight: bold; color: #666; }
.footer { text-align: center; padding: 20px; color: #666; font-size: 12px; }
.success { background: #d4edda; border: 1px solid #c3e6cb; padding: 15px; margin: 15px 0; border-radius: 4px; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 style="margin:0; color: #fff;">✅ Anfrage gesendet</h1>
</div>
<div class="content">
<div class="success">
<strong>Ihre Anfrage wurde erfolgreich gesendet!</strong>
</div>
<p>Vielen Dank für Ihre Anfrage an <strong>' . htmlspecialchars($data['band_name']) . '</strong>.</p>
<h3>Details Ihrer Anfrage:</h3>
<div class="info-row">
<span class="label">Event-Datum:</span> ' . htmlspecialchars($data['event_date']) . '
</div>
<div class="info-row">
<span class="label">Ort:</span> ' . htmlspecialchars($data['location']) . '
</div>
<p style="margin-top: 20px;">
Die Band wird sich in Kürze bei Ihnen melden. Bitte überprüfen Sie auch Ihren Spam-Ordner.
</p>
<p>
Bei Fragen können Sie uns jederzeit unter <a href="mailto:' . SUPPORT_EMAIL . '">' . SUPPORT_EMAIL . '</a> erreichen.
</p>
</div>
<div class="footer">
Vielen Dank, dass Sie ' . htmlspecialchars($data['site_name']) . ' nutzen!
</div>
</div>
</body>
</html>
',
];
return $templates[$templateName] ?? '';
}
+159
View File
@@ -0,0 +1,159 @@
<?php
require_once __DIR__ . '/database.php';
function allBands(array $filters = []): array
{
$pdo = db();
$where = ['status = :status'];
$params = [':status' => 'aktiv'];
if (!empty($filters['genre'])) {
$where[] = 'genre LIKE :genre';
$params[':genre'] = '%' . $filters['genre'] . '%';
}
if (!empty($filters['city'])) {
$where[] = 'city LIKE :city';
$params[':city'] = '%' . $filters['city'] . '%';
}
$sql = 'SELECT * FROM bands WHERE ' . implode(' AND ', $where) . ' ORDER BY name';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function findBand(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM bands WHERE id = :id');
$stmt->execute([':id' => $id]);
$band = $stmt->fetch(PDO::FETCH_ASSOC);
return $band ?: null;
}
function bandMedia(int $bandId): array
{
$stmt = db()->prepare('SELECT * FROM band_media WHERE band_id = :id');
$stmt->execute([':id' => $bandId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function bandAvailability(int $bandId): array
{
$stmt = db()->prepare('SELECT * FROM band_availability WHERE band_id = :id ORDER BY event_date');
$stmt->execute([':id' => $bandId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function bandReviews(int $bandId): array
{
$stmt = db()->prepare('SELECT r.*, u.name AS author
FROM reviews r
JOIN users u ON u.id = r.user_id
WHERE r.band_id = :id AND r.status = "freigegeben"
ORDER BY r.created_at DESC');
$stmt->execute([':id' => $bandId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function averageRating(int $bandId): ?float
{
$stmt = db()->prepare('SELECT AVG(rating) FROM reviews WHERE band_id = :id AND status = "freigegeben"');
$stmt->execute([':id' => $bandId]);
$value = $stmt->fetchColumn();
return $value ? round((float) $value, 1) : null;
}
function formatPrice(int $amount): string
{
return number_format($amount, 0, ',', '.') . ' CHF';
}
function createRequest(array $data): void
{
$stmt = db()->prepare('INSERT INTO requests (band_id, user_id, event_date, location, budget, event_type, message, status, created_at)
VALUES (:band_id, :user_id, :event_date, :location, :budget, :event_type, :message, :status, :created_at)');
$stmt->execute([
':band_id' => $data['band_id'],
':user_id' => $data['user_id'],
':event_date' => $data['event_date'],
':location' => $data['location'],
':budget' => $data['budget'],
':event_type' => $data['event_type'],
':message' => $data['message'],
':status' => 'neu',
':created_at' => (new DateTimeImmutable())->format('c'),
]);
}
function userRequests(int $userId): array
{
$stmt = db()->prepare('SELECT * FROM requests WHERE user_id = :id ORDER BY created_at DESC');
$stmt->execute([':id' => $userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function storeReview(array $data): void
{
$stmt = db()->prepare('INSERT INTO reviews (band_id, user_id, rating, comment, status, created_at)
VALUES (:band_id, :user_id, :rating, :comment, :status, :created_at)');
$stmt->execute([
':band_id' => $data['band_id'],
':user_id' => $data['user_id'],
':rating' => $data['rating'],
':comment' => $data['comment'],
':status' => 'wartend',
':created_at' => (new DateTimeImmutable())->format('c'),
]);
}
function eligibleForReview(int $bandId, int $userId): bool
{
$stmt = db()->prepare('SELECT COUNT(*) FROM requests WHERE band_id = :band AND user_id = :user AND status = "bestätigt"');
$stmt->execute([':band' => $bandId, ':user' => $userId]);
return (int) $stmt->fetchColumn() > 0;
}
function settings(): array
{
$stmt = db()->query('SELECT key, value FROM settings');
$data = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
return $data ?: ['paypal_enabled' => '0', 'service_fee' => '0'];
}
function updateSetting(string $key, string $value): void
{
$stmt = db()->prepare('INSERT INTO settings (key, value) VALUES (:key, :value)
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
$stmt->execute([':key' => $key, ':value' => $value]);
}
function moderationItems(string $type): array
{
$pdo = db();
if ($type === 'bands') {
return $pdo->query('SELECT * FROM bands WHERE status != "aktiv"')->fetchAll(PDO::FETCH_ASSOC);
}
if ($type === 'reviews') {
return $pdo->query('SELECT r.*, b.name AS band_name, u.name AS author
FROM reviews r
JOIN bands b ON b.id = r.band_id
JOIN users u ON u.id = r.user_id
WHERE r.status = "wartend"')->fetchAll(PDO::FETCH_ASSOC);
}
return [];
}
function changeBandStatus(int $bandId, string $status): void
{
$stmt = db()->prepare('UPDATE bands SET status = :status WHERE id = :id');
$stmt->execute([':status' => $status, ':id' => $bandId]);
}
function changeReviewStatus(int $reviewId, string $status): void
{
$stmt = db()->prepare('UPDATE reviews SET status = :status WHERE id = :id');
$stmt->execute([':status' => $status, ':id' => $reviewId]);
}
+98 -314
View File
@@ -1,327 +1,111 @@
<?php <?php
$title = "MausSynth Lab"; declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
$filters = [
'genre' => $_GET['genre'] ?? '',
'city' => $_GET['city'] ?? '',
];
$bands = allBands($filters);
$settings = settings();
$user = currentUser();
?> ?>
<!DOCTYPE html> <!DOCTYPE html>
<html lang="de"> <html lang="de">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); ?></title> <title><?= SITE_NAME ?> Bands buchen</title>
<style> <link rel="stylesheet" href="assets/css/style.css">
:root { <link rel="preconnect" href="https://fonts.googleapis.com">
--bg: radial-gradient(circle at center, #1f014d 0%, #05010d 100%); <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
--accent: #ff2bd7; <link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet">
--glow: rgba(255, 43, 215, 0.35);
--text: #f7f3ff;
}
* {
box-sizing: border-box;
cursor: none;
}
body {
margin: 0;
font-family: 'Orbitron', 'Segoe UI', sans-serif;
color: var(--text);
background: var(--bg);
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
h1 {
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
margin-bottom: 0.5rem;
text-shadow: 0 0 8px var(--accent);
}
p.description {
margin: 0;
opacity: 0.7;
letter-spacing: 0.08em;
text-align: center;
max-width: 420px;
}
.stage {
position: relative;
width: min(90vw, 900px);
height: min(70vh, 520px);
border-radius: 24px;
border: 2px solid rgba(255, 255, 255, 0.15);
background: rgba(10, 5, 25, 0.7);
backdrop-filter: blur(6px);
box-shadow: 0 0 30px rgba(5, 0, 20, 0.7);
overflow: hidden;
}
canvas#visualizer {
width: 100%;
height: 100%;
display: block;
}
.cursor {
position: absolute;
top: 0;
left: 0;
width: 80px;
height: 80px;
margin-top: -40px;
margin-left: -40px;
border-radius: 50%;
pointer-events: none;
border: 2px solid var(--accent);
box-shadow: 0 0 30px var(--accent), inset 0 0 20px var(--glow);
mix-blend-mode: screen;
transition: transform 0.1s ease-out;
}
.hud {
position: absolute;
right: 16px;
bottom: 16px;
padding: 12px 16px;
background: rgba(10, 5, 25, 0.8);
border-radius: 12px;
font-size: 0.85rem;
letter-spacing: 0.05em;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.hud span.label {
color: rgba(255,255,255,0.6);
margin-right: 8px;
}
.hint {
margin-top: 1rem;
font-size: 0.9rem;
letter-spacing: 0.05em;
opacity: 0.75;
}
@media (max-width: 600px) {
* {
cursor: default;
}
.cursor {
display: none;
}
}
</style>
</head> </head>
<body> <body>
<h1><?= htmlspecialchars($title, ENT_QUOTES, 'UTF-8'); ?></h1> <header>
<p class="description">Schiebe deine Maus über das Klangfeld, um Frequenz, Filter und verrückte Modulation zu steuern. Klicke, um Beats zu triggern.</p> <nav class="badge-list" style="justify-content: flex-end;">
<?php if ($user): ?>
<div class="stage" id="stage"> <span class="badge">Hallo <?= htmlspecialchars($user['name']) ?></span>
<canvas id="visualizer"></canvas> <a class="badge" href="profil.php">Mein Profil</a>
<div class="cursor" id="cursor"></div> <?php if ($user['role'] === 'admin'): ?>
<div class="hud" id="hud"> <a class="badge" href="admin/dashboard.php">Admin</a>
<div><span class="label">Freq</span><span id="freqReadout">-- Hz</span></div> <?php endif; ?>
<div><span class="label">Reso</span><span id="resoReadout">--</span></div> <a class="badge" href="login.php?action=logout">Logout</a>
<div><span class="label">Noise</span><span id="noiseReadout">--</span></div> <?php else: ?>
<a class="badge" href="login.php">Login / Registrieren</a>
<?php endif; ?>
</nav>
<section class="hero">
<div>
<p class="badge">Schritt 3 · Frontend Release</p>
<h1>Finde deine <span style="color: var(--primary);">Funky Liveband</span></h1>
<p>GetYourBand bringt verifizierte Live-Acts mit Veranstalter:innen in der ganzen Schweiz zusammen. Mit Bewertungen,
moderner Suche und aktivierbarer Vermittlungsgebühr.</p>
<div class="badge-list">
<span class="badge">Bewertungen geprüft</span>
<span class="badge badge-rating">PayPal <?= $settings['paypal_enabled'] === '1' ? 'aktiv' : 'optional' ?></span>
<span class="badge">Service Fee <?= htmlspecialchars($settings['service_fee']) ?>%</span>
</div>
</div>
<form class="search-panel" method="get" data-filter-form>
<div>
<label for="genre">Stil / Genre</label>
<input type="text" id="genre" name="genre" value="<?= htmlspecialchars($filters['genre']) ?>" placeholder="Funk, Party, Jazz">
</div>
<div>
<label for="city">Ort / PLZ</label>
<input type="text" id="city" name="city" value="<?= htmlspecialchars($filters['city']) ?>" placeholder="Zürich, Basel">
</div>
<div>
<label>&nbsp;</label>
<button type="submit" class="btn-primary">Filtern</button>
</div>
</form>
</section>
</header>
<main>
<h2>Aktive Bands (<?= count($bands) ?>)</h2>
<section class="band-grid">
<?php foreach ($bands as $band): $rating = averageRating((int) $band['id']); ?>
<article class="band-card">
<p class="badge"><?= htmlspecialchars($band['genre']) ?></p>
<h3><?= htmlspecialchars($band['name']) ?></h3>
<p class="card-meta">Standort: <?= htmlspecialchars($band['city'] ?? '') ?></p>
<p><?= htmlspecialchars($band['description']) ?></p>
<p class="price-tag">ab <?= formatPrice((int) $band['price']) ?></p>
<?php if ($rating): ?>
<p class="card-meta">Bewertung: <?= $rating ?> ★</p>
<?php endif; ?>
<div class="badge-list">
<?php foreach (array_filter(array_map('trim', explode(',', (string) $band['style_tags']))) as $tag): ?>
<span class="badge">#<?= htmlspecialchars($tag) ?></span>
<?php endforeach; ?>
</div>
<p>
<a class="btn-primary" href="band-detail.php?id=<?= (int) $band['id'] ?>">Band ansehen</a>
</p>
</article>
<?php endforeach; ?>
<?php if (!$bands): ?>
<p>Keine Bands gefunden ändere deine Filter.</p>
<?php endif; ?>
</section>
</main>
<footer>
<div>
<strong>Legal</strong><br>
<a href="#">Datenschutz</a> · <a href="#">AGB</a>
</div> </div>
<div>
<strong>Kontakt</strong><br>
support@getyourband.ch
</div>
</footer>
<div class="cookie-banner">
<p>Wir verwenden Cookies für Performance-Analysen. Mit Klick auf "Okay" akzeptierst du das.</p>
<button class="btn-primary" data-cookie-accept>Okay!</button>
</div> </div>
<script src="assets/js/app.js" defer></script>
<p class="hint">Tipp: Halte die Maustaste gedrückt, bewege dich in Kreisen &ndash; und genieße den abgefahrenen Klangteppich!</p>
<script>
const stage = document.getElementById('stage');
const cursor = document.getElementById('cursor');
const canvas = document.getElementById('visualizer');
const ctx = canvas.getContext('2d');
const freqReadout = document.getElementById('freqReadout');
const resoReadout = document.getElementById('resoReadout');
const noiseReadout = document.getElementById('noiseReadout');
let audioCtx;
let masterGain;
let filter;
let lfo;
let lfoGain;
let noise;
let noiseGain;
let compressor;
let isPressed = false;
let analyser;
let bufferLength;
let dataArray;
function setupAudio() {
if (audioCtx) return;
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
const osc = audioCtx.createOscillator();
osc.type = 'sawtooth';
filter = audioCtx.createBiquadFilter();
filter.type = 'lowpass';
filter.frequency.value = 200;
filter.Q.value = 12;
lfo = audioCtx.createOscillator();
lfo.type = 'sine';
lfo.frequency.value = 4;
lfoGain = audioCtx.createGain();
lfoGain.gain.value = 1200;
noise = createNoiseSource();
noiseGain = audioCtx.createGain();
noiseGain.gain.value = 0.05;
masterGain = audioCtx.createGain();
masterGain.gain.value = 0.0;
compressor = audioCtx.createDynamicsCompressor();
compressor.threshold.value = -24;
compressor.ratio.value = 12;
compressor.attack.value = 0.005;
compressor.release.value = 0.2;
analyser = audioCtx.createAnalyser();
analyser.fftSize = 1024;
bufferLength = analyser.frequencyBinCount;
dataArray = new Uint8Array(bufferLength);
osc.connect(filter);
filter.connect(masterGain);
lfo.connect(lfoGain);
lfoGain.connect(filter.frequency);
noise.connect(noiseGain).connect(masterGain);
masterGain.connect(compressor).connect(audioCtx.destination);
masterGain.connect(analyser);
osc.start();
lfo.start();
noise.start(0);
}
function createNoiseSource() {
const rate = audioCtx.sampleRate;
const bufferSize = rate * 2;
const buffer = audioCtx.createBuffer(1, bufferSize, rate);
const output = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
const noiseSource = audioCtx.createBufferSource();
noiseSource.buffer = buffer;
noiseSource.loop = true;
return noiseSource;
}
function resizeCanvas() {
canvas.width = stage.clientWidth;
canvas.height = stage.clientHeight;
}
function updateAudio(x, y) {
if (!audioCtx) return;
const rect = stage.getBoundingClientRect();
const normX = (x - rect.left) / rect.width;
const normY = (y - rect.top) / rect.height;
const minFreq = 80;
const maxFreq = 1200;
const freq = minFreq * Math.pow(maxFreq / minFreq, normX);
filter.frequency.setTargetAtTime(freq, audioCtx.currentTime, 0.02);
const q = 4 + normY * 20;
filter.Q.setTargetAtTime(q, audioCtx.currentTime, 0.02);
const noiseLevel = normY * 0.3;
noiseGain.gain.setTargetAtTime(noiseLevel, audioCtx.currentTime, 0.05);
const lfoSpeed = 0.5 + normX * 8;
lfo.frequency.setTargetAtTime(lfoSpeed, audioCtx.currentTime, 0.05);
freqReadout.textContent = `${freq.toFixed(1)} Hz`;
resoReadout.textContent = q.toFixed(2);
noiseReadout.textContent = noiseLevel.toFixed(2);
}
function animate() {
requestAnimationFrame(animate);
if (!analyser) return;
analyser.getByteFrequencyData(dataArray);
ctx.clearRect(0, 0, canvas.width, canvas.height);
const barWidth = (canvas.width / bufferLength) * 2.5;
let x = 0;
for (let i = 0; i < bufferLength; i++) {
const barHeight = dataArray[i] / 255 * canvas.height;
const hue = (i / bufferLength) * 360;
ctx.fillStyle = `hsla(${hue}, 90%, 60%, 0.6)`;
ctx.fillRect(x, canvas.height - barHeight, barWidth, barHeight);
x += barWidth + 1;
}
}
function pointerMove(event) {
const x = event.clientX;
const y = event.clientY;
cursor.style.transform = `translate(${x}px, ${y}px) scale(${isPressed ? 1.2 : 1})`;
updateAudio(x, y);
}
function pointerDown() {
isPressed = true;
if (!audioCtx) {
setupAudio();
}
if (!audioCtx) return;
masterGain.gain.cancelScheduledValues(audioCtx.currentTime);
masterGain.gain.setTargetAtTime(0.7, audioCtx.currentTime, 0.02);
lfoGain.gain.setTargetAtTime(900, audioCtx.currentTime, 0.08);
}
function pointerUp() {
isPressed = false;
if (!audioCtx) return;
masterGain.gain.setTargetAtTime(0.0, audioCtx.currentTime, 0.05);
lfoGain.gain.setTargetAtTime(200, audioCtx.currentTime, 0.08);
}
stage.addEventListener('pointermove', pointerMove);
stage.addEventListener('pointerdown', pointerDown);
window.addEventListener('pointerup', pointerUp);
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
animate();
document.addEventListener('keydown', (event) => {
if (!audioCtx) return;
if (event.code === 'Space') {
const now = audioCtx.currentTime;
const burst = audioCtx.createOscillator();
burst.type = 'square';
burst.frequency.value = filter.frequency.value * 0.5;
const burstGain = audioCtx.createGain();
burstGain.gain.value = 0;
burst.connect(burstGain).connect(masterGain);
burst.start(now);
burstGain.gain.setValueAtTime(0, now);
burstGain.gain.linearRampToValueAtTime(0.8, now + 0.05);
burstGain.gain.exponentialRampToValueAtTime(0.001, now + 0.5);
burst.stop(now + 0.6);
}
});
</script>
</body> </body>
</html> </html>
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/email.php';
$action = $_GET['action'] ?? '';
if ($action === 'logout') {
logout();
header('Location: index.php');
exit;
}
$error = '';
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['login'])) {
try {
if (!login($_POST['email'], $_POST['password'])) {
$error = 'Login fehlgeschlagen.';
} else {
header('Location: index.php');
exit;
}
} catch (RuntimeException $ex) {
$error = $ex->getMessage();
}
} elseif (isset($_POST['register'])) {
if ($_POST['password'] !== $_POST['password_confirm']) {
$error = 'Passwörter stimmen nicht überein.';
} else {
$result = register([
'name' => trim((string) $_POST['name']),
'email' => trim((string) $_POST['email']),
'password' => $_POST['password'],
'role' => $_POST['role'],
'city' => trim((string) $_POST['city']),
'band_name' => $_POST['band_name'] ?? null,
'genre' => $_POST['genre'] ?? null,
]);
$verificationLink = BASE_URL . '/verify-email.php?token=' . urlencode($result['token']);
sendEmail($_POST['email'], 'E-Mail bestätigen', 'Bitte bestätige dein Konto: ' . $verificationLink);
$message = 'Check deine Inbox wir haben dir den Verifizierungslink geschickt: ' . htmlspecialchars($verificationLink);
}
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login / Registrierung <?= SITE_NAME ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="index.php">← Zurück</a>
<h1>Login / Registrieren</h1>
</header>
<main>
<?php if ($message): ?><div class="alert alert-success"><?= $message ?></div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<section class="band-detail-grid">
<div>
<h2>Login</h2>
<form method="post">
<input type="hidden" name="login" value="1">
<label>E-Mail
<input class="form-control" type="email" name="email" required>
</label>
<label>Passwort
<input class="form-control" type="password" name="password" required>
</label>
<button class="btn-primary">Einloggen</button>
</form>
</div>
<div>
<h2>Registrierung</h2>
<form method="post">
<input type="hidden" name="register" value="1">
<label>Name
<input class="form-control" type="text" name="name" required>
</label>
<label>E-Mail
<input class="form-control" type="email" name="email" required>
</label>
<label>Ort
<input class="form-control" type="text" name="city">
</label>
<label>Rolle
<select class="form-control" name="role">
<option value="kunde">Veranstalter:in</option>
<option value="band">Band</option>
</select>
</label>
<label>Bandname (falls Band)
<input class="form-control" type="text" name="band_name">
</label>
<label>Genre
<input class="form-control" type="text" name="genre">
</label>
<label>Passwort
<input class="form-control" type="password" name="password" required>
</label>
<label>Passwort wiederholen
<input class="form-control" type="password" name="password_confirm" required>
</label>
<button class="btn-primary">Account anlegen</button>
</form>
</div>
</section>
</main>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
#!/usr/bin/env php
<?php
require __DIR__ . '/vendor/autoload.php';
use Database\Database;
use Dotenv\Dotenv;
// Load environment variables
$dotenv = Dotenv::createImmutable(__DIR__);
$dotenv->load();
try {
echo "Starting database migrations...\n\n";
Database::runMigrations(__DIR__ . '/database/migrations');
echo "\n✓ All migrations completed successfully!\n";
} catch (Exception $e) {
echo "\n✗ Migration failed: " . $e->getMessage() . "\n";
exit(1);
}
+35
View File
@@ -0,0 +1,35 @@
<?php
/**
* Migration: Add email column to bands table
* Run this once to update existing databases
*/
require_once __DIR__ . '/includes/database.php';
try {
$pdo = db();
$columns = $pdo->query("PRAGMA table_info(bands)")->fetchAll(PDO::FETCH_ASSOC);
$hasEmail = false;
foreach ($columns as $column) {
if ($column['name'] === 'email') {
$hasEmail = true;
break;
}
}
if (!$hasEmail) {
echo "Adding email column to bands table...\n";
$pdo->exec("ALTER TABLE bands ADD COLUMN email TEXT");
echo "✓ Email column added successfully!\n";
} else {
echo "✓ Email column already exists.\n";
}
echo "\nMigration completed successfully!\n";
} catch (PDOException $e) {
echo "Error: " . $e->getMessage() . "\n";
exit(1);
}
+22
View File
@@ -0,0 +1,22 @@
{
"name": "getyourband-platform",
"version": "1.0.0",
"description": "Modern band booking platform",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"watch": "vite build --watch"
},
"devDependencies": {
"vite": "^5.0.0",
"tailwindcss": "^3.4.0",
"postcss": "^8.4.32",
"autoprefixer": "^10.4.16",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/typography": "^0.5.10"
},
"dependencies": {
"alpinejs": "^3.13.3"
}
}
+167
View File
@@ -0,0 +1,167 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
$requestId = isset($_GET['request_id']) ? (int) $_GET['request_id'] : 0;
if (!$requestId) {
http_response_code(400);
echo 'Keine Anfrage-ID angegeben';
exit;
}
$user = currentUser();
// Get request details
$stmt = db()->prepare('SELECT r.*, b.name as band_name, b.price as band_price
FROM requests r
JOIN bands b ON b.id = r.band_id
WHERE r.id = :id AND r.user_id = :user_id');
$stmt->execute([':id' => $requestId, ':user_id' => $user['id']]);
$request = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$request) {
http_response_code(404);
echo 'Anfrage nicht gefunden';
exit;
}
$settings = settings();
if ($settings['paypal_enabled'] !== '1') {
http_response_code(403);
echo 'PayPal-Zahlungen sind derzeit nicht aktiviert';
exit;
}
// Calculate amounts
$bandPrice = (int) $request['band_price'];
$serviceFeePercent = (float) $settings['service_fee'];
$serviceFee = $bandPrice * ($serviceFeePercent / 100);
$totalAmount = $bandPrice + $serviceFee;
// Check if already paid
$stmt = db()->prepare('SELECT * FROM payments WHERE request_id = :id AND status = "completed"');
$stmt->execute([':id' => $requestId]);
$existingPayment = $stmt->fetch(PDO::FETCH_ASSOC);
if ($existingPayment) {
$message = 'Diese Buchung wurde bereits bezahlt.';
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>PayPal Zahlung <?= SITE_NAME ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="profil.php">← Zurück zum Profil</a>
<h1>Zahlung für Buchung</h1>
</header>
<main style="max-width: 600px; margin: 0 auto;">
<?php if (isset($message)): ?>
<div class="alert alert-success"><?= htmlspecialchars($message) ?></div>
<?php else: ?>
<h2>Buchungsdetails</h2>
<table class="table" style="margin-bottom: 2rem;">
<tr><td><strong>Band:</strong></td><td><?= htmlspecialchars($request['band_name']) ?></td></tr>
<tr><td><strong>Event-Datum:</strong></td><td><?= htmlspecialchars($request['event_date']) ?></td></tr>
<tr><td><strong>Location:</strong></td><td><?= htmlspecialchars($request['location']) ?></td></tr>
<tr><td><strong>Event-Typ:</strong></td><td><?= htmlspecialchars($request['event_type']) ?></td></tr>
</table>
<h2>Zahlungsübersicht</h2>
<table class="table" style="margin-bottom: 2rem;">
<tr><td><strong>Band-Gage:</strong></td><td><?= formatPrice($bandPrice) ?></td></tr>
<tr><td><strong>Service Fee (<?= htmlspecialchars($serviceFeePercent) ?>%):</strong></td><td><?= formatPrice((int) $serviceFee) ?></td></tr>
<tr style="border-top: 2px solid #ffb703;"><td><strong>Gesamtbetrag:</strong></td><td><strong><?= formatPrice((int) $totalAmount) ?></strong></td></tr>
</table>
<div id="payment-status" style="display:none; padding: 1rem; margin-bottom: 1rem; border-radius: 4px;"></div>
<!-- PayPal Button Container -->
<div id="paypal-button-container" style="margin: 2rem 0;"></div>
<p style="color: #666; font-size: 0.875rem; margin-top: 2rem;">
<strong>Hinweis:</strong> Dies ist eine Demo-Integration. Für die Produktivumgebung benötigen Sie echte PayPal API-Credentials.
Aktuell wird im Sandbox-Modus gearbeitet.
</p>
<?php endif; ?>
</main>
<?php if (!isset($message)): ?>
<!-- PayPal SDK -->
<script src="https://www.paypal.com/sdk/js?client-id=YOUR_PAYPAL_CLIENT_ID&currency=CHF"></script>
<script>
paypal.Buttons({
createOrder: function(data, actions) {
return actions.order.create({
purchase_units: [{
amount: {
value: '<?= number_format($totalAmount, 2, '.', '') ?>',
currency_code: 'CHF',
breakdown: {
item_total: {
value: '<?= number_format($bandPrice, 2, '.', '') ?>',
currency_code: 'CHF'
},
tax_total: {
value: '<?= number_format($serviceFee, 2, '.', '') ?>',
currency_code: 'CHF'
}
}
},
description: 'Buchung: <?= htmlspecialchars($request['band_name']) ?> - <?= htmlspecialchars($request['event_date']) ?>'
}]
});
},
onApprove: function(data, actions) {
return actions.order.capture().then(function(details) {
// Save payment to database
const statusDiv = document.getElementById('payment-status');
statusDiv.style.display = 'block';
statusDiv.style.background = '#28a745';
statusDiv.style.color = 'white';
statusDiv.textContent = 'Zahlung erfolgreich! Verarbeite Transaktion...';
fetch('paypal-process.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
request_id: <?= $requestId ?>,
amount: <?= $bandPrice ?>,
service_fee: <?= number_format($serviceFee, 2, '.', '') ?>,
total_amount: <?= number_format($totalAmount, 2, '.', '') ?>,
paypal_order_id: data.orderID,
paypal_payer_id: details.payer.payer_id
})
})
.then(response => response.json())
.then(result => {
if (result.success) {
statusDiv.textContent = 'Zahlung erfolgreich abgeschlossen! Sie werden weitergeleitet...';
setTimeout(() => {
window.location.href = 'profil.php?payment_success=1';
}, 2000);
} else {
statusDiv.style.background = '#dc3545';
statusDiv.textContent = 'Fehler beim Speichern der Zahlung: ' + result.error;
}
});
});
},
onError: function(err) {
const statusDiv = document.getElementById('payment-status');
statusDiv.style.display = 'block';
statusDiv.style.background = '#dc3545';
statusDiv.style.color = 'white';
statusDiv.textContent = 'Fehler bei der Zahlung: ' + err;
}
}).render('#paypal-button-container');
</script>
<?php endif; ?>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/email.php';
requireLogin();
header('Content-Type: application/json');
$user = currentUser();
// Get JSON input
$input = json_decode(file_get_contents('php://input'), true);
if (!$input || !isset($input['request_id'])) {
http_response_code(400);
echo json_encode(['error' => 'Ungültige Anfrage']);
exit;
}
$requestId = (int) $input['request_id'];
$amount = (float) $input['amount'];
$serviceFee = (float) $input['service_fee'];
$totalAmount = (float) $input['total_amount'];
$paypalOrderId = $input['paypal_order_id'] ?? '';
$paypalPayerId = $input['paypal_payer_id'] ?? '';
// Verify request belongs to user
$stmt = db()->prepare('SELECT r.*, b.name as band_name, b.user_id as band_user_id
FROM requests r
JOIN bands b ON b.id = r.band_id
WHERE r.id = :id AND r.user_id = :user_id');
$stmt->execute([':id' => $requestId, ':user_id' => $user['id']]);
$request = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$request) {
http_response_code(404);
echo json_encode(['error' => 'Anfrage nicht gefunden']);
exit;
}
// Check if already paid
$stmt = db()->prepare('SELECT * FROM payments WHERE request_id = :id AND status = "completed"');
$stmt->execute([':id' => $requestId]);
if ($stmt->fetch(PDO::FETCH_ASSOC)) {
http_response_code(400);
echo json_encode(['error' => 'Diese Buchung wurde bereits bezahlt']);
exit;
}
try {
// Save payment
$stmt = db()->prepare('INSERT INTO payments (request_id, amount, service_fee, total_amount, paypal_order_id, paypal_payer_id, status, completed_at)
VALUES (:request_id, :amount, :service_fee, :total_amount, :paypal_order_id, :paypal_payer_id, :status, :completed_at)');
$stmt->execute([
':request_id' => $requestId,
':amount' => $amount,
':service_fee' => $serviceFee,
':total_amount' => $totalAmount,
':paypal_order_id' => $paypalOrderId,
':paypal_payer_id' => $paypalPayerId,
':status' => 'completed',
':completed_at' => (new DateTimeImmutable())->format('c')
]);
// Update request status to confirmed
$stmt = db()->prepare('UPDATE requests SET status = :status WHERE id = :id');
$stmt->execute([':status' => 'bestätigt', ':id' => $requestId]);
// Send confirmation emails
sendEmail($user['email'], 'Zahlungsbestätigung',
'Ihre Zahlung für die Buchung von ' . $request['band_name'] . ' wurde erfolgreich verarbeitet.');
// Notify band
if ($request['band_user_id']) {
$bandUserStmt = db()->prepare('SELECT email FROM users WHERE id = :id');
$bandUserStmt->execute([':id' => $request['band_user_id']]);
$bandUser = $bandUserStmt->fetch(PDO::FETCH_ASSOC);
if ($bandUser) {
sendEmail($bandUser['email'], 'Neue bezahlte Buchung',
'Sie haben eine neue bezahlte Buchung für ' . $request['event_date'] . ' erhalten.');
}
}
echo json_encode([
'success' => true,
'message' => 'Zahlung erfolgreich verarbeitet',
'payment_id' => (int) db()->lastInsertId()
]);
} catch (Exception $e) {
http_response_code(500);
echo json_encode(['error' => 'Fehler beim Speichern der Zahlung: ' . $e->getMessage()]);
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+213
View File
@@ -0,0 +1,213 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
requireLogin();
$user = currentUser();
$band = null;
$message = '';
if ($user['role'] === 'band') {
$stmt = db()->prepare('SELECT * FROM bands WHERE user_id = :id');
$stmt->execute([':id' => $user['id']]);
$band = $stmt->fetch(PDO::FETCH_ASSOC);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt = db()->prepare('UPDATE bands SET name = :name, email = :email, city = :city, genre = :genre, price = :price, description = :description, style_tags = :tags WHERE id = :id');
$stmt->execute([
':name' => $_POST['name'],
':email' => $_POST['email'] ?? null,
':city' => $_POST['city'],
':genre' => $_POST['genre'],
':price' => (int) $_POST['price'],
':description' => $_POST['description'],
':tags' => $_POST['style_tags'],
':id' => $band['id'],
]);
$message = 'Bandprofil aktualisiert (wartet ggf. auf Freigabe).';
$band = findBand((int) $band['id']);
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Mein Bereich <?= SITE_NAME ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="index.php">← Startseite</a>
<h1>Hallo <?= htmlspecialchars($user['name']) ?></h1>
<p>Rolle: <?= htmlspecialchars($user['role']) ?></p>
</header>
<main>
<?php if ($message): ?><div class="alert alert-success"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<?php if ($band): ?>
<h2>Bandprofil</h2>
<form method="post">
<label>Bandname
<input class="form-control" name="name" value="<?= htmlspecialchars($band['name']) ?>" required>
</label>
<label>Email für Buchungsanfragen
<input class="form-control" type="email" name="email" value="<?= htmlspecialchars($band['email'] ?? '') ?>" placeholder="band@example.ch">
<small>An diese Adresse werden Buchungsanfragen gesendet</small>
</label>
<label>Ort
<input class="form-control" name="city" value="<?= htmlspecialchars($band['city']) ?>">
</label>
<label>Genre
<input class="form-control" name="genre" value="<?= htmlspecialchars($band['genre']) ?>">
</label>
<label>Tags
<input class="form-control" name="style_tags" value="<?= htmlspecialchars($band['style_tags']) ?>">
</label>
<label>Preis (CHF)
<input class="form-control" type="number" name="price" value="<?= (int) $band['price'] ?>">
</label>
<label>Beschreibung
<textarea class="form-control" name="description" rows="4"><?= htmlspecialchars($band['description']) ?></textarea>
</label>
<button class="btn-primary">Speichern</button>
</form>
<h2 style="margin-top: 2rem;">Band-Galerie</h2>
<div id="upload-status" style="display:none; padding: 1rem; margin-bottom: 1rem; background: #28a745; color: white; border-radius: 4px;"></div>
<div style="margin-bottom: 1rem;">
<label class="btn-primary" style="display: inline-block; cursor: pointer;">
<input type="file" id="image-upload" accept="image/*" style="display: none;">
+ Bild hochladen
</label>
<small style="display: block; margin-top: 0.5rem; color: #666;">Max 5MB (JPG, PNG, GIF, WEBP)</small>
</div>
<div id="gallery" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 1rem;">
<?php foreach (bandMedia((int) $band['id']) as $media): ?>
<div class="gallery-item" data-media-id="<?= $media['id'] ?>">
<img src="<?= htmlspecialchars($media['url']) ?>" alt="Band Foto" style="width: 100%; height: 200px; object-fit: cover; border-radius: 4px;">
<button class="delete-image" data-id="<?= $media['id'] ?>" style="margin-top: 0.5rem; background: #dc3545; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; width: 100%;">Löschen</button>
</div>
<?php endforeach; ?>
</div>
<script>
document.getElementById('image-upload').addEventListener('change', function(e) {
const file = e.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('image', file);
const statusDiv = document.getElementById('upload-status');
statusDiv.style.display = 'block';
statusDiv.style.background = '#ffc107';
statusDiv.textContent = 'Uploading...';
fetch('upload-handler.php', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusDiv.style.background = '#28a745';
statusDiv.textContent = data.message;
// Add to gallery
const gallery = document.getElementById('gallery');
const div = document.createElement('div');
div.className = 'gallery-item';
div.setAttribute('data-media-id', data.id);
div.innerHTML = `
<img src="${data.url}" alt="Band Foto" style="width: 100%; height: 200px; object-fit: cover; border-radius: 4px;">
<button class="delete-image" data-id="${data.id}" style="margin-top: 0.5rem; background: #dc3545; color: white; border: none; padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; width: 100%;">Löschen</button>
`;
gallery.appendChild(div);
setTimeout(() => { statusDiv.style.display = 'none'; }, 3000);
} else {
statusDiv.style.background = '#dc3545';
statusDiv.textContent = data.error;
}
})
.catch(error => {
statusDiv.style.background = '#dc3545';
statusDiv.textContent = 'Upload fehlgeschlagen: ' + error.message;
});
e.target.value = '';
});
document.addEventListener('click', function(e) {
if (e.target.classList.contains('delete-image')) {
if (!confirm('Bild wirklich löschen?')) return;
const mediaId = e.target.getAttribute('data-id');
const galleryItem = e.target.closest('.gallery-item');
fetch('upload-handler.php', {
method: 'DELETE',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'media_id=' + mediaId
})
.then(response => response.json())
.then(data => {
if (data.success) {
galleryItem.remove();
} else {
alert(data.error);
}
});
}
});
</script>
<?php else: ?>
<p>Du hast noch kein Bandprofil angelegt.</p>
<?php endif; ?>
<?php if ($user['role'] === 'kunde'): ?>
<?php if (isset($_GET['payment_success'])): ?>
<div class="alert alert-success">Zahlung erfolgreich abgeschlossen! Vielen Dank für Ihre Buchung.</div>
<?php endif; ?>
<h2>Meine Anfragen</h2>
<table class="table">
<thead><tr><th>Band</th><th>Datum</th><th>Status</th><th>Zahlung</th><th>Aktion</th></tr></thead>
<tbody>
<?php
$settings = settings();
foreach (userRequests((int) $user['id']) as $request):
$bandName = findBand((int) $request['band_id']);
// Check payment status
$stmt = db()->prepare('SELECT * FROM payments WHERE request_id = :id AND status = "completed"');
$stmt->execute([':id' => $request['id']]);
$payment = $stmt->fetch(PDO::FETCH_ASSOC);
?>
<tr>
<td><?= htmlspecialchars($bandName['name'] ?? 'Band #' . $request['band_id']) ?></td>
<td><?= htmlspecialchars($request['event_date']) ?></td>
<td><?= htmlspecialchars($request['status']) ?></td>
<td>
<?php if ($payment): ?>
<span style="color: #28a745;">✓ Bezahlt</span><br>
<small style="color: #666;"><?= formatPrice((int) $payment['total_amount']) ?></small>
<?php else: ?>
<span style="color: #dc3545;">Ausstehend</span>
<?php endif; ?>
</td>
<td>
<?php if (!$payment && $settings['paypal_enabled'] === '1'): ?>
<a href="paypal-checkout.php?request_id=<?= $request['id'] ?>" class="badge" style="background: #0070ba; color: white; text-decoration: none;">
PayPal bezahlen
</a>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</main>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
RewriteEngine On
# Redirect all requests to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php [L,QSA]
# Security headers
<IfModule mod_headers.c>
Header set X-Content-Type-Options "nosniff"
Header set X-Frame-Options "SAMEORIGIN"
Header set X-XSS-Protection "1; mode=block"
</IfModule>
# Disable directory browsing
Options -Indexes
# Compress assets
<IfModule mod_deflate.c>
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css text/javascript application/javascript application/json
</IfModule>
# Browser caching
<IfModule mod_expires.c>
ExpiresActive On
ExpiresByType image/jpeg "access plus 1 year"
ExpiresByType image/png "access plus 1 year"
ExpiresByType image/webp "access plus 1 year"
ExpiresByType text/css "access plus 1 month"
ExpiresByType application/javascript "access plus 1 month"
</IfModule>
+28
View File
@@ -0,0 +1,28 @@
<?php
require_once __DIR__ . '/../bootstrap.php';
use App\Core\Router;
// Initialize router
$router = new Router();
// Load routes
require_once __DIR__ . '/../routes/web.php';
// Dispatch request
$requestMethod = $_SERVER['REQUEST_METHOD'];
$requestUri = $_SERVER['REQUEST_URI'];
try {
$router->dispatch($requestMethod, $requestUri);
} catch (Exception $e) {
if (config('app.debug')) {
echo "<h1>Error</h1>";
echo "<p>{$e->getMessage()}</p>";
echo "<pre>{$e->getTraceAsString()}</pre>";
} else {
http_response_code(500);
echo "500 - Internal Server Error";
}
}
View File
+4429
View File
File diff suppressed because it is too large Load Diff
+49
View File
@@ -0,0 +1,49 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
@apply scroll-smooth;
}
body {
@apply bg-gray-50 text-gray-900 antialiased;
}
}
@layer components {
.btn {
@apply px-4 py-2 rounded-lg font-medium transition-all duration-200 inline-flex items-center justify-center;
}
.btn-primary {
@apply bg-primary-500 text-white hover:bg-primary-600 active:bg-primary-700;
}
.btn-secondary {
@apply bg-gray-200 text-gray-800 hover:bg-gray-300 active:bg-gray-400;
}
.card {
@apply bg-white rounded-xl shadow-md p-6 transition-shadow hover:shadow-lg;
}
.input-field {
@apply w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-transparent;
}
.badge {
@apply inline-flex items-center px-3 py-1 rounded-full text-sm font-medium;
}
.badge-yellow {
@apply bg-accent-100 text-accent-800;
}
}
@layer utilities {
.text-balance {
text-wrap: balance;
}
}
+99
View File
@@ -0,0 +1,99 @@
import Alpine from 'alpinejs';
// Make Alpine available globally
window.Alpine = Alpine;
// Alpine Components
Alpine.data('searchBands', () => ({
query: '',
filters: {
genre: '',
location: '',
priceMin: '',
priceMax: '',
},
results: [],
loading: false,
init() {
console.log('Search component initialized');
},
async search() {
this.loading = true;
try {
const params = new URLSearchParams({
q: this.query,
...this.filters
});
const response = await fetch(`/api/bands/search?${params}`);
this.results = await response.json();
} catch (error) {
console.error('Search error:', error);
} finally {
this.loading = false;
}
}
}));
Alpine.data('bookingForm', () => ({
formData: {
bandId: '',
eventDate: '',
location: '',
budget: '',
eventType: '',
message: ''
},
submitting: false,
async submit() {
this.submitting = true;
try {
const response = await fetch('/api/bookings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(this.formData)
});
if (response.ok) {
alert('Buchungsanfrage erfolgreich gesendet!');
this.reset();
}
} catch (error) {
console.error('Booking error:', error);
alert('Es gab einen Fehler. Bitte versuchen Sie es erneut.');
} finally {
this.submitting = false;
}
},
reset() {
this.formData = {
bandId: '',
eventDate: '',
location: '',
budget: '',
eventType: '',
message: ''
};
}
}));
// Initialize Alpine
Alpine.start();
// Smooth scroll for anchor links
document.addEventListener('DOMContentLoaded', () => {
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({ behavior: 'smooth' });
}
});
});
});
+49
View File
@@ -0,0 +1,49 @@
<?php
use App\Controllers\HomeController;
use App\Controllers\BandController;
use App\Controllers\BookingController;
use App\Controllers\AuthController;
use App\Controllers\ProfileController;
use App\Controllers\Admin\AdminController;
// Public routes
$router->get('/', [HomeController::class, 'index']);
$router->get('/bands', [BandController::class, 'index']);
$router->get('/bands/{slug}', [BandController::class, 'show']);
// Authentication routes
$router->get('/login', [AuthController::class, 'showLogin']);
$router->post('/login', [AuthController::class, 'login']);
$router->get('/register', [AuthController::class, 'showRegister']);
$router->post('/register', [AuthController::class, 'register']);
$router->post('/logout', [AuthController::class, 'logout']);
$router->get('/verify-email/{token}', [AuthController::class, 'verifyEmail']);
// Protected routes (require authentication)
$router->group(['middleware' => 'auth'], function($router) {
// Profile
$router->get('/profile', [ProfileController::class, 'show']);
$router->post('/profile/update', [ProfileController::class, 'update']);
// Booking routes
$router->post('/bookings/create', [BookingController::class, 'create']);
$router->get('/my-bookings', [BookingController::class, 'myBookings']);
// Band management (for band users)
$router->group(['middleware' => 'role:band'], function($router) {
$router->get('/band/manage', [BandController::class, 'manage']);
$router->post('/band/update', [BandController::class, 'update']);
$router->get('/band/bookings', [BookingController::class, 'bandBookings']);
$router->post('/band/bookings/{id}/respond', [BookingController::class, 'respond']);
});
// Admin routes
$router->group(['middleware' => 'role:admin'], function($router) {
$router->get('/admin', [AdminController::class, 'dashboard']);
$router->get('/admin/bands', [AdminController::class, 'bands']);
$router->post('/admin/bands/{id}/approve', [AdminController::class, 'approveBand']);
$router->get('/admin/reviews', [AdminController::class, 'reviews']);
$router->post('/admin/reviews/{id}/moderate', [AdminController::class, 'moderateReview']);
});
});
+5
View File
@@ -0,0 +1,5 @@
Options -Indexes
AddDefaultCharset UTF-8
<IfModule mod_rewrite.c>
RewriteEngine On
</IfModule>
+12
View File
@@ -0,0 +1,12 @@
{
"name": "Mein Webserver",
"host": "192.168.178.88",
"protocol": "sftp",
"port": 22,
"username": "root",
"password": "934290",
"remotePath": "/var/www/html/",
"uploadOnSave": true,
"useTempFile": false,
"openSsh": false
}
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/auth.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
changeBandStatus((int) $_POST['band_id'], $_POST['status']);
}
$bands = moderationItems('bands');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bands moderieren <?= SITE_NAME ?></title>
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header><div class="admin-nav"><a href="dashboard.php">Dashboard</a><a href="bands.php">Bands</a><a href="bewertungen.php">Bewertungen</a><a href="settings.php">Settings</a></div></header>
<main>
<h1>Bandfreigaben</h1>
<table class="table">
<thead><tr><th>Name</th><th>Ort</th><th>Status</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($bands as $band): ?>
<tr>
<td><?= htmlspecialchars($band['name']) ?></td>
<td><?= htmlspecialchars($band['city']) ?></td>
<td><?= htmlspecialchars($band['status']) ?></td>
<td>
<form method="post" style="display:inline-flex; gap:4px;">
<input type="hidden" name="band_id" value="<?= $band['id'] ?>">
<select name="status" class="form-control" style="width:auto;">
<option value="aktiv">Freigeben</option>
<option value="archiv">Archivieren</option>
<option value="prüfung">Zur Prüfung</option>
</select>
<button class="btn-primary">Speichern</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (!$bands): ?><p>Keine Bands warten auf Moderation.</p><?php endif; ?>
</main>
</body>
</html>
+48
View File
@@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/auth.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
changeReviewStatus((int) $_POST['review_id'], $_POST['status']);
}
$reviews = moderationItems('reviews');
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Bewertungen prüfen <?= SITE_NAME ?></title>
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header><div class="admin-nav"><a href="dashboard.php">Dashboard</a><a href="bands.php">Bands</a><a href="bewertungen.php">Bewertungen</a><a href="settings.php">Settings</a></div></header>
<main>
<h1>Bewertungen moderieren</h1>
<table class="table">
<thead><tr><th>Band</th><th>Autor</th><th>Bewertung</th><th>Kommentar</th><th>Aktion</th></tr></thead>
<tbody>
<?php foreach ($reviews as $review): ?>
<tr>
<td><?= htmlspecialchars($review['band_name']) ?></td>
<td><?= htmlspecialchars($review['author']) ?></td>
<td><?= (int) $review['rating'] ?> ★</td>
<td><?= htmlspecialchars($review['comment']) ?></td>
<td>
<form method="post" style="display:inline-flex; gap:4px;">
<input type="hidden" name="review_id" value="<?= $review['id'] ?>">
<select name="status" class="form-control" style="width:auto;">
<option value="freigegeben">Freigeben</option>
<option value="abgelehnt">Ablehnen</option>
</select>
<button class="btn-primary">Speichern</button>
</form>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php if (!$reviews): ?><p>Keine Bewertungen in Moderation.</p><?php endif; ?>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/auth.php';
requireAdmin();
$pdo = db();
$stats = [
'bands' => (int) $pdo->query("SELECT COUNT(*) FROM bands")->fetchColumn(),
'requests' => (int) $pdo->query("SELECT COUNT(*) FROM requests")->fetchColumn(),
'reviews' => (int) $pdo->query("SELECT COUNT(*) FROM reviews")->fetchColumn(),
];
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Admin Dashboard <?= SITE_NAME ?></title>
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header>
<div class="admin-nav">
<a href="dashboard.php">Dashboard</a>
<a href="bands.php">Bandfreigaben</a>
<a href="bewertungen.php">Bewertungen</a>
<a href="settings.php">Settings</a>
</div>
</header>
<main>
<section class="band-grid">
<article class="band-card"><h3>Bands</h3><p><?= $stats['bands'] ?></p></article>
<article class="band-card"><h3>Anfragen</h3><p><?= $stats['requests'] ?></p></article>
<article class="band-card"><h3>Bewertungen</h3><p><?= $stats['reviews'] ?></p></article>
</section>
</main>
</body>
</html>
+37
View File
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../includes/auth.php';
requireAdmin();
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
updateSetting('paypal_enabled', isset($_POST['paypal_enabled']) ? '1' : '0');
updateSetting('service_fee', (string) (int) $_POST['service_fee']);
$message = 'Einstellungen gespeichert.';
}
$config = settings();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Settings <?= SITE_NAME ?></title>
<link rel="stylesheet" href="../assets/css/style.css">
</head>
<body>
<header><div class="admin-nav"><a href="dashboard.php">Dashboard</a><a href="bands.php">Bands</a><a href="bewertungen.php">Bewertungen</a><a href="settings.php">Settings</a></div></header>
<main>
<h1>Vermittlungsgebühr & PayPal</h1>
<?php if ($message): ?><div class="alert alert-success"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<form method="post">
<label>
<input type="checkbox" name="paypal_enabled" <?= $config['paypal_enabled'] === '1' ? 'checked' : '' ?>> PayPal aktivieren
</label>
<label>Service Fee (%)
<input type="number" class="form-control" name="service_fee" value="<?= htmlspecialchars($config['service_fee']) ?>">
</label>
<button class="btn-primary">Speichern</button>
</form>
</main>
</body>
</html>
+342
View File
@@ -0,0 +1,342 @@
<?php
$altcoins = [
[
'name' => 'Ethereum (ETH)',
'symbol' => 'ETH',
'price' => 3725.42,
'ma200' => 3450.15,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'BNB',
'symbol' => 'BNB',
'price' => 598.12,
'ma200' => 612.77,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Solana (SOL)',
'symbol' => 'SOL',
'price' => 158.34,
'ma200' => 143.05,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'XRP',
'symbol' => 'XRP',
'price' => 0.57,
'ma200' => 0.63,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Dogecoin (DOGE)',
'symbol' => 'DOGE',
'price' => 0.19,
'ma200' => 0.15,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Cardano (ADA)',
'symbol' => 'ADA',
'price' => 0.48,
'ma200' => 0.62,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Avalanche (AVAX)',
'symbol' => 'AVAX',
'price' => 47.22,
'ma200' => 44.61,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Polkadot (DOT)',
'symbol' => 'DOT',
'price' => 8.81,
'ma200' => 7.29,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Chainlink (LINK)',
'symbol' => 'LINK',
'price' => 17.02,
'ma200' => 18.40,
'last_update' => '2024-04-21 14:00 UTC',
],
[
'name' => 'Polygon (MATIC)',
'symbol' => 'MATIC',
'price' => 0.92,
'ma200' => 0.98,
'last_update' => '2024-04-21 14:00 UTC',
],
];
function determineSignal(float $price, float $ma200): array
{
if ($price >= $ma200) {
return ['LONG', 'Preis notiert über dem 200-Tage-Durchschnitt.'];
}
return ['SHORT', 'Preis notiert unter dem 200-Tage-Durchschnitt.'];
}
function formatNumber(float $value, int $decimals = 2): string
{
return number_format($value, $decimals, ',', '.');
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Top 10 Altcoins MA200 Signale</title>
<style>
:root {
color-scheme: light dark;
--bg: #0f172a;
--fg: #f8fafc;
--card: #1e293b;
--accent: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--muted: #94a3b8;
}
* {
box-sizing: border-box;
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top, rgba(16,185,129,0.25), transparent 55%),
var(--bg);
color: var(--fg);
display: flex;
align-items: center;
justify-content: center;
padding: 2rem 1rem;
}
main {
width: min(1100px, 100%);
background: rgba(15,23,42,0.85);
border: 1px solid rgba(148,163,184,0.2);
border-radius: 24px;
box-shadow: 0 15px 60px rgba(0,0,0,0.45);
padding: 2rem 2.5rem 2.5rem;
}
header {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 2rem;
}
header h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 1.2;
}
header p {
margin: 0;
color: var(--muted);
}
table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
thead th {
text-align: left;
font-size: 0.85rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid rgba(148,163,184,0.2);
padding-bottom: 0.75rem;
}
tbody td {
padding: 1rem 0;
border-bottom: 1px solid rgba(148,163,184,0.1);
}
tbody tr:last-child td {
border-bottom: none;
}
.symbol {
color: var(--muted);
font-size: 0.9rem;
}
.price {
font-variant-numeric: tabular-nums;
}
.signal {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.35rem 0.75rem;
border-radius: 999px;
font-size: 0.9rem;
font-weight: 600;
letter-spacing: 0.05em;
}
.signal-long {
background: rgba(16,185,129,0.15);
border: 1px solid rgba(16,185,129,0.5);
color: var(--accent);
}
.signal-short {
background: rgba(239,68,68,0.15);
border: 1px solid rgba(239,68,68,0.5);
color: var(--danger);
}
.telegram-card {
margin-top: 2rem;
background: rgba(30,41,59,0.9);
border: 1px solid rgba(59,130,246,0.4);
border-radius: 18px;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.telegram-card h2 {
margin: 0;
font-size: 1.2rem;
}
.telegram-card p {
margin: 0;
color: var(--muted);
line-height: 1.5;
}
.telegram-card pre {
margin: 0;
padding: 1rem;
background: rgba(15,23,42,0.8);
border-radius: 12px;
border: 1px solid rgba(148,163,184,0.15);
font-size: 0.9rem;
white-space: pre-wrap;
}
@media (max-width: 720px) {
main {
padding: 1.5rem;
}
table, thead, tbody, tr, td, th {
display: block;
}
thead {
display: none;
}
tbody tr {
border: 1px solid rgba(148,163,184,0.2);
border-radius: 18px;
padding: 1rem 1.25rem;
margin-bottom: 1rem;
}
tbody td {
border: none;
padding: 0.35rem 0;
}
tbody td::before {
content: attr(data-label);
display: block;
font-size: 0.75rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 0.2rem;
}
}
</style>
</head>
<body>
<main>
<header>
<h1>Top 10 Altcoins &amp; MA200 Signale</h1>
<p>Überblick über mögliche Einstiegszonen basierend auf dem 200-Tage-Durchschnitt. Die Signale (Long oder Short)
können direkt an den Telegram-Channel weitergeleitet werden.</p>
</header>
<table>
<thead>
<tr>
<th>Asset</th>
<th>Preis (USD)</th>
<th>MA200 (USD)</th>
<th>Abweichung</th>
<th>Signal</th>
<th>Zuletzt aktualisiert</th>
</tr>
</thead>
<tbody>
<?php foreach ($altcoins as $coin):
[$signal, $reason] = determineSignal($coin['price'], $coin['ma200']);
$diff = $coin['price'] - $coin['ma200'];
$diffPercent = ($coin['ma200'] > 0)
? ($diff / $coin['ma200']) * 100
: 0;
?>
<tr>
<td data-label="Asset">
<strong><?php echo htmlspecialchars($coin['name']); ?></strong>
<div class="symbol"><?php echo htmlspecialchars($coin['symbol']); ?></div>
</td>
<td data-label="Preis" class="price">$ <?php echo formatNumber($coin['price']); ?></td>
<td data-label="MA200" class="price">$ <?php echo formatNumber($coin['ma200']); ?></td>
<td data-label="Abweichung">
<?php echo ($diff >= 0 ? '+' : '-') . formatNumber(abs($diff)); ?>
<span class="symbol">(<?php echo ($diffPercent >= 0 ? '+' : '-') . formatNumber(abs($diffPercent)); ?> %)</span>
</td>
<td data-label="Signal">
<span class="signal signal-<?php echo strtolower($signal); ?>">
<?php echo $signal; ?>
</span>
<div class="symbol"><?php echo $reason; ?></div>
</td>
<td data-label="Update" class="symbol"><?php echo htmlspecialchars($coin['last_update']); ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php
$signals = array_map(function ($coin) {
[$signal, $reason] = determineSignal($coin['price'], $coin['ma200']);
return sprintf('%s (%s): %s %s', $coin['name'], $coin['symbol'], $signal, $reason);
}, $altcoins);
?>
<section class="telegram-card">
<h2>Telegram Broadcast</h2>
<p>Kopiere die Zusammenfassung, um sie in deinem Signal-Channel zu posten, sobald eine MA200-Überschreitung
stattfindet.</p>
<pre><?php echo htmlspecialchars(implode("\n", $signals)); ?></pre>
</section>
</main>
</body>
</html>
+77
View File
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/email.php';
$bandId = isset($_GET['band_id']) ? (int) $_GET['band_id'] : 0;
$band = $bandId ? findBand($bandId) : null;
if (!$band) {
http_response_code(404);
echo 'Band nicht gefunden';
exit;
}
$user = currentUser();
$message = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$data = [
'band_id' => $bandId,
'user_id' => $user['id'] ?? null,
'event_date' => $_POST['event_date'] ?? '',
'location' => trim((string) $_POST['location'] ?? ''),
'budget' => (int) ($_POST['budget'] ?? 0),
'event_type' => trim((string) $_POST['event_type'] ?? ''),
'message' => trim((string) $_POST['message'] ?? ''),
];
if (!$data['event_date'] || !$data['location']) {
$error = 'Bitte Datum und Ort ausfüllen.';
} else {
createRequest($data);
$message = 'Anfrage gespeichert und an die Band gemeldet.';
sendEmail('info@' . preg_replace('/\s+/', '', strtolower($band['name'])) . '.ch', 'Neue Anfrage', 'Neue Anfrage für ' . $band['name']);
}
}
$settings = settings();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Anfrage <?= htmlspecialchars($band['name']) ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="band-detail.php?id=<?= $bandId ?>"> Zurück</a>
<h1>Anfrage an <?= htmlspecialchars($band['name']) ?></h1>
<p>PayPal Zahlungsabwicklung ist <?= $settings['paypal_enabled'] === '1' ? 'aktiviert' : 'optional' ?>, Service Fee: <?= htmlspecialchars($settings['service_fee']) ?>%.</p>
</header>
<main>
<?php if ($message): ?><div class="alert alert-success"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<form method="post">
<label>Event-Datum
<input type="date" class="form-control" name="event_date" required>
</label>
<label>Ort / Location
<input type="text" class="form-control" name="location" placeholder="Zürich, Kaufleuten" required>
</label>
<label>Event-Typ
<input type="text" class="form-control" name="event_type" placeholder="Hochzeit, Firmenfeier">
</label>
<label>Budget (CHF)
<input type="number" class="form-control" name="budget" placeholder="4500">
</label>
<label>Nachricht
<textarea class="form-control" name="message" rows="4"></textarea>
</label>
<button class="btn-primary">Anfrage senden</button>
</form>
</main>
</body>
</html>
+237
View File
@@ -0,0 +1,237 @@
:root {
--primary: #ffb703;
--secondary: #fb8500;
--dark: #0b0d17;
--darker: #090b13;
--light: #fefae0;
--gray: #8d99ae;
--gradient: linear-gradient(120deg, #ffb703, #fb5607, #ff006e);
}
* {
box-sizing: border-box;
}
body {
font-family: 'Space Grotesk', 'Segoe UI', system-ui, sans-serif;
margin: 0;
background: radial-gradient(circle at 10% 20%, rgba(255, 183, 3, 0.25), rgba(9, 11, 19, 0.95)), var(--dark);
color: var(--light);
min-height: 100vh;
}
header {
padding: 40px 5vw 20px;
}
.hero {
background: var(--darker);
border-radius: 24px;
padding: 40px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
align-items: center;
}
.hero h1 {
font-size: clamp(2.2rem, 5vw, 3.6rem);
margin-bottom: 10px;
}
.hero p {
color: rgba(255, 255, 255, 0.8);
}
.badge-list {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.badge {
background: rgba(255, 255, 255, 0.08);
padding: 6px 18px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
font-size: 0.9rem;
}
.search-panel {
margin-top: 30px;
background: rgba(0, 0, 0, 0.35);
border-radius: 18px;
padding: 20px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 15px;
}
.search-panel input,
.search-panel select {
width: 100%;
padding: 12px 16px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: var(--light);
}
.btn-primary {
padding: 14px 24px;
border-radius: 16px;
border: none;
font-weight: bold;
background: var(--gradient);
color: var(--dark);
cursor: pointer;
transition: transform 0.2s ease;
}
.btn-primary:hover {
transform: translateY(-2px);
}
main {
padding: 30px 5vw 80px;
}
.band-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.band-card {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 20px;
padding: 20px;
position: relative;
overflow: hidden;
transition: transform 0.2s ease, border-color 0.3s ease;
}
.band-card:hover {
transform: translateY(-4px);
border-color: var(--primary);
}
.band-card h3 {
margin-top: 0;
margin-bottom: 8px;
}
.price-tag {
font-size: 1.1rem;
color: var(--primary);
font-weight: bold;
}
.card-meta {
color: rgba(255, 255, 255, 0.7);
font-size: 0.9rem;
}
footer {
background: rgba(0, 0, 0, 0.4);
padding: 30px 5vw;
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 15px;
}
.cookie-banner {
position: fixed;
bottom: 20px;
right: 20px;
background: var(--darker);
border-radius: 16px;
padding: 20px;
width: min(360px, calc(100% - 40px));
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.5);
display: none;
}
.cookie-banner.active {
display: block;
}
.table {
width: 100%;
border-collapse: collapse;
}
.table th,
.table td {
padding: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
text-align: left;
}
.form-control {
width: 100%;
padding: 12px 16px;
margin-bottom: 15px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.05);
color: var(--light);
}
.alert {
padding: 15px 18px;
border-radius: 12px;
margin-bottom: 15px;
}
.alert-success {
background: rgba(56, 142, 60, 0.2);
border: 1px solid rgba(56, 142, 60, 0.4);
}
.alert-error {
background: rgba(213, 0, 0, 0.2);
border: 1px solid rgba(213, 0, 0, 0.4);
}
.band-detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 30px;
margin-top: 30px;
}
.gallery img {
width: 100%;
border-radius: 16px;
margin-bottom: 16px;
}
.badge-rating {
background: rgba(255, 183, 3, 0.2);
border-color: rgba(255, 183, 3, 0.5);
}
.admin-nav {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.admin-nav a {
color: var(--light);
text-decoration: none;
padding: 10px 16px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.08);
}
@media (max-width: 768px) {
.hero,
footer {
padding: 24px;
}
}
+20
View File
@@ -0,0 +1,20 @@
const cookieBanner = document.querySelector('.cookie-banner');
const cookieAccept = document.querySelector('[data-cookie-accept]');
if (cookieBanner && cookieAccept) {
const consent = localStorage.getItem('gyb-cookie');
if (!consent) {
cookieBanner.classList.add('active');
}
cookieAccept.addEventListener('click', () => {
localStorage.setItem('gyb-cookie', 'accepted');
cookieBanner.classList.remove('active');
});
}
const filterForm = document.querySelector('[data-filter-form]');
if (filterForm) {
filterForm.addEventListener('input', () => {
filterForm.submit();
});
}
+1720
View File
File diff suppressed because it is too large Load Diff
+4045
View File
File diff suppressed because it is too large Load Diff
+143
View File
@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
$bandId = isset($_GET['id']) ? (int) $_GET['id'] : 0;
$band = $bandId ? findBand($bandId) : null;
if (!$band) {
http_response_code(404);
echo 'Band nicht gefunden';
exit;
}
$media = bandMedia($bandId);
$availability = bandAvailability($bandId);
$reviews = bandReviews($bandId);
$user = currentUser();
$message = '';
$error = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST' && $user) {
if (isset($_POST['review'])) {
if (!eligibleForReview($bandId, (int) $user['id'])) {
$error = 'Für Bewertungen ist eine bestätigte Buchung nötig.';
} else {
$comment = trim((string) ($_POST['comment'] ?? ''));
if (mb_strlen($comment) > 200) {
$error = 'Maximal 200 Zeichen erlaubt.';
} else {
storeReview([
'band_id' => $bandId,
'user_id' => (int) $user['id'],
'rating' => (int) $_POST['rating'],
'comment' => $comment,
]);
$message = 'Danke! Deine Bewertung wartet auf Freigabe.';
}
}
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= htmlspecialchars($band['name']) ?> <?= SITE_NAME ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="index.php"> Zurück</a>
<div class="hero" style="margin-top: 20px;">
<div>
<p class="badge"><?= htmlspecialchars($band['genre']) ?></p>
<h1><?= htmlspecialchars($band['name']) ?></h1>
<p><?= nl2br(htmlspecialchars($band['description'])) ?></p>
<div class="badge-list">
<span class="badge">Ort: <?= htmlspecialchars($band['city'] ?? '') ?></span>
<span class="badge">ab <?= formatPrice((int) $band['price']) ?></span>
</div>
<div class="badge-list">
<?php foreach (array_filter(array_map('trim', explode(',', (string) $band['style_tags']))) as $tag): ?>
<span class="badge">#<?= htmlspecialchars($tag) ?></span>
<?php endforeach; ?>
</div>
<p>
<a class="btn-primary" href="anfrage.php?band_id=<?= $bandId ?>">Verfügbarkeit anfragen</a>
</p>
</div>
<div>
<?php if (!empty($band['video_url'])): ?>
<iframe width="100%" height="280" src="<?= htmlspecialchars($band['video_url']) ?>" title="Bandvideo" allowfullscreen></iframe>
<?php endif; ?>
</div>
</div>
</header>
<main>
<section class="band-detail-grid">
<div class="gallery">
<h3>Galerie</h3>
<?php foreach ($media as $item): ?>
<?php if ($item['type'] === 'image'): ?>
<img src="<?= htmlspecialchars($item['url']) ?>" alt="Bandbild">
<?php endif; ?>
<?php endforeach; ?>
</div>
<div>
<h3>Verfügbarkeit</h3>
<table class="table">
<thead>
<tr><th>Datum</th><th>Status</th></tr>
</thead>
<tbody>
<?php foreach ($availability as $slot): ?>
<tr>
<td><?= htmlspecialchars((new DateTimeImmutable($slot['event_date']))->format('d.m.Y')) ?></td>
<td><?= htmlspecialchars(ucfirst($slot['status'])) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</section>
<section style="margin-top: 40px;">
<h3>Bewertungen</h3>
<?php if ($message): ?><div class="alert alert-success"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<?php foreach ($reviews as $review): ?>
<article class="band-card">
<p><strong><?= htmlspecialchars($review['author']) ?></strong> <?= (int) $review['rating'] ?> ★</p>
<p><?= htmlspecialchars($review['comment']) ?></p>
<p class="card-meta"><?= (new DateTimeImmutable($review['created_at']))->format('d.m.Y') ?></p>
</article>
<?php endforeach; ?>
<?php if (!$reviews): ?>
<p>Noch keine freigegebenen Bewertungen.</p>
<?php endif; ?>
</section>
<?php if ($user && $user['role'] === 'kunde'): ?>
<section style="margin-top: 40px;">
<h3>Eigene Bewertung</h3>
<form method="post">
<input type="hidden" name="review" value="1">
<label>Sterne
<select class="form-control" name="rating">
<?php for ($i = 5; $i >= 1; $i--): ?>
<option value="<?= $i ?>"><?= $i ?></option>
<?php endfor; ?>
</select>
</label>
<label>Kommentar (max. 200 Zeichen)
<textarea class="form-control" name="comment" maxlength="200"></textarea>
</label>
<button class="btn-primary">Bewertung senden</button>
</form>
</section>
<?php endif; ?>
</main>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'kunde',
city TEXT,
verified INTEGER NOT NULL DEFAULT 0,
verification_token TEXT,
created_at TEXT
);
CREATE TABLE IF NOT EXISTS bands (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER,
name TEXT NOT NULL,
city TEXT,
genre TEXT,
price INTEGER DEFAULT 0,
description TEXT,
status TEXT NOT NULL DEFAULT 'prüfung',
style_tags TEXT,
video_url TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS band_media (
id INTEGER PRIMARY KEY AUTOINCREMENT,
band_id INTEGER NOT NULL,
type TEXT NOT NULL,
url TEXT NOT NULL,
FOREIGN KEY(band_id) REFERENCES bands(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS band_availability (
id INTEGER PRIMARY KEY AUTOINCREMENT,
band_id INTEGER NOT NULL,
event_date TEXT NOT NULL,
status TEXT NOT NULL,
FOREIGN KEY(band_id) REFERENCES bands(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
band_id INTEGER NOT NULL,
user_id INTEGER,
event_date TEXT,
location TEXT,
budget INTEGER,
event_type TEXT,
message TEXT,
status TEXT NOT NULL DEFAULT 'neu',
created_at TEXT,
FOREIGN KEY(band_id) REFERENCES bands(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS reviews (
id INTEGER PRIMARY KEY AUTOINCREMENT,
band_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
rating INTEGER NOT NULL,
comment TEXT,
status TEXT NOT NULL DEFAULT 'wartend',
created_at TEXT,
FOREIGN KEY(band_id) REFERENCES bands(id) ON DELETE CASCADE,
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/config.php';
$options = array_slice($argv, 1);
if (in_array('--fresh', $options, true) && file_exists(DB_PATH)) {
unlink(DB_PATH);
echo "Bestehende Datenbank entfernt wird neu initialisiert.\n";
}
require_once __DIR__ . '/includes/functions.php';
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/email.php';
if (!isset($_SERVER['REQUEST_METHOD'])) {
$_SERVER['REQUEST_METHOD'] = 'GET';
}
final class FunctionalTestRunner
{
private PDO $pdo;
private array $results = [];
private int $passed = 0;
private int $failed = 0;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
public function run(string $title, callable $test, bool $transactional = false): void
{
try {
if ($transactional) {
$this->pdo->beginTransaction();
}
$details = $test($this->pdo);
if ($transactional && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->record('PASS', $title, $details);
} catch (Throwable $e) {
if ($transactional && $this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
$this->record('FAIL', $title, $e->getMessage());
}
}
private function record(string $status, string $title, ?string $details): void
{
$status === 'PASS' ? $this->passed++ : $this->failed++;
$this->results[] = [
'status' => $status,
'title' => $title,
'details' => $details ?? '',
];
}
public function summary(): void
{
foreach ($this->results as $result) {
$symbol = $result['status'] === 'PASS' ? '\u{2705}' : '\u{274C}';
echo sprintf("%s %s\n", $symbol, $result['title']);
if ($result['details'] !== '') {
echo sprintf(" %s\n", $result['details']);
}
}
echo str_repeat('-', 50) . "\n";
echo sprintf("Ergebnis: %d bestanden, %d fehlgeschlagen\n", $this->passed, $this->failed);
exit($this->failed === 0 ? 0 : 1);
}
}
function renderPage(string $file, array $get = [], array $post = []): string
{
$previousGet = $_GET ?? [];
$previousPost = $_POST ?? [];
$previousMethod = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$_GET = $get;
$_POST = $post;
$_SERVER['REQUEST_METHOD'] = empty($post) ? 'GET' : 'POST';
ob_start();
include __DIR__ . '/' . ltrim($file, '/');
$output = ob_get_clean();
$_GET = $previousGet;
$_POST = $previousPost;
$_SERVER['REQUEST_METHOD'] = $previousMethod;
return $output;
}
function restartSession(): void
{
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
}
$pdo = db();
$runner = new FunctionalTestRunner($pdo);
$runner->run('Storage-Verzeichnis vorhanden', function () {
if (!is_dir(__DIR__ . '/storage')) {
throw new RuntimeException('Ordner storage fehlt.');
}
return 'Pfad: ' . realpath(__DIR__ . '/storage');
});
$runner->run('Datenbank initialisiert', function (PDO $pdo) {
if (!file_exists(DB_PATH)) {
throw new RuntimeException('database.sqlite wurde nicht erstellt.');
}
$tables = $pdo->query("SELECT name FROM sqlite_master WHERE type='table'")
->fetchAll(PDO::FETCH_COLUMN);
$required = ['users', 'bands', 'requests', 'reviews', 'settings'];
foreach ($required as $table) {
if (!in_array($table, $tables, true)) {
throw new RuntimeException('Tabelle ' . $table . ' fehlt.');
}
}
return 'Tabellen gefunden: ' . implode(', ', $required);
});
$runner->run('Seed-Daten verfügbar', function (PDO $pdo) {
$users = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
$bands = (int) $pdo->query('SELECT COUNT(*) FROM bands')->fetchColumn();
if ($users < 3 || $bands < 2) {
throw new RuntimeException('Seed-Daten unvollständig.');
}
return sprintf('Users: %d, Bands: %d', $users, $bands);
});
$runner->run('Login / Logout Workflow', function () {
if (!login('david@example.com', 'secret123')) {
throw new RuntimeException('Login schlug fehl.');
}
$user = currentUser();
if (!$user || $user['role'] !== 'kunde') {
throw new RuntimeException('Session liefert keinen Kunden.');
}
logout();
restartSession();
if (currentUser()) {
throw new RuntimeException('Logout hat Session nicht geleert.');
}
return 'Login erfolgreich für ' . $user['name'];
});
$runner->run('Band-Filter & Durchschnitt', function () {
$bands = allBands(['genre' => 'Funk']);
if (!$bands) {
throw new RuntimeException('Filter lieferte keine Band.');
}
$rating = averageRating((int) $bands[0]['id']);
if ($rating === null) {
throw new RuntimeException('Keine Bewertung vorhanden.');
}
return sprintf('%d Bands, Ø Bewertung %.1f★', count($bands), $rating);
});
$runner->run('Medien & Verfügbarkeiten geladen', function () {
$media = bandMedia(1);
$availability = bandAvailability(1);
$reviews = bandReviews(1);
if (!$media || !$availability || !$reviews) {
throw new RuntimeException('Band 1 hat unvollständige Daten.');
}
return sprintf('Medien: %d, Slots: %d, Reviews: %d', count($media), count($availability), count($reviews));
});
$runner->run('Anfrage speichern (Transaktion)', function (PDO $pdo) {
$before = (int) $pdo->query('SELECT COUNT(*) FROM requests')->fetchColumn();
createRequest([
'band_id' => 1,
'user_id' => 3,
'event_date' => (new DateTimeImmutable('+60 days'))->format('Y-m-d'),
'location' => 'Teststadt',
'budget' => 4500,
'event_type' => 'Testevent',
'message' => 'Funktionstest Anfrage',
]);
$after = (int) $pdo->query('SELECT COUNT(*) FROM requests')->fetchColumn();
if ($after !== $before + 1) {
throw new RuntimeException('Anfrage wurde nicht gespeichert.');
}
return 'Requests gesamt (temporär): ' . $after;
}, true);
$runner->run('Bewertungen speichern & Eligibility', function (PDO $pdo) {
if (!eligibleForReview(1, 3)) {
throw new RuntimeException('User 3 sollte berechtigt sein.');
}
$before = (int) $pdo->query('SELECT COUNT(*) FROM reviews')->fetchColumn();
storeReview([
'band_id' => 1,
'user_id' => 3,
'rating' => 4,
'comment' => 'Testkommentar',
]);
$after = (int) $pdo->query('SELECT COUNT(*) FROM reviews')->fetchColumn();
if ($after !== $before + 1) {
throw new RuntimeException('Review wurde nicht gespeichert.');
}
return 'Reviews gesamt (temporär): ' . $after;
}, true);
$runner->run('Einstellungen lesen & aktualisieren', function () {
$current = settings();
$originalFee = $current['service_fee'] ?? '0';
updateSetting('service_fee', '12');
$updated = settings();
if (($updated['service_fee'] ?? null) !== '12') {
throw new RuntimeException('Service Fee konnte nicht aktualisiert werden.');
}
updateSetting('service_fee', $originalFee);
return 'Service Fee temporär auf 12 gesetzt.';
}, true);
$runner->run('Moderations-Aktionen', function (PDO $pdo) {
changeBandStatus(1, 'prüfung');
$status = $pdo->query('SELECT status FROM bands WHERE id = 1')->fetchColumn();
if ($status !== 'prüfung') {
throw new RuntimeException('Bandstatus änderte sich nicht.');
}
changeReviewStatus(1, 'gesperrt');
$reviewStatus = $pdo->query('SELECT status FROM reviews WHERE id = 1')->fetchColumn();
if ($reviewStatus !== 'gesperrt') {
throw new RuntimeException('Reviewstatus änderte sich nicht.');
}
return 'Statusänderungen durchgeführt.';
}, true);
$runner->run('Registrierung legt Band an', function (PDO $pdo) {
$email = 'tester+' . uniqid('', true) . '@example.com';
$result = register([
'name' => 'Functional Tester',
'email' => $email,
'password' => 'secret123',
'role' => 'band',
'city' => 'Testingen',
'band_name' => 'QA Ensemble',
'genre' => 'QA Funk',
]);
if (empty($result['token']) || strlen($result['token']) < 20) {
throw new RuntimeException('Verifikationstoken fehlt.');
}
$user = $pdo->prepare('SELECT id, role FROM users WHERE email = :email');
$user->execute([':email' => $email]);
$userRow = $user->fetch(PDO::FETCH_ASSOC);
if (!$userRow || $userRow['role'] !== 'band') {
throw new RuntimeException('User wurde nicht gespeichert.');
}
$band = $pdo->prepare('SELECT status FROM bands WHERE user_id = :id');
$band->execute([':id' => $userRow['id']]);
$bandRow = $band->fetch(PDO::FETCH_ASSOC);
if (!$bandRow || $bandRow['status'] !== 'prüfung') {
throw new RuntimeException('Bandprofil wurde nicht angelegt.');
}
return 'Token erstellt und Bandstatus "prüfung" bestätigt.';
}, true);
$runner->run('Startseite rendert fehlerfrei', function () {
$html = renderPage('index.php');
if (strpos($html, 'Aktive Bands') === false) {
throw new RuntimeException('Indexseite liefert keinen Inhalt.');
}
return 'HTML-Länge: ' . strlen($html) . ' Zeichen';
});
$runner->run('Band-Detailseite rendert', function () {
$html = renderPage('band-detail.php', ['id' => 1]);
if (strpos($html, 'Verfügbarkeit') === false) {
throw new RuntimeException('Band-Detailseite unvollständig.');
}
return 'HTML-Länge: ' . strlen($html) . ' Zeichen';
});
$runner->run('Anfrageformular rendert', function () {
$html = renderPage('anfrage.php', ['band_id' => 1]);
if (strpos($html, 'Anfrage an') === false) {
throw new RuntimeException('Anfrageformular fehlgeschlagen.');
}
return 'HTML-Länge: ' . strlen($html) . ' Zeichen';
});
$runner->run('E-Mail Logging (kein Versand)', function () {
$logDir = __DIR__ . '/storage/logs';
if (!is_dir($logDir)) {
mkdir($logDir, 0775, true);
}
$logFile = $logDir . '/mail.log';
$before = file_exists($logFile) ? filesize($logFile) : 0;
sendEmail('qa@example.com', 'Functional Test', 'Nur Logeintrag kein Versand.');
$after = filesize($logFile);
if ($after <= $before) {
throw new RuntimeException('Mail-Log wurde nicht aktualisiert.');
}
return 'Logeintrag ergänzt, Versand erfolgt nur als Datei.';
});
$runner->summary();
+93
View File
@@ -0,0 +1,93 @@
<?php
require_once __DIR__ . '/functions.php';
if (session_status() === PHP_SESSION_NONE) {
session_start();
}
function currentUser(): ?array
{
if (empty($_SESSION['user_id'])) {
return null;
}
static $user;
if ($user) {
return $user;
}
$stmt = db()->prepare('SELECT * FROM users WHERE id = :id');
$stmt->execute([':id' => $_SESSION['user_id']]);
$user = $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
return $user;
}
function login(string $email, string $password): bool
{
$stmt = db()->prepare('SELECT * FROM users WHERE email = :email');
$stmt->execute([':email' => $email]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user || !password_verify($password, $user['password'])) {
return false;
}
if ((int) $user['verified'] !== 1) {
throw new RuntimeException('Bitte verifiziere zuerst deine E-Mail.');
}
$_SESSION['user_id'] = $user['id'];
return true;
}
function logout(): void
{
session_destroy();
}
function register(array $data): array
{
$token = bin2hex(random_bytes(16));
$stmt = db()->prepare('INSERT INTO users (name, email, password, role, city, verified, verification_token, created_at)
VALUES (:name, :email, :password, :role, :city, 0, :token, :created)');
$stmt->execute([
':name' => $data['name'],
':email' => $data['email'],
':password' => password_hash($data['password'], PASSWORD_DEFAULT),
':role' => $data['role'],
':city' => $data['city'] ?? null,
':token' => $token,
':created' => (new DateTimeImmutable())->format('c'),
]);
if ($data['role'] === 'band') {
$band = db()->prepare('INSERT INTO bands (user_id, name, city, genre, price, description, status)
VALUES (:user_id, :name, :city, :genre, :price, :description, :status)');
$band->execute([
':user_id' => (int) db()->lastInsertId(),
':name' => $data['band_name'] ?? 'Neue Band',
':city' => $data['city'] ?? '',
':genre' => $data['genre'] ?? '',
':price' => 0,
':description' => 'Bitte Profil ergänzen.',
':status' => 'prüfung',
]);
}
return ['token' => $token];
}
function requireLogin(): void
{
if (!currentUser()) {
header('Location: login.php');
exit;
}
}
function requireAdmin(): void
{
$user = currentUser();
if (!$user || $user['role'] !== 'admin') {
http_response_code(403);
echo 'Keine Berechtigung';
exit;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
const SITE_NAME = 'GetYourBand';
const DB_PATH = __DIR__ . '/../storage/database.sqlite';
const SUPPORT_EMAIL = 'support@getyourband.ch';
const BASE_URL = '';
const COOKIE_NAME = 'gyb_consent';
if (!is_dir(__DIR__ . '/../storage')) {
mkdir(__DIR__ . '/../storage', 0775, true);
}
+144
View File
@@ -0,0 +1,144 @@
<?php
require_once __DIR__ . '/config.php';
function db(): PDO
{
static $pdo = null;
if ($pdo instanceof PDO) {
return $pdo;
}
$dsn = 'sqlite:' . DB_PATH;
$pdo = new PDO($dsn);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
initializeDatabase($pdo);
return $pdo;
}
function initializeDatabase(PDO $pdo): void
{
$schema = file_get_contents(__DIR__ . '/../database.sql');
$pdo->exec($schema);
seedData($pdo);
}
function seedData(PDO $pdo): void
{
$count = (int) $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();
if ($count > 0) {
return;
}
$now = (new DateTimeImmutable())->format('c');
$password = password_hash('secret123', PASSWORD_DEFAULT);
$stmt = $pdo->prepare('INSERT INTO users (name, email, password, role, verified, verification_token, created_at)
VALUES (:name, :email, :password, :role, :verified, :token, :created)');
$stmt->execute([
':name' => 'Admin',
':email' => 'admin@getyourband.ch',
':password' => $password,
':role' => 'admin',
':verified' => 1,
':token' => null,
':created' => $now,
]);
$stmt->execute([
':name' => 'Maya Keller',
':email' => 'maya@getyourband.ch',
':password' => $password,
':role' => 'band',
':verified' => 1,
':token' => null,
':created' => $now,
]);
$stmt->execute([
':name' => 'David Graf',
':email' => 'david@example.com',
':password' => $password,
':role' => 'kunde',
':verified' => 1,
':token' => null,
':created' => $now,
]);
$bands = [
[
'user_id' => 2,
'name' => 'Neon Groove Kollektiv',
'city' => 'Zürich',
'genre' => 'Funk / Soul',
'price' => 4200,
'description' => '7-köpfige Funk- und Soulband mit knalligem Brass-Sound und interaktiver Show.',
'status' => 'aktiv',
'style_tags' => 'Funk,Retro,Showband',
'video_url' => 'https://www.youtube.com/embed/dQw4w9WgXcQ',
],
[
'user_id' => null,
'name' => 'Sonnenblitz Orchester',
'city' => 'Bern',
'genre' => 'Pop / Party',
'price' => 3700,
'description' => 'Party-Coverband mit LED-Lichtshow und zweistimmigem Gesang.',
'status' => 'aktiv',
'style_tags' => 'Pop,Party,LED',
'video_url' => 'https://www.youtube.com/embed/5NV6Rdv1a3I',
],
];
$bandStmt = $pdo->prepare('INSERT INTO bands (user_id, name, city, genre, price, description, status, style_tags, video_url)
VALUES (:user_id, :name, :city, :genre, :price, :description, :status, :style_tags, :video_url)');
foreach ($bands as $band) {
$bandStmt->execute([
':user_id' => $band['user_id'],
':name' => $band['name'],
':city' => $band['city'],
':genre' => $band['genre'],
':price' => $band['price'],
':description' => $band['description'],
':status' => $band['status'],
':style_tags' => $band['style_tags'],
':video_url' => $band['video_url'],
]);
$bandId = (int) $pdo->lastInsertId();
$mediaStmt = $pdo->prepare('INSERT INTO band_media (band_id, type, url) VALUES (:band_id, :type, :url)');
$mediaStmt->execute([':band_id' => $bandId, ':type' => 'image', ':url' => 'https://images.unsplash.com/photo-1507878866276-a947ef722fee']);
$mediaStmt->execute([':band_id' => $bandId, ':type' => 'image', ':url' => 'https://images.unsplash.com/photo-1489515217757-5fd1be406fef']);
$availStmt = $pdo->prepare('INSERT INTO band_availability (band_id, event_date, status) VALUES (:band_id, :event_date, :status)');
for ($i = 0; $i < 4; $i++) {
$availStmt->execute([
':band_id' => $bandId,
':event_date' => (new DateTimeImmutable('+' . ($i + 1) * 7 . ' days'))->format('Y-m-d'),
':status' => $i % 2 === 0 ? 'frei' : 'option',
]);
}
}
$pdo->exec("INSERT INTO settings (key, value) VALUES ('paypal_enabled', '0'), ('service_fee', '8')");
$requestStmt = $pdo->prepare('INSERT INTO requests (band_id, user_id, event_date, location, budget, event_type, message, status, created_at)
VALUES (:band_id, :user_id, :event_date, :location, :budget, :event_type, :message, :status, :created)');
$requestStmt->execute([
':band_id' => 1,
':user_id' => 3,
':event_date' => (new DateTimeImmutable('+30 days'))->format('Y-m-d'),
':location' => 'Basel',
':budget' => 5000,
':event_type' => 'Firmenfeier',
':message' => 'Wir suchen einen funky Act für die Sommerparty.',
':status' => 'bestätigt',
':created' => $now,
]);
$pdo->exec("INSERT INTO reviews (band_id, user_id, rating, comment, status, created_at) VALUES (1, 3, 5, 'Mega Stimmung und super Show!', 'freigegeben', datetime('now'))");
}
+10
View File
@@ -0,0 +1,10 @@
<?php
function sendEmail(string $to, string $subject, string $message): void
{
$logDir = __DIR__ . '/../storage/logs';
if (!is_dir($logDir)) {
mkdir($logDir, 0775, true);
}
$entry = sprintf("%s\nTo: %s\nSubject: %s\n%s\n---\n", date('c'), $to, $subject, $message);
file_put_contents($logDir . '/mail.log', $entry, FILE_APPEND);
}
+159
View File
@@ -0,0 +1,159 @@
<?php
require_once __DIR__ . '/database.php';
function allBands(array $filters = []): array
{
$pdo = db();
$where = ['status = :status'];
$params = [':status' => 'aktiv'];
if (!empty($filters['genre'])) {
$where[] = 'genre LIKE :genre';
$params[':genre'] = '%' . $filters['genre'] . '%';
}
if (!empty($filters['city'])) {
$where[] = 'city LIKE :city';
$params[':city'] = '%' . $filters['city'] . '%';
}
$sql = 'SELECT * FROM bands WHERE ' . implode(' AND ', $where) . ' ORDER BY name';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function findBand(int $id): ?array
{
$stmt = db()->prepare('SELECT * FROM bands WHERE id = :id');
$stmt->execute([':id' => $id]);
$band = $stmt->fetch(PDO::FETCH_ASSOC);
return $band ?: null;
}
function bandMedia(int $bandId): array
{
$stmt = db()->prepare('SELECT * FROM band_media WHERE band_id = :id');
$stmt->execute([':id' => $bandId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function bandAvailability(int $bandId): array
{
$stmt = db()->prepare('SELECT * FROM band_availability WHERE band_id = :id ORDER BY event_date');
$stmt->execute([':id' => $bandId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function bandReviews(int $bandId): array
{
$stmt = db()->prepare('SELECT r.*, u.name AS author
FROM reviews r
JOIN users u ON u.id = r.user_id
WHERE r.band_id = :id AND r.status = "freigegeben"
ORDER BY r.created_at DESC');
$stmt->execute([':id' => $bandId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function averageRating(int $bandId): ?float
{
$stmt = db()->prepare('SELECT AVG(rating) FROM reviews WHERE band_id = :id AND status = "freigegeben"');
$stmt->execute([':id' => $bandId]);
$value = $stmt->fetchColumn();
return $value ? round((float) $value, 1) : null;
}
function formatPrice(int $amount): string
{
return number_format($amount, 0, ',', '.') . ' CHF';
}
function createRequest(array $data): void
{
$stmt = db()->prepare('INSERT INTO requests (band_id, user_id, event_date, location, budget, event_type, message, status, created_at)
VALUES (:band_id, :user_id, :event_date, :location, :budget, :event_type, :message, :status, :created_at)');
$stmt->execute([
':band_id' => $data['band_id'],
':user_id' => $data['user_id'],
':event_date' => $data['event_date'],
':location' => $data['location'],
':budget' => $data['budget'],
':event_type' => $data['event_type'],
':message' => $data['message'],
':status' => 'neu',
':created_at' => (new DateTimeImmutable())->format('c'),
]);
}
function userRequests(int $userId): array
{
$stmt = db()->prepare('SELECT * FROM requests WHERE user_id = :id ORDER BY created_at DESC');
$stmt->execute([':id' => $userId]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
}
function storeReview(array $data): void
{
$stmt = db()->prepare('INSERT INTO reviews (band_id, user_id, rating, comment, status, created_at)
VALUES (:band_id, :user_id, :rating, :comment, :status, :created_at)');
$stmt->execute([
':band_id' => $data['band_id'],
':user_id' => $data['user_id'],
':rating' => $data['rating'],
':comment' => $data['comment'],
':status' => 'wartend',
':created_at' => (new DateTimeImmutable())->format('c'),
]);
}
function eligibleForReview(int $bandId, int $userId): bool
{
$stmt = db()->prepare('SELECT COUNT(*) FROM requests WHERE band_id = :band AND user_id = :user AND status = "bestätigt"');
$stmt->execute([':band' => $bandId, ':user' => $userId]);
return (int) $stmt->fetchColumn() > 0;
}
function settings(): array
{
$stmt = db()->query('SELECT key, value FROM settings');
$data = $stmt->fetchAll(PDO::FETCH_KEY_PAIR);
return $data ?: ['paypal_enabled' => '0', 'service_fee' => '0'];
}
function updateSetting(string $key, string $value): void
{
$stmt = db()->prepare('INSERT INTO settings (key, value) VALUES (:key, :value)
ON CONFLICT(key) DO UPDATE SET value = excluded.value');
$stmt->execute([':key' => $key, ':value' => $value]);
}
function moderationItems(string $type): array
{
$pdo = db();
if ($type === 'bands') {
return $pdo->query('SELECT * FROM bands WHERE status != "aktiv"')->fetchAll(PDO::FETCH_ASSOC);
}
if ($type === 'reviews') {
return $pdo->query('SELECT r.*, b.name AS band_name, u.name AS author
FROM reviews r
JOIN bands b ON b.id = r.band_id
JOIN users u ON u.id = r.user_id
WHERE r.status = "wartend"')->fetchAll(PDO::FETCH_ASSOC);
}
return [];
}
function changeBandStatus(int $bandId, string $status): void
{
$stmt = db()->prepare('UPDATE bands SET status = :status WHERE id = :id');
$stmt->execute([':status' => $status, ':id' => $bandId]);
}
function changeReviewStatus(int $reviewId, string $status): void
{
$stmt = db()->prepare('UPDATE reviews SET status = :status WHERE id = :id');
$stmt->execute([':status' => $status, ':id' => $reviewId]);
}
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
$filters = [
'genre' => $_GET['genre'] ?? '',
'city' => $_GET['city'] ?? '',
];
$bands = allBands($filters);
$settings = settings();
$user = currentUser();
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title><?= SITE_NAME ?> Bands buchen</title>
<link rel="stylesheet" href="assets/css/style.css">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet">
</head>
<body>
<header>
<nav class="badge-list" style="justify-content: flex-end;">
<?php if ($user): ?>
<span class="badge">Hallo <?= htmlspecialchars($user['name']) ?></span>
<a class="badge" href="profil.php">Mein Profil</a>
<?php if ($user['role'] === 'admin'): ?>
<a class="badge" href="admin/dashboard.php">Admin</a>
<?php endif; ?>
<a class="badge" href="login.php?action=logout">Logout</a>
<?php else: ?>
<a class="badge" href="login.php">Login / Registrieren</a>
<?php endif; ?>
</nav>
<section class="hero">
<div>
<p class="badge">Schritt 3 · Frontend Release</p>
<h1>Finde deine <span style="color: var(--primary);">Funky Liveband</span></h1>
<p>GetYourBand bringt verifizierte Live-Acts mit Veranstalter:innen in der ganzen Schweiz zusammen. Mit Bewertungen,
moderner Suche und aktivierbarer Vermittlungsgebühr.</p>
<div class="badge-list">
<span class="badge">Bewertungen geprüft</span>
<span class="badge badge-rating">PayPal <?= $settings['paypal_enabled'] === '1' ? 'aktiv' : 'optional' ?></span>
<span class="badge">Service Fee <?= htmlspecialchars($settings['service_fee']) ?>%</span>
</div>
</div>
<form class="search-panel" method="get" data-filter-form>
<div>
<label for="genre">Stil / Genre</label>
<input type="text" id="genre" name="genre" value="<?= htmlspecialchars($filters['genre']) ?>" placeholder="Funk, Party, Jazz">
</div>
<div>
<label for="city">Ort / PLZ</label>
<input type="text" id="city" name="city" value="<?= htmlspecialchars($filters['city']) ?>" placeholder="Zürich, Basel">
</div>
<div>
<label>&nbsp;</label>
<button type="submit" class="btn-primary">Filtern</button>
</div>
</form>
</section>
</header>
<main>
<h2>Aktive Bands (<?= count($bands) ?>)</h2>
<section class="band-grid">
<?php foreach ($bands as $band): $rating = averageRating((int) $band['id']); ?>
<article class="band-card">
<p class="badge"><?= htmlspecialchars($band['genre']) ?></p>
<h3><?= htmlspecialchars($band['name']) ?></h3>
<p class="card-meta">Standort: <?= htmlspecialchars($band['city'] ?? '') ?></p>
<p><?= htmlspecialchars($band['description']) ?></p>
<p class="price-tag">ab <?= formatPrice((int) $band['price']) ?></p>
<?php if ($rating): ?>
<p class="card-meta">Bewertung: <?= $rating ?> ★</p>
<?php endif; ?>
<div class="badge-list">
<?php foreach (array_filter(array_map('trim', explode(',', (string) $band['style_tags']))) as $tag): ?>
<span class="badge">#<?= htmlspecialchars($tag) ?></span>
<?php endforeach; ?>
</div>
<p>
<a class="btn-primary" href="band-detail.php?id=<?= (int) $band['id'] ?>">Band ansehen</a>
</p>
</article>
<?php endforeach; ?>
<?php if (!$bands): ?>
<p>Keine Bands gefunden ändere deine Filter.</p>
<?php endif; ?>
</section>
</main>
<footer>
<div>
<strong>Legal</strong><br>
<a href="#">Datenschutz</a> · <a href="#">AGB</a>
</div>
<div>
<strong>Kontakt</strong><br>
support@getyourband.ch
</div>
</footer>
<div class="cookie-banner">
<p>Wir verwenden Cookies für Performance-Analysen. Mit Klick auf "Okay" akzeptierst du das.</p>
<button class="btn-primary" data-cookie-accept>Okay!</button>
</div>
<script src="assets/js/app.js" defer></script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
initial
+115
View File
@@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
require_once __DIR__ . '/includes/email.php';
$action = $_GET['action'] ?? '';
if ($action === 'logout') {
logout();
header('Location: index.php');
exit;
}
$error = '';
$message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['login'])) {
try {
if (!login($_POST['email'], $_POST['password'])) {
$error = 'Login fehlgeschlagen.';
} else {
header('Location: index.php');
exit;
}
} catch (RuntimeException $ex) {
$error = $ex->getMessage();
}
} elseif (isset($_POST['register'])) {
if ($_POST['password'] !== $_POST['password_confirm']) {
$error = 'Passwörter stimmen nicht überein.';
} else {
$result = register([
'name' => trim((string) $_POST['name']),
'email' => trim((string) $_POST['email']),
'password' => $_POST['password'],
'role' => $_POST['role'],
'city' => trim((string) $_POST['city']),
'band_name' => $_POST['band_name'] ?? null,
'genre' => $_POST['genre'] ?? null,
]);
$verificationLink = BASE_URL . '/verify-email.php?token=' . urlencode($result['token']);
sendEmail($_POST['email'], 'E-Mail bestätigen', 'Bitte bestätige dein Konto: ' . $verificationLink);
$message = 'Check deine Inbox wir haben dir den Verifizierungslink geschickt: ' . htmlspecialchars($verificationLink);
}
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login / Registrierung <?= SITE_NAME ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="index.php"> Zurück</a>
<h1>Login / Registrieren</h1>
</header>
<main>
<?php if ($message): ?><div class="alert alert-success"><?= $message ?></div><?php endif; ?>
<?php if ($error): ?><div class="alert alert-error"><?= htmlspecialchars($error) ?></div><?php endif; ?>
<section class="band-detail-grid">
<div>
<h2>Login</h2>
<form method="post">
<input type="hidden" name="login" value="1">
<label>E-Mail
<input class="form-control" type="email" name="email" required>
</label>
<label>Passwort
<input class="form-control" type="password" name="password" required>
</label>
<button class="btn-primary">Einloggen</button>
</form>
</div>
<div>
<h2>Registrierung</h2>
<form method="post">
<input type="hidden" name="register" value="1">
<label>Name
<input class="form-control" type="text" name="name" required>
</label>
<label>E-Mail
<input class="form-control" type="email" name="email" required>
</label>
<label>Ort
<input class="form-control" type="text" name="city">
</label>
<label>Rolle
<select class="form-control" name="role">
<option value="kunde">Veranstalter:in</option>
<option value="band">Band</option>
</select>
</label>
<label>Bandname (falls Band)
<input class="form-control" type="text" name="band_name">
</label>
<label>Genre
<input class="form-control" type="text" name="genre">
</label>
<label>Passwort
<input class="form-control" type="password" name="password" required>
</label>
<label>Passwort wiederholen
<input class="form-control" type="password" name="password_confirm" required>
</label>
<button class="btn-primary">Account anlegen</button>
</form>
</div>
</section>
</main>
</body>
</html>
+90
View File
@@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/includes/auth.php';
requireLogin();
$user = currentUser();
$band = null;
$message = '';
if ($user['role'] === 'band') {
$stmt = db()->prepare('SELECT * FROM bands WHERE user_id = :id');
$stmt->execute([':id' => $user['id']]);
$band = $stmt->fetch(PDO::FETCH_ASSOC);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$stmt = db()->prepare('UPDATE bands SET name = :name, city = :city, genre = :genre, price = :price, description = :description, style_tags = :tags WHERE id = :id');
$stmt->execute([
':name' => $_POST['name'],
':city' => $_POST['city'],
':genre' => $_POST['genre'],
':price' => (int) $_POST['price'],
':description' => $_POST['description'],
':tags' => $_POST['style_tags'],
':id' => $band['id'],
]);
$message = 'Bandprofil aktualisiert (wartet ggf. auf Freigabe).';
$band = findBand((int) $band['id']);
}
}
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<title>Mein Bereich <?= SITE_NAME ?></title>
<link rel="stylesheet" href="assets/css/style.css">
</head>
<body>
<header>
<a class="badge" href="index.php"> Startseite</a>
<h1>Hallo <?= htmlspecialchars($user['name']) ?></h1>
<p>Rolle: <?= htmlspecialchars($user['role']) ?></p>
</header>
<main>
<?php if ($message): ?><div class="alert alert-success"><?= htmlspecialchars($message) ?></div><?php endif; ?>
<?php if ($band): ?>
<h2>Bandprofil</h2>
<form method="post">
<label>Bandname
<input class="form-control" name="name" value="<?= htmlspecialchars($band['name']) ?>">
</label>
<label>Ort
<input class="form-control" name="city" value="<?= htmlspecialchars($band['city']) ?>">
</label>
<label>Genre
<input class="form-control" name="genre" value="<?= htmlspecialchars($band['genre']) ?>">
</label>
<label>Tags
<input class="form-control" name="style_tags" value="<?= htmlspecialchars($band['style_tags']) ?>">
</label>
<label>Preis (CHF)
<input class="form-control" type="number" name="price" value="<?= (int) $band['price'] ?>">
</label>
<label>Beschreibung
<textarea class="form-control" name="description" rows="4"><?= htmlspecialchars($band['description']) ?></textarea>
</label>
<button class="btn-primary">Speichern</button>
</form>
<?php else: ?>
<p>Du hast noch kein Bandprofil angelegt.</p>
<?php endif; ?>
<?php if ($user['role'] === 'kunde'): ?>
<h2>Meine Anfragen</h2>
<table class="table">
<thead><tr><th>Band</th><th>Datum</th><th>Status</th></tr></thead>
<tbody>
<?php foreach (userRequests((int) $user['id']) as $request): $bandName = findBand((int) $request['band_id']); ?>
<tr>
<td><?= htmlspecialchars($bandName['name'] ?? 'Band #' . $request['band_id']) ?></td>
<td><?= htmlspecialchars($request['event_date']) ?></td>
<td><?= htmlspecialchars($request['status']) ?></td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
<?php endif; ?>
</main>
</body>
</html>
+4429
View File
File diff suppressed because it is too large Load Diff
View File
+184
View File
@@ -0,0 +1,184 @@
<?php
$btcPrice = null;
$btcChange = null;
$btcSource = 'https://api.coindesk.com/v1/bpi/currentprice/USD.json';
try {
$response = @file_get_contents($btcSource);
if ($response !== false) {
$payload = json_decode($response, true);
if (isset($payload['bpi']['USD']['rate_float'])) {
$btcPrice = (float) $payload['bpi']['USD']['rate_float'];
}
if (isset($payload['chartName'])) {
$btcChange = $payload['chartName'];
}
}
} catch (Throwable $e) {
// Silently ignore network failures, we degrade gracefully in the UI.
}
$btcLabel = $btcPrice ? number_format($btcPrice, 2) . ' $' : 'unbekannt';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mouse Synth Lab</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;600&display=swap" rel="stylesheet">
<style>
:root {
color-scheme: dark;
font-family: 'Space Grotesk', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: radial-gradient(circle at top, #10152b, #050608 60%);
color: #e4f6ff;
}
body {
margin: 0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2rem;
padding: 2rem;
}
.panel {
background: rgba(15, 18, 40, 0.85);
border: 1px solid rgba(102, 204, 255, 0.25);
border-radius: 20px;
padding: 2rem;
width: min(960px, 100%);
box-shadow: 0 30px 60px rgba(5, 10, 50, 0.55);
backdrop-filter: blur(10px);
}
h1 {
margin: 0 0 1rem 0;
font-size: clamp(2rem, 4vw, 3rem);
letter-spacing: 0.04em;
text-transform: uppercase;
}
p {
margin: 0 0 1rem 0;
line-height: 1.6;
}
.synth-pad {
border-radius: 24px;
background: linear-gradient(135deg, rgba(35, 58, 122, 0.9), rgba(161, 92, 255, 0.75));
border: 1px solid rgba(255,255,255,0.2);
height: 320px;
position: relative;
overflow: hidden;
cursor: crosshair;
}
.synth-pad::after {
content: "";
position: absolute;
inset: 1rem;
border: 1px dashed rgba(255,255,255,0.2);
border-radius: 18px;
}
.pad-indicator {
position: absolute;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px solid #ffffff;
pointer-events: none;
transform: translate(-50%, -50%);
transition: transform 0.1s ease-out;
}
.controls {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
margin-top: 1.5rem;
}
label {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.1em;
color: rgba(255,255,255,0.65);
}
input[type="range"] {
width: 100%;
}
button {
border: none;
border-radius: 999px;
padding: 0.85rem 1.8rem;
font-size: 1rem;
cursor: pointer;
background: linear-gradient(135deg, #6a5af9, #32d9ff);
color: #050608;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
button:hover {
transform: translateY(-2px);
box-shadow: 0 20px 30px rgba(50, 217, 255, 0.3);
}
.status {
font-size: 0.9rem;
color: rgba(255,255,255,0.7);
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.status span {
font-weight: 600;
color: #7fffd4;
}
</style>
</head>
<body data-btc-price="<?= htmlspecialchars((string) ($btcPrice ?? '')) ?>">
<div class="panel">
<h1>Mouse Synth Lab</h1>
<p>
Zieh deine Maus durch den Pad und verwandle Bewegungen in Klang. Drei LFOs,
FM-Experimente und ein Delay/Distortion-Hybrid werden live verschaltet.
Der aktuelle Bitcoin-Kurs (<strong><?= htmlspecialchars($btcLabel) ?></strong>)
steuert, wie aggressiv der Mix moduliert und rückgekoppelt wird.
</p>
<div class="status" id="btc-status">
<div>Bitcoin Quelle: <span><?= htmlspecialchars($btcSource) ?></span></div>
<div>Letzter Wert: <span><?= htmlspecialchars($btcLabel) ?></span></div>
</div>
<div class="controls">
<div>
<label for="fm-depth">FM-Intensität</label>
<input id="fm-depth" type="range" min="10" max="800" value="320">
</div>
<div>
<label for="lfo-speed">LFO-Speed</label>
<input id="lfo-speed" type="range" min="0.05" max="18" value="6" step="0.05">
</div>
<div>
<label for="texture">Texture Morph</label>
<input id="texture" type="range" min="0" max="1" step="0.01" value="0.4">
</div>
<div style="display:flex;align-items:end;gap:0.75rem;">
<button id="start-btn">Synth starten</button>
<button id="randomize-btn" type="button">Chaos Patch</button>
</div>
</div>
<div class="synth-pad" id="synth-pad">
<div class="pad-indicator" id="pad-indicator" style="left:50%;top:50%;"></div>
</div>
<p style="font-size:0.9rem;color:rgba(255,255,255,0.65);margin-top:1.5rem;">
Tipp: Halte die Maus gedrückt, damit der AudioContext aktiv bleibt, und lass den Cursor
Kreise fahren. Je nach Bitcoin-Laune schalten sich neue Rückkopplungen zu.
</p>
</div>
<script src="synth.js" type="module"></script>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More