======================================== PROJEKTSTRUKTUR ======================================== (Ermittelt via Git unter Berücksichtigung von .gitignore) .gitignore Dockerfile README.md copyChat.sh docker-compose.yml llm_context.txt next.config.js package-lock.json package.json src/app/api/attractions/[id]/route.ts src/app/api/attractions/route.ts src/app/api/hotels/[id]/attractions/route.ts src/app/api/hotels/[id]/history/route.ts src/app/api/hotels/[id]/prices/route.ts src/app/api/hotels/[id]/review-summary/route.ts src/app/api/hotels/[id]/route.ts src/app/api/hotels/[id]/scan/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? node_modules .next .git .gitignore llm_context.txt *.log Dockerfile docker-compose.yml ``` --- 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 volumes: # Spiegelt deinen lokalen Quellcode live in den Container: - ./src:/app/src - ./public:/app/public command: npm run dev ``` --- DATEI: next.config.js --- ``` /** @type {import('next').NextConfig} */ const nextConfig = { experimental: { serverComponentsExternalPackages: ['bullmq', 'ioredis'], }, }; module.exports = 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/attractions/[id]/route.ts --- ``` import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; export const dynamic = 'force-dynamic'; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); async function parseGoogleMapsUrl(inputUrl: string) { let targetUrl = inputUrl.trim(); if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) { try { const res = await fetch(targetUrl, { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36' }, redirect: 'follow', }); targetUrl = res.url || targetUrl; } catch (err) {} } let lat: number | null = null; let lon: number | null = null; const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/); if (coordMatch) { lat = parseFloat(coordMatch[1]); lon = parseFloat(coordMatch[2]); } return { lat, lon }; } async function geocode(address: string) { if (!address || address.startsWith('http')) return null; const search = async (q: string) => { try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`; const res = await fetch(url, { headers: { 'User-Agent': 'TravelPlannerApp/1.0' } }); const data = await res.json(); if (data && data.length > 0) return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) }; } catch (err) {} return null; }; let coords = await search(address); if (!coords && address.includes(',')) { const parts = address.split(',').map(p => p.trim()); await sleep(1100); coords = await search(`${parts[parts.length - 2]}, ${parts[parts.length - 1]}`); } return coords; } async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) { try { const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`; const res = await fetch(url); const data = await res.json(); if (data.code === 'Ok' && data.routes?.length > 0) { return { distanceKm: Math.round((data.routes[0].distance / 1000) * 10) / 10, durationMin: Math.round(data.routes[0].duration / 60), }; } } catch (err) {} return { distanceKm: null, durationMin: null }; } export async function PUT(req: NextRequest, { params }: { params: { id: string } }) { try { const { id } = params; const { name, address, category_icon, notes, hotel_ids, trip_id } = await req.json(); let sightCoords: { lat: number; lon: number } | null = null; if (address.includes('google.com/maps') || address.includes('goo.gl')) { const parsed = await parseGoogleMapsUrl(address); if (parsed.lat && parsed.lon) sightCoords = { lat: parsed.lat, lon: parsed.lon }; } if (!sightCoords) { sightCoords = await geocode(address || name); } const res = await query( `UPDATE hotel_attractions SET name = $1, address = $2, category_icon = $3, notes = $4, trip_id = $5, lat = COALESCE($6, lat), lon = COALESCE($7, lon) WHERE id = $8 RETURNING *`, [ name, address, category_icon || '🏛️', notes || null, trip_id || null, sightCoords?.lat || null, sightCoords?.lon || null, id, ] ); await query('DELETE FROM hotel_attraction_assignments WHERE attraction_id = $1', [id]); const selectedHotelIds: string[] = Array.isArray(hotel_ids) ? hotel_ids : []; for (const hId of selectedHotelIds) { let distanceKm: number | null = null; let durationMin: number | null = null; const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hId]); if (hotelRes.rows.length > 0) { const hotel = hotelRes.rows[0]; await sleep(1100); let hCoords = await geocode(hotel.address); if (!hCoords && hotel.name) { await sleep(1100); hCoords = await geocode(hotel.name); } if (hCoords && sightCoords) { const drive = await getDriveInfo(hCoords, sightCoords); distanceKm = drive.distanceKm; durationMin = drive.durationMin; } } await query( `INSERT INTO hotel_attraction_assignments (attraction_id, hotel_id, distance_km, duration_minutes) VALUES ($1, $2, $3, $4)`, [id, hId, distanceKm, durationMin] ); } return NextResponse.json({ success: true, attraction: res.rows[0] }); } catch (e: any) { return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) { try { await query('DELETE FROM hotel_attractions WHERE id = $1', [params.id]); return NextResponse.json({ success: true }); } catch (e: any) { return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } ``` --- DATEI: src/app/api/attractions/route.ts --- ``` import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; export const dynamic = 'force-dynamic'; const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); async function parseGoogleMapsUrl(inputUrl: string) { let targetUrl = inputUrl.trim(); if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) { try { const res = await fetch(targetUrl, { method: 'GET', headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', }, redirect: 'follow', }); targetUrl = res.url || targetUrl; } catch (err) { console.error('Fehler beim Auflösen des Google Maps Links:', err); } } let extractedName: string | null = null; let lat: number | null = null; let lon: number | null = null; const placeMatch = targetUrl.match(/\/place\/([^/@?]+)/); if (placeMatch && placeMatch[1]) { extractedName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); } const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/); if (coordMatch) { lat = parseFloat(coordMatch[1]); lon = parseFloat(coordMatch[2]); } else { const dataCoordMatch = targetUrl.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/); if (dataCoordMatch) { lat = parseFloat(dataCoordMatch[1]); lon = parseFloat(dataCoordMatch[2]); } else { const queryCoordMatch = targetUrl.match(/[?&](?:q|query|ll)=(-?\d+\.\d+),(-?\d+\.\d+)/); if (queryCoordMatch) { lat = parseFloat(queryCoordMatch[1]); lon = parseFloat(queryCoordMatch[2]); } } } return { name: extractedName, lat, lon, resolvedUrl: targetUrl }; } // Geocoding mit intelligentem Adress-Fallback async function geocode(address: string): Promise<{ lat: number; lon: number } | null> { if (!address || address.startsWith('http')) return null; const search = async (q: string) => { try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(q)}&format=json&limit=1`; const res = await fetch(url, { headers: { 'User-Agent': 'TravelPlannerApp/1.0 (Homelab Private Usage)' }, }); if (res.status === 429) { await sleep(1500); return null; } const data = await res.json(); if (data && data.length > 0) { return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) }; } } catch (err) { console.error(`Geocoding Fehler bei "${q}":`, err); } return null; }; // 1. Exakter Adresstext let coords = await search(address); if (coords) return coords; // 2. Fallback: Ort und Land aus der Komma-Kette filtern const parts = address.split(',').map(p => p.trim()); if (parts.length > 2) { await sleep(1100); const cityFallback = `${parts[parts.length - 2]}, ${parts[parts.length - 1]}`; coords = await search(cityFallback); } return coords; } // OSRM Routing-Berechnung async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) { try { const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`; const res = await fetch(url); const data = await res.json(); if (data.code === 'Ok' && data.routes && data.routes.length > 0) { const distanceKm = Math.round((data.routes[0].distance / 1000) * 10) / 10; const durationMin = Math.round(data.routes[0].duration / 60); return { distanceKm, durationMin }; } } catch (err) { console.error('OSRM Routing Fehler:', err); } return { distanceKm: null, durationMin: null }; } // GET: Alle Sehenswürdigkeiten laden export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); const tripId = searchParams.get('tripId'); let sql = ` SELECT a.*, COALESCE( json_agg( json_build_object( 'hotel_id', haa.hotel_id, 'distance_km', haa.distance_km, 'duration_minutes', haa.duration_minutes ) ) FILTER (WHERE haa.id IS NOT NULL), '[]' ) AS assignments FROM hotel_attractions a LEFT JOIN hotel_attraction_assignments haa ON a.id = haa.attraction_id WHERE 1=1 `; const params: any[] = []; if (tripId && tripId !== 'ALL') { params.push(tripId); sql += ` AND a.trip_id = $${params.length}`; } sql += ' GROUP BY a.id ORDER BY a.created_at ASC'; const res = await query(sql, params); return NextResponse.json({ success: true, attractions: res.rows }); } catch (e: any) { return NextResponse.json({ success: false, error: e.message, attractions: [] }, { status: 500 }); } } // POST: Neues Ausflugsziel anlegen export async function POST(req: NextRequest) { try { const { trip_id, hotel_ids, name, address, category_icon, notes } = await req.json(); if (!address) { return NextResponse.json({ error: 'Google Maps Link oder Adresse erforderlich' }, { status: 400 }); } let finalName = name?.trim() || ''; let finalAddress = address.trim(); let sightCoords: { lat: number; lon: number } | null = null; if (finalAddress.includes('google.com/maps') || finalAddress.includes('goo.gl')) { const parsed = await parseGoogleMapsUrl(finalAddress); if (parsed.name && !finalName) finalName = parsed.name; if (parsed.lat && parsed.lon) sightCoords = { lat: parsed.lat, lon: parsed.lon }; } if (!sightCoords) { sightCoords = await geocode(finalAddress || finalName); } if (!finalName) finalName = finalAddress; // 1. In hotel_attractions anlegen const insertRes = await query( `INSERT INTO hotel_attractions (trip_id, name, address, category_icon, notes, lat, lon) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [ trip_id || null, finalName, finalAddress, category_icon || '🏛️', notes || null, sightCoords?.lat || null, sightCoords?.lon || null, ] ); const newAttraction = insertRes.rows[0]; // 2. Für alle ausgewählten Hotels Distanz berechnen (mit Pause gegen 429) const selectedHotelIds: string[] = Array.isArray(hotel_ids) ? hotel_ids : []; for (const hId of selectedHotelIds) { let distanceKm: number | null = null; let durationMin: number | null = null; const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hId]); if (hotelRes.rows.length > 0) { const hotel = hotelRes.rows[0]; await sleep(1100); // Fair-Use Rate-Limit für Nominatim einhalten let hCoords = await geocode(hotel.address); if (!hCoords && hotel.name) { await sleep(1100); hCoords = await geocode(hotel.name); } if (hCoords && sightCoords) { const drive = await getDriveInfo(hCoords, sightCoords); distanceKm = drive.distanceKm; durationMin = drive.durationMin; } } await query( `INSERT INTO hotel_attraction_assignments (attraction_id, hotel_id, distance_km, duration_minutes) VALUES ($1, $2, $3, $4) ON CONFLICT (attraction_id, hotel_id) DO UPDATE SET distance_km = EXCLUDED.distance_km, duration_minutes = EXCLUDED.duration_minutes`, [newAttraction.id, hId, distanceKm, durationMin] ); } return NextResponse.json({ success: true, attraction: newAttraction }); } catch (e: any) { console.error('Fehler beim Anlegen:', e); return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } ``` --- DATEI: src/app/api/hotels/[id]/attractions/route.ts --- ``` import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; export const dynamic = 'force-dynamic'; // Google Maps URL auflösen und Name + Koordinaten extrahieren async function parseGoogleMapsUrl(inputUrl: string) { let targetUrl = inputUrl.trim(); // Kurzlinks (maps.app.goo.gl oder goo.gl) auflösen if (targetUrl.includes('goo.gl') || targetUrl.includes('maps.app.goo.gl')) { try { const res = await fetch(targetUrl, { method: 'HEAD', redirect: 'follow' }); targetUrl = res.url || targetUrl; } catch (err) { console.error('Fehler beim Auflösen des Google Maps Kurzlinks:', err); } } let extractedName: string | null = null; let lat: number | null = null; let lon: number | null = null; // 1. Name extrahieren aus /place/Ortsname/ const placeMatch = targetUrl.match(/\/place\/([^/@?]+)/); if (placeMatch && placeMatch[1]) { extractedName = decodeURIComponent(placeMatch[1].replace(/\+/g, ' ')); } // 2. Koordinaten aus @lat,lon extrahieren const coordMatch = targetUrl.match(/@(-?\d+\.\d+),(-?\d+\.\d+)/); if (coordMatch) { lat = parseFloat(coordMatch[1]); lon = parseFloat(coordMatch[2]); } else { // Alternativ-Format: !3dLAT!4dLON const dataCoordMatch = targetUrl.match(/!3d(-?\d+\.\d+)!4d(-?\d+\.\d+)/); if (dataCoordMatch) { lat = parseFloat(dataCoordMatch[1]); lon = parseFloat(dataCoordMatch[2]); } } return { name: extractedName, lat, lon, resolvedUrl: targetUrl }; } // Fallback Nominatim Geocoding async function geocode(address: string): Promise<{ lat: number; lon: number } | null> { try { const url = `https://nominatim.openstreetmap.org/search?q=${encodeURIComponent(address)}&format=json&limit=1`; const res = await fetch(url, { headers: { 'User-Agent': 'TravelPlannerApp/1.0 (Homelab Private Usage)' }, }); const data = await res.json(); if (data && data.length > 0) { return { lat: parseFloat(data[0].lat), lon: parseFloat(data[0].lon) }; } } catch (err) { console.error('Geocoding Fehler:', err); } return null; } // OSRM Fahrzeit- & Distanzberechnung async function getDriveInfo(origin: { lat: number; lon: number }, dest: { lat: number; lon: number }) { try { const url = `http://router.project-osrm.org/route/v1/driving/${origin.lon},${origin.lat};${dest.lon},${dest.lat}?overview=false`; const res = await fetch(url); const data = await res.json(); if (data.code === 'Ok' && data.routes && data.routes.length > 0) { const distanceKm = Math.round((data.routes[0].distance / 1000) * 10) / 10; const durationMin = Math.round(data.routes[0].duration / 60); return { distanceKm, durationMin }; } } catch (err) { console.error('OSRM Routing Fehler:', err); } return { distanceKm: null, durationMin: null }; } // GET: Alle Sehenswürdigkeiten eines Hotels abrufen export async function GET(req: NextRequest, { params }: { params: { id: string } }) { try { const res = await query( `SELECT * FROM hotel_attractions WHERE hotel_id = $1 ORDER BY distance_km ASC NULLS LAST, created_at ASC`, [params.id] ); return NextResponse.json({ success: true, attractions: res.rows }); } catch (e: any) { return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } // POST: Google Maps Link oder Adresse verarbeiten & Route berechnen export async function POST(req: NextRequest, { params }: { params: { id: string } }) { try { const hotelId = params.id; const { name, input_value, category_icon, notes } = await req.json(); if (!input_value) { return NextResponse.json({ error: 'Google Maps Link oder Adresse ist erforderlich' }, { status: 400 }); } // 1. Hotel-Koordinaten ermitteln const hotelRes = await query('SELECT address, name FROM hotels WHERE id = $1', [hotelId]); if (hotelRes.rows.length === 0) { return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 }); } const hotel = hotelRes.rows[0]; const hotelCoords = await geocode(hotel.address || hotel.name); let finalName = name?.trim() || ''; let finalAddress = input_value.trim(); let sightCoords: { lat: number; lon: number } | null = null; // 2. Google Maps URL prüfen & parsen if (input_value.includes('google.com/maps') || input_value.includes('goo.gl')) { const parsedMaps = await parseGoogleMapsUrl(input_value); if (parsedMaps.name && !finalName) { finalName = parsedMaps.name; } if (parsedMaps.lat && parsedMaps.lon) { sightCoords = { lat: parsedMaps.lat, lon: parsedMaps.lon }; } } // Falls keine Koordinaten aus dem Link gelesen werden konnten: Fallback auf Geocoding if (!sightCoords) { sightCoords = await geocode(finalAddress || finalName); } if (!finalName) { finalName = finalAddress; } // 3. Fahrzeit & Distanz via OSRM berechnen let distanceKm: number | null = null; let durationMin: number | null = null; if (hotelCoords && sightCoords) { const drive = await getDriveInfo(hotelCoords, sightCoords); distanceKm = drive.distanceKm; durationMin = drive.durationMin; } // 4. In Postgres speichern const insertRes = await query( `INSERT INTO hotel_attractions (hotel_id, name, address, category_icon, notes, distance_km, duration_minutes) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`, [hotelId, finalName, finalAddress, category_icon || '🏛️', notes || null, distanceKm, durationMin] ); return NextResponse.json({ success: true, attraction: insertRes.rows[0] }); } catch (e: any) { console.error('Fehler beim Anlegen der Sehenswürdigkeit:', e); return NextResponse.json({ success: false, error: e.message }, { status: 500 }); } } ``` --- 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 AttractionAssignment { hotel_id: string; distance_km?: number | null; duration_minutes?: number | null; } interface Attraction { id: string; trip_id?: string | null; name: string; address: string; category_icon: string; notes?: string | null; assignments?: AttractionAssignment[]; } 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; } interface HotelCluster { clusterKey: string; checkin_date?: string | null; checkout_date?: string | null; hotels: Hotel[]; } export default function TravelDashboard() { const [trips, setTrips] = useState([]); const [activeTripId, setActiveTripId] = useState('ALL'); const [hotels, setHotels] = useState([]); const [attractions, setAttractions] = 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); // Reise & Hotel 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); // Ausflugsziele: Zentral & Mehrfachzuordnung const [showManageSightsModal, setShowManageSightsModal] = useState(false); const [showSightFormModal, setShowSightFormModal] = useState(false); const [editingSight, setEditingSight] = useState(null); const [sightName, setSightName] = useState(''); const [sightAddress, setSightAddress] = useState(''); const [sightIcon, setSightIcon] = useState('🏛️'); const [sightNotes, setSightNotes] = useState(''); const [selectedHotelIds, setSelectedHotelIds] = useState([]); const [isSubmittingSight, setIsSubmittingSight] = 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 { try { const resTrips = await fetch('/api/trips'); if (resTrips.ok) { const dataTrips = await resTrips.json(); setTrips(dataTrips.trips || []); } } catch (e) {} try { const resHotels = await fetch('/api/hotels'); if (resHotels.ok) { const dataHotels = await resHotels.json(); setHotels(dataHotels.hotels || []); } } catch (e) {} try { const resAttractions = await fetch('/api/attractions'); if (resAttractions.ok) { const dataAttractions = await resAttractions.json(); setAttractions(dataAttractions.attractions || []); } } catch (e) {} } finally { setLoading(false); } }; const refreshAttractions = async () => { try { const res = await fetch('/api/attractions'); const data = await res.json(); if (data.success) { setAttractions(data.attractions || []); } } catch (e) { console.error('Fehler beim Aktualisieren der Ausflugsziele:', e); } }; 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 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); } }; 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); } }; // Ausflug Modal öffnen (mit Checkboxen für Mehrfachauswahl) const openSightModal = (sight?: Attraction, preselectedHotelId?: string) => { if (sight) { setEditingSight(sight); setSightName(sight.name); setSightAddress(sight.address); setSightIcon(sight.category_icon || '🏛️'); setSightNotes(sight.notes || ''); setSelectedHotelIds((sight.assignments || []).map(a => a.hotel_id)); } else { setEditingSight(null); setSightName(''); setSightAddress(''); setSightIcon('🏛️'); setSightNotes(''); setSelectedHotelIds(preselectedHotelId ? [preselectedHotelId] : []); } setShowSightFormModal(true); }; const toggleHotelSelection = (hotelId: string) => { setSelectedHotelIds(prev => prev.includes(hotelId) ? prev.filter(id => id !== hotelId) : [...prev, hotelId] ); }; const handleSaveSight = async (e: React.FormEvent) => { e.preventDefault(); if (!sightAddress) return; setIsSubmittingSight(true); try { const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId; const payload = { name: sightName, address: sightAddress, category_icon: sightIcon, notes: sightNotes, hotel_ids: selectedHotelIds, trip_id: targetTripId }; if (editingSight) { const res = await fetch(`/api/attractions/${editingSight.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json(); if (!data.success) throw new Error(data.error); } else { const res = await fetch('/api/attractions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); const data = await res.json(); if (!data.success) throw new Error(data.error); } await refreshAttractions(); setShowSightFormModal(false); } catch (err: any) { alert('Fehler beim Speichern: ' + err.message); } finally { setIsSubmittingSight(false); } }; const deleteSight = async (sightId: string) => { if (!confirm('Sehenswürdigkeit wirklich löschen?')) return; try { await fetch(`/api/attractions/${sightId}`, { method: 'DELETE' }); await refreshAttractions(); } catch (e) { console.error('Fehler beim Löschen:', 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); } }; 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(); }); const parkedHotels = hotels.filter(h => !h.is_active); const currentAttractions = attractions.filter(a => { if (activeTripId === 'ALL') return true; return String(a.trip_id).toLowerCase() === String(activeTripId).toLowerCase(); }); 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; if (!checkin) { acc.push({ clusterKey: `single_${hotel.id}`, checkin_date: null, checkout_date: null, hotels: [hotel] }); return acc; } 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 (

