diff --git a/copyChat.sh b/copyChat.sh new file mode 100755 index 0000000..bada8fa --- /dev/null +++ b/copyChat.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash + +OUTPUT_FILE="${1:-llm_context.txt}" +ROOT_DIR="${2:-.}" + +# Absolute Pfade auflösen +OUTPUT_PATH="$(cd "$(dirname "$OUTPUT_FILE")" 2>/dev/null && pwd)/$(basename "$OUTPUT_FILE")" + +cd "$ROOT_DIR" || exit 1 + +# Ausgabedatei initialisieren / leeren +> "$OUTPUT_PATH" + +echo "========================================" >> "$OUTPUT_PATH" +echo "PROJEKTSTRUKTUR" >> "$OUTPUT_PATH" +echo "========================================" >> "$OUTPUT_PATH" + +# Prüfen, ob Git verfügbar und das Verzeichnis ein Git-Repo ist +IS_GIT_REPO=false +if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &> /dev/null; then + IS_GIT_REPO=true +fi + +if [ "$IS_GIT_REPO" = true ]; then + echo "(Ermittelt via Git unter Berücksichtigung von .gitignore)" >> "$OUTPUT_PATH" + # Zeigt alle getrackten + ungetrackten Dateien an, ignoriert .gitignore-Treffer + git ls-files -co --exclude-standard >> "$OUTPUT_PATH" +else + if command -v tree &> /dev/null; then + tree -I 'node_modules|.git|venv|.venv|__pycache__|dist|build|.next|target' >> "$OUTPUT_PATH" + else + find . -maxdepth 3 -not -path '*/.*' -not -path '*/node_modules*' -not -path '*/venv*' >> "$OUTPUT_PATH" + fi +fi + +echo -e "\n========================================" >> "$OUTPUT_PATH" +echo "DATEIINHALTE" >> "$OUTPUT_PATH" +echo "========================================" >> "$OUTPUT_PATH" + +# Dateiliste ermitteln +get_file_list() { + if [ "$IS_GIT_REPO" = true ]; then + # -c = cached/tracked, -o = others/untracked, --exclude-standard = .gitignore beachten + git ls-files -co --exclude-standard + else + find . -type f \ + -not -path '*/.git/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/venv/*' \ + -not -path '*/.venv/*' \ + -not -path '*/__pycache__/*' \ + -not -path '*/dist/*' \ + -not -path '*/build/*' \ + -not -path '*/.next/*' + fi +} + +# Dateien verarbeiten +get_file_list | sort | while IFS= read -r file; do + # Überspringe leere Pfade, die Zieldatei selbst oder typische Binär-Endungen + [ -z "$file" ] && continue + [ "$file" = "$OUTPUT_FILE" ] && continue + [ "$(realpath "$file" 2>/dev/null)" = "$OUTPUT_PATH" ] && continue + + case "$file" in + *.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.zip|*.tar.gz|*.lock|package-lock.json|yarn.lock) + continue + ;; + esac + + # Prüfen, ob die Datei existiert und lesbarer Text ist + if [ -f "$file" ] && (grep -Iq . "$file" 2>/dev/null || [ ! -s "$file" ]); then + echo -e "\n--- DATEI: $file ---" >> "$OUTPUT_PATH" + echo '```' >> "$OUTPUT_PATH" + cat "$file" >> "$OUTPUT_PATH" + echo -e "\n"'```' >> "$OUTPUT_PATH" + fi +done + +echo "Fertig! Projektkontext liegt in: $OUTPUT_PATH" \ No newline at end of file diff --git a/llm_context.txt b/llm_context.txt new file mode 100644 index 0000000..4449139 --- /dev/null +++ b/llm_context.txt @@ -0,0 +1,1404 @@ +======================================== +PROJEKTSTRUKTUR +======================================== +(Ermittelt via Git unter Berücksichtigung von .gitignore) +copyChat.sh +llm_context.txt +src/app/api/hotels/[id]/prices/route.ts +src/app/api/hotels/[id]/scan/route.ts +.gitignore +Dockerfile +README.md +docker-compose.yml +next.config.js +next.config.mjs +package-lock.json +package.json +src/app/api/hotels/[id]/history/route.ts +src/app/api/hotels/[id]/review-summary/route.ts +src/app/api/hotels/[id]/route.ts +src/app/api/hotels/route.ts +src/app/api/jobs/trigger-daily-check/route.ts +src/app/api/trips/route.ts +src/app/api/webhooks/scraper/route.ts +src/app/layout.tsx +src/app/page.tsx +src/lib/db.ts +tsconfig.json + +======================================== +DATEIINHALTE +======================================== + +--- DATEI: .gitignore --- +``` +# ========================================== +# 1. Lokale Umgebungs- & Geheimdateien +# ========================================== +.env +.env*.local +.env.development.local +.env.test.local +.env.production.local + +# ========================================== +# 2. Abhängigkeiten & Paketmanager +# ========================================== +node_modules/ +.pnpm-store/ + +# ========================================== +# 3. Next.js Build- & Output-Dateien +# ========================================== +.next/ +out/ +build/ +dist/ + +# ========================================== +# 4. Logs & temporäre Dateien +# ========================================== +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +.pnpm-debug.log* +*.log + +# ========================================== +# 5. IDEs & Betriebssystem +# ========================================== +.DS_Store +*.pem +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +``` + +--- DATEI: Dockerfile --- +``` +FROM node:20-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . +RUN npm run build + +EXPOSE 3001 +ENV PORT=3001 + +CMD ["npm", "start"] + +``` + +--- DATEI: README.md --- +``` + +``` + +--- DATEI: copyChat.sh --- +``` +#!/usr/bin/env bash + +OUTPUT_FILE="${1:-llm_context.txt}" +ROOT_DIR="${2:-.}" + +# Absolute Pfade auflösen +OUTPUT_PATH="$(cd "$(dirname "$OUTPUT_FILE")" 2>/dev/null && pwd)/$(basename "$OUTPUT_FILE")" + +cd "$ROOT_DIR" || exit 1 + +# Ausgabedatei initialisieren / leeren +> "$OUTPUT_PATH" + +echo "========================================" >> "$OUTPUT_PATH" +echo "PROJEKTSTRUKTUR" >> "$OUTPUT_PATH" +echo "========================================" >> "$OUTPUT_PATH" + +# Prüfen, ob Git verfügbar und das Verzeichnis ein Git-Repo ist +IS_GIT_REPO=false +if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &> /dev/null; then + IS_GIT_REPO=true +fi + +if [ "$IS_GIT_REPO" = true ]; then + echo "(Ermittelt via Git unter Berücksichtigung von .gitignore)" >> "$OUTPUT_PATH" + # Zeigt alle getrackten + ungetrackten Dateien an, ignoriert .gitignore-Treffer + git ls-files -co --exclude-standard >> "$OUTPUT_PATH" +else + if command -v tree &> /dev/null; then + tree -I 'node_modules|.git|venv|.venv|__pycache__|dist|build|.next|target' >> "$OUTPUT_PATH" + else + find . -maxdepth 3 -not -path '*/.*' -not -path '*/node_modules*' -not -path '*/venv*' >> "$OUTPUT_PATH" + fi +fi + +echo -e "\n========================================" >> "$OUTPUT_PATH" +echo "DATEIINHALTE" >> "$OUTPUT_PATH" +echo "========================================" >> "$OUTPUT_PATH" + +# Dateiliste ermitteln +get_file_list() { + if [ "$IS_GIT_REPO" = true ]; then + # -c = cached/tracked, -o = others/untracked, --exclude-standard = .gitignore beachten + git ls-files -co --exclude-standard + else + find . -type f \ + -not -path '*/.git/*' \ + -not -path '*/node_modules/*' \ + -not -path '*/venv/*' \ + -not -path '*/.venv/*' \ + -not -path '*/__pycache__/*' \ + -not -path '*/dist/*' \ + -not -path '*/build/*' \ + -not -path '*/.next/*' + fi +} + +# Dateien verarbeiten +get_file_list | sort | while IFS= read -r file; do + # Überspringe leere Pfade, die Zieldatei selbst oder typische Binär-Endungen + [ -z "$file" ] && continue + [ "$file" = "$OUTPUT_FILE" ] && continue + [ "$(realpath "$file" 2>/dev/null)" = "$OUTPUT_PATH" ] && continue + + case "$file" in + *.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.zip|*.tar.gz|*.lock|package-lock.json|yarn.lock) + continue + ;; + esac + + # Prüfen, ob die Datei existiert und lesbarer Text ist + if [ -f "$file" ] && (grep -Iq . "$file" 2>/dev/null || [ ! -s "$file" ]); then + echo -e "\n--- DATEI: $file ---" >> "$OUTPUT_PATH" + echo '```' >> "$OUTPUT_PATH" + cat "$file" >> "$OUTPUT_PATH" + echo -e "\n"'```' >> "$OUTPUT_PATH" + fi +done + +echo "Fertig! Projektkontext liegt in: $OUTPUT_PATH" +``` + +--- DATEI: docker-compose.yml --- +``` +services: + travel-app: + build: . + container_name: travel-app + restart: always + ports: + - "3002:3001" + env_file: + - .env + +``` + +--- DATEI: next.config.js --- +``` +/** @type {import('next').NextConfig} */ +const nextConfig = { + serverExternalPackages: ['bullmq', 'ioredis'], +}; + +module.exports = nextConfig; + +``` + +--- DATEI: next.config.mjs --- +``` +/** @type {import('next').NextConfig} */ +const nextConfig = { + serverExternalPackages: ['bullmq', 'ioredis'], +}; + +export default nextConfig; + +``` + +--- DATEI: package.json --- +``` +{ + "name": "travel-planner-app", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev -p 3001", + "build": "next build", + "start": "next start -p 3001" + }, + "dependencies": { + "axios": "^1.7.0", + "bullmq": "^6.0.9", + "ioredis": "^6.0.0", + "next": "^14.2.0", + "pg": "^8.11.5", + "react": "^18.3.0", + "react-dom": "^18.3.0" + }, + "devDependencies": { + "@types/node": "^20.0.0", + "@types/pg": "^8.11.0", + "@types/react": "^18.3.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "tailwindcss": "^3.4.0", + "typescript": "^5.0.0" + } +} + +``` + +--- DATEI: src/app/api/hotels/[id]/history/route.ts --- +``` +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function GET(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { id } = params; + const res = await query( + `SELECT * FROM hotel_price_history + WHERE hotel_id = $1 + ORDER BY tracked_at DESC`, + [id] + ); + + // Alle SQL-NUMERIC Strings in echte JS-Numbers umwandeln + const parsedHistory = res.rows.map((entry) => ({ + ...entry, + price_numeric: entry.price_numeric ? parseFloat(entry.price_numeric) : 0, + genius_price_numeric: entry.genius_price_numeric ? parseFloat(entry.genius_price_numeric) : null, + price_per_night: entry.price_per_night ? parseFloat(entry.price_per_night) : null, + price_per_person: entry.price_per_person ? parseFloat(entry.price_per_person) : null, + nights: entry.nights ? parseInt(entry.nights, 10) : 1, + persons: entry.persons ? parseInt(entry.persons, 10) : 2, + })); + + return NextResponse.json({ success: true, history: parsedHistory }); + } catch (e: any) { + console.error('Error fetching history:', e); + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } +} + +``` + +--- DATEI: src/app/api/hotels/[id]/prices/route.ts --- +``` +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { id } = params; + + const res = await query( + `SELECT + id, + price_numeric::float AS price, + price_per_night::float AS price_per_night, + price_per_person::float AS price_per_person, + nights, + persons, + tracked_at AS created_at + FROM hotel_price_history + WHERE hotel_id::text = $1::text + ORDER BY tracked_at ASC`, + [id] + ); + + return NextResponse.json({ success: true, prices: res.rows }); + } catch (error: any) { + console.error('Fehler beim Laden der Preishistorie:', error.message); + return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 }); + } +} + +``` + +--- DATEI: src/app/api/hotels/[id]/review-summary/route.ts --- +``` +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export async function POST(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { id } = params; + const { summary_positive, summary_passable, summary_negative, summary_bathroom, summary_count } = await req.json(); + + if (!id) { + return NextResponse.json({ error: 'Hotel-ID fehlt' }, { status: 400 }); + } + + await query( + `UPDATE hotels + SET review_summary_positive = $1, + review_summary_passable = $2, + review_summary_negative = $3, + review_summary_bathroom = $4, + review_summary_count = $5, + reviews_updated_at = NOW() + WHERE id = $6`, + [summary_positive, summary_passable, summary_negative, summary_bathroom || null, summary_count || null, id] + ); + + return NextResponse.json({ success: true, message: 'Review-Zusammenfassungen gespeichert.' }); + } catch (error: any) { + console.error('Fehler beim Speichern der Review-Zusammenfassung:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +``` + +--- DATEI: src/app/api/hotels/[id]/route.ts --- +``` +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +// PATCH: Tracking aktivieren / deaktivieren (is_active) +export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { id } = params; + const { is_active } = await req.json(); + + await query('UPDATE hotels SET is_active = $1 WHERE id = $2', [is_active, id]); + return NextResponse.json({ success: true }); + } catch (e: any) { + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } +} + +// DELETE: Hotel entfernen +export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { id } = params; + await query('DELETE FROM hotels WHERE id = $1', [id]); + return NextResponse.json({ success: true }); + } catch (e: any) { + return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } +} + +``` + +--- DATEI: src/app/api/hotels/[id]/scan/route.ts --- +``` +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + + const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null; + if (!bullmq || !bullmq.Queue) { + throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.'); + } + + const { Queue } = bullmq; + + const connection = { + host: process.env.REDIS_HOST || '172.17.0.1', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + connectTimeout: 5000, + maxRetriesPerRequest: 3, + }; + + const scrapeQueue = new Queue('scrape-jobs', { connection }); + + // Nur dieses eine Hotel laden + const res = await query('SELECT id, booking_url, name FROM hotels WHERE id = $1', [id]); + if (res.rows.length === 0) { + await scrapeQueue.close(); + return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 }); + } + + const hotel = res.rows[0]; + + const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews'; + const n8nWebhookUrl = n8nBaseUrl.includes('?') + ? `${n8nBaseUrl}&hotelId=${hotel.id}` + : `${n8nBaseUrl}?hotelId=${hotel.id}`; + + const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`; + + // Nur DIESEN einzelnen Job in die Queue schreiben + await scrapeQueue.add('scrape-hotel', { + hotelId: hotel.id, + url: hotel.booking_url, + appWebhookUrl, + webhookUrl: n8nWebhookUrl, + }); + + await scrapeQueue.close(); + + return NextResponse.json({ + success: true, + message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`, + }); + } catch (error: any) { + console.error('Fehler beim Starten des Einzel-Scrapes:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +``` + +--- DATEI: src/app/api/hotels/route.ts --- +``` +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const res = await query(` + SELECT + h.*, + t.name AS trip_name, + ph.price_numeric::float AS latest_price, + ph.price_per_night::float AS latest_price_per_night, + ph.price_per_person::float AS latest_price_per_person, + ph.nights AS latest_nights, + ph.persons AS latest_persons + FROM hotels h + LEFT JOIN trips t ON h.trip_id = t.id + LEFT JOIN LATERAL ( + SELECT price_numeric, price_per_night, price_per_person, nights, persons + FROM hotel_price_history + WHERE hotel_id::text = h.id::text + ORDER BY tracked_at DESC NULLS LAST, id DESC + LIMIT 1 + ) ph ON true + ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC + `); + + return NextResponse.json({ success: true, hotels: res.rows }); + } catch (error: any) { + console.error('Fehler in GET /api/hotels:', error.message); + try { + const fallback = await query(`SELECT * FROM hotels ORDER BY id ASC`); + return NextResponse.json({ success: true, hotels: fallback.rows }); + } catch (fallbackErr: any) { + return NextResponse.json({ success: false, error: error.message, hotels: [] }, { status: 500 }); + } + } +} + +export async function POST(req: NextRequest) { + try { + const { name, booking_url, trip_id } = await req.json(); + + if (!booking_url) { + return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 }); + } + + let checkin_date: string | null = null; + let checkout_date: string | null = null; + + try { + const parsedUrl = new URL(booking_url); + checkin_date = parsedUrl.searchParams.get('checkin'); + checkout_date = parsedUrl.searchParams.get('checkout'); + } catch (e) {} + + const res = await query( + `INSERT INTO hotels (name, booking_url, checkin_date, checkout_date, trip_id, is_active) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING *`, + [name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null] + ); + + return NextResponse.json({ success: true, hotel: res.rows[0] }); + } catch (error: any) { + console.error('Fehler in POST /api/hotels:', error.message); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +``` + +--- DATEI: src/app/api/jobs/trigger-daily-check/route.ts --- +``` +import { NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST() { + try { + const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null; + if (!bullmq || !bullmq.Queue) { + throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.'); + } + + const { Queue } = bullmq; + + const connection = { + host: process.env.REDIS_HOST || '172.17.0.1', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + connectTimeout: 5000, + maxRetriesPerRequest: 3, + }; + + const scrapeQueue = new Queue('scrape-jobs', { connection }); + + const res = await query('SELECT id, booking_url FROM hotels WHERE is_active = true'); + const activeHotels = res.rows; + + if (activeHotels.length === 0) { + await scrapeQueue.close(); + return NextResponse.json({ + success: true, + message: 'Keine aktiven Hotels zum Scrapen gefunden.', + }); + } + + // Für ALLE aktiven Hotels nur den Preis-Scan anwerfen (OHNE n8n / OHNE LLM) + for (const hotel of activeHotels) { + const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`; + + await scrapeQueue.add('scrape-hotel', { + hotelId: hotel.id, + url: hotel.booking_url, + appWebhookUrl, // Speichert den Preis in Postgres + webhookUrl: null, // KEIN n8n -> KOSTENLOS! + }); + } + + await scrapeQueue.close(); + + return NextResponse.json({ + success: true, + message: `Preis-Scan für ${activeHotels.length} Hotels gestartet.`, + }); + } catch (error: any) { + console.error('Fehler beim Starten des Preis-Scans:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +``` + +--- DATEI: src/app/api/trips/route.ts --- +``` +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const res = await query(`SELECT * FROM trips ORDER BY id ASC`); + return NextResponse.json({ success: true, trips: res.rows }); + } catch (error: any) { + return NextResponse.json({ success: false, error: error.message, trips: [] }, { status: 500 }); + } +} + +export async function POST(req: NextRequest) { + try { + const { name } = await req.json(); + if (!name) { + return NextResponse.json({ error: 'Name der Reise erforderlich' }, { status: 400 }); + } + + const res = await query( + `INSERT INTO trips (name) VALUES ($1) RETURNING *`, + [name] + ); + + return NextResponse.json({ success: true, trip: res.rows[0] }); + } catch (error: any) { + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} + +``` + +--- DATEI: src/app/api/webhooks/scraper/route.ts --- +``` +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +function parseGermanPrice(priceStr: any): number | null { + if (!priceStr) return null; + if (typeof priceStr === 'number') return Math.round(priceStr); + + let cleaned = String(priceStr).replace(/[^0-9.,]/g, '').trim(); + if (!cleaned) return null; + + if (cleaned.includes('.') && !cleaned.includes(',')) { + cleaned = cleaned.replace(/\./g, ''); + } else if (cleaned.includes('.') && cleaned.includes(',')) { + cleaned = cleaned.replace(/\./g, '').replace(',', '.'); + } else if (cleaned.includes(',')) { + cleaned = cleaned.replace(',', '.'); + } + + const parsed = parseFloat(cleaned); + return isNaN(parsed) ? null : Math.round(parsed); +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json().catch(() => ({})); + const { searchParams } = new URL(req.url); + const hotelId = searchParams.get('hotelId') || body.hotelId; + + if (!hotelId) { + return NextResponse.json({ success: false, error: 'hotelId fehlt' }, { status: 400 }); + } + + const numericPrice = parseGermanPrice(body.price); + + // 1. In hotel_price_history eintragen mit echten Spalten + if (numericPrice !== null && numericPrice > 0) { + try { + await query( + `INSERT INTO hotel_price_history (hotel_id, price_numeric, price_raw, currency, nights, persons, tracked_at) + VALUES ($1::uuid, $2, $3, 'EUR', $4, $5, NOW())`, + [hotelId, numericPrice, String(body.price || ''), body.nights || 1, body.persons || 2] + ); + } catch (err: any) { + console.error('DB Insert Error (hotel_price_history):', err.message); + } + } + + // 2. Stammdaten aktualisieren + try { + await query( + `UPDATE hotels + SET name = COALESCE($1, name), + rating = COALESCE($2, rating), + review_count = COALESCE($3, review_count), + address = COALESCE($4, address), + image_url = COALESCE($5, image_url), + room_size_m2 = COALESCE($6, room_size_m2), + updated_at = NOW() + WHERE id = $7::uuid`, + [ + body.hotelName || null, + body.rating || null, + body.reviewCount || null, + body.address || null, + body.image || null, + body.roomSizeM2 || null, + hotelId + ] + ); + } catch (err: any) { + console.error('DB Update Error (hotels):', err.message); + } + + return NextResponse.json({ success: true, hotelId, parsedPrice: numericPrice }); + } catch (error: any) { + console.error('Allgemeiner Fehler im Webhook:', error.message); + return NextResponse.json({ success: false, error: error.message }, { status: 200 }); + } +} + +``` + +--- DATEI: src/app/layout.tsx --- +``` +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Reiseplaner App', + description: 'Hotelpreise und Reisen tracken', +}; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + {children} + + + ); +} + +``` + +--- DATEI: src/app/page.tsx --- +``` +'use client'; + +import { useState, useEffect } from 'react'; + +interface Trip { + id: string; + name: string; +} + +interface PriceEntry { + id: string; + price: number; + price_per_night?: number | null; + price_per_person?: number | null; + nights: number; + persons: number; + created_at: string; +} + +interface Hotel { + id: string; + name: string; + booking_url: string; + is_active: boolean; + trip_id?: string | null; + trip_name?: string | null; + latest_price?: number | null; + latest_price_per_night?: number | null; + latest_price_per_person?: number | null; + latest_nights?: number | null; + latest_persons?: number | null; + rating?: number | null; + review_count?: number | null; + image_url?: string | null; + address?: string | null; + room_size_m2?: number | null; + review_summary_positive?: string | null; + review_summary_passable?: string | null; + review_summary_negative?: string | null; + review_summary_bathroom?: string | null; + review_summary_count?: number | null; + reviews_updated_at?: string | null; + checkin_date?: string | null; + checkout_date?: string | null; +} + +export default function TravelDashboard() { + const [trips, setTrips] = useState([]); + const [activeTripId, setActiveTripId] = useState('ALL'); + const [hotels, setHotels] = useState([]); + const [loading, setLoading] = useState(true); + + const [scanningAllPrices, setScanningAllPrices] = useState(false); + const [scanningHotelId, setScanningHotelId] = useState(null); + + // Preishistorie Modal + const [selectedPriceHotel, setSelectedPriceHotel] = useState(null); + const [priceHistory, setPriceHistory] = useState([]); + const [loadingPrices, setLoadingPrices] = useState(false); + + // Modals + const [showAddTripModal, setShowAddTripModal] = useState(false); + const [newTripName, setNewTripName] = useState(''); + + const [showAddHotelModal, setShowAddHotelModal] = useState(false); + const [newBookingUrl, setNewBookingUrl] = useState(''); + const [newHotelName, setNewHotelName] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + + useEffect(() => { + loadData(); + }, []); + + const formatDateGerman = (dateStr?: string | null) => { + if (!dateStr) return ''; + const date = new Date(dateStr); + return isNaN(date.getTime()) ? dateStr : date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + }; + + const calculateNights = (checkin?: string | null, checkout?: string | null) => { + if (!checkin || !checkout) return null; + const start = new Date(checkin).getTime(); + const end = new Date(checkout).getTime(); + if (isNaN(start) || isNaN(end)) return null; + const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24)); + return diffDays > 0 ? diffDays : null; + }; + + const loadData = async () => { + setLoading(true); + try { + const resTrips = await fetch('/api/trips'); + const dataTrips = await resTrips.json(); + setTrips(dataTrips.trips || []); + + const resHotels = await fetch('/api/hotels'); + const dataHotels = await resHotels.json(); + setHotels(dataHotels.hotels || []); + } catch (e) { + console.error('Fehler beim Laden:', e); + } finally { + setLoading(false); + } + }; + + const openPriceHistoryModal = async (hotel: Hotel) => { + setSelectedPriceHotel(hotel); + setLoadingPrices(true); + setPriceHistory([]); + try { + const res = await fetch(`/api/hotels/${hotel.id}/prices`); + const data = await res.json(); + if (data.success) { + setPriceHistory(data.prices || []); + } + } catch (e) { + console.error('Fehler beim Laden der Preise:', e); + } finally { + setLoadingPrices(false); + } + }; + + const handleAddTrip = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newTripName.trim()) return; + + try { + const res = await fetch('/api/trips', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newTripName.trim() }) + }); + const data = await res.json(); + if (data.success) { + setTrips([...trips, data.trip]); + setActiveTripId(String(data.trip.id)); + setNewTripName(''); + setShowAddTripModal(false); + } + } catch (e: any) { + alert('Fehler: ' + e.message); + } + }; + + const handleAddHotel = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newBookingUrl) return alert('Bitte URL eingeben'); + + setIsSubmitting(true); + try { + const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId; + const res = await fetch('/api/hotels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newHotelName || 'Neues Hotel', + booking_url: newBookingUrl, + trip_id: targetTripId + }) + }); + + const data = await res.json(); + if (res.ok || data.success) { + setNewBookingUrl(''); + setNewHotelName(''); + setShowAddHotelModal(false); + loadData(); + } + } catch (err: any) { + alert('Fehler: ' + err.message); + } finally { + setIsSubmitting(false); + } + }; + + const toggleHotelActive = async (hotel: Hotel) => { + try { + const updatedActive = !hotel.is_active; + await fetch(`/api/hotels/${hotel.id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_active: updatedActive }) + }); + setHotels(hotels.map(h => h.id === hotel.id ? { ...h, is_active: updatedActive } : h)); + } catch (e) {} + }; + + const deleteHotel = async (id: string, name: string) => { + if (!confirm(`Hotel "${name}" löschen?`)) return; + await fetch(`/api/hotels/${id}`, { method: 'DELETE' }); + setHotels(hotels.filter(h => h.id !== id)); + }; + + const triggerBatchPriceScan = async () => { + setScanningAllPrices(true); + try { + const res = await fetch('/api/jobs/trigger-daily-check', { method: 'POST' }); + const data = await res.json(); + alert(data.success ? '📊 Preis-Scan gestartet!' : 'Fehler: ' + data.error); + setTimeout(loadData, 3000); + } finally { + setScanningAllPrices(false); + } + }; + + const scanSingleHotel = async (hotelId: string) => { + setScanningHotelId(hotelId); + try { + const res = await fetch(`/api/hotels/${hotelId}/scan`, { method: 'POST' }); + const data = await res.json(); + alert(data.message || data.error); + } finally { + setScanningHotelId(null); + } + }; + + const currentHotels = hotels + .filter(h => { + if (activeTripId === 'ALL') return true; + if (!h.trip_id) return true; + return String(h.trip_id).toLowerCase() === String(activeTripId).toLowerCase(); + }) + .sort((a, b) => { + if (!a.checkin_date && !b.checkin_date) return 0; + if (!a.checkin_date) return 1; + if (!b.checkin_date) return -1; + return new Date(a.checkin_date).getTime() - new Date(b.checkin_date).getTime(); + }); + + const pricesList = priceHistory.map(p => Number(p.price)); + const minPrice = pricesList.length > 0 ? Math.min(...pricesList) : 0; + const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0; + const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 0; + + return ( +
+ +
+

