diff --git a/llm_context.txt b/llm_context.txt index 4449139..bbf54ae 100644 --- a/llm_context.txt +++ b/llm_context.txt @@ -2,21 +2,23 @@ PROJEKTSTRUKTUR ======================================== (Ermittelt via Git unter Berücksichtigung von .gitignore) -copyChat.sh -llm_context.txt -src/app/api/hotels/[id]/prices/route.ts -src/app/api/hotels/[id]/scan/route.ts .gitignore Dockerfile README.md +copyChat.sh docker-compose.yml +llm_context.txt next.config.js -next.config.mjs package-lock.json package.json +src/app/api/attractions/[id]/route.ts +src/app/api/attractions/route.ts +src/app/api/hotels/[id]/attractions/route.ts src/app/api/hotels/[id]/history/route.ts +src/app/api/hotels/[id]/prices/route.ts src/app/api/hotels/[id]/review-summary/route.ts src/app/api/hotels/[id]/route.ts +src/app/api/hotels/[id]/scan/route.ts src/app/api/hotels/route.ts src/app/api/jobs/trigger-daily-check/route.ts src/app/api/trips/route.ts @@ -82,6 +84,15 @@ pnpm-debug.log* *.sln *.sw? +node_modules +.next +.git +.gitignore +llm_context.txt +*.log +Dockerfile +docker-compose.yml + ``` --- DATEI: Dockerfile --- @@ -203,31 +214,26 @@ services: - "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 = { - serverExternalPackages: ['bullmq', 'ioredis'], + experimental: { + serverComponentsExternalPackages: ['bullmq', 'ioredis'], + }, }; module.exports = nextConfig; ``` ---- DATEI: next.config.mjs --- -``` -/** @type {import('next').NextConfig} */ -const nextConfig = { - serverExternalPackages: ['bullmq', 'ioredis'], -}; - -export default nextConfig; - -``` - --- DATEI: package.json --- ``` { @@ -261,6 +267,556 @@ export default nextConfig; ``` +--- 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'; @@ -770,6 +1326,22 @@ interface PriceEntry { 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; @@ -797,12 +1369,19 @@ interface Hotel { checkout_date?: string | null; } +interface HotelCluster { + clusterKey: string; + checkin_date?: string | null; + checkout_date?: string | null; + hotels: Hotel[]; +} + export default function TravelDashboard() { const [trips, setTrips] = useState([]); const [activeTripId, setActiveTripId] = useState('ALL'); const [hotels, setHotels] = useState([]); + const [attractions, setAttractions] = useState([]); const [loading, setLoading] = useState(true); - const [scanningAllPrices, setScanningAllPrices] = useState(false); const [scanningHotelId, setScanningHotelId] = useState(null); @@ -811,14 +1390,25 @@ export default function TravelDashboard() { const [priceHistory, setPriceHistory] = useState([]); const [loadingPrices, setLoadingPrices] = useState(false); - // Modals + // Reise & Hotel Modals const [showAddTripModal, setShowAddTripModal] = useState(false); const [newTripName, setNewTripName] = useState(''); - const [showAddHotelModal, setShowAddHotelModal] = useState(false); const [newBookingUrl, setNewBookingUrl] = useState(''); const [newHotelName, setNewHotelName] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [showParkedModal, setShowParkedModal] = useState(false); + + // Ausflugsziele: Zentral & Mehrfachzuordnung + const [showManageSightsModal, setShowManageSightsModal] = useState(false); + const [showSightFormModal, setShowSightFormModal] = useState(false); + const [editingSight, setEditingSight] = useState(null); + const [sightName, setSightName] = useState(''); + const [sightAddress, setSightAddress] = useState(''); + const [sightIcon, setSightIcon] = useState('🏛️'); + const [sightNotes, setSightNotes] = useState(''); + const [selectedHotelIds, setSelectedHotelIds] = useState([]); + const [isSubmittingSight, setIsSubmittingSight] = useState(false); useEffect(() => { loadData(); @@ -827,7 +1417,9 @@ export default function TravelDashboard() { 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' }); + 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) => { @@ -842,20 +1434,46 @@ export default function TravelDashboard() { const loadData = async () => { setLoading(true); try { - const resTrips = await fetch('/api/trips'); - const dataTrips = await resTrips.json(); - setTrips(dataTrips.trips || []); + try { + const resTrips = await fetch('/api/trips'); + if (resTrips.ok) { + const dataTrips = await resTrips.json(); + setTrips(dataTrips.trips || []); + } + } catch (e) {} - const resHotels = await fetch('/api/hotels'); - const dataHotels = await resHotels.json(); - setHotels(dataHotels.hotels || []); - } catch (e) { - console.error('Fehler beim Laden:', e); + try { + const resHotels = await fetch('/api/hotels'); + if (resHotels.ok) { + const dataHotels = await resHotels.json(); + setHotels(dataHotels.hotels || []); + } + } catch (e) {} + + try { + const resAttractions = await fetch('/api/attractions'); + if (resAttractions.ok) { + const dataAttractions = await resAttractions.json(); + setAttractions(dataAttractions.attractions || []); + } + } catch (e) {} } finally { setLoading(false); } }; + const refreshAttractions = async () => { + try { + const res = await fetch('/api/attractions'); + const data = await res.json(); + if (data.success) { + setAttractions(data.attractions || []); + } + } catch (e) { + console.error('Fehler beim Aktualisieren der Ausflugsziele:', e); + } + }; + const openPriceHistoryModal = async (hotel: Hotel) => { setSelectedPriceHotel(hotel); setLoadingPrices(true); @@ -863,9 +1481,7 @@ export default function TravelDashboard() { try { const res = await fetch(`/api/hotels/${hotel.id}/prices`); const data = await res.json(); - if (data.success) { - setPriceHistory(data.prices || []); - } + if (data.success) setPriceHistory(data.prices || []); } catch (e) { console.error('Fehler beim Laden der Preise:', e); } finally { @@ -876,7 +1492,6 @@ export default function TravelDashboard() { const handleAddTrip = async (e: React.FormEvent) => { e.preventDefault(); if (!newTripName.trim()) return; - try { const res = await fetch('/api/trips', { method: 'POST', @@ -898,7 +1513,6 @@ export default function TravelDashboard() { 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; @@ -911,7 +1525,6 @@ export default function TravelDashboard() { trip_id: targetTripId }) }); - const data = await res.json(); if (res.ok || data.success) { setNewBookingUrl(''); @@ -926,22 +1539,108 @@ export default function TravelDashboard() { } }; - const toggleHotelActive = async (hotel: Hotel) => { + const parkHotel = async (id: string, name: string) => { try { - const updatedActive = !hotel.is_active; - await fetch(`/api/hotels/${hotel.id}`, { + await fetch(`/api/hotels/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ is_active: updatedActive }) + body: JSON.stringify({ is_active: false }) }); - setHotels(hotels.map(h => h.id === hotel.id ? { ...h, is_active: updatedActive } : h)); - } catch (e) {} + setHotels(hotels.map(h => h.id === id ? { ...h, is_active: false } : h)); + } catch (e) { + console.error('Fehler beim Ausmustern:', e); + } }; - const deleteHotel = async (id: string, name: string) => { - if (!confirm(`Hotel "${name}" löschen?`)) return; - await fetch(`/api/hotels/${id}`, { method: 'DELETE' }); - setHotels(hotels.filter(h => h.id !== id)); + const restoreHotel = async (id: string) => { + try { + await fetch(`/api/hotels/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ is_active: true }) + }); + setHotels(hotels.map(h => h.id === id ? { ...h, is_active: true } : h)); + } catch (e) { + console.error('Fehler beim Wiederaufnehmen:', e); + } + }; + + // Ausflug Modal öffnen (mit Checkboxen für Mehrfachauswahl) + const openSightModal = (sight?: Attraction, preselectedHotelId?: string) => { + if (sight) { + setEditingSight(sight); + setSightName(sight.name); + setSightAddress(sight.address); + setSightIcon(sight.category_icon || '🏛️'); + setSightNotes(sight.notes || ''); + setSelectedHotelIds((sight.assignments || []).map(a => a.hotel_id)); + } else { + setEditingSight(null); + setSightName(''); + setSightAddress(''); + setSightIcon('🏛️'); + setSightNotes(''); + setSelectedHotelIds(preselectedHotelId ? [preselectedHotelId] : []); + } + setShowSightFormModal(true); + }; + + const toggleHotelSelection = (hotelId: string) => { + setSelectedHotelIds(prev => + prev.includes(hotelId) ? prev.filter(id => id !== hotelId) : [...prev, hotelId] + ); + }; + + const handleSaveSight = async (e: React.FormEvent) => { + e.preventDefault(); + if (!sightAddress) return; + setIsSubmittingSight(true); + try { + const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId; + const payload = { + name: sightName, + address: sightAddress, + category_icon: sightIcon, + notes: sightNotes, + hotel_ids: selectedHotelIds, + trip_id: targetTripId + }; + + if (editingSight) { + const res = await fetch(`/api/attractions/${editingSight.id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error); + } else { + const res = await fetch('/api/attractions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + if (!data.success) throw new Error(data.error); + } + + await refreshAttractions(); + setShowSightFormModal(false); + } catch (err: any) { + alert('Fehler beim Speichern: ' + err.message); + } finally { + setIsSubmittingSight(false); + } + }; + + const deleteSight = async (sightId: string) => { + if (!confirm('Sehenswürdigkeit wirklich löschen?')) return; + try { + await fetch(`/api/attractions/${sightId}`, { method: 'DELETE' }); + await refreshAttractions(); + } catch (e) { + console.error('Fehler beim Löschen:', e); + } }; const triggerBatchPriceScan = async () => { @@ -968,6 +1667,7 @@ export default function TravelDashboard() { }; const currentHotels = hotels + .filter(h => h.is_active) .filter(h => { if (activeTripId === 'ALL') return true; if (!h.trip_id) return true; @@ -980,32 +1680,81 @@ export default function TravelDashboard() { return new Date(a.checkin_date).getTime() - new Date(b.checkin_date).getTime(); }); + const parkedHotels = hotels.filter(h => !h.is_active); + + const currentAttractions = attractions.filter(a => { + if (activeTripId === 'ALL') return true; + return String(a.trip_id).toLowerCase() === String(activeTripId).toLowerCase(); + }); + + const clusteredStops = currentHotels.reduce((acc, hotel) => { + const checkin = hotel.checkin_date ? hotel.checkin_date.split('T')[0] : null; + const checkout = hotel.checkout_date ? hotel.checkout_date.split('T')[0] : null; + + if (!checkin) { + acc.push({ clusterKey: `single_${hotel.id}`, checkin_date: null, checkout_date: null, hotels: [hotel] }); + return acc; + } + + const key = `${checkin}_${checkout || 'NO_CHECKOUT'}`; + const existingCluster = acc.find(c => c.clusterKey === key); + + if (existingCluster) { + existingCluster.hotels.push(hotel); + } else { + acc.push({ clusterKey: key, checkin_date: checkin, checkout_date: checkout, hotels: [hotel] }); + } + return acc; + }, []); + const pricesList = priceHistory.map(p => Number(p.price)); const minPrice = pricesList.length > 0 ? Math.min(...pricesList) : 0; const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0; const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 0; return ( -
+

📍 Reise-Ablauf & Timeline

-

Routenplanung, Preishistorie & KI-Bewertungen

- -
+

Routenplanung, Sehenswürdigkeiten, Fahrzeiten & KI-Reviews

+ +
- + + +
@@ -1025,9 +1774,8 @@ export default function TravelDashboard() { whiteSpace: 'nowrap' }} > - 🌐 Alle Hotels ({hotels.length}) + 🌐 Alle aktiven Hotels ({currentHotels.length}) - {trips.map(trip => (
+ {/* MODAL: AUSFLÜGE ZENTRAL VERWALTEN */} + {showManageSightsModal && ( +
+
+
+
+

🏛️ Zentrale Ausflugsziele

+

Sehenswürdigkeiten bearbeiten und flexibel mehreren Hotels zuweisen

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

Noch keine Ausflugsziele für diesen Trip hinterlegt.

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

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

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

📦 Ausgemusterte Hotels

+

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

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

🎉 Aktuell sind keine Hotels ausgemustert.

+ ) : ( +
+ {parkedHotels.map(h => ( +
+
+
{h.name}
+
+ {h.checkin_date ? `🗓️ ${formatDateGerman(h.checkin_date)} – ${formatDateGerman(h.checkout_date)}` : 'Kein Datum'} + {h.latest_price ? ` • 💰 Gesamt: ${h.latest_price.toLocaleString('de-DE')} €` : ''} +
+
+ +
+ ))} +
+ )} +
+
+
+ )} + {/* PREISHISTORIE MODAL */} {selectedPriceHotel && (
@@ -1070,7 +2019,7 @@ export default function TravelDashboard() { {loadingPrices ? (

Lade Preishistorie...

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

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

+

Noch keine Preishistorie vorhanden.

) : (
@@ -1093,7 +2042,6 @@ export default function TravelDashboard() { - {(() => { const range = maxPrice - minPrice || 1; const points = priceHistory.map((p, idx) => { @@ -1101,7 +2049,6 @@ export default function TravelDashboard() { const y = 130 - ((p.price - minPrice) / range) * 110; return `${x},${y}`; }).join(' '); - return ( <> @@ -1121,27 +2068,6 @@ export default function TravelDashboard() {
)} - -
- - - - - - - - - - {priceHistory.map(p => ( - - - - - - ))} - -
Datum & UhrzeitPreisDetails
{new Date(p.created_at).toLocaleString('de-DE')}{p.price} €{p.nights || 1} Nächte, {p.persons || 2} Personen
-
)}
@@ -1154,11 +2080,11 @@ export default function TravelDashboard() {

🌍 Neue Reise anlegen

- setNewTripName(e.target.value)} style={{ width: '100%', padding: '0.7rem', borderRadius: '6px', border: '1px solid #d1d5db', marginBottom: '1rem' }} /> @@ -1176,19 +2102,15 @@ export default function TravelDashboard() {

➕ Neues Hotel hinzufügen

-

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

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

Lade Daten...

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

Keine Hotels für diese Auswahl gefunden.

+

Keine aktiven Hotels für diese Auswahl gefunden.

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

{hotel.name}

-

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

- - {/* ANZEIGE DER DREI PREIS-WERTE */} - {totalPrice !== null ? ( -
- - 💰 Gesamt: {totalPrice.toLocaleString('de-DE')} € - - - | 🌙 {pricePerNight?.toLocaleString('de-DE')} € / Nacht - - - | 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. & Nacht - -
- ) : ( -
- 💰 Noch kein Preis erfasst -
+ {isCluster && ( + + ⚖️ {stop.hotels.length} Optionen im Vergleich + )} - -
- ⭐ {hotel.rating || 'N/A'}/10 - 💬 {hotel.review_count || 0} Bewertungen - {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²} -
- -
- - - - - - 🔗 Booking.com ↗ - -
- {(hotel.review_summary_positive || hotel.review_summary_bathroom) && ( -
-
- - 📋 KI-Zusammenfassung - - (Basierend auf {hotel.review_summary_count || 'mehreren'} analysierten Bewertungen) - - + {/* Hotel Grid */} +
+ {stop.hotels.map((hotel, hIdx) => { + const hotelNights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || nights || 1; + const persons = hotel.latest_persons || 2; + const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null; + const pricePerNight = hotel.latest_price_per_night + ? Math.round(Number(hotel.latest_price_per_night)) + : (totalPrice ? Math.round(totalPrice / hotelNights) : null); + const pricePerPersonPerNight = hotel.latest_price_per_person + ? Math.round(Number(hotel.latest_price_per_person)) + : (totalPrice ? Math.round(totalPrice / (hotelNights * persons)) : null); -
- {hotel.review_summary_positive && ( -
- 💚 Bestnoten & Highlights: -
{hotel.review_summary_positive}
+ const optionColors = ['#2563eb', '#7c3aed', '#059669', '#d97706']; + const currentAccent = optionColors[hIdx % optionColors.length]; + + // Filtert Ausflüge, die DIESEM Hotel zugeordnet sind + const hotelSights = attractions.filter(a => + (a.assignments || []).some(asgn => asgn.hotel_id === hotel.id) + ); + + return ( +
+
+ {/* Option Header */} +
+ {isCluster ? ( + + Option {String.fromCharCode(65 + hIdx)} + + ) : ( +
{hotel.trip_name && {hotel.trip_name}}
+ )} + +
- )} - {hotel.review_summary_passable && ( -
- 🟡 Passabel & Neutrales: -
{hotel.review_summary_passable}
+ + {/* Hotel Body */} +
+ {hotel.image_url && {hotel.name}} +
+

{hotel.name}

+

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

+
- )} - {hotel.review_summary_negative && ( -
- 🔴 Schlechteste & Mängel: -
{hotel.review_summary_negative}
+ + {/* Preis-Box */} + {totalPrice !== null ? ( +
+ + 💰 Gesamt: {totalPrice.toLocaleString('de-DE')} € + + + | 🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. + + + | 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. + +
+ ) : ( +
+ 💰 Noch kein Preis erfasst +
+ )} + + {/* Meta Tags */} +
+ ⭐ {hotel.rating || 'N/A'}/10 + 💬 {hotel.review_count || 0} Bew. + {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²}
- )} -
- 🚽 Bad & WC Fokus: -
- {hotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC gefunden.'} + + {/* SEHENSWÜRDIGKEITEN & FAHRZEITEN FÜR DIESES HOTEL */} +
+
+
+ 🏛️ Ausflugsziele ({hotelSights.length}) +
+ +
+ +
+ {hotelSights.length === 0 ? ( +

Keine Ausflugsziele zugeordnet.

+ ) : ( + hotelSights.map(sight => { + const assignment = (sight.assignments || []).find(a => a.hotel_id === hotel.id); + return ( +
+
+ {sight.category_icon || '🏛️'} +
+
+ {sight.name} +
+ {sight.notes && ( +
+ {sight.notes} +
+ )} +
+
+ + {assignment?.distance_km !== null && assignment?.distance_km !== undefined ? ( +
+ 🚗 {assignment.distance_km} km • {assignment.duration_minutes} Min. +
+ ) : ( + 📍 Route n.v. + )} + +
+ + Maps ↗ + + + +
+
+ ); + }) + )} +
+ + {/* Action Buttons */} +
+ + + + 🔗 Booking ↗ + +
-
-
- )} + ); + })} +
); })} @@ -1356,7 +2380,6 @@ export default function TravelDashboard() {
); } - ``` --- DATEI: src/lib/db.ts --- diff --git a/src/app/api/hotels/route.ts b/src/app/api/hotels/route.ts index 8671604..54c7697 100644 --- a/src/app/api/hotels/route.ts +++ b/src/app/api/hotels/route.ts @@ -5,28 +5,36 @@ export const dynamic = 'force-dynamic'; export async function GET() { try { - const res = await query(` - SELECT - h.*, - t.name AS trip_name, - ph.price_numeric::float AS latest_price, - ph.price_per_night::float AS latest_price_per_night, - ph.price_per_person::float AS latest_price_per_person, - ph.nights AS latest_nights, - ph.persons AS latest_persons - FROM hotels h - LEFT JOIN trips t ON h.trip_id = t.id - LEFT JOIN LATERAL ( - SELECT price_numeric, price_per_night, price_per_person, nights, persons - FROM hotel_price_history - WHERE hotel_id::text = h.id::text - ORDER BY tracked_at DESC NULLS LAST, id DESC - LIMIT 1 - ) ph ON true - ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC - `); - - return NextResponse.json({ success: true, hotels: res.rows }); + const res = await query(` + SELECT + h.*, + t.name AS trip_name, + ph.price_numeric::float AS latest_price, + ph.price_per_night::float AS latest_price_per_night, + ph.price_per_person::float AS latest_price_per_person, + ph.nights AS latest_nights, + ph.persons AS latest_persons, + bp.best_price, + bp.best_price_per_night + FROM hotels h + LEFT JOIN trips t ON h.trip_id = t.id + LEFT JOIN LATERAL ( + SELECT price_numeric, price_per_night, price_per_person, nights, persons + FROM hotel_price_history + WHERE hotel_id::text = h.id::text + ORDER BY tracked_at DESC NULLS LAST, id DESC + LIMIT 1 + ) ph ON true + LEFT JOIN LATERAL ( + SELECT + MIN(price_numeric)::float AS best_price, + MIN(price_per_night)::float AS best_price_per_night + FROM hotel_price_history + WHERE hotel_id::text = h.id::text AND price_numeric > 0 + ) bp ON true + ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC + `); + return NextResponse.json({ success: true, hotels: res.rows }); } catch (error: any) { console.error('Fehler in GET /api/hotels:', error.message); try { @@ -67,4 +75,7 @@ export async function POST(req: NextRequest) { console.error('Fehler in POST /api/hotels:', error.message); return NextResponse.json({ success: false, error: error.message }, { status: 500 }); } + + } + diff --git a/src/app/page.tsx b/src/app/page.tsx index e35ec91..3a22978 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -45,6 +45,8 @@ interface Hotel { latest_price_per_person?: number | null; latest_nights?: number | null; latest_persons?: number | null; + best_price?: number | null; + best_price_per_night?: number | null; rating?: number | null; review_count?: number | null; image_url?: string | null; @@ -938,24 +940,63 @@ export default function TravelDashboard() {
- {/* Preis-Box */} - {totalPrice !== null ? ( -
- - 💰 Gesamt: {totalPrice.toLocaleString('de-DE')} € - - - | 🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. - - - | 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. - -
- ) : ( -
- 💰 Noch kein Preis erfasst -
- )} +{/* Preis-Box mit Aktuellem Kurs & Bestpreis */} +{totalPrice !== null ? ( +
+ {/* Zeile 1: Aktueller Preis */} +
+
+ Aktuell: + + 💰 {totalPrice.toLocaleString('de-DE')} € + + + (🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P.) + +
+ + {hotel.best_price && totalPrice > hotel.best_price ? ( + + ▲ +{(totalPrice - hotel.best_price).toLocaleString('de-DE')} € über Tiefst + + ) : ( + + 🔥 Tiefstpreis + + )} +
+ + {/* Zeile 2: Bestpreis / Historischer Tiefststand */} + {hotel.best_price && ( +
+
+ 🏆 Bestpreis: + + {Number(hotel.best_price).toLocaleString('de-DE')} € + + {hotel.best_price_per_night && ( + + (🌙 {Math.round(Number(hotel.best_price_per_night)).toLocaleString('de-DE')} € / N.) + + )} +
+
+ )} +
+) : ( +
+ 💰 Noch kein Preis erfasst +
+)} {/* Meta Tags */}