From 6225924c7bf451d6ce5d3033756c64f1b085bf40 Mon Sep 17 00:00:00 2001 From: gitea Date: Fri, 21 Aug 2026 20:05:02 +0000 Subject: [PATCH] feat: add trip selection modal, auto-scrape on hotel creation, on-demand stammdaten refresh, and update README --- README.md | 63 +++ src/app/api/hotels/[id]/scan/route.ts | 23 +- src/app/api/hotels/route.ts | 34 +- src/app/page.tsx | 768 +++++++++++++++++--------- 4 files changed, 628 insertions(+), 260 deletions(-) diff --git a/README.md b/README.md index e69de29..0e93998 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,63 @@ +# 📍 Travel Dashboard & Hotel Ranking + +A Next.js web application for comparing hotels, planning travel stops, tracking historical Booking.com prices (including Genius discounts), and rating accommodations with friends using German school grades (1–6). + +--- + +## 🌟 Key Features + +- **Trip Management & Hotel Assignment:** Organize hotels across multiple trips or unassigned stops. When adding a hotel, you can directly choose which trip to assign it to. +- **Automated Metadata & Price Scraping:** Adding a Booking.com URL automatically triggers a background BullMQ job that fetches the hotel name, star rating, address, hero image, room size, and prices. +- **On-Demand Stammdaten Refresh:** Each hotel card features a **"🔄 Stammdaten"** button to perform a fast re-scan of metadata and prices on demand. +- **AI Review Summaries:** Integrates with local LLMs (via Ollama/n8n) to summarize up to 80+ guest reviews into categorized highlights (Positive, Neutral, Negative, Bathroom inspection). +- **Bathroom Photos & Google Maps Inspection:** Upload manual bathroom photos or inspect Google Maps imagery to assess room cleanliness. +- **Price History & Genius Tracking:** Records price changes over time with automatic detection of Genius discount badges (-10%). +- **Sights & Attractions:** Manage points of interest and calculate distances/durations from each hotel. + +--- + +## 🏗️ Architecture & Stack + +- **Frontend & API:** Next.js (App Router, TypeScript, React) +- **Database:** PostgreSQL +- **Task Queue:** BullMQ / Redis (shared with `scraper-service`) +- **Containerization:** Docker & Docker Compose + +--- + +## 🚀 Getting Started + +### Prerequisites +- Docker & Docker Compose +- Running PostgreSQL & Redis instances + +### Environment Variables (`.env`) +```env +DATABASE_URL=postgresql://user:password@host:5432/travel_db +REDIS_HOST=172.17.0.1 +REDIS_PORT=6379 +APP_WEBHOOK_URL=https://n8n.your-domain.de/webhook/scrape-reviews +``` + +### Running with Docker Compose +```bash +docker compose up -d --build +``` +The app will be accessible at `http://localhost:3002`. + +--- + +## 📡 API Endpoints + +- `GET /api/hotels` — List all hotels, current prices, and ratings. +- `POST /api/hotels` — Create a new hotel and enqueue background metadata scraping. +- `PATCH /api/hotels/[id]` — Update hotel properties (e.g. active/parked status). +- `POST /api/hotels/[id]/scan` — Trigger on-demand scraping: + - `?mode=stammdaten` (or `mode=quick`): Fast scan for name, address, rating, price, and images. + - Default: Full review scan with n8n/LLM review summary. + - `?debug=true`: Generates raw HTML and screenshot dumps in `scraper-service`. +- `POST /api/jobs/trigger-daily-check` — Enqueue batch price updates for all active hotels. +- `POST /api/webhooks/scraper` — Webhook receiver for scraped metadata from `scraper-service`. +- `GET /api/trips` / `POST /api/trips` — Manage trip entities. +- `GET /api/attractions` / `POST /api/attractions` — Manage central sights and hotel assignments. + diff --git a/src/app/api/hotels/[id]/scan/route.ts b/src/app/api/hotels/[id]/scan/route.ts index d8e3d76..49f7cf9 100644 --- a/src/app/api/hotels/[id]/scan/route.ts +++ b/src/app/api/hotels/[id]/scan/route.ts @@ -36,26 +36,37 @@ export async function POST( 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 { searchParams } = new URL(req.url); + const mode = searchParams.get('mode'); + const isQuick = mode === 'stammdaten' || mode === 'quick'; + const isDebug = searchParams.get('debug') === 'true' || searchParams.get('debug') === '1'; + + let n8nWebhookUrl: string | null = null; + if (!isQuick) { + const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews'; + 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 + // Job in die Queue schreiben await scrapeQueue.add('scrape-hotel', { hotelId: hotel.id, url: hotel.booking_url, appWebhookUrl, webhookUrl: n8nWebhookUrl, + debug: isDebug, }); await scrapeQueue.close(); return NextResponse.json({ success: true, - message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`, + message: isQuick + ? `Stammdaten-Scan für "${hotel.name}" erfolgreich gestartet.` + : `KI-Review-Job für "${hotel.name}" erfolgreich gestartet.`, }); } catch (error: any) { console.error('Fehler beim Starten des Einzel-Scrapes:', error); diff --git a/src/app/api/hotels/route.ts b/src/app/api/hotels/route.ts index bbd07cf..6675042 100644 --- a/src/app/api/hotels/route.ts +++ b/src/app/api/hotels/route.ts @@ -1,5 +1,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { query } from '@/lib/db'; + +export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export async function GET() { @@ -88,7 +90,37 @@ export async function POST(req: NextRequest) { RETURNING *`, [name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null] ); - return NextResponse.json({ success: true, hotel: res.rows[0] }); + + const newHotel = res.rows[0]; + + // Trigger background scraper job to fetch hotel details (name, rating, price, images, address, etc.) + try { + const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null; + if (bullmq && bullmq.Queue) { + 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 appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${newHotel.id}`; + + await scrapeQueue.add('scrape-hotel', { + hotelId: newHotel.id, + url: newHotel.booking_url, + appWebhookUrl, + webhookUrl: null, + }); + + await scrapeQueue.close(); + } + } catch (queueErr: any) { + console.error('Fehler beim Einreihen des Scrape-Jobs nach Hotel-Erstellung:', queueErr.message); + } + + return NextResponse.json({ success: true, hotel: newHotel }); } catch (error: any) { 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 0adee5a..4abe146 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -106,9 +106,11 @@ export default function TravelDashboard() { const [loading, setLoading] = useState(true); const [scanningAllPrices, setScanningAllPrices] = useState(false); const [scanningHotelId, setScanningHotelId] = useState(null); + const [refreshingMetadataHotelId, setRefreshingMetadataHotelId] = useState(null); - // Preishistorie Modal - const [selectedPriceHotel, setSelectedPriceHotel] = useState(null); + // Detail Modal: Preishistorie & KI-Reviews + const [selectedDetailHotel, setSelectedDetailHotel] = useState(null); + const [detailActiveTab, setDetailActiveTab] = useState<'reviews' | 'history'>('reviews'); const [priceHistory, setPriceHistory] = useState([]); const [loadingPrices, setLoadingPrices] = useState(false); @@ -124,6 +126,7 @@ export default function TravelDashboard() { const [showAddHotelModal, setShowAddHotelModal] = useState(false); const [newBookingUrl, setNewBookingUrl] = useState(''); const [newHotelName, setNewHotelName] = useState(''); + const [newHotelTripId, setNewHotelTripId] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); const [showParkedModal, setShowParkedModal] = useState(false); @@ -259,6 +262,59 @@ export default function TravelDashboard() { } }; + // ========================================== + // HANDLERS: DETAIL MODAL (HISTORIE & REVIEWS) + // ========================================== + const openHotelDetails = async (hotel: Hotel, initialTab: 'reviews' | 'history' = 'reviews') => { + setSelectedDetailHotel(hotel); + setDetailActiveTab(initialTab); + 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 scanSingleHotel = async (hotel: Hotel) => { + const reviewCountDisplay = hotel.review_summary_count || hotel.review_count || 80; + const isConfirmed = confirm( + `Möchtest du die KI-Review-Analyse für "${hotel.name}" wirklich neu starten?\n\nHinweis: Dabei werden bis zu ${reviewCountDisplay} Gästebewertungen gescrapt und per n8n/Ollama neu zusammengefasst.` + ); + if (!isConfirmed) return; + + setScanningHotelId(hotel.id); + try { + const res = await fetch(`/api/hotels/${hotel.id}/scan`, { method: 'POST' }); + const data = await res.json(); + alert(data.message || (data.success ? 'Scrape-Job erfolgreich gestartet!' : 'Fehler beim Starten')); + setTimeout(loadData, 5000); + } catch (err: any) { + alert('Fehler beim Ausführen: ' + err.message); + } finally { + setScanningHotelId(null); + } + }; + + const refreshHotelMetadata = async (hotel: Hotel) => { + setRefreshingMetadataHotelId(hotel.id); + try { + const res = await fetch(`/api/hotels/${hotel.id}/scan?mode=stammdaten`, { method: 'POST' }); + const data = await res.json(); + alert(data.message || (data.success ? 'Stammdaten-Scan gestartet!' : 'Fehler beim Starten')); + setTimeout(loadData, 4000); + } catch (err: any) { + alert('Fehler beim Ausführen: ' + err.message); + } finally { + setRefreshingMetadataHotelId(null); + } + }; + // ========================================== // HANDLERS: MANUELLE BAD-BILDER // ========================================== @@ -276,7 +332,6 @@ export default function TravelDashboard() { const data = await res.json(); if (data.success) { setManualPhotoUrl(''); - // Lokalen Zustand aktualisieren const updatedImages = data.bathroom_images; setSelectedBathroomHotel({ ...selectedBathroomHotel, bathroom_images: updatedImages }); setHotels(hotels.map(h => h.id === selectedBathroomHotel.id ? { ...h, bathroom_images: updatedImages } : h)); @@ -433,21 +488,13 @@ export default function TravelDashboard() { }; // ========================================== - // HANDLERS: HOTELS & SCANS + // HANDLERS: HOTELS & 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 openAddHotelModal = () => { + setNewHotelTripId(activeTripId !== 'ALL' ? activeTripId : (trips[0] ? String(trips[0].id) : '')); + setNewBookingUrl(''); + setNewHotelName(''); + setShowAddHotelModal(true); }; const handleAddHotel = async (e: React.FormEvent) => { @@ -455,13 +502,13 @@ export default function TravelDashboard() { if (!newBookingUrl) return alert('Bitte URL eingeben'); setIsSubmitting(true); try { - const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId; + const targetTripId = newHotelTripId || null; const res = await fetch('/api/hotels', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - name: newHotelName || 'Neues Hotel', - booking_url: newBookingUrl, + name: newHotelName.trim() || null, + booking_url: newBookingUrl.trim(), trip_id: targetTripId }) }); @@ -471,6 +518,8 @@ export default function TravelDashboard() { setNewHotelName(''); setShowAddHotelModal(false); loadData(); + } else { + alert('Fehler: ' + (data.error || 'Unbekannter Fehler')); } } catch (err: any) { alert('Fehler: ' + err.message); @@ -592,18 +641,6 @@ export default function TravelDashboard() { } }; - 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); - setTimeout(loadData, 4000); - } finally { - setScanningHotelId(null); - } - }; - // ========================================== // CLUSTERING & FILTER // ========================================== @@ -667,11 +704,11 @@ export default function TravelDashboard() { 📍 Reise-Ablauf & Freunde-Ranking

