Files
travel-app/llm_context.txt

1405 lines
52 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
========================================
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 (
<html lang="de">
<body style={{ margin: 0, padding: 0, backgroundColor: '#fafafa' }}>
{children}
</body>
</html>
);
}
```
--- 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<Trip[]>([]);
const [activeTripId, setActiveTripId] = useState<string>('ALL');
const [hotels, setHotels] = useState<Hotel[]>([]);
const [loading, setLoading] = useState(true);
const [scanningAllPrices, setScanningAllPrices] = useState(false);
const [scanningHotelId, setScanningHotelId] = useState<string | null>(null);
// Preishistorie Modal
const [selectedPriceHotel, setSelectedPriceHotel] = useState<Hotel | null>(null);
const [priceHistory, setPriceHistory] = useState<PriceEntry[]>([]);
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 (
<div style={{ maxWidth: '950px', margin: '0 auto', padding: '2rem 1rem', fontFamily: 'system-ui, sans-serif', color: '#1f2937' }}>
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
<h1 style={{ fontSize: '2.2rem', marginBottom: '0.5rem', fontWeight: '800' }}>📍 Reise-Ablauf & Timeline</h1>
<p style={{ color: '#6b7280', margin: 0 }}>Routenplanung, Preishistorie & KI-Bewertungen</p>
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<button
onClick={() => setShowAddHotelModal(true)}
style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.75rem 1.4rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}
>
➕ Neues Hotel hinzufügen
</button>
<button
onClick={triggerBatchPriceScan}
disabled={scanningAllPrices}
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.75rem 1.4rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer', opacity: scanningAllPrices ? 0.7 : 1 }}
>
{scanningAllPrices ? '🔄 Scanne...' : '📊 Preise aller Hotels aktualisieren'}
</button>
</div>
</header>
{/* TABS */}
<div style={{ display: 'flex', gap: '0.5rem', borderBottom: '2px solid #e5e7eb', marginBottom: '2rem', overflowX: 'auto', paddingBottom: '0.5rem' }}>
<button
onClick={() => setActiveTripId('ALL')}
style={{
padding: '0.6rem 1.2rem',
borderRadius: '8px',
border: 'none',
background: activeTripId === 'ALL' ? '#2563eb' : '#f3f4f6',
color: activeTripId === 'ALL' ? '#ffffff' : '#4b5563',
fontWeight: 'bold',
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
>
🌐 Alle Hotels ({hotels.length})
</button>
{trips.map(trip => (
<button
key={trip.id}
onClick={() => setActiveTripId(String(trip.id))}
style={{
padding: '0.6rem 1.2rem',
borderRadius: '8px',
border: 'none',
background: String(activeTripId).toLowerCase() === String(trip.id).toLowerCase() ? '#2563eb' : '#f3f4f6',
color: String(activeTripId).toLowerCase() === String(trip.id).toLowerCase() ? '#ffffff' : '#4b5563',
fontWeight: 'bold',
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
>
🌍 {trip.name}
</button>
))}
<button
onClick={() => setShowAddTripModal(true)}
style={{ padding: '0.6rem 1rem', borderRadius: '8px', border: '1px dashed #2563eb', background: '#eff6ff', color: '#2563eb', fontWeight: 'bold', cursor: 'pointer' }}
>
➕ Neue Reise
</button>
</div>
{/* PREISHISTORIE MODAL */}
{selectedPriceHotel && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
<div style={{ background: '#fff', padding: '1.8rem', borderRadius: '12px', maxWidth: '650px', width: '100%', boxShadow: '0 20px 25px -5px rgba(0,0,0,0.1)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.3rem' }}>📈 Preishistorie: {selectedPriceHotel.name}</h3>
<p style={{ margin: 0, color: '#6b7280', fontSize: '0.85rem' }}>Entwicklung aller erfassten Preis-Scans</p>
</div>
<button onClick={() => setSelectedPriceHotel(null)} style={{ background: '#f3f4f6', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
{loadingPrices ? (
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Lade Preishistorie...</p>
) : priceHistory.length === 0 ? (
<p style={{ textAlign: 'center', padding: '2rem', color: '#6b7280' }}>Noch keine Preishistorie vorhanden. Klicke oben auf "Preise aller Hotels aktualisieren".</p>
) : (
<div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', marginBottom: '1.5rem', textAlign: 'center' }}>
<div style={{ background: '#f0fdf4', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bbf7d0' }}>
<small style={{ color: '#166534', fontWeight: 'bold' }}>Tiefstpreis</small>
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#15803d' }}>{minPrice} €</div>
</div>
<div style={{ background: '#eff6ff', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bfdbfe' }}>
<small style={{ color: '#1e40af', fontWeight: 'bold' }}>Aktuellster Preis</small>
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1d4ed8' }}>{currentPrice} €</div>
</div>
<div style={{ background: '#fef2f2', padding: '0.8rem', borderRadius: '8px', border: '1px solid #fecaca' }}>
<small style={{ color: '#991b1b', fontWeight: 'bold' }}>Höchstpreis</small>
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#b91c1c' }}>{maxPrice} €</div>
</div>
</div>
{priceHistory.length > 1 && (
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '8px', border: '1px solid #f3f4f6', marginBottom: '1.5rem' }}>
<svg viewBox="0 0 500 150" style={{ width: '100%', height: '160px', overflow: 'visible' }}>
<line x1="0" y1="20" x2="500" y2="20" stroke="#e5e7eb" strokeDasharray="4" />
<line x1="0" y1="130" x2="500" y2="130" stroke="#e5e7eb" strokeDasharray="4" />
{(() => {
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 (
<>
<polyline fill="none" stroke="#2563eb" strokeWidth="3" points={points} />
{priceHistory.map((p, idx) => {
const x = (idx / (priceHistory.length - 1)) * 500;
const y = 130 - ((p.price - minPrice) / range) * 110;
return (
<g key={p.id}>
<circle cx={x} cy={y} r="5" fill="#2563eb" stroke="#ffffff" strokeWidth="2" />
<text x={x} y={y - 10} fontSize="10" textAnchor="middle" fill="#374151" fontWeight="bold">{p.price}€</text>
</g>
);
})}
</>
);
})()}
</svg>
</div>
)}
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
<thead>
<tr style={{ background: '#f3f4f6', textAlign: 'left' }}>
<th style={{ padding: '0.5rem' }}>Datum & Uhrzeit</th>
<th style={{ padding: '0.5rem' }}>Preis</th>
<th style={{ padding: '0.5rem' }}>Details</th>
</tr>
</thead>
<tbody>
{priceHistory.map(p => (
<tr key={p.id} style={{ borderBottom: '1px solid #f3f4f6' }}>
<td style={{ padding: '0.5rem' }}>{new Date(p.created_at).toLocaleString('de-DE')}</td>
<td style={{ padding: '0.5rem', fontWeight: 'bold', color: '#2563eb' }}>{p.price} €</td>
<td style={{ padding: '0.5rem', color: '#6b7280' }}>{p.nights || 1} Nächte, {p.persons || 2} Personen</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
</div>
)}
{/* MODAL: NEUE REISE */}
{showAddTripModal && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
<div style={{ background: '#fff', padding: '2rem', borderRadius: '12px', maxWidth: '400px', width: '100%' }}>
<h3 style={{ marginTop: 0 }}>🌍 Neue Reise anlegen</h3>
<form onSubmit={handleAddTrip}>
<input
type="text"
required
placeholder="z. B. Indien 2026 oder Japan 2027"
value={newTripName}
onChange={e => setNewTripName(e.target.value)}
style={{ width: '100%', padding: '0.7rem', borderRadius: '6px', border: '1px solid #d1d5db', marginBottom: '1rem' }}
/>
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
<button type="button" onClick={() => setShowAddTripModal(false)} style={{ padding: '0.6rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Abbrechen</button>
<button type="submit" style={{ padding: '0.6rem 1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>Anlegen</button>
</div>
</form>
</div>
</div>
)}
{/* MODAL: HOTEL HINZUFÜGEN */}
{showAddHotelModal && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
<div style={{ background: '#fff', padding: '2rem', borderRadius: '12px', maxWidth: '500px', width: '100%' }}>
<h2 style={{ marginTop: 0, marginBottom: '0.5rem' }}>➕ Neues Hotel hinzufügen</h2>
<p style={{ fontSize: '0.85rem', color: '#6b7280', marginBottom: '1.2rem' }}>Check-in/Check-out Datum wird automatisch aus dem Link gelesen.</p>
<form onSubmit={handleAddHotel} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Hotel Name (optional)</label>
<input type="text" placeholder="z. B. Abad Fort" value={newHotelName} onChange={e => setNewHotelName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
</div>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Booking.com URL *</label>
<input type="url" required placeholder="https://www.booking.com/hotel/..." value={newBookingUrl} onChange={e => setNewBookingUrl(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
</div>
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end', marginTop: '1rem' }}>
<button type="button" onClick={() => setShowAddHotelModal(false)} style={{ padding: '0.6rem 1.2rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Abbrechen</button>
<button type="submit" disabled={isSubmitting} style={{ padding: '0.6rem 1.2rem', background: '#10b981', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>
{isSubmitting ? 'Speichere...' : 'Speichern'}
</button>
</div>
</form>
</div>
</div>
)}
{/* TIMELINE */}
{loading ? (
<p style={{ textAlign: 'center', color: '#6b7280' }}>Lade Daten...</p>
) : currentHotels.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem', background: '#f9fafb', borderRadius: '12px', border: '1px dashed #d1d5db' }}>
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>Keine Hotels für diese Auswahl gefunden.</p>
<button onClick={() => setShowAddHotelModal(true)} style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.6rem 1.2rem', borderRadius: '6px', cursor: 'pointer' }}>
Erstes Hotel hinzufügen
</button>
</div>
) : (
<div style={{ position: 'relative', paddingLeft: '35px', borderLeft: '4px solid #3b82f6', marginLeft: '15px' }}>
{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 (
<div key={hotel.id} style={{ position: 'relative', marginBottom: '2.5rem', opacity: hotel.is_active ? 1 : 0.5 }}>
<div style={{ position: 'absolute', left: '-47px', top: '2px', width: '20px', height: '20px', borderRadius: '50%', background: hotel.is_active ? '#3b82f6' : '#9ca3af', border: '4px solid #ffffff', boxShadow: '0 0 0 2px #3b82f6' }} />
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', flexWrap: 'wrap', gap: '0.5rem' }}>
<div style={{ fontSize: '0.9rem', fontWeight: 'bold', color: '#2563eb', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span>📍 Stop #{index + 1}</span>
{hotel.trip_name && <span style={{ background: '#f3f4f6', color: '#4b5563', padding: '0.2rem 0.5rem', borderRadius: '6px', fontSize: '0.75rem' }}>{hotel.trip_name}</span>}
{hotel.checkin_date ? (
<span style={{ background: '#dbeafe', color: '#1e40af', padding: '0.2rem 0.6rem', borderRadius: '12px', fontSize: '0.8rem' }}>
🗓️ {formatDateGerman(hotel.checkin_date)} – {formatDateGerman(hotel.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'})
</span>
) : (
<span style={{ color: '#9ca3af', fontSize: '0.8rem', fontStyle: 'italic' }}>(Warte auf Scan für Datum)</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.8rem', alignItems: 'center' }}>
<label style={{ fontSize: '0.8rem', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.3rem', color: '#4b5563' }}>
<input type="checkbox" checked={hotel.is_active} onChange={() => toggleHotelActive(hotel)} />
{hotel.is_active ? 'Aktiv' : 'Inaktiv'}
</label>
<button onClick={() => deleteHotel(hotel.id, hotel.name)} style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: '0.85rem' }}>
🗑️ Löschen
</button>
</div>
</div>
<div style={{ background: '#ffffff', border: '1px solid #e5e7eb', borderRadius: '12px', padding: '1.2rem', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)', display: 'flex', gap: '1.2rem', flexWrap: 'wrap' }}>
{hotel.image_url && <img src={hotel.image_url} alt={hotel.name} style={{ width: '130px', height: '100px', objectFit: 'cover', borderRadius: '8px' }} />}
<div style={{ flex: 1, minWidth: '220px' }}>
<h3 style={{ margin: '0 0 0.4rem 0', fontSize: '1.2rem', color: '#1f2937' }}>{hotel.name}</h3>
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', color: '#6b7280' }}>📍 {hotel.address || 'Keine Adresse hinterlegt'}</p>
{/* ANZEIGE DER DREI PREIS-WERTE */}
{totalPrice !== null ? (
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap', marginBottom: '0.8rem', background: '#ecfdf5', padding: '0.6rem 0.8rem', borderRadius: '8px', border: '1px solid #a7f3d0', alignItems: 'center' }}>
<span style={{ fontWeight: '800', color: '#047857', fontSize: '0.95rem' }}>
💰 Gesamt: {totalPrice.toLocaleString('de-DE')} €
</span>
<span style={{ color: '#059669', fontSize: '0.85rem', fontWeight: '600' }}>
| 🌙 {pricePerNight?.toLocaleString('de-DE')} € / Nacht
</span>
<span style={{ color: '#059669', fontSize: '0.85rem' }}>
| 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. & Nacht
</span>
</div>
) : (
<div style={{ marginBottom: '0.8rem', fontSize: '0.85rem', color: '#6b7280', fontStyle: 'italic' }}>
💰 Noch kein Preis erfasst
</div>
)}
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', marginBottom: '1rem', color: '#374151', flexWrap: 'wrap', alignItems: 'center' }}>
<span>⭐ <strong>{hotel.rating || 'N/A'}</strong>/10</span>
<span>💬 {hotel.review_count || 0} Bewertungen</span>
{hotel.room_size_m2 && <span>📐 {hotel.room_size_m2} m²</span>}
</div>
<div style={{ display: 'flex', gap: '0.8rem', flexWrap: 'wrap' }}>
<button
onClick={() => openPriceHistoryModal(hotel)}
style={{ background: '#0284c7', color: '#ffffff', border: 'none', padding: '0.5rem 1rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.85rem' }}
>
📈 Preishistorie anzeigen
</button>
<button
onClick={() => scanSingleHotel(hotel.id)}
disabled={scanningHotelId === hotel.id || !hotel.is_active}
style={{ background: '#8b5cf6', color: '#ffffff', border: 'none', padding: '0.5rem 1rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.85rem', opacity: (!hotel.is_active || scanningHotelId === hotel.id) ? 0.6 : 1 }}
>
{scanningHotelId === hotel.id ? '🔄 Scanne KI-Reviews...' : '🤖 KI-Reviews scannen'}
</button>
<a href={hotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ color: '#2563eb', textDecoration: 'none', fontSize: '0.85rem', alignSelf: 'center', fontWeight: '500' }}>
🔗 Booking.com ↗
</a>
</div>
</div>
</div>
{(hotel.review_summary_positive || hotel.review_summary_bathroom) && (
<div style={{ marginTop: '0.8rem', paddingLeft: '0.2rem' }}>
<details style={{ background: '#f9fafb', padding: '0.8rem 1rem', borderRadius: '8px', border: '1px solid #e5e7eb' }}>
<summary style={{ cursor: 'pointer', fontWeight: 'bold', color: '#374151', fontSize: '0.9rem' }}>
📋 KI-Zusammenfassung
<span style={{ fontWeight: 'normal', color: '#6b7280', marginLeft: '0.5rem' }}>
(Basierend auf <strong>{hotel.review_summary_count || 'mehreren'}</strong> analysierten Bewertungen)
</span>
</summary>
<div style={{ marginTop: '1rem', fontSize: '0.88rem', display: 'grid', gap: '0.8rem' }}>
{hotel.review_summary_positive && (
<div style={{ background: '#e8f5e9', padding: '0.8rem', borderRadius: '6px', borderLeft: '4px solid #2e7d32' }}>
<strong style={{ color: '#1b5e20' }}>💚 Bestnoten & Highlights:</strong>
<div style={{ whiteSpace: 'pre-line', color: '#2e7d32', marginTop: '0.3rem' }}>{hotel.review_summary_positive}</div>
</div>
)}
{hotel.review_summary_passable && (
<div style={{ background: '#fffde7', padding: '0.8rem', borderRadius: '6px', borderLeft: '4px solid #fbc02d' }}>
<strong style={{ color: '#f57f17' }}>🟡 Passabel & Neutrales:</strong>
<div style={{ whiteSpace: 'pre-line', color: '#f57f17', marginTop: '0.3rem' }}>{hotel.review_summary_passable}</div>
</div>
)}
{hotel.review_summary_negative && (
<div style={{ background: '#ffebee', padding: '0.8rem', borderRadius: '6px', borderLeft: '4px solid #c62828' }}>
<strong style={{ color: '#b71c1c' }}>🔴 Schlechteste & Mängel:</strong>
<div style={{ whiteSpace: 'pre-line', color: '#c62828', marginTop: '0.3rem' }}>{hotel.review_summary_negative}</div>
</div>
)}
<div style={{ background: '#e0f7fa', padding: '0.8rem', borderRadius: '6px', borderLeft: '4px solid #00acc1' }}>
<strong style={{ color: '#006064' }}>🚽 Bad & WC Fokus:</strong>
<div style={{ whiteSpace: 'pre-line', color: '#006064', marginTop: '0.3rem' }}>
{hotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC gefunden.'}
</div>
</div>
</div>
</details>
</div>
)}
</div>
);
})}
</div>
)}
</div>
);
}
```
--- 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"]
}
```