Files
travel-app/llm_context.txt
2026-08-16 10:00:13 +00:00

3096 lines
130 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]/ratings/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/[id]/members/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]/ratings/route.ts ---
```
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
export const dynamic = 'force-dynamic';
// POST: Bewertung speichern oder aktualisieren
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const { member_id, grade, comment } = await req.json();
if (!member_id || !grade) {
return NextResponse.json({ error: 'Mitreisender und Note erforderlich' }, { status: 400 });
}
const res = await query(
`INSERT INTO hotel_ratings (hotel_id, member_id, grade, comment, updated_at)
VALUES ($1, $2, $3, $4, NOW())
ON CONFLICT (hotel_id, member_id)
DO UPDATE SET grade = EXCLUDED.grade, comment = EXCLUDED.comment, updated_at = NOW()
RETURNING *`,
[params.id, member_id, grade, comment ? comment.trim() : null]
);
return NextResponse.json({ success: true, rating: res.rows[0] });
} catch (e: any) {
return NextResponse.json({ success: false, error: e.message }, { 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.genius_price_numeric::float AS latest_genius_price,
ph.nights AS latest_nights,
ph.persons AS latest_persons,
bp.best_price,
hr.avg_grade,
hr.total_votes,
hr.ratings_json
FROM hotels h
LEFT JOIN trips t ON h.trip_id = t.id
LEFT JOIN LATERAL (
SELECT price_numeric, genius_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
LEFT JOIN LATERAL (
SELECT MIN(price_numeric)::float AS best_price
FROM hotel_price_history
WHERE hotel_id::text = h.id::text AND price_numeric > 0
) bp ON true
LEFT JOIN LATERAL (
SELECT
ROUND(AVG(hr_sub.grade), 1)::float AS avg_grade,
COUNT(hr_sub.id)::int AS total_votes,
COALESCE(
json_agg(
json_build_object(
'member_id', hr_sub.member_id,
'name', tm.name,
'color', tm.color,
'grade', hr_sub.grade,
'comment', hr_sub.comment
)
ORDER BY hr_sub.updated_at DESC
),
'[]'
) AS ratings_json
FROM hotel_ratings hr_sub
JOIN trip_members tm ON hr_sub.member_id = tm.id
WHERE hr_sub.hotel_id = h.id
) hr ON true
`);
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/[id]/members/route.ts ---
```
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
export const dynamic = 'force-dynamic';
// GET: Alle Mitreisenden eines Trips laden
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
try {
const res = await query(
`SELECT * FROM trip_members WHERE trip_id = $1 ORDER BY created_at ASC`,
[params.id]
);
return NextResponse.json({ success: true, members: res.rows });
} catch (e: any) {
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
}
}
// POST: Neuen Mitreisenden hinzufügen
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
try {
const { name, color } = await req.json();
if (!name) {
return NextResponse.json({ error: 'Name ist erforderlich' }, { status: 400 });
}
const defaultColors = ['#2563eb', '#db2777', '#059669', '#d97706', '#7c3aed', '#0891b2'];
const randomColor = color || defaultColors[Math.floor(Math.random() * defaultColors.length)];
const res = await query(
`INSERT INTO trip_members (trip_id, name, color)
VALUES ($1, $2, $3)
RETURNING *`,
[params.id, name.trim(), randomColor]
);
return NextResponse.json({ success: true, member: res.rows[0] });
} catch (e: any) {
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
}
}
// DELETE: Mitreisenden entfernen
export async function DELETE(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const memberId = searchParams.get('memberId');
if (!memberId) return NextResponse.json({ error: 'memberId fehlt' }, { status: 400 });
await query(`DELETE FROM trip_members WHERE id = $1`, [memberId]);
return NextResponse.json({ success: true });
} catch (e: any) {
return NextResponse.json({ success: false, error: e.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);
// Prüfen, ob das Genius-Badge vom Scraper erkannt wurde (z.B. body.hasGenius oder body.isGenius)
const hasGeniusBadge = Boolean(body.hasGenius || body.isGenius || body.genius);
// Wenn Genius vorhanden ist: 10% abziehen und runden
const geniusPriceNumeric = (numericPrice !== null && hasGeniusBadge)
? Math.round(numericPrice * 0.9)
: null;
if (numericPrice !== null && numericPrice > 0) {
try {
await query(
`INSERT INTO hotel_price_history (hotel_id, price_numeric, genius_price_numeric, price_raw, currency, nights, persons, tracked_at)
VALUES ($1::uuid, $2, $3, $4, 'EUR', $5, $6, NOW())`,
[hotelId, numericPrice, geniusPriceNumeric, 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';
// ==========================================
// TYPEN & INTERFACES
// ==========================================
interface Trip {
id: string;
name: string;
}
interface TripMember {
id: string;
trip_id: string;
name: string;
color: string;
created_at?: string;
}
interface RatingEntry {
member_id: string;
name: string;
color: string;
grade: number;
comment?: string | null;
}
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_genius_price?: number | null;
latest_nights?: number | null;
latest_persons?: number | null;
best_price?: number | null;
best_price_per_night?: number | null;
avg_grade?: number | null;
total_votes?: number | null;
ratings_json?: RatingEntry[];
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() {
// ==========================================
// STATES
// ==========================================
const [trips, setTrips] = useState<Trip[]>([]);
const [activeTripId, setActiveTripId] = useState<string>('ALL');
const [tripMembers, setTripMembers] = useState<TripMember[]>([]);
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);
// Mitreisende Modal
const [showMembersModal, setShowMembersModal] = useState(false);
const [newMemberName, setNewMemberName] = useState('');
const [isAddingMember, setIsAddingMember] = useState(false);
// Hotel-Bewertungs-Modal (Schulnoten 1 bis 6)
const [ratingHotel, setRatingHotel] = useState<Hotel | null>(null);
const [selectedMemberId, setSelectedMemberId] = useState<string>('');
const [selectedGrade, setSelectedGrade] = useState<number | null>(null);
const [ratingComment, setRatingComment] = useState('');
const [isSubmittingRating, setIsSubmittingRating] = useState(false);
// Ausflugsziele Modals
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);
// ==========================================
// EFFEKTE & DATEN LADEN
// ==========================================
useEffect(() => {
loadData();
}, []);
useEffect(() => {
if (activeTripId !== 'ALL') {
loadMembers(activeTripId);
} else {
setTripMembers([]);
}
}, [activeTripId, trips]);
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 loadMembers = async (tripId: string) => {
try {
const res = await fetch(`/api/trips/${tripId}/members`);
if (res.ok) {
const data = await res.json();
setTripMembers(data.members || []);
}
} catch (e) {
console.error('Fehler beim Laden der Mitreisenden:', e);
}
};
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);
}
};
// ==========================================
// HANDLERS: REISEN & MITREISENDE
// ==========================================
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 handleAddMember = async (e: React.FormEvent) => {
e.preventDefault();
if (!newMemberName.trim() || activeTripId === 'ALL') return;
setIsAddingMember(true);
try {
const res = await fetch(`/api/trips/${activeTripId}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newMemberName.trim() })
});
const data = await res.json();
if (data.success) {
setTripMembers([...tripMembers, data.member]);
setNewMemberName('');
loadData();
}
} catch (err: any) {
alert('Fehler beim Hinzufügen: ' + err.message);
} finally {
setIsAddingMember(false);
}
};
const handleDeleteMember = async (memberId: string) => {
if (!confirm('Mitreisenden wirklich entfernen? Alle zugehörigen Noten werden gelöscht.')) return;
try {
const res = await fetch(`/api/trips/${activeTripId}/members?memberId=${memberId}`, {
method: 'DELETE'
});
if (res.ok) {
setTripMembers(tripMembers.filter(m => m.id !== memberId));
loadData();
}
} catch (err: any) {
console.error('Fehler beim Löschen:', err);
}
};
// ==========================================
// HANDLERS: BEWERTUNG (1 bis 6)
// ==========================================
const openRatingModal = (hotel: Hotel) => {
setRatingHotel(hotel);
setSelectedMemberId(tripMembers[0]?.id || '');
setSelectedGrade(null);
setRatingComment('');
if (tripMembers.length > 0 && hotel.ratings_json) {
const existing = hotel.ratings_json.find(r => r.member_id === tripMembers[0].id);
if (existing) {
setSelectedGrade(existing.grade);
setRatingComment(existing.comment || '');
}
}
};
const handleSelectMemberForRating = (mId: string) => {
setSelectedMemberId(mId);
if (ratingHotel?.ratings_json) {
const existing = ratingHotel.ratings_json.find(r => r.member_id === mId);
if (existing) {
setSelectedGrade(existing.grade);
setRatingComment(existing.comment || '');
} else {
setSelectedGrade(null);
setRatingComment('');
}
}
};
const handleSaveRating = async (e: React.FormEvent) => {
e.preventDefault();
if (!ratingHotel || !selectedMemberId || !selectedGrade) {
return alert('Bitte wähle Mitreisenden und Note aus!');
}
setIsSubmittingRating(true);
try {
const res = await fetch(`/api/hotels/${ratingHotel.id}/ratings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
member_id: selectedMemberId,
grade: selectedGrade,
comment: ratingComment
})
});
const data = await res.json();
if (data.success) {
await loadData();
setRatingHotel(null);
} else {
alert('Fehler: ' + data.error);
}
} catch (err: any) {
alert('Fehler beim Speichern: ' + err.message);
} finally {
setIsSubmittingRating(false);
}
};
// ==========================================
// HANDLERS: HOTELS, PREISE, AUSFLÜGE
// ==========================================
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 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) => {
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);
}
};
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);
}
};
// ==========================================
// FILTER & CLUSTERING MIT AUTOMATISCHEM RANKING
// ==========================================
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;
const activeTripObj = trips.find(t => String(t.id) === String(activeTripId));
return (
<div style={{ maxWidth: '1100px', margin: '0 auto', padding: '2rem 1rem', fontFamily: 'system-ui, -apple-system, sans-serif', color: '#1f2937' }}>
{/* HEADER */}
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
<h1 style={{ fontSize: '2.2rem', marginBottom: '0.5rem', fontWeight: '800', color: '#0f172a' }}>
📍 Reise-Ablauf & Freunde-Ranking
</h1>
<p style={{ color: '#64748b', margin: 0, fontSize: '0.95rem' }}>
Hotelvergleich nach Schulnoten (1–6), Bestpreise, Routen & Fahrzeiten
</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.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer' }}
>
➕ Neues Hotel hinzufügen
</button>
<button
onClick={() => openSightModal()}
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer' }}
>
🏛️ Ausflug anlegen
</button>
<button
onClick={() => setShowManageSightsModal(true)}
style={{ background: '#f1f5f9', color: '#334155', border: '1px solid #cbd5e1', padding: '0.65rem 1.2rem', 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.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.4rem' }}
>
📦 Ausgemustert
{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.65rem 1.2rem', borderRadius: '8px', fontWeight: '700', cursor: 'pointer', opacity: scanningAllPrices ? 0.7 : 1 }}
>
{scanningAllPrices ? '🔄 Scanne...' : '📊 Preise aktualisieren'}
</button>
</div>
</header>
{/* REISE TABS */}
<div style={{ display: 'flex', gap: '0.5rem', borderBottom: '2px solid #e2e8f0', marginBottom: '1.5rem', overflowX: 'auto', paddingBottom: '0.5rem' }}>
<button
onClick={() => setActiveTripId('ALL')}
style={{
padding: '0.6rem 1.2rem',
borderRadius: '8px',
border: 'none',
background: activeTripId === 'ALL' ? '#2563eb' : '#f1f5f9',
color: activeTripId === 'ALL' ? '#ffffff' : '#475569',
fontWeight: 'bold',
cursor: 'pointer',
whiteSpace: 'nowrap'
}}
>
🌐 Alle 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' : '#f1f5f9',
color: String(activeTripId).toLowerCase() === String(trip.id).toLowerCase() ? '#ffffff' : '#475569',
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>
{/* MITREISENDE SECTION */}
{activeTripId !== 'ALL' && activeTripObj && (
<div style={{
background: '#ffffff',
border: '1px solid #e2e8f0',
borderRadius: '12px',
padding: '0.85rem 1.2rem',
marginBottom: '2rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: '1rem',
boxShadow: '0 2px 4px rgba(0,0,0,0.02)'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem', flexWrap: 'wrap' }}>
<span style={{ fontWeight: '800', fontSize: '0.95rem', color: '#1e293b' }}>
👥 Mitreisende ({tripMembers.length}):
</span>
{tripMembers.length === 0 ? (
<span style={{ fontSize: '0.82rem', color: '#94a3b8', fontStyle: 'italic' }}>
Noch keine Freunde hinterlegt
</span>
) : (
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap' }}>
{tripMembers.map(m => (
<span
key={m.id}
style={{
background: '#f8fafc',
border: '1px solid #cbd5e1',
padding: '0.2rem 0.6rem',
borderRadius: '9999px',
fontSize: '0.8rem',
fontWeight: '700',
color: '#334155',
display: 'inline-flex',
alignItems: 'center',
gap: '0.35rem'
}}
>
<span style={{ width: '16px', height: '16px', borderRadius: '50%', background: m.color || '#2563eb', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.65rem', fontWeight: '800' }}>
{m.name[0]}
</span>
{m.name}
</span>
))}
</div>
)}
</div>
<button
onClick={() => setShowMembersModal(true)}
style={{
background: '#eff6ff',
color: '#2563eb',
border: '1px dashed #93c5fd',
padding: '0.4rem 0.8rem',
borderRadius: '6px',
fontWeight: '700',
fontSize: '0.8rem',
cursor: 'pointer'
}}
>
⚙️ Mitreisende verwalten
</button>
</div>
)}
{/* TIMELINE MIT AUTOMATISCHEM RANKING */}
{loading ? (
<p style={{ textAlign: 'center', color: '#64748b', padding: '3rem 0' }}>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', fontWeight: 'bold' }}>
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;
const sortedHotels = [...stop.hotels].sort((a, b) => {
const gradeA = a.avg_grade !== null && a.avg_grade !== undefined ? Number(a.avg_grade) : 99;
const gradeB = b.avg_grade !== null && b.avg_grade !== undefined ? Number(b.avg_grade) : 99;
if (gradeA !== gradeB) return gradeA - gradeB;
return (a.latest_price || 99999) - (b.latest_price || 99999);
});
return (
<div key={stop.clusterKey} style={{ position: 'relative', marginBottom: '3.5rem' }}>
<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.9rem', 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 Freunde-Ranking
</span>
)}
</div>
</div>
{/* Hotel Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: isCluster ? 'repeat(auto-fit, minmax(340px, 1fr))' : '1fr',
gap: '1.3rem',
background: isCluster ? '#fdfcff' : 'transparent',
padding: isCluster ? '1.2rem' : '0',
borderRadius: '16px',
border: isCluster ? '2px dashed #c4b5fd' : 'none'
}}>
{sortedHotels.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 hasGrade = hotel.avg_grade !== null && hotel.avg_grade !== undefined;
const rankBadgeClass = hIdx === 0 && hasGrade ? '🥇 Rang #1 · Favorit' : hIdx === 1 && hasGrade ? '🥈 Rang #2' : `🥉 Rang #${hIdx + 1}`;
const rankTopBorder = hIdx === 0 && hasGrade ? '#f59e0b' : hIdx === 1 && hasGrade ? '#94a3b8' : '#cbd5e1';
let gradeColor = '#15803d';
let gradeBg = '#f0fdf4';
let gradeBorder = '#bbf7d0';
let gradeDesc = 'Sehr gut';
if (hasGrade) {
const g = Number(hotel.avg_grade);
if (g > 1.5 && g <= 2.5) { gradeColor = '#059669'; gradeBg = '#ecfdf5'; gradeBorder = '#a7f3d0'; gradeDesc = 'Gut'; }
else if (g > 2.5 && g <= 3.5) { gradeColor = '#d97706'; gradeBg = '#fffbeb'; gradeBorder = '#fde68a'; gradeDesc = 'Befriedigend'; }
else if (g > 3.5 && g <= 4.5) { gradeColor = '#ea580c'; gradeBg = '#fff7ed'; gradeBorder = '#fed7aa'; gradeDesc = 'Ausreichend'; }
else if (g > 4.5) { gradeColor = '#dc2626'; gradeBg = '#fef2f2'; gradeBorder = '#fecaca'; gradeDesc = 'Mangelhaft'; }
}
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 #e2e8f0',
borderTop: isCluster ? `4px solid ${rankTopBorder}` : '1px solid #e2e8f0',
borderRadius: '14px',
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 / Ranking Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.65rem' }}>
{isCluster ? (
<span style={{
fontSize: '0.78rem',
fontWeight: '800',
textTransform: 'uppercase',
background: hIdx === 0 && hasGrade ? '#fef3c7' : '#f1f5f9',
color: hIdx === 0 && hasGrade ? '#92400e' : '#475569',
padding: '0.2rem 0.55rem',
borderRadius: '6px',
border: hIdx === 0 && hasGrade ? '1px solid #fde68a' : '1px solid #e2e8f0'
}}>
{hasGrade ? rankBadgeClass : `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)}
title="Dieses Hotel aus der Timeline nehmen"
style={{ background: 'transparent', border: '1px solid #cbd5e1', color: '#64748b', padding: '0.25rem 0.55rem', borderRadius: '6px', fontSize: '0.75rem', fontWeight: '600', cursor: 'pointer' }}
>
👁️‍🗨️ Nicht betrachten
</button>
</div>
{/* Hotel Main Body */}
<div style={{ display: 'flex', gap: '0.9rem', marginBottom: '0.8rem' }}>
{hotel.image_url && (
<img src={hotel.image_url} alt={hotel.name} style={{ width: '100px', height: '80px', objectFit: 'cover', borderRadius: '8px', flexShrink: 0 }} />
)}
<div style={{ flex: 1, minWidth: '160px' }}>
<h3 style={{ margin: '0 0 0.25rem 0', fontSize: '1.05rem', color: '#0f172a', lineHeight: 1.3 }}>{hotel.name}</h3>
<p style={{ margin: '0 0 0.3rem 0', fontSize: '0.78rem', color: '#64748b' }}>📍 {hotel.address || 'Keine Adresse'}</p>
<div style={{ fontSize: '0.75rem', color: '#475569' }}>
⭐ <strong>{hotel.rating || 'N/A'}</strong>/10 · {hotel.review_count || 0} Bew.
</div>
</div>
</div>
{/* NOTENBOX: FREUNDE BEWERTUNG */}
<div style={{
background: gradeBg,
border: `1px solid ${gradeBorder}`,
borderLeft: `5px solid ${gradeColor}`,
borderRadius: '10px',
padding: '0.65rem 0.85rem',
marginBottom: '0.75rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<div style={{ background: gradeColor, color: '#fff', fontSize: '1.2rem', fontWeight: '900', padding: '0.2rem 0.5rem', borderRadius: '6px', lineHeight: 1 }}>
{hasGrade ? Number(hotel.avg_grade).toFixed(1) : '–'}
</div>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span style={{ fontSize: '0.8rem', fontWeight: '800', color: '#0f172a' }}>
{hasGrade ? `${gradeDesc} (⌀ Note)` : 'Noch keine Note'}
</span>
<span style={{ fontSize: '0.72rem', color: '#64748b' }}>
{hotel.total_votes || 0} von {tripMembers.length || 0} Stimmen
</span>
</div>
</div>
<button
onClick={() => openRatingModal(hotel)}
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.35rem 0.75rem', borderRadius: '6px', fontSize: '0.78rem', fontWeight: '700', cursor: 'pointer' }}
>
✍️ Bewerten
</button>
</div>
{/* MITREISENDE ABSTIMMUNGS-STATUS */}
{tripMembers.length > 0 && (
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '0.5rem 0.7rem', marginBottom: '0.75rem' }}>
<div style={{ fontSize: '0.7rem', fontWeight: '700', color: '#64748b', marginBottom: '0.3rem', display: 'flex', justifyContent: 'space-between' }}>
<span>STIMMEN DER FREUNDE:</span>
<span>{hotel.ratings_json?.length || 0}/{tripMembers.length} abgegeben</span>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.3rem' }}>
{tripMembers.map(m => {
const vote = hotel.ratings_json?.find(r => r.member_id === m.id);
return (
<span
key={m.id}
style={{
fontSize: '0.72rem',
padding: '0.1rem 0.4rem',
borderRadius: '4px',
fontWeight: '600',
background: vote ? '#ecfdf5' : '#f1f5f9',
color: vote ? '#065f46' : '#94a3b8',
border: vote ? '1px solid #a7f3d0' : '1px dashed #cbd5e1'
}}
>
{vote ? `✓ ${m.name}: ${Number(vote.grade).toFixed(1)}` : `⏳ ${m.name}`}
</span>
);
})}
</div>
</div>
)}
{/* MINI-KOMMENTARE VORSCHAU */}
{hotel.ratings_json && hotel.ratings_json.filter(r => r.comment).length > 0 && (
<div style={{ background: '#fafafa', borderTop: '1px dashed #e2e8f0', paddingTop: '0.4rem', marginBottom: '0.75rem', fontSize: '0.75rem' }}>
{hotel.ratings_json.filter(r => r.comment).slice(0, 2).map((r, cIdx) => (
<div key={cIdx} style={{ display: 'flex', gap: '0.35rem', marginBottom: '0.2rem', alignItems: 'baseline' }}>
<span style={{ fontWeight: '700', color: '#334155' }}>{r.name}:</span>
<span style={{ color: '#64748b', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
„{r.comment}“
</span>
</div>
))}
</div>
)}
{/* PREIS-BOX MIT BESTPREIS-VERGLEICH & GENIUS */}
{totalPrice !== null ? (
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '10px', padding: '0.6rem 0.8rem', marginBottom: '0.8rem', display: 'flex', flexDirection: 'column', gap: '0.35rem' }}>
{hotel.latest_genius_price ? (
<>
{/* Durchgestrichener Normalpreis bei aktivem Genius */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: '0.76rem', color: '#64748b' }}>
<span>Regulär: <span style={{ textDecoration: 'line-through' }}>{totalPrice.toLocaleString('de-DE')} €</span></span>
<span style={{ background: '#16a34a', color: '#fff', fontSize: '0.68rem', fontWeight: '800', padding: '0.1rem 0.35rem', borderRadius: '4px' }}>-10% GENIUS</span>
</div>
{/* Aktive Genius-Zeile mit Tiefstpreis-Badge */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.3rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.78rem', fontWeight: '700', color: '#003580' }}>Genius:</span>
<span style={{ fontWeight: '900', color: '#003580', fontSize: '0.95rem' }}>
💙 {Number(hotel.latest_genius_price).toLocaleString('de-DE')} €
</span>
<span style={{ color: '#0052b4', fontSize: '0.76rem' }}>
(🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N.)
</span>
</div>
{hotel.best_price && Number(hotel.latest_genius_price) > Number(hotel.best_price) ? (
<span style={{ background: '#fee2e2', color: '#991b1b', border: '1px solid #fecaca', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '700' }}>
▲ +{(Number(hotel.latest_genius_price) - Number(hotel.best_price)).toLocaleString('de-DE')} €
</span>
) : (
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '800' }}>
🔥 Tiefstpreis
</span>
)}
</div>
</>
) : (
/* Standard-Preiszeile mit Tiefstpreis-Badge */
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: '0.3rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem', flexWrap: 'wrap' }}>
<span style={{ fontSize: '0.78rem', fontWeight: '700', color: '#475569' }}>Aktuell:</span>
<span style={{ fontWeight: '800', color: '#0f172a', fontSize: '0.92rem' }}>
💰 {totalPrice.toLocaleString('de-DE')} €
</span>
<span style={{ color: '#64748b', fontSize: '0.76rem' }}>
(🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.)
</span>
</div>
{hotel.best_price && totalPrice > Number(hotel.best_price) ? (
<span style={{ background: '#fee2e2', color: '#991b1b', border: '1px solid #fecaca', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '700' }}>
▲ +{(totalPrice - Number(hotel.best_price)).toLocaleString('de-DE')} €
</span>
) : (
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', fontSize: '0.7rem', padding: '0.1rem 0.35rem', borderRadius: '4px', fontWeight: '800' }}>
🔥 Tiefstpreis
</span>
)}
</div>
)}
{/* Allzeit-Bestpreis Zeile */}
{hotel.best_price && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', borderTop: '1px dashed #e2e8f0', paddingTop: '0.3rem', fontSize: '0.75rem' }}>
<span style={{ fontWeight: '700', color: '#059669' }}>🏆 Bestpreis:</span>
<span style={{ fontWeight: '800', color: '#047857' }}>
{Number(hotel.best_price).toLocaleString('de-DE')} €
</span>
{hotel.best_price_per_night && (
<span style={{ color: '#64748b', fontSize: '0.72rem' }}>
(🌙 {Math.round(Number(hotel.best_price_per_night)).toLocaleString('de-DE')} € / N.)
</span>
)}
</div>
)}
</div>
) : (
<div style={{ marginBottom: '0.8rem', fontSize: '0.8rem', color: '#6b7280', fontStyle: 'italic' }}>
💰 Noch kein Preis erfasst
</div>
)}
{/* SEHENSWÜRDIGKEITEN & FAHRZEITEN */}
<div style={{ background: '#f8fafc', border: '1px solid #e2e8f0', borderRadius: '8px', padding: '0.7rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.4rem' }}>
<span style={{ fontWeight: '700', fontSize: '0.8rem', color: '#334155' }}>
🏛️ Ausflugsziele ({hotelSights.length})
</span>
<button
onClick={() => openSightModal(undefined, hotel.id)}
style={{ background: '#eff6ff', color: '#2563eb', border: '1px dashed #bfdbfe', padding: '0.2rem 0.5rem', borderRadius: '4px', fontSize: '0.72rem', fontWeight: '700', cursor: 'pointer' }}
>
➕ Zuordnen
</button>
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.3rem' }}>
{hotelSights.length === 0 ? (
<span style={{ fontSize: '0.74rem', color: '#94a3b8', fontStyle: 'italic' }}>Keine Ausflugsziele zugeordnet.</span>
) : (
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.4rem 0.6rem', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '0.4rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem', overflow: 'hidden' }}>
<span>{sight.category_icon || '🏛️'}</span>
<span style={{ fontWeight: '700', fontSize: '0.78rem', color: '#0f172a', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{sight.name}
</span>
</div>
{assignment?.distance_km !== null && assignment?.distance_km !== undefined ? (
<span style={{ background: '#ecfdf5', border: '1px solid #a7f3d0', color: '#047857', padding: '0.15rem 0.4rem', borderRadius: '4px', fontSize: '0.72rem', fontWeight: '700', whiteSpace: 'nowrap' }}>
🚗 {assignment.distance_km} km · {assignment.duration_minutes} Min.
</span>
) : null}
</div>
);
})
)}
</div>
</div>
</div>
{/* Card Actions Footer */}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginTop: '0.8rem', paddingTop: '0.6rem', borderTop: '1px solid #f1f5f9', alignItems: 'center' }}>
<button
onClick={() => openRatingModal(hotel)}
style={{ background: '#f1f5f9', border: '1px solid #cbd5e1', padding: '0.35rem 0.65rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem', color: '#334155' }}
>
💬 Stimmen ({hotel.ratings_json?.length || 0})
</button>
<button
onClick={() => openPriceHistoryModal(hotel)}
style={{ background: '#0284c7', color: '#ffffff', border: 'none', padding: '0.35rem 0.65rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.75rem' }}
>
📈 Preishistorie
</button>
<button
onClick={() => scanSingleHotel(hotel.id)}
disabled={scanningHotelId === hotel.id || !hotel.is_active}
style={{ background: '#8b5cf6', color: '#ffffff', border: 'none', padding: '0.35rem 0.65rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.75rem', 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', marginLeft: 'auto', fontWeight: '700' }}>
Booking ↗
</a>
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
)}
{/* MODAL 1: STIMMABGABE */}
{ratingHotel && (
<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: '560px', 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' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.15rem', fontWeight: '800', color: '#0f172a' }}>Hotel bewerten</h3>
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>{ratingHotel.name}</p>
</div>
<button onClick={() => setRatingHotel(null)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
<form onSubmit={handleSaveRating} style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1.1rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.4rem' }}>
1. Wer bewertet? *
</label>
{tripMembers.length === 0 ? (
<p style={{ margin: 0, fontSize: '0.8rem', color: '#dc2626' }}>
Bitte lege zuerst Mitreisende in der Reise-Leiste an!
</p>
) : (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(110px, 1fr))', gap: '0.4rem' }}>
{tripMembers.map(m => {
const isSelected = selectedMemberId === m.id;
const hasVoted = ratingHotel.ratings_json?.some(r => r.member_id === m.id);
return (
<div
key={m.id}
onClick={() => handleSelectMemberForRating(m.id)}
style={{
border: isSelected ? '2px solid #2563eb' : '1px solid #e2e8f0',
background: isSelected ? '#eff6ff' : '#f8fafc',
borderRadius: '8px',
padding: '0.45rem',
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
gap: '0.4rem'
}}
>
<span style={{ width: '18px', height: '18px', borderRadius: '50%', background: m.color || '#2563eb', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.65rem', fontWeight: '800' }}>
{m.name[0]}
</span>
<div>
<div style={{ fontSize: '0.8rem', fontWeight: isSelected ? '800' : '600', color: isSelected ? '#1e40af' : '#1e293b' }}>
{m.name}
</div>
<div style={{ fontSize: '0.65rem', color: hasVoted ? '#059669' : '#94a3b8' }}>
{hasVoted ? 'Bereits bewertet' : 'Offen'}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
2. Schulnote (1 = Sehr gut bis 6 = Ungenügend) *
</label>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(6, 1fr)', gap: '0.4rem' }}>
{[1, 2, 3, 4, 5, 6].map(gradeNum => {
const isSelected = selectedGrade === gradeNum;
return (
<button
type="button"
key={gradeNum}
onClick={() => setSelectedGrade(gradeNum)}
style={{
padding: '0.6rem 0.2rem',
borderRadius: '8px',
fontWeight: '800',
fontSize: '1.1rem',
cursor: 'pointer',
border: isSelected ? '2px solid #2563eb' : '1px solid #cbd5e1',
background: isSelected ? '#2563eb' : '#f8fafc',
color: isSelected ? '#ffffff' : '#1e293b'
}}
>
{gradeNum}
</button>
);
})}
</div>
</div>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '800', color: '#1e293b', marginBottom: '0.3rem' }}>
3. Notiz / Begründung (optional)
</label>
<input
type="text"
placeholder="z. B. Strandnähe top, schöner Balkon..."
value={ratingComment}
onChange={e => setRatingComment(e.target.value)}
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', fontSize: '0.85rem' }}
/>
</div>
<div style={{ display: 'flex', gap: '0.6rem', justifyContent: 'flex-end', marginTop: '0.4rem' }}>
<button type="button" onClick={() => setRatingHotel(null)} style={{ padding: '0.55rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.85rem' }}>
Abbrechen
</button>
<button type="submit" disabled={isSubmittingRating} style={{ padding: '0.55rem 1.1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.85rem' }}>
{isSubmittingRating ? 'Speichere...' : '💾 Note speichern'}
</button>
</div>
<div style={{ borderTop: '1px solid #e2e8f0', paddingTop: '0.8rem', marginTop: '0.4rem' }}>
<h4 style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', fontWeight: '800', color: '#1e293b' }}>
Bisherige Bewertungen ({ratingHotel.ratings_json?.length || 0}):
</h4>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{(!ratingHotel.ratings_json || ratingHotel.ratings_json.length === 0) ? (
<span style={{ fontSize: '0.78rem', color: '#94a3b8', fontStyle: 'italic' }}>Noch keine Stimmen hinterlegt.</span>
) : (
ratingHotel.ratings_json.map((r, idx) => (
<div key={idx} style={{ background: '#f8fafc', border: '1px solid #e2e8f0', padding: '0.45rem 0.7rem', borderRadius: '6px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<span style={{ fontWeight: '700', fontSize: '0.8rem', color: '#0f172a' }}>👤 {r.name}: </span>
<span style={{ fontSize: '0.78rem', color: '#475569' }}>{r.comment || '(Kein Kommentar)'}</span>
</div>
<span style={{ background: '#ecfdf5', color: '#047857', border: '1px solid #a7f3d0', padding: '0.1rem 0.4rem', borderRadius: '4px', fontWeight: '800', fontSize: '0.75rem' }}>
Note {Number(r.grade).toFixed(1)}
</span>
</div>
))
)}
</div>
</div>
</form>
</div>
</div>
)}
{/* MODAL 2: MITREISENDE VERWALTEN */}
{showMembersModal && (
<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: '450px', 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.2rem', fontWeight: '800' }}>👥 Mitreisende verwalten</h3>
<p style={{ margin: 0, fontSize: '0.8rem', color: '#64748b' }}>Für: {activeTripObj?.name}</p>
</div>
<button onClick={() => setShowMembersModal(false)} style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
</div>
<div style={{ padding: '1.3rem 1.5rem', overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<form onSubmit={handleAddMember} style={{ display: 'flex', gap: '0.5rem' }}>
<input
type="text"
required
placeholder="Name (z. B. Alex)..."
value={newMemberName}
onChange={e => setNewMemberName(e.target.value)}
style={{ flex: 1, padding: '0.55rem', borderRadius: '6px', border: '1px solid #cbd5e1', fontSize: '0.85rem' }}
/>
<button
type="submit"
disabled={isAddingMember}
style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.55rem 1rem', borderRadius: '6px', fontWeight: '700', fontSize: '0.85rem', cursor: 'pointer' }}
>
➕ Hinzufügen
</button>
</form>
<div style={{ fontSize: '0.8rem', fontWeight: '700', color: '#475569' }}>Aktuelle Mitreisende:</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.4rem' }}>
{tripMembers.length === 0 ? (
<p style={{ margin: 0, fontSize: '0.8rem', color: '#94a3b8', fontStyle: 'italic', textAlign: 'center', padding: '1rem 0' }}>
Noch keine Mitreisenden angelegt.
</p>
) : (
tripMembers.map(m => (
<div key={m.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc', border: '1px solid #e2e8f0', padding: '0.5rem 0.8rem', borderRadius: '6px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span style={{ width: '18px', height: '18px', borderRadius: '50%', background: m.color || '#2563eb', color: '#fff', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', fontSize: '0.65rem', fontWeight: '800' }}>
{m.name[0]}
</span>
<span style={{ fontWeight: '700', fontSize: '0.85rem', color: '#1e293b' }}>{m.name}</span>
</div>
<button
onClick={() => handleDeleteMember(m.id)}
style={{ background: 'none', border: 'none', color: '#ef4444', fontWeight: '700', cursor: 'pointer', fontSize: '0.8rem' }}
>
🗑️
</button>
</div>
))
)}
</div>
</div>
<div style={{ padding: '0.8rem 1.5rem', borderTop: '1px solid #e2e8f0', display: 'flex', justifyContent: 'flex-end' }}>
<button onClick={() => setShowMembersModal(false)} style={{ padding: '0.5rem 1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', fontWeight: '700', cursor: 'pointer', fontSize: '0.85rem' }}>
Fertig
</button>
</div>
</div>
</div>
)}
{/* MODAL 3: PREISHISTORIE */}
{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>
)}
{/* 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 nicht in der 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>
)}
{/* MODAL: AUSFLÜGE 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 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' }}>
<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' }}>
{assignedCount > 0 ? (
<span style={{ background: '#dbeafe', color: '#1e40af', padding: '0.15rem 0.5rem', borderRadius: '6px', fontWeight: '600' }}>
🏨 {assignedCount} {assignedCount === 1 ? 'Hotel' : 'Hotels'} zugeordnet
</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</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>
<div>
<label style={{ display: 'block', fontSize: '0.82rem', fontWeight: '700', marginBottom: '0.3rem' }}>🏨 Diesen Hotels zuordnen (Distanz wird 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.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. Vorab Tickets kaufen" 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>
)}
</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"]
}
```