diff --git a/src/app/api/hotels/bulk/route.ts b/src/app/api/hotels/bulk/route.ts new file mode 100644 index 0000000..8f4ed4a --- /dev/null +++ b/src/app/api/hotels/bulk/route.ts @@ -0,0 +1,113 @@ +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) { + try { + const { urls, bulk_text, trip_id } = await req.json(); + + let rawUrls: string[] = []; + if (Array.isArray(urls)) { + rawUrls = urls; + } else if (typeof bulk_text === 'string') { + rawUrls = bulk_text + .split(/[\r\n,\s]+/) + .map(u => u.trim()) + .filter(u => u.length > 0); + } + + // Filter valid Booking.com URLs + const validUrls = Array.from( + new Set( + rawUrls.filter(u => { + try { + const parsed = new URL(u); + return parsed.hostname.includes('booking.com') && parsed.pathname.includes('/hotel/'); + } catch { + return false; + } + }) + ) + ); + + if (validUrls.length === 0) { + return NextResponse.json( + { success: false, error: 'Keine gültigen Booking.com Hotel-Links gefunden.' }, + { status: 400 } + ); + } + + const insertedHotels = []; + const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null; + let scrapeQueue: any = 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, + }; + scrapeQueue = new Queue('scrape-jobs', { connection }); + } + + for (const bookingUrl of validUrls) { + let checkin_date: string | null = null; + let checkout_date: string | null = null; + let hotelName = 'Neues Hotel'; + + try { + const parsed = new URL(bookingUrl); + checkin_date = parsed.searchParams.get('checkin'); + checkout_date = parsed.searchParams.get('checkout'); + const slugMatch = parsed.pathname.match(/\/hotel\/[a-z]{2}\/([^.]+)/i); + if (slugMatch && slugMatch[1]) { + hotelName = slugMatch[1] + .replace(/-/g, ' ') + .replace(/\b\w/g, c => c.toUpperCase()); + } + } catch {} + + 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 *`, + [hotelName, bookingUrl, checkin_date, checkout_date, trip_id || null] + ); + + const newHotel = res.rows[0]; + insertedHotels.push(newHotel); + + if (scrapeQueue) { + try { + 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, + }); + } catch (queueErr: any) { + console.error(`Fehler beim Einreihen von Hotel ${newHotel.id}:`, queueErr.message); + } + } + } + + if (scrapeQueue) { + await scrapeQueue.close().catch(() => {}); + } + + return NextResponse.json({ + success: true, + message: `${insertedHotels.length} Hotel(s) erfolgreich importiert und zur Erfassung eingereiht!`, + count: insertedHotels.length, + hotels: insertedHotels, + }); + } catch (error: any) { + console.error('Fehler in POST /api/hotels/bulk:', 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 baee5d2..31962cb 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -124,12 +124,18 @@ export default function TravelDashboard() { const [showAddTripModal, setShowAddTripModal] = useState(false); const [newTripName, setNewTripName] = useState(''); const [showAddHotelModal, setShowAddHotelModal] = useState(false); + const [addHotelMode, setAddHotelMode] = useState<'single' | 'bulk'>('single'); const [newBookingUrl, setNewBookingUrl] = useState(''); const [newHotelName, setNewHotelName] = useState(''); const [newHotelTripId, setNewHotelTripId] = useState(''); + const [bulkBookingUrls, setBulkBookingUrls] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); + const [isSubmittingBulk, setIsSubmittingBulk] = useState(false); const [showParkedModal, setShowParkedModal] = useState(false); + // Preishistorie Zeitraum Filter (7 Tage vs Gesamter Verlauf) + const [priceHistoryRange, setPriceHistoryRange] = useState<'7d' | 'all'>('7d'); + // Scraper Debug-Modus Toggle const [isDebugMode, setIsDebugMode] = useState(false); @@ -578,6 +584,8 @@ export default function TravelDashboard() { setNewHotelTripId(activeTripId !== 'ALL' ? activeTripId : (trips[0] ? String(trips[0].id) : '')); setNewBookingUrl(''); setNewHotelName(''); + setBulkBookingUrls(''); + setAddHotelMode('single'); setShowAddHotelModal(true); }; @@ -612,6 +620,36 @@ export default function TravelDashboard() { } }; + const handleBulkAddHotels = async (e: React.FormEvent) => { + e.preventDefault(); + if (!bulkBookingUrls.trim()) return alert('Bitte mindestens eine Booking.com URL einfügen'); + setIsSubmittingBulk(true); + try { + const targetTripId = newHotelTripId || null; + const res = await fetch('/api/hotels/bulk', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + bulk_text: bulkBookingUrls.trim(), + trip_id: targetTripId + }) + }); + const data = await res.json(); + if (data.success) { + alert(data.message || `${data.count} Hotels erfolgreich importiert!`); + setBulkBookingUrls(''); + setShowAddHotelModal(false); + loadData(); + } else { + alert('Fehler: ' + (data.error || 'Unbekannter Fehler')); + } + } catch (err: any) { + alert('Fehler beim Bulk-Import: ' + err.message); + } finally { + setIsSubmittingBulk(false); + } + }; + const parkHotel = async (id: string) => { try { await fetch(`/api/hotels/${id}`, { @@ -1349,25 +1387,64 @@ export default function TravelDashboard() { {/* Card Actions Footer */} -
- - - + {(() => { + const hasReviews = Boolean(hotel.review_summary_positive || hotel.review_summary_negative || hotel.review_summary_passable || hotel.reviews_updated_at); + const hasPrices = Boolean(hotel.latest_price !== null && hotel.latest_price !== undefined && Number(hotel.latest_price) > 0); + + return ( +
+ + + {/* REVIEW BUTTON INDICATOR */} + + + {/* PREISHISTORIE BUTTON INDICATOR */} +
-
+ ); + })()} + ); })} @@ -1624,176 +1703,285 @@ export default function TravelDashboard() { )} - {/* TAB 2: PREISHISTORIE (MIT WIEDERHERGESTELLTEM 2-KURVEN-CHART) */} + {/* TAB 2: PREISHISTORIE (7-TAGE RESTRIKTION, GESAMTTREND-TOGGLE & SAUBERES CHART) */} {detailActiveTab === 'history' && (
{loadingPrices ? (

Lade Preishistorie...

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

Noch keine Preishistorie vorhanden.

- ) : ( -
- {/* DUAL STATS CARDS */} -
-
-
- 🏷️ Regulärer Preis +
+ 📉 +

+ Noch keine Preishistorie erfasst. +

+

+ Klicke auf "Stammdaten aktualisieren" oder "Preise aktualisieren", um den ersten Messpunkt zu speichern. +

+
+ ) : (() => { + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + const last7DaysPrices = priceHistory.filter(p => new Date(p.created_at).getTime() >= sevenDaysAgo); + const activePriceHistory = priceHistoryRange === '7d' + ? (last7DaysPrices.length > 0 ? last7DaysPrices : priceHistory) + : priceHistory; + + const activePricesList = activePriceHistory.map(p => Number(p.price)).filter(p => p > 0); + const chartMinPrice = activePricesList.length > 0 ? Math.min(...activePricesList) : 0; + const chartMaxPrice = activePricesList.length > 0 ? Math.max(...activePricesList) : 0; + const chartCurrentPrice = activePricesList.length > 0 ? activePricesList[activePricesList.length - 1] : 0; + + const activeGeniusPricesList = activePriceHistory + .map(p => (p.genius_price !== null && p.genius_price !== undefined ? Number(p.genius_price) : null)) + .filter((p): p is number => p !== null && p > 0); + const chartMinGenius = activeGeniusPricesList.length > 0 ? Math.min(...activeGeniusPricesList) : null; + const chartMaxGenius = activeGeniusPricesList.length > 0 ? Math.max(...activeGeniusPricesList) : null; + const chartCurrentGenius = activeGeniusPricesList.length > 0 ? activeGeniusPricesList[activeGeniusPricesList.length - 1] : null; + const hasChartGenius = activeGeniusPricesList.length > 0; + + return ( +
+ {/* ZEITRAUM FILTER TOGGLE BUTTONS */} +
+
+ + Ansicht: + + + ({activePriceHistory.length} von {priceHistory.length} Messpunkten) +
-
-
- Tiefst - {minPrice} € -
-
- Aktuell - {currentPrice} € -
-
- Höchst - {maxPrice} € -
+
+ +
- {hasAnyGeniusPrice && ( -
-
- 💙 Genius Vorteil (-10%) + {/* DUAL STATS CARDS */} +
+
+
+ 🏷️ Regulärer Preis {priceHistoryRange === '7d' ? '(7 Tage)' : '(Gesamt)'}
-
- Genius Min - {minGeniusPrice} € 🔥 +
+ Tiefst + {chartMinPrice} €
-
- Aktuell - {currentGeniusPrice} € +
+ Aktuell + {chartCurrentPrice} €
-
- Höchst - {maxGeniusPrice} € +
+ Höchst + {chartMaxPrice} €
+ + {hasChartGenius && ( +
+
+ 💙 Genius Vorteil (-10%) {priceHistoryRange === '7d' ? '(7 Tage)' : '(Gesamt)'} +
+
+
+ Genius Min + {chartMinGenius} € 🔥 +
+
+ Aktuell + {chartCurrentGenius} € +
+
+ Höchst + {chartMaxGenius} € +
+
+
+ )} +
+ + {/* 2-KURVEN SVG-CHART */} + {activePriceHistory.length > 1 ? ( +
+
+
+
+ Regulärer Preis +
+ {hasChartGenius && ( +
+
+ Genius Preis (-10%) +
+ )} +
+ + + + {(() => { + const allValues = [ + ...activePricesList, + ...activeGeniusPricesList + ]; + 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 = activePriceHistory.map((p, idx) => { + const x = (idx / (activePriceHistory.length - 1)) * 500; + const y = 130 - ((Number(p.price) - chartMin) / range) * 105; + return `${x},${y}`; + }).join(' '); + + const geniusPoints = activePriceHistory + .map((p, idx) => { + if (p.genius_price === null || p.genius_price === undefined) return null; + const x = (idx / (activePriceHistory.length - 1)) * 500; + const y = 130 - ((Number(p.genius_price) - chartMin) / range) * 105; + return { x, y, val: p.genius_price, id: p.id, date: p.created_at }; + }) + .filter((pt): pt is { x: number; y: number; val: number; id: string; date: string } => pt !== null); + + const geniusPolylineStr = geniusPoints.map(pt => `${pt.x},${pt.y}`).join(' '); + + return ( + <> + {/* Normal Line */} + + {activePriceHistory.map((p, idx) => { + const x = (idx / (activePriceHistory.length - 1)) * 500; + const y = 130 - ((Number(p.price) - chartMin) / range) * 105; + const isMin = Number(p.price) === chartMinPrice; + const isMax = Number(p.price) === chartMaxPrice; + const isFirst = idx === 0; + const isLast = idx === activePriceHistory.length - 1; + // Only show label if few points or on key landmarks (Min, Max, First, Last) to avoid collision + const showLabel = activePriceHistory.length <= 8 || isMin || isMax || isFirst || isLast; + + return ( + + + {`${Number(p.price).toLocaleString('de-DE')} € am ${new Date(p.created_at).toLocaleString('de-DE')}`} + + {showLabel && ( + + {p.price}€ + + )} + + ); + })} + + {/* Genius Line */} + {geniusPoints.length > 1 && ( + + )} + {geniusPoints.map((pt, idx) => { + const isMin = Number(pt.val) === chartMinGenius; + const isMax = Number(pt.val) === chartMaxGenius; + const isFirst = idx === 0; + const isLast = idx === geniusPoints.length - 1; + const showLabel = geniusPoints.length <= 8 || isMin || isMax || isFirst || isLast; + + return ( + + + {`Genius: ${Number(pt.val).toLocaleString('de-DE')} € am ${new Date(pt.date).toLocaleString('de-DE')}`} + + {showLabel && ( + + {pt.val}€ + + )} + + ); + })} + + ); + })()} + +
+ ) : ( +
+

+ 📌 1 Messpunkt vorhanden: {activePriceHistory[0]?.price} € ({new Date(activePriceHistory[0]?.created_at).toLocaleString('de-DE')}) +

+

+ Sobald weitere Scans durchgeführt werden, zeichnet das System automatisch den interaktiven Verlaufschart. +

+
)} -
- {/* 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 */} +
+ + + + + + + + + + + {activePriceHistory.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', year: '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')} €` : '–'} +
- )} - - {/* 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')} €` : '–'} -
-
- )} + ); + })()}
)} @@ -2248,43 +2436,147 @@ export default function TravelDashboard() {
)} - {/* MODAL: HOTEL HINZUFÜGEN */} + {/* MODAL: HOTEL HINZUFÜGEN (EINZELN ODER BULK) */} {showAddHotelModal && (
-
-

➕ Neues Hotel hinzufügen

-
-
- - -
-
- - 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' }} /> -
-
- - -
-
+
+
+

+ 🏨 Hotel(s) hinzufügen +

+ +
+ + {/* TAB SELECTOR: EINZELN VS. BULK */} +
+ + +
+ + {/* FORMULAR: EINZELNES HOTEL */} + {addHotelMode === 'single' ? ( +
+
+ + +
+
+ + 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' }} /> +
+
+ + +
+
+ ) : ( + /* FORMULAR: BULK INSERT */ +
+
+ + +
+
+
+ + + {bulkBookingUrls.split(/[\r\n]+/).filter(u => u.trim().includes('booking.com/hotel')).length} Links erkannt + +
+