📍 Reise-Ablauf & Timeline

+

Routenplanung, Preishistorie & KI-Bewertungen

+ +
+ + + +
+
+ + {/* TABS */} +
+ + + {trips.map(trip => ( + + ))} + + +
+ + {/* PREISHISTORIE MODAL */} + {selectedPriceHotel && ( +
+
+
+
+

📈 Preishistorie: {selectedPriceHotel.name}

+

Entwicklung aller erfassten Preis-Scans

+
+ +
+ + {loadingPrices ? ( +

Lade Preishistorie...

+ ) : priceHistory.length === 0 ? ( +

Noch keine Preishistorie vorhanden. Klicke oben auf "Preise aller Hotels aktualisieren".

+ ) : ( +
+
+
+ Tiefstpreis +
{minPrice} €
+
+
+ Aktuellster Preis +
{currentPrice} €
+
+
+ Höchstpreis +
{maxPrice} €
+
+
+ + {priceHistory.length > 1 && ( +
+ + + + + {(() => { + const range = maxPrice - minPrice || 1; + const points = priceHistory.map((p, idx) => { + const x = (idx / (priceHistory.length - 1)) * 500; + const y = 130 - ((p.price - minPrice) / range) * 110; + return `${x},${y}`; + }).join(' '); + + return ( + <> + + {priceHistory.map((p, idx) => { + const x = (idx / (priceHistory.length - 1)) * 500; + const y = 130 - ((p.price - minPrice) / range) * 110; + return ( + + + {p.price}€ + + ); + })} + + ); + })()} + +
+ )} + +
+ + + + + + + + + + {priceHistory.map(p => ( + + + + + + ))} + +
Datum & UhrzeitPreisDetails
{new Date(p.created_at).toLocaleString('de-DE')}{p.price} €{p.nights || 1} Nächte, {p.persons || 2} Personen
+
+
+ )} +
+
+ )} + + {/* MODAL: NEUE REISE */} + {showAddTripModal && ( +
+
+

🌍 Neue Reise anlegen

+
+ setNewTripName(e.target.value)} + style={{ width: '100%', padding: '0.7rem', borderRadius: '6px', border: '1px solid #d1d5db', marginBottom: '1rem' }} + /> +
+ + +
+
+
+
+ )} + + {/* MODAL: HOTEL HINZUFÜGEN */} + {showAddHotelModal && ( +
+
+

➕ Neues Hotel hinzufügen

+

Check-in/Check-out Datum wird automatisch aus dem Link gelesen.

+ +
+
+ + setNewHotelName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} /> +
+ +
+ + setNewBookingUrl(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} /> +
+ +
+ + +
+
+
+
+ )} + + {/* TIMELINE */} + {loading ? ( +

Lade Daten...

+ ) : currentHotels.length === 0 ? ( +
+

Keine Hotels für diese Auswahl gefunden.

+ +
+ ) : ( +
+ {currentHotels.map((hotel, index) => { + const nights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || 1; + const persons = hotel.latest_persons || 2; + const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null; + + // Bevorzuge vorberechnete Werte aus der DB, sonst dyn. Fallback + const pricePerNight = hotel.latest_price_per_night + ? Math.round(Number(hotel.latest_price_per_night)) + : (totalPrice ? Math.round(totalPrice / nights) : null); + + const pricePerPersonPerNight = hotel.latest_price_per_person + ? Math.round(Number(hotel.latest_price_per_person)) + : (totalPrice ? Math.round(totalPrice / (nights * persons)) : null); + + return ( +
+
+ +
+
+ 📍 Stop #{index + 1} + {hotel.trip_name && {hotel.trip_name}} + {hotel.checkin_date ? ( + + 🗓️ {formatDateGerman(hotel.checkin_date)} – {formatDateGerman(hotel.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'}) + + ) : ( + (Warte auf Scan für Datum) + )} +
+ +
+ + +
+
+ +
+ {hotel.image_url && {hotel.name}} + +
+

{hotel.name}

+

📍 {hotel.address || 'Keine Adresse hinterlegt'}

+ + {/* ANZEIGE DER DREI PREIS-WERTE */} + {totalPrice !== null ? ( +
+ + 💰 Gesamt: {totalPrice.toLocaleString('de-DE')} € + + + | 🌙 {pricePerNight?.toLocaleString('de-DE')} € / Nacht + + + | 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. & Nacht + +
+ ) : ( +
+ 💰 Noch kein Preis erfasst +
+ )} + +
+ ⭐ {hotel.rating || 'N/A'}/10 + 💬 {hotel.review_count || 0} Bewertungen + {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²} +
+ +
+ + + + + + 🔗 Booking.com ↗ + +
+
+
+ + {(hotel.review_summary_positive || hotel.review_summary_bathroom) && ( +
+
+ + 📋 KI-Zusammenfassung + + (Basierend auf {hotel.review_summary_count || 'mehreren'} analysierten Bewertungen) + + + +
+ {hotel.review_summary_positive && ( +
+ 💚 Bestnoten & Highlights: +
{hotel.review_summary_positive}
+
+ )} + {hotel.review_summary_passable && ( +
+ 🟡 Passabel & Neutrales: +
{hotel.review_summary_passable}
+
+ )} + {hotel.review_summary_negative && ( +
+ 🔴 Schlechteste & Mängel: +
{hotel.review_summary_negative}
+
+ )} +
+ 🚽 Bad & WC Fokus: +
+ {hotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC gefunden.'} +
+
+
+
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} + +``` + +--- DATEI: src/lib/db.ts --- +``` +import { Pool } from 'pg'; + +const pool = new Pool({ + connectionString: process.env.DATABASE_URL, +}); + +export const query = (text: string, params?: any[]) => pool.query(text, params); + +``` + +--- DATEI: tsconfig.json --- +``` +{ + "compilerOptions": { + "target": "es2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} + +``` diff --git a/src/app/api/hotels/[id]/prices/route.ts b/src/app/api/hotels/[id]/prices/route.ts new file mode 100644 index 0000000..536b9e0 --- /dev/null +++ b/src/app/api/hotels/[id]/prices/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const dynamic = 'force-dynamic'; + +export async function GET(req: NextRequest, { params }: { params: { id: string } }) { + try { + const { id } = params; + + const res = await query( + `SELECT + id, + price_numeric::float AS price, + price_per_night::float AS price_per_night, + price_per_person::float AS price_per_person, + nights, + persons, + tracked_at AS created_at + FROM hotel_price_history + WHERE hotel_id::text = $1::text + ORDER BY tracked_at ASC`, + [id] + ); + + return NextResponse.json({ success: true, prices: res.rows }); + } catch (error: any) { + console.error('Fehler beim Laden der Preishistorie:', error.message); + return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 }); + } +} diff --git a/src/app/api/hotels/[id]/review-summary/route.ts b/src/app/api/hotels/[id]/review-summary/route.ts index 3759d88..e6526cb 100644 --- a/src/app/api/hotels/[id]/review-summary/route.ts +++ b/src/app/api/hotels/[id]/review-summary/route.ts @@ -4,7 +4,7 @@ import { query } from '@/lib/db'; export async function POST(req: NextRequest, { params }: { params: { id: string } }) { try { const { id } = params; - const { summary_positive, summary_passable, summary_negative, summary_bathroom } = await req.json(); + const { summary_positive, summary_passable, summary_negative, summary_bathroom, summary_count } = await req.json(); if (!id) { return NextResponse.json({ error: 'Hotel-ID fehlt' }, { status: 400 }); @@ -16,9 +16,10 @@ export async function POST(req: NextRequest, { params }: { params: { id: string review_summary_passable = $2, review_summary_negative = $3, review_summary_bathroom = $4, + review_summary_count = $5, reviews_updated_at = NOW() - WHERE id = $5`, - [summary_positive, summary_passable, summary_negative, summary_bathroom || null, id] + WHERE id = $6`, + [summary_positive, summary_passable, summary_negative, summary_bathroom || null, summary_count || null, id] ); return NextResponse.json({ success: true, message: 'Review-Zusammenfassungen gespeichert.' }); diff --git a/src/app/api/hotels/[id]/scan/route.ts b/src/app/api/hotels/[id]/scan/route.ts new file mode 100644 index 0000000..d8e3d76 --- /dev/null +++ b/src/app/api/hotels/[id]/scan/route.ts @@ -0,0 +1,64 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { query } from '@/lib/db'; + +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +export async function POST( + req: NextRequest, + { params }: { params: { id: string } } +) { + try { + const { id } = params; + + const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null; + if (!bullmq || !bullmq.Queue) { + throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.'); + } + + const { Queue } = bullmq; + + const connection = { + host: process.env.REDIS_HOST || '172.17.0.1', + port: parseInt(process.env.REDIS_PORT || '6379', 10), + connectTimeout: 5000, + maxRetriesPerRequest: 3, + }; + + const scrapeQueue = new Queue('scrape-jobs', { connection }); + + // Nur dieses eine Hotel laden + const res = await query('SELECT id, booking_url, name FROM hotels WHERE id = $1', [id]); + if (res.rows.length === 0) { + await scrapeQueue.close(); + return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 }); + } + + const hotel = res.rows[0]; + + const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews'; + const n8nWebhookUrl = n8nBaseUrl.includes('?') + ? `${n8nBaseUrl}&hotelId=${hotel.id}` + : `${n8nBaseUrl}?hotelId=${hotel.id}`; + + const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`; + + // Nur DIESEN einzelnen Job in die Queue schreiben + await scrapeQueue.add('scrape-hotel', { + hotelId: hotel.id, + url: hotel.booking_url, + appWebhookUrl, + webhookUrl: n8nWebhookUrl, + }); + + await scrapeQueue.close(); + + return NextResponse.json({ + success: true, + message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`, + }); + } catch (error: any) { + console.error('Fehler beim Starten des Einzel-Scrapes:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + } +} diff --git a/src/app/api/hotels/route.ts b/src/app/api/hotels/route.ts index 425e236..8671604 100644 --- a/src/app/api/hotels/route.ts +++ b/src/app/api/hotels/route.ts @@ -1,21 +1,70 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +export const dynamic = 'force-dynamic'; + +export async function GET() { + try { + const res = await query(` + SELECT + h.*, + t.name AS trip_name, + ph.price_numeric::float AS latest_price, + ph.price_per_night::float AS latest_price_per_night, + ph.price_per_person::float AS latest_price_per_person, + ph.nights AS latest_nights, + ph.persons AS latest_persons + FROM hotels h + LEFT JOIN trips t ON h.trip_id = t.id + LEFT JOIN LATERAL ( + SELECT price_numeric, price_per_night, price_per_person, nights, persons + FROM hotel_price_history + WHERE hotel_id::text = h.id::text + ORDER BY tracked_at DESC NULLS LAST, id DESC + LIMIT 1 + ) ph ON true + ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC + `); + + return NextResponse.json({ success: true, hotels: res.rows }); + } catch (error: any) { + console.error('Fehler in GET /api/hotels:', error.message); + try { + const fallback = await query(`SELECT * FROM hotels ORDER BY id ASC`); + return NextResponse.json({ success: true, hotels: fallback.rows }); + } catch (fallbackErr: any) { + return NextResponse.json({ success: false, error: error.message, hotels: [] }, { status: 500 }); + } + } +} + export async function POST(req: NextRequest) { try { - const { trip_id, booking_url, name } = await req.json(); - if (!trip_id || !booking_url) { - return NextResponse.json({ error: 'trip_id und booking_url sind erforderlich' }, { status: 400 }); + const { name, booking_url, trip_id } = await req.json(); + + if (!booking_url) { + return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 }); } + let checkin_date: string | null = null; + let checkout_date: string | null = null; + + try { + const parsedUrl = new URL(booking_url); + checkin_date = parsedUrl.searchParams.get('checkin'); + checkout_date = parsedUrl.searchParams.get('checkout'); + } catch (e) {} + const res = await query( - `INSERT INTO hotels (trip_id, booking_url, name, is_active) - VALUES ($1, $2, $3, true) RETURNING *`, - [trip_id, booking_url, name || 'Booking Hotel'] + `INSERT INTO hotels (name, booking_url, checkin_date, checkout_date, trip_id, is_active) + VALUES ($1, $2, $3, $4, $5, true) + RETURNING *`, + [name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null] ); return NextResponse.json({ success: true, hotel: res.rows[0] }); - } catch (e: any) { - return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } catch (error: any) { + console.error('Fehler in POST /api/hotels:', error.message); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } } diff --git a/src/app/api/jobs/trigger-daily-check/route.ts b/src/app/api/jobs/trigger-daily-check/route.ts index b1ff101..3fda6e4 100644 --- a/src/app/api/jobs/trigger-daily-check/route.ts +++ b/src/app/api/jobs/trigger-daily-check/route.ts @@ -33,21 +33,15 @@ export async function POST() { }); } - const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews'; - + // Für ALLE aktiven Hotels nur den Preis-Scan anwerfen (OHNE n8n / OHNE LLM) for (const hotel of activeHotels) { - const n8nWebhookUrl = n8nBaseUrl.includes('?') - ? `${n8nBaseUrl}&hotelId=${hotel.id}` - : `${n8nBaseUrl}?hotelId=${hotel.id}`; - - // Direkt-URL zur Next.js App für Preishistorie const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`; await scrapeQueue.add('scrape-hotel', { hotelId: hotel.id, url: hotel.booking_url, - appWebhookUrl, // Für Preishistorie in Postgres - webhookUrl: n8nWebhookUrl, // Für n8n KI-Reviews + appWebhookUrl, // Speichert den Preis in Postgres + webhookUrl: null, // KEIN n8n -> KOSTENLOS! }); } @@ -55,13 +49,10 @@ export async function POST() { return NextResponse.json({ success: true, - message: `${activeHotels.length} Scrape-Jobs erfolgreich gestartet.`, + message: `Preis-Scan für ${activeHotels.length} Hotels gestartet.`, }); } catch (error: any) { - console.error('Fehler beim Starten der Jobs:', error); - return NextResponse.json( - { success: false, error: error.message }, - { status: 500 } - ); + console.error('Fehler beim Starten des Preis-Scans:', error); + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } } diff --git a/src/app/api/trips/route.ts b/src/app/api/trips/route.ts index 85b9e35..7c66068 100644 --- a/src/app/api/trips/route.ts +++ b/src/app/api/trips/route.ts @@ -1,88 +1,31 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +export const dynamic = 'force-dynamic'; + export async function GET() { try { - const tripsRes = await query('SELECT * FROM trips ORDER BY created_at DESC'); - const trips = tripsRes.rows; - - const hotelsRes = await query(` - SELECT h.*, - ph.price_numeric as latest_price, - ph.genius_price_numeric as latest_genius_price, - ph.price_per_night, - ph.price_per_person, - ph.nights, - ph.persons, - ph.tracked_at as last_tracked - FROM hotels h - LEFT JOIN LATERAL ( - SELECT * FROM hotel_price_history - WHERE hotel_id = h.id - ORDER BY tracked_at DESC LIMIT 1 - ) ph ON true - ORDER BY h.created_at DESC - `); - - // Zahlen-Typen parsen & fehlende Raten bei älteren Einträgen live berechnen - const parsedHotels = hotelsRes.rows.map((h) => { - const latest_price = h.latest_price ? parseFloat(h.latest_price) : null; - let latest_genius_price = h.latest_genius_price ? parseFloat(h.latest_genius_price) : null; - if (!latest_genius_price && latest_price) { - latest_genius_price = parseFloat((latest_price * 0.90).toFixed(2)); - } - - const nights = h.nights ? parseInt(h.nights, 10) : 1; - const persons = h.persons ? parseInt(h.persons, 10) : 2; - - const activePrice = latest_genius_price || latest_price; - - // Fallback-Berechnung falls DB-Feld NULL ist - let price_per_night = h.price_per_night ? parseFloat(h.price_per_night) : null; - if (!price_per_night && activePrice && nights > 0) { - price_per_night = parseFloat((activePrice / nights).toFixed(2)); - } - - let price_per_person = h.price_per_person ? parseFloat(h.price_per_person) : null; - if (!price_per_person && activePrice && nights > 0 && persons > 0) { - price_per_person = parseFloat((activePrice / (nights * persons)).toFixed(2)); - } - - return { - ...h, - rating: h.rating ? parseFloat(h.rating) : null, - latest_price, - latest_genius_price, - price_per_night, - price_per_person, - nights, - persons, - }; - }); - - const tripsWithHotels = trips.map((trip) => ({ - ...trip, - hotels: parsedHotels.filter((h) => h.trip_id === trip.id), - })); - - return NextResponse.json({ success: true, trips: tripsWithHotels }); - } catch (e: any) { - console.error('Error in GET /api/trips:', e); - return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + const res = await query(`SELECT * FROM trips ORDER BY id ASC`); + return NextResponse.json({ success: true, trips: res.rows }); + } catch (error: any) { + return NextResponse.json({ success: false, error: error.message, trips: [] }, { status: 500 }); } } export async function POST(req: NextRequest) { try { - const { title, destination } = await req.json(); - if (!title) return NextResponse.json({ error: 'Titel ist erforderlich' }, { status: 400 }); + const { name } = await req.json(); + if (!name) { + return NextResponse.json({ error: 'Name der Reise erforderlich' }, { status: 400 }); + } const res = await query( - 'INSERT INTO trips (title, destination) VALUES ($1, $2) RETURNING *', - [title, destination || null] + `INSERT INTO trips (name) VALUES ($1) RETURNING *`, + [name] ); + return NextResponse.json({ success: true, trip: res.rows[0] }); - } catch (e: any) { - return NextResponse.json({ success: false, error: e.message }, { status: 500 }); + } catch (error: any) { + return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } } diff --git a/src/app/api/webhooks/scraper/route.ts b/src/app/api/webhooks/scraper/route.ts index 399174d..9469305 100644 --- a/src/app/api/webhooks/scraper/route.ts +++ b/src/app/api/webhooks/scraper/route.ts @@ -1,90 +1,79 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; +function parseGermanPrice(priceStr: any): number | null { + if (!priceStr) return null; + if (typeof priceStr === 'number') return Math.round(priceStr); + + let cleaned = String(priceStr).replace(/[^0-9.,]/g, '').trim(); + if (!cleaned) return null; + + if (cleaned.includes('.') && !cleaned.includes(',')) { + cleaned = cleaned.replace(/\./g, ''); + } else if (cleaned.includes('.') && cleaned.includes(',')) { + cleaned = cleaned.replace(/\./g, '').replace(',', '.'); + } else if (cleaned.includes(',')) { + cleaned = cleaned.replace(',', '.'); + } + + const parsed = parseFloat(cleaned); + return isNaN(parsed) ? null : Math.round(parsed); +} + export async function POST(req: NextRequest) { try { + const body = await req.json().catch(() => ({})); const { searchParams } = new URL(req.url); - const hotelId = searchParams.get('hotelId'); - const body = await req.json(); - - const { - price, - roomSizeM2, - hotelName, - rating, - reviewCount, - address, - image, - nights = 1, - persons = 2, - } = body; + const hotelId = searchParams.get('hotelId') || body.hotelId; if (!hotelId) { - return NextResponse.json({ error: 'hotelId fehlt' }, { status: 400 }); + return NextResponse.json({ success: false, error: 'hotelId fehlt' }, { status: 400 }); } - const parseNum = (val: any): number | null => { - if (typeof val === 'number') return val; - if (!val || typeof val !== 'string') return null; - const match = val.replace(',', '.').match(/\d+(\.\d+)?/); - return match ? parseFloat(match[0]) : null; - }; + const numericPrice = parseGermanPrice(body.price); - const numPrice = parseNum(price); - - // Automatisch 10 % Genius Rabatt berechnen - let numGenius: number | null = null; - if (numPrice !== null) { - numGenius = parseFloat((numPrice * 0.90).toFixed(2)); + // 1. In hotel_price_history eintragen mit echten Spalten + if (numericPrice !== null && numericPrice > 0) { + try { + await query( + `INSERT INTO hotel_price_history (hotel_id, price_numeric, price_raw, currency, nights, persons, tracked_at) + VALUES ($1::uuid, $2, $3, 'EUR', $4, $5, NOW())`, + [hotelId, numericPrice, String(body.price || ''), body.nights || 1, body.persons || 2] + ); + } catch (err: any) { + console.error('DB Insert Error (hotel_price_history):', err.message); + } } - // Ratenberechnung auf Basis des Genius-Preises - const basePrice = numGenius || numPrice; - let pricePerNight = null; - let pricePerPersonPerNight = null; - - if (basePrice !== null && nights > 0 && persons > 0) { - // 1. Zimmerpreis pro Nacht - pricePerNight = parseFloat((basePrice / nights).toFixed(2)); - // 2. Preis pro Person & Nacht - pricePerPersonPerNight = parseFloat((basePrice / (nights * persons)).toFixed(2)); - } - - // 1. In Preishistorie eintragen - if (numPrice !== null) { + // 2. Stammdaten aktualisieren + try { await query( - `INSERT INTO hotel_price_history - (hotel_id, price_numeric, genius_price_numeric, price_raw, price_per_night, price_per_person, nights, persons) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, + `UPDATE hotels + SET name = COALESCE($1, name), + rating = COALESCE($2, rating), + review_count = COALESCE($3, review_count), + address = COALESCE($4, address), + image_url = COALESCE($5, image_url), + room_size_m2 = COALESCE($6, room_size_m2), + updated_at = NOW() + WHERE id = $7::uuid`, [ - hotelId, - numPrice, - numGenius, - price ? String(price) : null, - pricePerNight, - pricePerPersonPerNight, - nights, - persons, + body.hotelName || null, + body.rating || null, + body.reviewCount || null, + body.address || null, + body.image || null, + body.roomSizeM2 || null, + hotelId ] ); + } catch (err: any) { + console.error('DB Update Error (hotels):', err.message); } - // 2. Hotel Stammdaten aktualisieren - await query( - `UPDATE hotels - SET name = COALESCE($1, name), - rating = COALESCE($2, rating), - review_count = COALESCE($3, review_count), - address = COALESCE($4, address), - image_url = COALESCE($5, image_url), - room_size_m2 = COALESCE($6, room_size_m2) - WHERE id = $7`, - [hotelName, rating, reviewCount, address, image, roomSizeM2, hotelId] - ); - - return NextResponse.json({ success: true }); + return NextResponse.json({ success: true, hotelId, parsedPrice: numericPrice }); } catch (error: any) { - console.error('Webhook Error:', error); - return NextResponse.json({ success: false, error: error.message }, { status: 500 }); + console.error('Allgemeiner Fehler im Webhook:', error.message); + return NextResponse.json({ success: false, error: error.message }, { status: 200 }); } } diff --git a/src/app/page.tsx b/src/app/page.tsx index 6dd959c..5b3a211 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -2,69 +2,108 @@ import { useState, useEffect } from 'react'; +interface Trip { + id: string; + name: string; +} + +interface PriceEntry { + id: string; + price: number; + price_per_night?: number | null; + price_per_person?: number | null; + nights: number; + persons: number; + created_at: string; +} + interface Hotel { id: string; name: string; booking_url: string; is_active: boolean; - latest_price: number | string | null; - latest_genius_price: number | string | null; - price_per_night: number | string | null; - price_per_person: number | string | null; - nights: number | null; - persons: number | null; - rating: number | string | null; - review_count: number | null; - room_size_m2: number | null; - address: string | null; - image_url: string | null; + trip_id?: string | null; + trip_name?: string | null; + latest_price?: number | null; + latest_price_per_night?: number | null; + latest_price_per_person?: number | null; + latest_nights?: number | null; + latest_persons?: number | null; + rating?: number | null; + review_count?: number | null; + image_url?: string | null; + address?: string | null; + room_size_m2?: number | null; review_summary_positive?: string | null; review_summary_passable?: string | null; review_summary_negative?: string | null; review_summary_bathroom?: string | null; + review_summary_count?: number | null; reviews_updated_at?: string | null; + checkin_date?: string | null; + checkout_date?: string | null; } -interface Trip { - id: string; - title: string; - destination: string | null; +interface HotelCluster { + clusterKey: string; + checkin_date?: string | null; + checkout_date?: string | null; hotels: Hotel[]; } -interface PriceHistoryEntry { - id: string; - price_numeric: number; - genius_price_numeric: number | null; - price_per_night: number | null; - price_per_person: number | null; - nights: number | null; - persons: number | null; - tracked_at: string; -} - -export default function Dashboard() { +export default function TravelDashboard() { const [trips, setTrips] = useState([]); + const [activeTripId, setActiveTripId] = useState('ALL'); + const [hotels, setHotels] = useState([]); const [loading, setLoading] = useState(true); + const [scanningAllPrices, setScanningAllPrices] = useState(false); + const [scanningHotelId, setScanningHotelId] = useState(null); - // Detail / Modal State - const [selectedHotel, setSelectedHotel] = useState(null); - const [activeTab, setActiveTab] = useState<'history' | 'reviews'>('history'); - const [history, setHistory] = useState([]); - const [loadingHistory, setLoadingHistory] = useState(false); + // Preishistorie Modal + const [selectedPriceHotel, setSelectedPriceHotel] = useState(null); + const [priceHistory, setPriceHistory] = useState([]); + const [loadingPrices, setLoadingPrices] = useState(false); - // Forms - const [newTripTitle, setNewTripTitle] = useState(''); - const [newTripDest, setNewTripDest] = useState(''); - const [selectedTripId, setSelectedTripId] = useState(null); + // Modals + const [showAddTripModal, setShowAddTripModal] = useState(false); + const [newTripName, setNewTripName] = useState(''); + const [showAddHotelModal, setShowAddHotelModal] = useState(false); const [newBookingUrl, setNewBookingUrl] = useState(''); + const [newHotelName, setNewHotelName] = useState(''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [showParkedModal, setShowParkedModal] = useState(false); + + useEffect(() => { + loadData(); + }, []); + + const formatDateGerman = (dateStr?: string | null) => { + if (!dateStr) return ''; + const date = new Date(dateStr); + return isNaN(date.getTime()) + ? dateStr + : date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + }; + + const calculateNights = (checkin?: string | null, checkout?: string | null) => { + if (!checkin || !checkout) return null; + const start = new Date(checkin).getTime(); + const end = new Date(checkout).getTime(); + if (isNaN(start) || isNaN(end)) return null; + const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24)); + return diffDays > 0 ? diffDays : null; + }; const loadData = async () => { setLoading(true); try { - const res = await fetch('/api/trips'); - const data = await res.json(); - if (data.success) setTrips(data.trips); + const resTrips = await fetch('/api/trips'); + const dataTrips = await resTrips.json(); + setTrips(dataTrips.trips || []); + + const resHotels = await fetch('/api/hotels'); + const dataHotels = await resHotels.json(); + setHotels(dataHotels.hotels || []); } catch (e) { console.error('Fehler beim Laden:', e); } finally { @@ -72,321 +111,657 @@ export default function Dashboard() { } }; - useEffect(() => { loadData(); }, []); - - const openHotelDetails = async (hotel: Hotel) => { - setSelectedHotel(hotel); - setActiveTab('history'); - setHistory([]); - setLoadingHistory(true); + const openPriceHistoryModal = async (hotel: Hotel) => { + setSelectedPriceHotel(hotel); + setLoadingPrices(true); + setPriceHistory([]); try { - const res = await fetch(`/api/hotels/${hotel.id}/history`); + const res = await fetch(`/api/hotels/${hotel.id}/prices`); const data = await res.json(); - if (data.success) setHistory(data.history); + if (data.success) { + setPriceHistory(data.prices || []); + } } catch (e) { - console.error('Fehler beim Laden der Historie:', e); + console.error('Fehler beim Laden der Preise:', e); } finally { - setLoadingHistory(false); + setLoadingPrices(false); } }; - const handleCreateTrip = async (e: React.FormEvent) => { + const handleAddTrip = async (e: React.FormEvent) => { e.preventDefault(); - if (!newTripTitle) return; - await fetch('/api/trips', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ title: newTripTitle, destination: newTripDest }) + if (!newTripName.trim()) return; + try { + const res = await fetch('/api/trips', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newTripName.trim() }) + }); + const data = await res.json(); + if (data.success) { + setTrips([...trips, data.trip]); + setActiveTripId(String(data.trip.id)); + setNewTripName(''); + setShowAddTripModal(false); + } + } catch (e: any) { + alert('Fehler: ' + e.message); + } + }; + + const handleAddHotel = async (e: React.FormEvent) => { + e.preventDefault(); + if (!newBookingUrl) return alert('Bitte URL eingeben'); + setIsSubmitting(true); + try { + const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId; + const res = await fetch('/api/hotels', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name: newHotelName || 'Neues Hotel', + booking_url: newBookingUrl, + trip_id: targetTripId + }) + }); + const data = await res.json(); + if (res.ok || data.success) { + setNewBookingUrl(''); + setNewHotelName(''); + setShowAddHotelModal(false); + loadData(); + } + } catch (err: any) { + alert('Fehler: ' + err.message); + } finally { + setIsSubmitting(false); + } + }; + + // Hotel ausmustern (parken) + const parkHotel = async (id: string, name: string) => { + try { + await fetch(`/api/hotels/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_active: false }) + }); + setHotels(hotels.map(h => h.id === id ? { ...h, is_active: false } : h)); + } catch (e) { + console.error('Fehler beim Ausmustern:', e); + } + }; + + // Ausgemustertes Hotel wiederherstellen + const restoreHotel = async (id: string) => { + try { + await fetch(`/api/hotels/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_active: true }) + }); + setHotels(hotels.map(h => h.id === id ? { ...h, is_active: true } : h)); + } catch (e) { + console.error('Fehler beim Wiederaufnehmen:', e); + } + }; + + const triggerBatchPriceScan = async () => { + setScanningAllPrices(true); + try { + const res = await fetch('/api/jobs/trigger-daily-check', { method: 'POST' }); + const data = await res.json(); + alert(data.success ? '📊 Preis-Scan gestartet!' : 'Fehler: ' + data.error); + setTimeout(loadData, 3000); + } finally { + setScanningAllPrices(false); + } + }; + + const scanSingleHotel = async (hotelId: string) => { + setScanningHotelId(hotelId); + try { + const res = await fetch(`/api/hotels/${hotelId}/scan`, { method: 'POST' }); + const data = await res.json(); + alert(data.message || data.error); + } finally { + setScanningHotelId(null); + } + }; + + // 1. Nur AKTIVE Hotels filtern und chronologisch sortieren + const currentHotels = hotels + .filter(h => h.is_active) + .filter(h => { + if (activeTripId === 'ALL') return true; + if (!h.trip_id) return true; + return String(h.trip_id).toLowerCase() === String(activeTripId).toLowerCase(); + }) + .sort((a, b) => { + if (!a.checkin_date && !b.checkin_date) return 0; + if (!a.checkin_date) return 1; + if (!b.checkin_date) return -1; + return new Date(a.checkin_date).getTime() - new Date(b.checkin_date).getTime(); }); - setNewTripTitle(''); - setNewTripDest(''); - loadData(); - }; - const handleAddHotel = async (tripId: string) => { - if (!newBookingUrl) return alert('Bitte Booking.com URL eingeben'); - await fetch('/api/hotels', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ trip_id: tripId, booking_url: newBookingUrl }) - }); - setNewBookingUrl(''); - setSelectedTripId(null); - loadData(); - }; + // 2. Ausgemusterte (inaktive) Hotels für das Modal ermitteln + const parkedHotels = hotels.filter(h => !h.is_active); - const toggleHotelActive = async (e: React.SyntheticEvent, hotelId: string, currentStatus: boolean) => { - e.stopPropagation(); - await fetch(`/api/hotels/${hotelId}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ is_active: !currentStatus }) - }); - loadData(); - }; + // 3. Clustering der aktiven Hotels + const clusteredStops = currentHotels.reduce((acc, hotel) => { + const checkin = hotel.checkin_date ? hotel.checkin_date.split('T')[0] : null; + const checkout = hotel.checkout_date ? hotel.checkout_date.split('T')[0] : null; - const deleteHotel = async (e: React.MouseEvent, hotelId: string) => { - e.stopPropagation(); - if (!confirm('Hotel wirklich löschen?')) return; - await fetch(`/api/hotels/${hotelId}`, { method: 'DELETE' }); - loadData(); - }; + if (!checkin) { + acc.push({ + clusterKey: `single_${hotel.id}`, + checkin_date: null, + checkout_date: null, + hotels: [hotel] + }); + return acc; + } - const triggerScrape = async () => { - alert('Scrape & Review-Analyse gestartet...'); - await fetch('/api/jobs/trigger-daily-check', { method: 'POST' }); - setTimeout(() => loadData(), 12000); - }; + const key = `${checkin}_${checkout || 'NO_CHECKOUT'}`; + const existingCluster = acc.find(c => c.clusterKey === key); + + if (existingCluster) { + existingCluster.hotels.push(hotel); + } else { + acc.push({ + clusterKey: key, + checkin_date: checkin, + checkout_date: checkout, + hotels: [hotel] + }); + } + return acc; + }, []); + + const pricesList = priceHistory.map(p => Number(p.price)); + const minPrice = pricesList.length > 0 ? Math.min(...pricesList) : 0; + const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0; + const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 0; return ( -
-
-

✈️ Reiseplaner & Preis-Tracker

- +
+ +
+

📍 Reise-Ablauf & Timeline

+

Routenplanung, Datums-Cluster, Preishistorie & KI-Bewertungen

+ +
+ + + +
- {/* Neue Reise */} -
-

➕ Neue Reise planen

-
- setNewTripTitle(e.target.value)} style={styles.input} /> - setNewTripDest(e.target.value)} style={styles.input} /> - -
-
+ {/* TABS */} +
+ + {trips.map(trip => ( + + ))} + +
- {/* Reisen Liste */} - {loading ?

Lade Reisen...

: trips.map((trip) => ( -
-
-
-

{trip.title}

- 📍 {trip.destination || 'Kein Zielort angegeben'} + {/* MODAL: AUSGEMUSTERTE HOTELS */} + {showParkedModal && ( +
+
+
+
+

📦 Ausgemusterte Hotels

+

Diese Hotels sind aktuell nicht Teil deiner Timeline, bleiben aber gespeichert.

+
+ +
+
+ {parkedHotels.length === 0 ? ( +

🎉 Aktuell sind keine Hotels ausgemustert. Alle Hotels befinden sich in deiner Route!

+ ) : ( +
+ {parkedHotels.map(h => ( +
+
+
{h.name}
+
+ {h.checkin_date ? `🗓️ ${formatDateGerman(h.checkin_date)} – ${formatDateGerman(h.checkout_date)}` : 'Kein Datum'} + {h.latest_price ? ` • 💰 Gesamt: ${h.latest_price.toLocaleString('de-DE')} €` : ''} +
+
+ +
+ ))} +
+ )}
-
+
+ )} - {selectedTripId === trip.id && ( -
- setNewBookingUrl(e.target.value)} style={{ ...styles.input, width: '100%', marginBottom: '10px' }} /> - + {/* PREISHISTORIE MODAL */} + {selectedPriceHotel && ( +
+
+
+
+

📈 Preishistorie: {selectedPriceHotel.name}

+

Entwicklung aller erfassten Preis-Scans

+
+
- )} -
- {trip.hotels.length === 0 ? ( -

Noch keine Hotels angelegt.

+ {loadingPrices ? ( +

Lade Preishistorie...

+ ) : priceHistory.length === 0 ? ( +

Noch keine Preishistorie vorhanden. Klicke oben auf "Preise aller Hotels aktualisieren".

) : ( - trip.hotels.map((hotel) => { - const pReg = hotel.latest_price !== null ? parseFloat(String(hotel.latest_price)) : null; - const pGenius = hotel.latest_genius_price !== null ? parseFloat(String(hotel.latest_genius_price)) : (pReg ? pReg * 0.9 : null); - const nights = hotel.nights ? parseInt(String(hotel.nights), 10) : 1; - const persons = hotel.persons ? parseInt(String(hotel.persons), 10) : 2; - const activePrice = pGenius || pReg; +
+
+
+ Tiefstpreis +
{minPrice} €
+
+
+ Aktuellster Preis +
{currentPrice} €
+
+
+ Höchstpreis +
{maxPrice} €
+
+
- const pNight = hotel.price_per_night ? parseFloat(String(hotel.price_per_night)).toFixed(2) : (activePrice ? (activePrice / nights).toFixed(2) : '-'); - const pPerson = hotel.price_per_person ? parseFloat(String(hotel.price_per_person)).toFixed(2) : (activePrice ? (activePrice / (nights * persons)).toFixed(2) : '-'); + {priceHistory.length > 1 && ( +
+ + + + {(() => { + const range = maxPrice - minPrice || 1; + const points = priceHistory.map((p, idx) => { + const x = (idx / (priceHistory.length - 1)) * 500; + const y = 130 - ((p.price - minPrice) / range) * 110; + return `${x},${y}`; + }).join(' '); + return ( + <> + + {priceHistory.map((p, idx) => { + const x = (idx / (priceHistory.length - 1)) * 500; + const y = 130 - ((p.price - minPrice) / range) * 110; + return ( + + + {p.price}€ + + ); + })} + + ); + })()} + +
+ )} - return ( -
openHotelDetails(hotel)} - style={{ - ...styles.hotelRow, - opacity: hotel.is_active ? 1 : 0.6, - backgroundColor: hotel.is_active ? '#fff' : '#f9f9f9', - cursor: 'pointer' - }} - > -
- {hotel.image_url && ( - - )} -
- {hotel.name || 'Unbekanntes Hotel'} -
{hotel.address || 'Keine Adresse'}
- - + )} +
+
+ )} + + {/* MODAL: NEUE REISE */} + {showAddTripModal && ( +
+
+

🌍 Neue Reise anlegen

+
+ setNewTripName(e.target.value)} + style={{ width: '100%', padding: '0.7rem', borderRadius: '6px', border: '1px solid #d1d5db', marginBottom: '1rem' }} + /> +
+ + +
+
+
+
+ )} + + {/* MODAL: HOTEL HINZUFÜGEN */} + {showAddHotelModal && ( +
+
+

➕ Neues Hotel hinzufügen

+

Check-in/Check-out Datum wird automatisch aus dem Link gelesen.

+ +
+
+ + setNewHotelName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} /> +
+
+ + setNewBookingUrl(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} /> +
+
+ + +
+
+
+
+ )} + + {/* TIMELINE MIT CLUSTERING */} + {loading ? ( +

Lade Daten...

+ ) : clusteredStops.length === 0 ? ( +
+

Keine aktiven Hotels für diese Auswahl gefunden.

+ +
+ ) : ( +
+ {clusteredStops.map((stop, stopIndex) => { + const isCluster = stop.hotels.length > 1; + const nights = calculateNights(stop.checkin_date, stop.checkout_date) || stop.hotels[0]?.latest_nights || 1; + + return ( +
+ {/* Timeline Knotenpunkt */} +
+ + {/* Stop Header */} +
+
+ 📍 Stop #{stopIndex + 1} + + {stop.checkin_date ? ( + + 🗓️ {formatDateGerman(stop.checkin_date)} – {formatDateGerman(stop.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'}) + + ) : ( + (Warte auf Scan für Datum) + )} + + {isCluster && ( + + ⚖️ {stop.hotels.length} Optionen im Vergleich + + )} +
+
+ + {/* Container: Cluster-Hintergrund wenn mehrere Hotels */} +
+ {stop.hotels.map((hotel, hIdx) => { + const hotelNights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || nights || 1; + const persons = hotel.latest_persons || 2; + const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null; + const pricePerNight = hotel.latest_price_per_night + ? Math.round(Number(hotel.latest_price_per_night)) + : (totalPrice ? Math.round(totalPrice / hotelNights) : null); + const pricePerPersonPerNight = hotel.latest_price_per_person + ? Math.round(Number(hotel.latest_price_per_person)) + : (totalPrice ? Math.round(totalPrice / (hotelNights * persons)) : null); + + const optionColors = ['#2563eb', '#7c3aed', '#059669', '#d97706']; + const currentAccent = optionColors[hIdx % optionColors.length]; + + return ( +
+
+ {/* Option Header & Ausmustern-Button */} +
+ {isCluster ? ( + + Option {String.fromCharCode(65 + hIdx)} + + ) : ( +
{hotel.trip_name && {hotel.trip_name}}
+ )} + + +
+ + {/* Hotel Body */} +
+ {hotel.image_url && {hotel.name}} +
+

{hotel.name}

+

📍 {hotel.address || 'Keine Adresse hinterlegt'}

+
+
+ + {/* Preis-Box */} + {totalPrice !== null ? ( +
+ + 💰 Gesamt: {totalPrice.toLocaleString('de-DE')} € + + + | 🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. + + + | 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. + +
+ ) : ( +
+ 💰 Noch kein Preis erfasst +
+ )} + + {/* Meta Tags */} +
+ ⭐ {hotel.rating || 'N/A'}/10 + 💬 {hotel.review_count || 0} Bew. + {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²} +
+ + {/* KI Reviews Accordion */} + {(hotel.review_summary_positive || hotel.review_summary_bathroom) && ( +
+
+ + 📋 KI-Zusammenfassung ({hotel.review_summary_count || 'viele'} Reviews) + +
+ {hotel.review_summary_positive && ( +
+ 💚 Highlights:
{hotel.review_summary_positive} +
+ )} + {hotel.review_summary_passable && ( +
+ 🟡 Neutral:
{hotel.review_summary_passable} +
+ )} + {hotel.review_summary_negative && ( +
+ 🔴 Mängel:
{hotel.review_summary_negative} +
+ )} + {hotel.review_summary_bathroom && ( +
+ 🚽 Bad & WC:
{hotel.review_summary_bathroom} +
+ )} +
+
+
+ )} +
+ + {/* Action Buttons */} +
-
- -
-
- {pReg !== null ? ( -
-
Regulär: {pReg.toFixed(2)} €
-
- Genius - {pGenius ? `${pGenius.toFixed(2)} €` : '-'} -
- -
-
{pNight} € / Nacht (Zimmer)
-
{pPerson} € / Pers. & Nacht
-
({nights} {nights === 1 ? 'Nacht' : 'Nächte'}, {persons} Pers.)
-
-
- ) : ( -
Kein Preis
- )} -
- - - - -
-
- ); - }) - )} -
-
- ))} - - {/* Detail Modal (Historie & KI Reviews) */} - {selectedHotel && ( -
setSelectedHotel(null)}> -
e.stopPropagation()}> -
-
-

{selectedHotel.name}

- - 🔗 Auf Booking.com öffnen ↗ - + ); + })} +
- -
- - {/* Tab Navigation */} -
- - -
- - {/* TAB 1: PREISHISTORIE */} - {activeTab === 'history' && ( - loadingHistory ?

Lade Historie...

: ( - - - - - - - - - - - - {history.map((entry) => ( - - - - - - - - ))} - -
DatumRegulärGenius (-10%)Pro NachtPro Person/Nacht
{new Date(entry.tracked_at).toLocaleString('de-DE')}{entry.price_numeric} €{entry.genius_price_numeric || (entry.price_numeric * 0.9).toFixed(2)} €{entry.price_per_night ? `${entry.price_per_night} €` : '-'}{entry.price_per_person ? `${entry.price_per_person} €` : '-'}
- ) - )} -{/* TAB 2: KI REVIEWS */} -{activeTab === 'reviews' && ( -
- {!selectedHotel.review_summary_positive ? ( -

- Noch keine KI-Analyse vorhanden. Klicke oben auf "Alle Hotels scannen", um die Bewertungen zu scrapen. -

- ) : ( -
- {/* 1. Bestnoten */} -
-

💚 Bestnoten & Highlights (Positiv)

-
- {selectedHotel.review_summary_positive} -
-
- - {/* 2. Passabel */} -
-

🟡 Passabel & Neutrales

-
- {selectedHotel.review_summary_passable} -
-
- - {/* 3. Schlechteste */} -
-

🔴 Schlechteste & Mängel (Kritik)

-
- {selectedHotel.review_summary_negative} -
-
- - {/* 4. BAD & WC FOKUS (NEU) */} -
-

🚽 Bad & WC Fokus

-
- {selectedHotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC in den Kommentaren gefunden.'} -
-
- - {selectedHotel.reviews_updated_at && ( - - Zuletzt analysiert: {new Date(selectedHotel.reviews_updated_at).toLocaleString('de-DE')} - - )} -
- )} -
-)} -
+ ); + })}
)}
); -} - -const styles: Record = { - container: { maxWidth: '980px', margin: '0 auto', padding: '2rem', fontFamily: 'system-ui, sans-serif' }, - header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' }, - card: { background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.05)', marginBottom: '2rem' }, - tripCard: { background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: '1.5rem' }, - tripHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #eee', paddingBottom: '0.8rem' }, - inlineForm: { display: 'flex', gap: '1rem', marginTop: '1rem' }, - input: { padding: '0.6rem 1rem', borderRadius: '4px', border: '1px solid #ccc', flex: 1 }, - btn: { padding: '0.6rem 1.2rem', background: '#333', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' }, - primaryBtn: { padding: '0.6rem 1.2rem', background: '#0070f3', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold' }, - btnSecondary: { padding: '0.4rem 0.8rem', background: '#e0e0e0', border: 'none', borderRadius: '4px', cursor: 'pointer' }, - tabBtn: { padding: '0.6rem 1rem', background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.95rem' }, - dangerBtn: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.1rem' }, - addHotelBox: { background: '#f8f9fa', padding: '1rem', borderRadius: '6px', marginTop: '1rem', border: '1px solid #e9ecef' }, - hotelRow: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem', borderRadius: '6px', marginBottom: '0.5rem', border: '1px solid #eee', transition: 'all 0.2s' }, - badge: { background: '#f0f0f0', padding: '3px 7px', borderRadius: '4px', color: '#444', fontSize: '0.75rem' }, - bookingLinkBtn: { background: '#003580', color: '#fff', padding: '3px 8px', borderRadius: '4px', fontSize: '0.75rem', textDecoration: 'none', fontWeight: 'bold' }, - geniusTag: { background: '#003580', color: '#fff', fontSize: '0.7rem', padding: '2px 6px', borderRadius: '4px', marginRight: '6px', fontWeight: 'bold' }, - modalOverlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 9999 }, - modalContent: { background: '#fff', padding: '2rem', borderRadius: '8px', width: '90%', maxWidth: '850px', maxHeight: '80vh', overflowY: 'auto' } -}; +} \ No newline at end of file