Files
travel-app/llm_context.txt
2026-08-16 09:06:48 +00:00

2428 lines
97 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)
.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 (
<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 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<Trip[]>([]);
const [activeTripId, setActiveTripId] = useState<string>('ALL');
const [hotels, setHotels] = useState<Hotel[]>([]);
const [attractions, setAttractions] = useState<Attraction[]>([]);
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);
// 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<Attraction | null>(null);
const [sightName, setSightName] = useState('');
const [sightAddress, setSightAddress] = useState('');
const [sightIcon, setSightIcon] = useState('🏛️');
const [sightNotes, setSightNotes] = useState('');
const [selectedHotelIds, setSelectedHotelIds] = useState<string[]>([]);
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<HotelCluster[]>((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 (
<div style={{ maxWidth: '1050px', 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, Sehenswürdigkeiten, Fahrzeiten & KI-Reviews</p>
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
<button
onClick={() => setShowAddHotelModal(true)}
style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}
>
➕ Neues Hotel hinzufügen
</button>
<button
onClick={() => openSightModal()}
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}
>
🏛️ Ausflug anlegen
</button>
<button
onClick={() => setShowManageSightsModal(true)}
style={{ background: '#f1f5f9', color: '#334155', border: '1px solid #cbd5e1', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
📋 Alle Ausflüge ({currentAttractions.length})
</button>
<button
onClick={() => setShowParkedModal(true)}
style={{ background: '#f1f5f9', color: '#334155', border: '1px solid #cbd5e1', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
📦 Ausgemusterte Hotels
{parkedHotels.length > 0 && (
<span style={{ background: '#dc2626', color: '#fff', fontSize: '0.75rem', padding: '0.15rem 0.5rem', borderRadius: '9999px', fontWeight: '800' }}>
{parkedHotels.length}
</span>
)}
</button>
<button
onClick={triggerBatchPriceScan}
disabled={scanningAllPrices}
style={{ background: '#3b82f6', color: '#fff', border: 'none', padding: '0.7rem 1.3rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer', opacity: scanningAllPrices ? 0.7 : 1 }}
>
{scanningAllPrices ? '🔄 Scanne...' : '📊 Preise 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 aktiven Hotels ({currentHotels.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>
{/* MODAL: AUSFLÜGE ZENTRAL VERWALTEN */}
{showManageSightsModal && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '750px', width: '100%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' }}>
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.3rem', fontWeight: '800' }}>🏛️ Zentrale Ausflugsziele</h3>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#64748b' }}>Sehenswürdigkeiten bearbeiten und flexibel mehreren Hotels zuweisen</p>
</div>
<button onClick={() => setShowManageSightsModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
<div style={{ padding: '1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
{currentAttractions.length === 0 ? (
<p style={{ textAlign: 'center', color: '#64748b', padding: '2rem 0' }}>Noch keine Ausflugsziele für diesen Trip hinterlegt.</p>
) : (
currentAttractions.map(sight => {
const assignedCount = (sight.assignments || []).length;
return (
<div key={sight.id} style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.9rem 1.1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '240px', overflow: 'hidden' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
<span style={{ fontSize: '1.2rem' }}>{sight.category_icon || '🏛️'}</span>
<span style={{ fontWeight: '700', fontSize: '0.95rem', color: '#0f172a' }}>{sight.name}</span>
</div>
<div style={{ fontSize: '0.8rem', color: '#64748b', marginTop: '0.3rem', display: 'flex', gap: '0.4rem', alignItems: 'center', flexWrap: 'wrap' }}>
{assignedCount > 0 ? (
<span style={{ background: '#dbeafe', color: '#1e40af', padding: '0.15rem 0.5rem', borderRadius: '6px', fontWeight: '600' }}>
🏨 Zugewiesen zu {assignedCount} {assignedCount === 1 ? 'Hotel' : 'Hotels'}
</span>
) : (
<span style={{ background: '#f1f5f9', color: '#64748b', padding: '0.15rem 0.5rem', borderRadius: '6px' }}>
Keinem Hotel zugewiesen
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem' }}>
<button
onClick={() => { setShowManageSightsModal(false); openSightModal(sight); }}
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', padding: '0.4rem 0.7rem', borderRadius: '6px', fontSize: '0.8rem', fontWeight: '600', cursor: 'pointer' }}
>
✏️ Bearbeiten
</button>
<button
onClick={() => deleteSight(sight.id)}
style={{ background: '#fee2e2', color: '#b91c1c', border: 'none', padding: '0.4rem 0.7rem', borderRadius: '6px', fontSize: '0.8rem', fontWeight: '600', cursor: 'pointer' }}
>
🗑️
</button>
</div>
</div>
);
})
)}
</div>
<div style={{ padding: '1rem 1.5rem', borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'flex-end' }}>
<button
onClick={() => { setShowManageSightsModal(false); openSightModal(); }}
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.6rem 1.2rem', borderRadius: '6px', fontWeight: '700', cursor: 'pointer' }}
>
➕ Neues Ausflugsziel
</button>
</div>
</div>
</div>
)}
{/* MODAL: AUSFLUG ANLEGEN / BEARBEITEN */}
{showSightFormModal && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1001, padding: '1rem' }}>
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '520px', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)', overflow: 'hidden' }}>
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: '800' }}>
{editingSight ? '✏️ Ausflugsziel bearbeiten' : '🏛️ Neues Ausflugsziel'}
</h3>
<button onClick={() => setShowSightFormModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '30px', height: '30px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
<form onSubmit={handleSaveSight} style={{ padding: '1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '0.9rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>
🔗 Google Maps Link oder Adresse *
</label>
<input
type="text"
required
placeholder="https://maps.app.goo.gl/... oder Adresse"
value={sightAddress}
onChange={e => setSightAddress(e.target.value)}
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }}
/>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>
Name der Sehenswürdigkeit (optional bei Maps-Link)
</label>
<input
type="text"
placeholder="z. B. Chinesische Fischernetze"
value={sightName}
onChange={e => setSightName(e.target.value)}
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }}
/>
</div>
{/* MEHRFACHAUSWAHL VON HOTELS */}
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.3rem' }}>
🏨 Diesen Hotels zuordnen (Distanz wird für jedes Hotel berechnet):
</label>
<div style={{ maxHeight: '140px', overflowY: 'auto', border: '1px solid #d1d5db', borderRadius: '6px', padding: '0.5rem', background: '#fafafa', display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
{hotels.length === 0 ? (
<span style={{ fontSize: '0.8rem', color: '#9ca3af' }}>Keine Hotels angelegt.</span>
) : (
hotels.map(h => {
const isChecked = selectedHotelIds.includes(h.id);
return (
<label key={h.id} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.82rem', cursor: 'pointer', padding: '0.2rem 0.3rem', borderRadius: '4px', background: isChecked ? '#eff6ff' : 'transparent' }}>
<input
type="checkbox"
checked={isChecked}
onChange={() => toggleHotelSelection(h.id)}
/>
<span style={{ fontWeight: isChecked ? '700' : 'normal', color: isChecked ? '#1e40af' : '#374151' }}>
{h.name} {h.checkin_date ? `(${formatDateGerman(h.checkin_date)})` : ''}
</span>
</label>
);
})
)}
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>Kategorie</label>
<select value={sightIcon} onChange={e => setSightIcon(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }}>
<option value="🏛️">🏛️ Kultur & Bauwerk</option>
<option value="🌿">🌿 Natur & Park</option>
<option value="🏖️">🏖️ Strand & Meer</option>
<option value="🐘">🐘 Safari & Tiere</option>
<option value="🍛">🍛 Food & Markt</option>
<option value="🧗">🧗 Abenteuer & Wandern</option>
</select>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.2rem' }}>Notizen / Tipps (optional)</label>
<input type="text" placeholder="z. B. Öffnungszeiten, Tickets vorab" value={sightNotes} onChange={e => setSightNotes(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
</div>
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
<button type="button" onClick={() => setShowSightFormModal(false)} style={{ padding: '0.55rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' }}>Abbrechen</button>
<button type="submit" disabled={isSubmittingSight} style={{ padding: '0.55rem 1.1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.85rem' }}>
{isSubmittingSight ? 'Berechne Routen...' : 'Speichern & Berechnen'}
</button>
</div>
</form>
</div>
</div>
)}
{/* MODAL: AUSGEMUSTERTE HOTELS */}
{showParkedModal && (
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(15, 23, 42, 0.6)', backdropFilter: 'blur(3px)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
<div style={{ background: '#fff', borderRadius: '16px', maxWidth: '700px', width: '100%', maxHeight: '85vh', display: 'flex', flexDirection: 'column', boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)' }}>
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.3rem', fontWeight: '800' }}>📦 Ausgemusterte Hotels</h3>
<p style={{ margin: 0, fontSize: '0.85rem', color: '#64748b' }}>Diese Hotels sind aktuell nicht Teil deiner Timeline, bleiben aber gespeichert.</p>
</div>
<button onClick={() => setShowParkedModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
<div style={{ padding: '1.5rem', overflowY: 'auto' }}>
{parkedHotels.length === 0 ? (
<p style={{ textAlign: 'center', color: '#64748b', padding: '2rem 0' }}>🎉 Aktuell sind keine Hotels ausgemustert.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.8rem' }}>
{parkedHotels.map(h => (
<div key={h.id} style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '1rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '1rem', flexWrap: 'wrap' }}>
<div style={{ flex: 1, minWidth: '220px' }}>
<div style={{ fontWeight: '700', fontSize: '1rem', color: '#0f172a' }}>{h.name}</div>
<div style={{ fontSize: '0.82rem', color: '#64748b', marginTop: '0.2rem' }}>
{h.checkin_date ? `🗓️ ${formatDateGerman(h.checkin_date)} – ${formatDateGerman(h.checkout_date)}` : 'Kein Datum'}
{h.latest_price ? ` • 💰 Gesamt: ${h.latest_price.toLocaleString('de-DE')} €` : ''}
</div>
</div>
<button
onClick={() => restoreHotel(h.id)}
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.5rem 1rem', borderRadius: '6px', fontSize: '0.85rem', fontWeight: '700', cursor: 'pointer' }}
>
➕ Wieder in Trip aufnehmen
</button>
</div>
))}
</div>
)}
</div>
</div>
</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.</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>
)}
</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"
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>
<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 MIT CLUSTERING */}
{loading ? (
<p style={{ textAlign: 'center', color: '#6b7280' }}>Lade Daten...</p>
) : clusteredStops.length === 0 ? (
<div style={{ textAlign: 'center', padding: '3rem', background: '#f9fafb', borderRadius: '12px', border: '1px dashed #d1d5db' }}>
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>Keine aktiven 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' }}>
{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 (
<div key={stop.clusterKey} style={{ position: 'relative', marginBottom: '3rem' }}>
<div style={{
position: 'absolute',
left: '-47px',
top: '2px',
width: '20px',
height: '20px',
borderRadius: '50%',
background: isCluster ? '#7c3aed' : '#3b82f6',
border: '4px solid #ffffff',
boxShadow: `0 0 0 2px ${isCluster ? '#7c3aed' : '#3b82f6'}`
}} />
{/* Stop Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.8rem', flexWrap: 'wrap', gap: '0.5rem' }}>
<div style={{ fontSize: '0.95rem', fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
<span style={{ color: isCluster ? '#6d28d9' : '#2563eb' }}>📍 Stop #{stopIndex + 1}</span>
{stop.checkin_date ? (
<span style={{ background: isCluster ? '#faf5ff' : '#dbeafe', color: isCluster ? '#6b21a8' : '#1e40af', padding: '0.25rem 0.7rem', borderRadius: '9999px', fontSize: '0.82rem', border: isCluster ? '1px solid #e9d5ff' : 'none', fontWeight: '600' }}>
🗓️ {formatDateGerman(stop.checkin_date)} – {formatDateGerman(stop.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>
)}
{isCluster && (
<span style={{ background: '#f5f3ff', color: '#7c3aed', border: '1px solid #ddd6fe', padding: '0.25rem 0.7rem', borderRadius: '9999px', fontSize: '0.8rem', fontWeight: '700' }}>
⚖️ {stop.hotels.length} Optionen im Vergleich
</span>
)}
</div>
</div>
{/* Hotel Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: isCluster ? 'repeat(auto-fit, minmax(340px, 1fr))' : '1fr',
gap: '1.2rem',
background: isCluster ? '#fdfcff' : 'transparent',
padding: isCluster ? '1.2rem' : '0',
borderRadius: '16px',
border: isCluster ? '2px dashed #c4b5fd' : 'none'
}}>
{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 (
<div
key={hotel.id}
style={{
background: '#ffffff',
border: '1px solid #e5e7eb',
borderTop: isCluster ? `4px solid ${currentAccent}` : '1px solid #e5e7eb',
borderRadius: '12px',
padding: '1.2rem',
boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)',
opacity: hotel.is_active ? 1 : 0.5,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between'
}}
>
<div>
{/* Option Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
{isCluster ? (
<span style={{ fontSize: '0.75rem', fontWeight: '800', textTransform: 'uppercase', background: '#f3f4f6', color: currentAccent, padding: '0.2rem 0.5rem', borderRadius: '4px', border: '1px solid #e5e7eb' }}>
Option {String.fromCharCode(65 + hIdx)}
</span>
) : (
<div>{hotel.trip_name && <span style={{ background: '#f3f4f6', color: '#4b5563', padding: '0.2rem 0.5rem', borderRadius: '6px', fontSize: '0.75rem' }}>{hotel.trip_name}</span>}</div>
)}
<button
onClick={() => parkHotel(hotel.id, hotel.name)}
title="Dieses Hotel aus der Timeline nehmen"
style={{ background: 'transparent', border: '1px solid #cbd5e1', color: '#64748b', padding: '0.3rem 0.6rem', borderRadius: '6px', fontSize: '0.78rem', fontWeight: '600', cursor: 'pointer' }}
>
👁️‍🗨️ Nicht betrachten
</button>
</div>
{/* Hotel Body */}
<div style={{ display: 'flex', gap: '1rem', flexWrap: 'wrap', marginBottom: '0.8rem' }}>
{hotel.image_url && <img src={hotel.image_url} alt={hotel.name} style={{ width: '110px', height: '85px', objectFit: 'cover', borderRadius: '8px', flexShrink: 0 }} />}
<div style={{ flex: 1, minWidth: '180px' }}>
<h3 style={{ margin: '0 0 0.3rem 0', fontSize: '1.1rem', color: '#1f2937', lineHeight: 1.3 }}>{hotel.name}</h3>
<p style={{ margin: '0 0 0.4rem 0', fontSize: '0.8rem', color: '#6b7280' }}>📍 {hotel.address || 'Keine Adresse hinterlegt'}</p>
</div>
</div>
{/* Preis-Box */}
{totalPrice !== null ? (
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '0.8rem', background: '#ecfdf5', padding: '0.5rem 0.7rem', borderRadius: '8px', border: '1px solid #a7f3d0', alignItems: 'center' }}>
<span style={{ fontWeight: '800', color: '#047857', fontSize: '0.92rem' }}>
💰 Gesamt: {totalPrice.toLocaleString('de-DE')} €
</span>
<span style={{ color: '#059669', fontSize: '0.8rem', fontWeight: '600' }}>
| 🌙 {pricePerNight?.toLocaleString('de-DE')} € / N.
</span>
<span style={{ color: '#059669', fontSize: '0.8rem' }}>
| 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.
</span>
</div>
) : (
<div style={{ marginBottom: '0.8rem', fontSize: '0.82rem', color: '#6b7280', fontStyle: 'italic' }}>
💰 Noch kein Preis erfasst
</div>
)}
{/* Meta Tags */}
<div style={{ display: 'flex', gap: '0.8rem', fontSize: '0.8rem', marginBottom: '0.8rem', color: '#374151', flexWrap: 'wrap' }}>
<span>⭐ <strong>{hotel.rating || 'N/A'}</strong>/10</span>
<span>💬 {hotel.review_count || 0} Bew.</span>
{hotel.room_size_m2 && <span>📐 {hotel.room_size_m2} m²</span>}
</div>
{/* SEHENSWÜRDIGKEITEN & FAHRZEITEN FÜR DIESES HOTEL */}
<div style={{ marginTop: '0.8rem', background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.8rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.6rem' }}>
<div style={{ fontWeight: '700', fontSize: '0.85rem', color: '#334155', display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
🏛️ Ausflugsziele ({hotelSights.length})
</div>
<button
onClick={() => openSightModal(undefined, hotel.id)}
style={{ background: '#eff6ff', color: '#2563eb', border: '1px dashed #bfdbfe', padding: '0.25rem 0.6rem', borderRadius: '6px', fontSize: '0.75rem', fontWeight: '700', cursor: 'pointer' }}
>
➕ Ausflug zuordnen
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{hotelSights.length === 0 ? (
<p style={{ margin: 0, fontSize: '0.75rem', color: '#94a3b8', fontStyle: 'italic' }}>Keine Ausflugsziele zugeordnet.</p>
) : (
hotelSights.map(sight => {
const assignment = (sight.assignments || []).find(a => a.hotel_id === hotel.id);
return (
<div key={sight.id} style={{ background: '#ffffff', border: '1px solid #e2e8f0', borderRadius: '6px', padding: '0.5rem 0.7rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', flex: 1, minWidth: '160px', overflow: 'hidden' }}>
<span style={{ fontSize: '1.1rem', flexShrink: 0 }}>{sight.category_icon || '🏛️'}</span>
<div style={{ overflow: 'hidden' }}>
<div style={{ fontWeight: '700', fontSize: '0.82rem', color: '#0f172a', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{sight.name}
</div>
{sight.notes && (
<div style={{ fontSize: '0.72rem', color: '#64748b', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{sight.notes}
</div>
)}
</div>
</div>
{assignment?.distance_km !== null && assignment?.distance_km !== undefined ? (
<div style={{ background: '#ecfdf5', border: '1px solid #a7f3d0', color: '#047857', padding: '0.2rem 0.5rem', borderRadius: '6px', fontSize: '0.78rem', fontWeight: '700', whiteSpace: 'nowrap' }}>
🚗 {assignment.distance_km} km • {assignment.duration_minutes} Min.
</div>
) : (
<span style={{ fontSize: '0.72rem', color: '#94a3b8' }}>📍 Route n.v.</span>
)}
<div style={{ display: 'flex', gap: '0.3rem', alignItems: 'center' }}>
<a
href={sight.address.startsWith('http') ? sight.address : `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(sight.name + ' ' + sight.address)}`}
target="_blank"
rel="noopener noreferrer"
style={{ color: '#2563eb', background: '#eff6ff', border: '1px solid #bfdbfe', padding: '0.2rem 0.4rem', borderRadius: '4px', fontSize: '0.72rem', textDecoration: 'none', fontWeight: '600' }}
>
Maps ↗
</a>
<button
onClick={() => openSightModal(sight)}
title="Bearbeiten"
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', cursor: 'pointer', fontSize: '0.75rem', padding: '0.2rem 0.4rem', borderRadius: '4px' }}
>
✏️
</button>
<button
onClick={() => deleteSight(sight.id)}
title="Löschen"
style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer', fontSize: '0.8rem', padding: '0.1rem 0.3rem' }}
>
✕
</button>
</div>
</div>
);
})
)}
</div>
</div>
</div>
{/* Action Buttons */}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.6rem', paddingTop: '0.6rem', borderTop: '1px solid #f3f4f6' }}>
<button
onClick={() => openPriceHistoryModal(hotel)}
style={{ background: '#0284c7', color: '#ffffff', border: 'none', padding: '0.4rem 0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.78rem' }}
>
📈 Preishistorie
</button>
<button
onClick={() => scanSingleHotel(hotel.id)}
disabled={scanningHotelId === hotel.id || !hotel.is_active}
style={{ background: '#8b5cf6', color: '#ffffff', border: 'none', padding: '0.4rem 0.7rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.78rem', opacity: (!hotel.is_active || scanningHotelId === hotel.id) ? 0.6 : 1 }}
>
{scanningHotelId === hotel.id ? '🔄 Scanne...' : '🤖 KI-Reviews'}
</button>
<a href={hotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ color: '#2563eb', textDecoration: 'none', fontSize: '0.78rem', alignSelf: 'center', marginLeft: 'auto', fontWeight: '500' }}>
🔗 Booking ↗
</a>
</div>
</div>
);
})}
</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"]
}
```