- Hotelvergleich nach Schulnoten (1–6), Bad-Inspektion, Genius-Tracking & Routen + Hotelvergleich nach Schulnoten (1–6), Bad-Inspektion, Genius-Tracking & KI-Reviews

@@ -973,6 +1010,9 @@ export default function TravelDashboard() {
⭐ {hotel.rating || 'N/A'}/10 ({hotel.review_count || 0}) {hotel.room_size_m2 && · 📐 {hotel.room_size_m2} m²} + + 👥 {persons} {persons === 1 ? 'Person' : 'Personen'} +
@@ -1056,7 +1096,7 @@ export default function TravelDashboard() { - {/* PREIS-BOX */} + {/* PREIS-BOX MIT PERSONEN-AUFSCHLÜSSELUNG */} {totalPrice !== null ? (
@@ -1074,12 +1114,12 @@ export default function TravelDashboard() { )}
-
+
{totalPrice.toLocaleString('de-DE')} € - (🌙 {pricePerNight?.toLocaleString('de-DE')} € / N.) + (🌙 {pricePerNight?.toLocaleString('de-DE')} € / N. · 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. · 👥 {persons} Pers.)
{hotel.best_price && ( @@ -1106,12 +1146,12 @@ export default function TravelDashboard() { )}
-
+
{Number(hotel.latest_genius_price).toLocaleString('de-DE')} € - (🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N.) + (🌙 {Math.round(Number(hotel.latest_genius_price) / hotelNights).toLocaleString('de-DE')} € / N. · 👤 {Math.round(Number(hotel.latest_genius_price) / (hotelNights * persons)).toLocaleString('de-DE')} € / P. · 👥 {persons} Pers.)
{hotel.best_genius_price && ( @@ -1170,25 +1210,39 @@ export default function TravelDashboard() {
{/* Card Actions Footer */} -
+
+ + Booking ↗ @@ -1204,6 +1258,399 @@ export default function TravelDashboard() {
)} + {/* ======================================================== */} + {/* MODAL 3: KOMBINIERTES DETAIL-MODAL (HISTORIE & REVIEWS) */} + {/* ======================================================== */} + {selectedDetailHotel && ( +
setSelectedDetailHotel(null)} + style={{ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + background: 'rgba(15, 23, 42, 0.75)', + backdropFilter: 'blur(4px)', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + zIndex: 9999, + padding: '1rem' + }} + > +
e.stopPropagation()} + style={{ + background: '#ffffff', + borderRadius: '16px', + maxWidth: '880px', + width: '100%', + maxHeight: '92vh', + display: 'flex', + flexDirection: 'column', + boxShadow: '0 25px 50px -12px rgba(0,0,0,0.35)', + overflow: 'hidden' + }} + > + {/* Modal Header */} +
+ + +
+ + {/* Tab Navigation mit dynamischer Review-Anzahl */} +
+ + +
+ + {/* Modal Body */} +
+ + {/* TAB 1: KI REVIEWS (VERTIKALE KARTEN UNTEREINANDER) */} + {detailActiveTab === 'reviews' && ( +
+ {!selectedDetailHotel.review_summary_positive ? ( +
+ 🤖 +

+ Noch keine KI-Bewertungsanalyse vorhanden. +

+

+ Klicke auf den Button, um bis zu {selectedDetailHotel.review_summary_count || selectedDetailHotel.review_count || 80} Gästebewertungen über Playwright & Ollama auswerten zu lassen. +

+ +
+ ) : ( +
+ + {/* 1. Bestnoten */} +
+
+

+ 💚 Bestnoten & Highlights (Positiv) +

+ + Großes Lob + +
+
+ {selectedDetailHotel.review_summary_positive} +
+
+ + {/* 2. Passabel */} +
+
+

+ 🟡 Passabel & Neutrale Hinweise +

+ + Hinweise + +
+
+ {selectedDetailHotel.review_summary_passable} +
+
+ + {/* 3. Schlechteste / Mängel */} +
+
+

+ 🔴 Schlechteste Aspekte & Mängel (Kritik) +

+ + Zu beachten + +
+
+ {selectedDetailHotel.review_summary_negative} +
+
+ + {/* 4. Bad & WC Fokus */} +
+
+

+ 🚽 Bad & WC Fokus +

+ + Inspektion + +
+
+ {selectedDetailHotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC in den Kommentaren gefunden.'} +
+
+ + {selectedDetailHotel.reviews_updated_at && ( +
+ Zuletzt analysiert: {new Date(selectedDetailHotel.reviews_updated_at).toLocaleString('de-DE')} (via n8n & Ollama) +
+ )} +
+ )} +
+ )} + + {/* TAB 2: PREISHISTORIE (MIT WIEDERHERGESTELLTEM 2-KURVEN-CHART) */} + {detailActiveTab === 'history' && ( +
+ {loadingPrices ? ( +

Lade Preishistorie...

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

Noch keine Preishistorie vorhanden.

+ ) : ( +
+ {/* DUAL STATS CARDS */} +
+
+
+ 🏷️ Regulärer Preis +
+
+
+ Tiefst + {minPrice} € +
+
+ Aktuell + {currentPrice} € +
+
+ Höchst + {maxPrice} € +
+
+
+ + {hasAnyGeniusPrice && ( +
+
+ 💙 Genius Vorteil (-10%) +
+
+
+ Genius Min + {minGeniusPrice} € 🔥 +
+
+ Aktuell + {currentGeniusPrice} € +
+
+ Höchst + {maxGeniusPrice} € +
+
+
+ )} +
+ + {/* 2-KURVEN SVG-CHART */} + {priceHistory.length > 1 && ( +
+
+
+
+ Regulärer Preis +
+ {hasAnyGeniusPrice && ( +
+
+ Genius Preis (-10%) +
+ )} +
+ + + + {(() => { + const allValues = [ + ...pricesList, + ...geniusPricesList + ]; + const chartMin = allValues.length > 0 ? Math.min(...allValues) : 0; + const chartMax = allValues.length > 0 ? Math.max(...allValues) : 100; + const range = (chartMax - chartMin) || 1; + + const normalPoints = priceHistory.map((p, idx) => { + const x = (idx / (priceHistory.length - 1)) * 500; + const y = 130 - ((Number(p.price) - chartMin) / range) * 110; + return `${x},${y}`; + }).join(' '); + + const geniusPoints = priceHistory + .map((p, idx) => { + if (p.genius_price === null || p.genius_price === undefined) return null; + const x = (idx / (priceHistory.length - 1)) * 500; + const y = 130 - ((Number(p.genius_price) - chartMin) / range) * 110; + return { x, y, val: p.genius_price, id: p.id }; + }) + .filter((pt): pt is { x: number; y: number; val: number; id: string } => pt !== null); + + const geniusPolylineStr = geniusPoints.map(pt => `${pt.x},${pt.y}`).join(' '); + + return ( + <> + {/* Normal Line */} + + {priceHistory.map((p, idx) => { + const x = (idx / (priceHistory.length - 1)) * 500; + const y = 130 - ((Number(p.price) - chartMin) / range) * 110; + return ( + + + + {p.price}€ + + + ); + })} + + {/* Genius Line */} + {geniusPoints.length > 1 && ( + + )} + {geniusPoints.map((pt, idx) => ( + + + + {pt.val}€ + + + ))} + + ); + })()} + +
+ )} + + {/* HISTORIEN TABELLE */} +
+ + + + + + + + + + + {priceHistory.slice().reverse().map((entry, idx) => { + const hasGen = entry.genius_price !== null && entry.genius_price !== undefined; + return ( + + + + + + + ); + })} + +
Datum / UhrzeitRegulärGenius (-10%)Pro Nacht
+ {new Date(entry.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })} + + {Number(entry.price).toLocaleString('de-DE')} € + + {hasGen ? `${Number(entry.genius_price).toLocaleString('de-DE')} €` : '–'} + + {entry.price_per_night ? `${Number(entry.price_per_night).toLocaleString('de-DE')} €` : '–'} +
+
+
+ )} +
+ )} + +
+ + {/* Modal Footer */} +
+
+ + +
+ +
+
+
+ )} + {/* ======================================================== */} {/* MODAL: BADEZIMMER & GAST-FOTOS MIT MANUELLEM IMPORT */} {/* ======================================================== */} @@ -1261,7 +1708,7 @@ export default function TravelDashboard() { {/* Modal Body */}
- {/* 1. MANUELLER LINK-IMPORT */} + {/* MANUELLER LINK-IMPORT */}
➕ Google Maps Bild-Link oder Foto-URL einfügen: @@ -1285,18 +1732,6 @@ export default function TravelDashboard() {
- {/* KI-ZUSAMMENFASSUNG */} - {selectedBathroomHotel.review_summary_bathroom && ( -
- - 🤖 KI-Zusammenfassung zu den Bädern: - - - {selectedBathroomHotel.review_summary_bathroom} - -
- )} - {/* FOTOGALERIE MIT LÖSCH-FUNKTION */} {(() => { const photos = parseBathroomImages(selectedBathroomHotel.bathroom_images); @@ -1611,195 +2046,6 @@ export default function TravelDashboard() {
)} - {/* MODAL 3: PREISHISTORIE (GENIUS & REGULÄR) */} - {selectedPriceHotel && ( -
-
-
-
-

📈 Preishistorie: {selectedPriceHotel.name}

-

Entwicklung der Scans & Genius-Vergleich

-
- -
- - {loadingPrices ? ( -

Lade Preishistorie...

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

Noch keine Preishistorie vorhanden.

- ) : ( -
- {/* DUAL STATS CARDS */} -
- - {/* Regulärer Preis */} -
-
- 🏷️ Regulärer Preis -
-
-
- Tiefst - {minPrice} € -
-
- Aktuell - {currentPrice} € -
-
- Höchst - {maxPrice} € -
-
-
- - {/* Genius Preis */} - {hasAnyGeniusPrice && ( -
-
- 💙 Genius Vorteil (-10%) -
-
-
- Genius Min - {minGeniusPrice} € 🔥 -
-
- Aktuell - {currentGeniusPrice} € -
-
- Höchst - {maxGeniusPrice} € -
-
-
- )} - -
- - {/* DUAL SVG CHART */} - {priceHistory.length > 1 && ( -
-
-
-
- Regulärer Preis -
- {hasAnyGeniusPrice && ( -
-
- Genius Preis (-10%) -
- )} -
- - - - {(() => { - const allValues = [ - ...pricesList, - ...geniusPricesList - ]; - const chartMin = allValues.length > 0 ? Math.min(...allValues) : 0; - const chartMax = allValues.length > 0 ? Math.max(...allValues) : 100; - const range = (chartMax - chartMin) || 1; - - const normalPoints = priceHistory.map((p, idx) => { - const x = (idx / (priceHistory.length - 1)) * 500; - const y = 130 - ((Number(p.price) - chartMin) / range) * 110; - return `${x},${y}`; - }).join(' '); - - const geniusPoints = priceHistory - .map((p, idx) => { - if (p.genius_price === null || p.genius_price === undefined) return null; - const x = (idx / (priceHistory.length - 1)) * 500; - const y = 130 - ((Number(p.genius_price) - chartMin) / range) * 110; - return { x, y, val: p.genius_price, id: p.id }; - }) - .filter((pt): pt is { x: number; y: number; val: number; id: string } => pt !== null); - - const geniusPolylineStr = geniusPoints.map(pt => `${pt.x},${pt.y}`).join(' '); - - return ( - <> - {/* Normal Line */} - - {priceHistory.map((p, idx) => { - const x = (idx / (priceHistory.length - 1)) * 500; - const y = 130 - ((Number(p.price) - chartMin) / range) * 110; - return ( - - - - {p.price}€ - - - ); - })} - - {/* Genius Line */} - {geniusPoints.length > 1 && ( - - )} - {geniusPoints.map((pt, idx) => ( - - - - {pt.val}€ - - - ))} - - ); - })()} - -
- )} - - {/* HISTORIEN TABELLE */} -
- - - - - - - - - - - {priceHistory.slice().reverse().map((entry, idx) => { - const hasGen = entry.genius_price !== null && entry.genius_price !== undefined; - const saving = hasGen ? Math.round(Number(entry.price) - Number(entry.genius_price)) : 0; - return ( - - - - - - - ); - })} - -
Datum / UhrzeitRegulärGenius (-10%)Ersparnis
- {new Date(entry.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })} - - {Number(entry.price).toLocaleString('de-DE')} € - - {hasGen ? `${Number(entry.genius_price).toLocaleString('de-DE')} €` : '–'} - 0 ? '#047857' : '#94a3b8', fontWeight: '700' }}> - {saving > 0 ? `-${saving} €` : '–'} -
-
- -
- )} -
-
- )} - {/* MODAL: NEUE REISE */} {showAddTripModal && (
@@ -1830,17 +2076,33 @@ export default function TravelDashboard() {

➕ Neues Hotel hinzufügen

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