📍 Reise-Ablauf & Timeline

Routenplanung, Sehenswürdigkeiten, Fahrzeiten & KI-Reviews

{/* TABS */}
{trips.map(trip => ( ))}
{/* MODAL: AUSFLÜGE ZENTRAL VERWALTEN */} {showManageSightsModal && (

🏛️ Zentrale Ausflugsziele

Sehenswürdigkeiten bearbeiten und flexibel mehreren Hotels zuweisen

{currentAttractions.length === 0 ? (

Noch keine Ausflugsziele für diesen Trip hinterlegt.

) : ( currentAttractions.map(sight => { const assignedCount = (sight.assignments || []).length; return (
{sight.category_icon || '🏛️'} {sight.name}
{assignedCount > 0 ? ( 🏨 Zugewiesen zu {assignedCount} {assignedCount === 1 ? 'Hotel' : 'Hotels'} ) : ( Keinem Hotel zugewiesen )}
); }) )}
)} {/* MODAL: AUSFLUG ANLEGEN / BEARBEITEN */} {showSightFormModal && (

{editingSight ? '✏️ Ausflugsziel bearbeiten' : '🏛️ Neues Ausflugsziel'}

setSightAddress(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
setSightName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
{/* MEHRFACHAUSWAHL VON HOTELS */}
{hotels.length === 0 ? ( Keine Hotels angelegt. ) : ( hotels.map(h => { const isChecked = selectedHotelIds.includes(h.id); return ( ); }) )}
setSightNotes(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
)} {/* 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.

) : (
{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')} €` : ''}
))}
)}
)} {/* PREISHISTORIE MODAL */} {selectedPriceHotel && (

📈 Preishistorie: {selectedPriceHotel.name}

Entwicklung aller erfassten Preis-Scans

{loadingPrices ? (

Lade Preishistorie...

) : priceHistory.length === 0 ? (

Noch keine Preishistorie vorhanden.

) : (
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}€ ); })} ); })()}
)}
)}
)} {/* 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

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 (
{/* 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 )}
{/* Hotel Grid */}
{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]; // Filtert Ausflüge, die DIESEM Hotel zugeordnet sind const hotelSights = attractions.filter(a => (a.assignments || []).some(asgn => asgn.hotel_id === hotel.id) ); return (
{/* Option Header */}
{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²}
{/* SEHENSWÜRDIGKEITEN & FAHRZEITEN FÜR DIESES HOTEL */}
🏛️ Ausflugsziele ({hotelSights.length})
{hotelSights.length === 0 ? (

Keine Ausflugsziele zugeordnet.

) : ( hotelSights.map(sight => { const assignment = (sight.assignments || []).find(a => a.hotel_id === hotel.id); return (
{sight.category_icon || '🏛️'}
{sight.name}
{sight.notes && (
{sight.notes}
)}
{assignment?.distance_km !== null && assignment?.distance_km !== undefined ? (
🚗 {assignment.distance_km} km • {assignment.duration_minutes} Min.
) : ( 📍 Route n.v. )}
Maps ↗
); }) )}
{/* Action Buttons */}
🔗 Booking ↗
); })}
); })}
)}
); } ``` --- 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"] } ```