Compare commits

...

5 Commits

7 changed files with 1187 additions and 217 deletions

21
AGENTS.md Normal file
View File

@@ -0,0 +1,21 @@
# Engineering & Scraping Guidelines
## 1. Git Commit & Repository Hygiene
- **Conventional Commits:** Write clear, concise commit messages prefixed with standard types (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
- **Exclude Transient Artifacts:** Never stage or commit generated debug files (e.g., `src/*.html`, `src/*.png`, large logs) to version control. Always maintain `.gitignore` to keep git packs lean and prevent remote unpacker errors on Gitea/GitHub.
## 2. Web Scraping & DOM Inspection Strategy
- **Rendered DOM Dumping for Debugging:** When debugging extraction failures on dynamic or anti-bot protected pages:
- Capture the post-render HTML via `await page.content()` and save it to `src/hotel-debug.html`.
- Capture a synchronized screenshot (`src/hotel-debug.png`).
- Inspect the raw rendered DOM to verify if the page was served as standard SSR, an A/B test SPA variant, or intercepted by a challenge (e.g. AWS WAF).
- **Multi-Tier Extraction Hierarchy:** Always implement fallbacks in this order:
1. **Deep JSON-LD Parsing:** Traverse all `<script type="application/ld+json">` tags, including `@graph` arrays and varied accommodation schemas (`Hotel`, `Resort`, `Apartment`, `Hostel`, `LodgingBusiness`, etc.).
2. **DOM Selectors:** Fallback to standard test IDs, classes, and element hierarchy (`data-testid`, header titles, score components).
3. **Meta Tags:** Fallback to OpenGraph and Twitter meta tags (`og:title`, `og:image`, `og:description`).
4. **URL Slug Extraction:** As an ultimate safety net, derive clean human-readable names from the URL path if the HTML body was obstructed.
- **Anti-Bot & WAF Handling:**
- Sanitize input URLs by stripping transient tokens (`chal_t`, `force_referer`, session IDs).
- Emulate full browser properties (`window.chrome`, `navigator.plugins`, `navigator.languages`).
- Actively detect WAF challenge interstitial pages and wait for client-side JavaScript token computation and automatic redirection before evaluating the DOM.

5
next-env.d.ts vendored Normal file
View File

@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -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 });
}
}

View File

@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { sendTelegramMessage } from '@/lib/telegram';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
export async function POST(req: NextRequest) {
try {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (!botToken || !chatId) {
return NextResponse.json({
success: false,
error: 'TELEGRAM_BOT_TOKEN oder TELEGRAM_CHAT_ID ist in .env nicht gesetzt.',
configured: false,
}, { status: 400 });
}
const testMessage = `🤖 <b>Telegram-Benachrichtigungstest</b>
✅ Die Verbindung zwischen deiner <b>Travel Tracker App</b> und dem Telegram Bot funktioniert einwandfrei!
🔔 Sobald ein Hotelpreis sinkt oder ein neuer Bestpreis erreicht wird, erhältst du hier automatisch Push-Nachrichten.`;
const res = await sendTelegramMessage(testMessage);
if (res.success) {
return NextResponse.json({
success: true,
message: 'Test-Nachricht wurde erfolgreich an Telegram gesendet!',
chatId: chatId.slice(0, 4) + '***',
});
} else {
return NextResponse.json({
success: false,
error: res.error || 'Fehler beim Senden an Telegram API',
}, { status: 500 });
}
} catch (error: any) {
return NextResponse.json({
success: false,
error: error.message,
}, { status: 500 });
}
}

View File

@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from 'next/server';
import { query } from '@/lib/db';
import { notifyPriceChange } from '@/lib/telegram';
function parseGermanPrice(priceStr: any): number | null {
if (!priceStr) return null;
@@ -42,11 +43,52 @@ export async function POST(req: NextRequest) {
if (numericPrice !== null && numericPrice > 0) {
try {
// Vorherigen Preis und bisherigen Allzeit-Tiefstpreis abfragen
const prevRes = await query(
`SELECT
h.name, h.booking_url, h.checkin_date, h.checkout_date,
t.name as trip_name,
(SELECT price_numeric FROM hotel_price_history WHERE hotel_id = h.id ORDER BY tracked_at DESC, id DESC LIMIT 1) as last_price,
(SELECT MIN(price_numeric) FROM hotel_price_history WHERE hotel_id = h.id AND price_numeric > 0) as min_price
FROM hotels h
LEFT JOIN trips t ON h.trip_id = t.id
WHERE h.id = $1::uuid`,
[hotelId]
);
const hotelRecord = prevRes.rows[0] || null;
const previousPrice = hotelRecord?.last_price ? Math.round(Number(hotelRecord.last_price)) : null;
const allTimeBestPrice = hotelRecord?.min_price ? Math.round(Number(hotelRecord.min_price)) : null;
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]
);
// Telegram Push-Benachrichtigung bei Preissenkung oder neuem Allzeit-Bestpreis
if (hotelRecord && previousPrice !== null && previousPrice > 0) {
const isPriceDrop = numericPrice < previousPrice;
const isNewBestPrice = allTimeBestPrice !== null ? (numericPrice < allTimeBestPrice) : true;
if (isPriceDrop || isNewBestPrice) {
notifyPriceChange({
hotelName: hotelRecord.name || body.hotelName || 'Hotel',
tripName: hotelRecord.trip_name || null,
oldPrice: previousPrice,
newPrice: numericPrice,
newGeniusPrice: geniusPriceNumeric,
allTimeBestPrice: allTimeBestPrice,
isNewBestPrice,
isPriceDrop,
checkinDate: hotelRecord.checkin_date || body.checkin || null,
checkoutDate: hotelRecord.checkout_date || body.checkout || null,
nights: body.nights || null,
persons: body.persons || null,
bookingUrl: hotelRecord.booking_url || body.url || ''
}).catch(e => console.error('[Telegram] Fehler beim Senden der Benachrichtigung:', e.message));
}
}
} catch (err: any) {
console.error('DB Insert Error (hotel_price_history):', err.message);
}

View File

@@ -124,12 +124,64 @@ 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<string>('');
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');
// Telegram Test Status
const [isTestingTelegram, setIsTestingTelegram] = useState(false);
const testTelegramNotification = async () => {
setIsTestingTelegram(true);
try {
const res = await fetch('/api/telegram/test', { method: 'POST' });
const data = await res.json();
if (data.success) {
alert('✅ Telegram-Test erfolgreich! Nachricht wurde an deinen Chat gesendet.');
} else {
alert(`❌ Telegram-Test fehlgeschlagen:\n${data.error || 'Unbekannter Fehler'}\n\nBitte prüfe TELEGRAM_BOT_TOKEN und TELEGRAM_CHAT_ID in deiner .env Datei.`);
}
} catch (err: any) {
alert('Fehler beim Testen von Telegram: ' + err.message);
} finally {
setIsTestingTelegram(false);
}
};
// Scraper Debug-Modus Toggle
const [isDebugMode, setIsDebugMode] = useState(false);
// Initialisiere Debug-Modus aus localStorage im Client
useEffect(() => {
if (typeof window !== 'undefined') {
setIsDebugMode(localStorage.getItem('scraper_debug_mode') === 'true');
}
}, []);
const toggleDebugMode = () => {
const next = !isDebugMode;
setIsDebugMode(next);
if (typeof window !== 'undefined') {
localStorage.setItem('scraper_debug_mode', String(next));
}
};
// Hotel Löschen Status
const [deletingHotelId, setDeletingHotelId] = useState<string | null>(null);
// Hotel in Trip kopieren Modal & Status
const [copyHotelTarget, setCopyHotelTarget] = useState<Hotel | null>(null);
const [selectedTargetTripId, setSelectedTargetTripId] = useState<string>('');
const [isCopyingHotel, setIsCopyingHotel] = useState(false);
// Mitreisende Modal
const [showMembersModal, setShowMembersModal] = useState(false);
const [newMemberName, setNewMemberName] = useState('');
@@ -284,13 +336,14 @@ export default function TravelDashboard() {
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.`
`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.${isDebugMode ? '\n\n🐛 Scraper-Debug-Modus ist AKTIV (HTML & Screenshots werden gespeichert).' : ''}`
);
if (!isConfirmed) return;
setScanningHotelId(hotel.id);
try {
const res = await fetch(`/api/hotels/${hotel.id}/scan`, { method: 'POST' });
const debugQuery = isDebugMode ? '?debug=true' : '';
const res = await fetch(`/api/hotels/${hotel.id}/scan${debugQuery}`, { method: 'POST' });
const data = await res.json();
alert(data.message || (data.success ? 'Scrape-Job erfolgreich gestartet!' : 'Fehler beim Starten'));
setTimeout(loadData, 5000);
@@ -304,7 +357,8 @@ export default function TravelDashboard() {
const refreshHotelMetadata = async (hotel: Hotel) => {
setRefreshingMetadataHotelId(hotel.id);
try {
const res = await fetch(`/api/hotels/${hotel.id}/scan?mode=stammdaten`, { method: 'POST' });
const debugQuery = isDebugMode ? '&debug=true' : '';
const res = await fetch(`/api/hotels/${hotel.id}/scan?mode=stammdaten${debugQuery}`, { method: 'POST' });
const data = await res.json();
alert(data.message || (data.success ? 'Stammdaten-Scan gestartet!' : 'Fehler beim Starten'));
setTimeout(loadData, 4000);
@@ -315,6 +369,62 @@ export default function TravelDashboard() {
}
};
const handleDeleteHotel = async (hotel: Hotel) => {
const isConfirmed = confirm(`Möchtest du das Hotel "${hotel.name}" wirklich unwiderruflich löschen?`);
if (!isConfirmed) return;
setDeletingHotelId(hotel.id);
try {
const res = await fetch(`/api/hotels/${hotel.id}`, { method: 'DELETE' });
const data = await res.json();
if (data.success) {
if (selectedDetailHotel?.id === hotel.id) {
setSelectedDetailHotel(null);
}
setHotels(prev => prev.filter(h => h.id !== hotel.id));
alert(`Hotel "${hotel.name}" wurde erfolgreich gelöscht.`);
} else {
alert('Fehler beim Löschen: ' + data.error);
}
} catch (err: any) {
alert('Fehler beim Löschen: ' + err.message);
} finally {
setDeletingHotelId(null);
}
};
const handleCopyHotelToTrip = async (e: React.FormEvent) => {
e.preventDefault();
if (!copyHotelTarget || !selectedTargetTripId) return;
setIsCopyingHotel(true);
try {
const res = await fetch('/api/hotels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: copyHotelTarget.name,
booking_url: copyHotelTarget.booking_url,
trip_id: selectedTargetTripId
})
});
const data = await res.json();
if (data.success) {
const targetTripName = trips.find(t => t.id === selectedTargetTripId)?.name || 'dem Trip';
alert(`Hotel "${copyHotelTarget.name}" wurde erfolgreich zu "${targetTripName}" hinzugefügt!`);
setCopyHotelTarget(null);
setSelectedTargetTripId('');
loadData();
} else {
alert('Fehler beim Hinzufügen: ' + data.error);
}
} catch (err: any) {
alert('Fehler beim Hinzufügen: ' + err.message);
} finally {
setIsCopyingHotel(false);
}
};
// ==========================================
// HANDLERS: MANUELLE BAD-BILDER
// ==========================================
@@ -494,6 +604,8 @@ export default function TravelDashboard() {
setNewHotelTripId(activeTripId !== 'ALL' ? activeTripId : (trips[0] ? String(trips[0].id) : ''));
setNewBookingUrl('');
setNewHotelName('');
setBulkBookingUrls('');
setAddHotelMode('single');
setShowAddHotelModal(true);
};
@@ -528,6 +640,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}`, {
@@ -743,6 +885,87 @@ export default function TravelDashboard() {
>
{scanningAllPrices ? '🔄 Scanne...' : '📊 Preise aktualisieren'}
</button>
{/* DEBUG SLIDER SWITCH */}
<label
title="Aktiviert das Speichern von hotel-debug.html und Debug-Screenshots bei Scraper-Jobs"
style={{
display: 'flex',
alignItems: 'center',
gap: '0.55rem',
background: isDebugMode ? '#fef3c7' : '#f8fafc',
border: isDebugMode ? '1px solid #f59e0b' : '1px solid #cbd5e1',
padding: '0.45rem 0.85rem',
borderRadius: '8px',
cursor: 'pointer',
fontWeight: '700',
fontSize: '0.82rem',
color: isDebugMode ? '#92400e' : '#475569',
userSelect: 'none',
boxShadow: isDebugMode ? '0 0 0 1px #f59e0b' : 'none',
transition: 'all 0.2s ease'
}}
>
<span>🐛 Debug-Modus</span>
<div
onClick={(e) => {
e.preventDefault();
toggleDebugMode();
}}
style={{
width: '38px',
height: '20px',
background: isDebugMode ? '#f59e0b' : '#cbd5e1',
borderRadius: '9999px',
position: 'relative',
transition: 'background 0.2s ease',
cursor: 'pointer'
}}
>
<div
style={{
width: '16px',
height: '16px',
background: '#ffffff',
borderRadius: '50%',
position: 'absolute',
top: '2px',
left: isDebugMode ? '20px' : '2px',
transition: 'left 0.2s ease',
boxShadow: '0 1px 3px rgba(0,0,0,0.25)'
}}
/>
</div>
<span style={{ fontSize: '0.75rem', fontWeight: '800', minWidth: '26px' }}>
{isDebugMode ? 'AN' : 'AUS'}
</span>
</label>
{/* TELEGRAM TEST BUTTON */}
<button
type="button"
onClick={testTelegramNotification}
disabled={isTestingTelegram}
title="Sendet eine Test-Nachricht an den konfigurierten Telegram Bot"
style={{
background: '#0284c7',
color: '#ffffff',
border: 'none',
padding: '0.55rem 0.95rem',
borderRadius: '8px',
fontWeight: '700',
cursor: 'pointer',
fontSize: '0.82rem',
display: 'flex',
alignItems: 'center',
gap: '0.35rem',
opacity: isTestingTelegram ? 0.6 : 1,
transition: 'all 0.2s ease'
}}
>
<span>🔔</span>
<span>{isTestingTelegram ? 'Sende...' : 'Telegram Test'}</span>
</button>
</div>
</header>
@@ -1210,6 +1433,11 @@ export default function TravelDashboard() {
</div>
{/* 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 (
<div style={{ display: 'flex', gap: '0.4rem', flexWrap: 'wrap', marginTop: '0.8rem', paddingTop: '0.6rem', borderTop: '1px solid #f1f5f9', alignItems: 'center' }}>
<button
onClick={() => openRatingModal(hotel)}
@@ -1217,17 +1445,51 @@ export default function TravelDashboard() {
>
💬 Stimmen ({hotel.ratings_json?.length || 0})
</button>
{/* REVIEW BUTTON INDICATOR */}
<button
onClick={() => openHotelDetails(hotel, 'reviews')}
style={{ background: '#f8fafc', border: '1px solid #cbd5e1', color: '#334155', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem' }}
style={{
background: hasReviews ? '#ecfdf5' : '#f8fafc',
border: hasReviews ? '1.5px solid #10b981' : '1px solid #cbd5e1',
color: hasReviews ? '#047857' : '#64748b',
padding: '0.35rem 0.55rem',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: hasReviews ? '800' : '600',
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
boxShadow: hasReviews ? '0 1px 2px rgba(16,185,129,0.15)' : 'none'
}}
title={hasReviews ? 'KI-Reviews wurden bereits erfolgreich erfasst' : 'KI-Reviews wurden noch nicht erfasst'}
>
🤖 Reviews
<span>{hasReviews ? '✅' : '🤖'}</span>
<span>{hasReviews ? `Reviews (${hotel.review_summary_count || hotel.review_count || 0})` : 'Reviews (fehlt)'}</span>
</button>
{/* PREISHISTORIE BUTTON INDICATOR */}
<button
onClick={() => openHotelDetails(hotel, 'history')}
style={{ background: '#0284c7', color: '#ffffff', border: 'none', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem' }}
style={{
background: hasPrices ? '#eff6ff' : '#f8fafc',
border: hasPrices ? '1.5px solid #3b82f6' : '1px solid #cbd5e1',
color: hasPrices ? '#1d4ed8' : '#64748b',
padding: '0.35rem 0.55rem',
borderRadius: '6px',
cursor: 'pointer',
fontWeight: hasPrices ? '800' : '600',
fontSize: '0.75rem',
display: 'flex',
alignItems: 'center',
gap: '0.25rem',
boxShadow: hasPrices ? '0 1px 2px rgba(59,130,246,0.15)' : 'none'
}}
title={hasPrices ? `Preise erfasst (Aktuell: ${Number(hotel.latest_price).toLocaleString('de-DE')} €)` : 'Noch keine Preise erfasst'}
>
📈 Preishistorie
<span>📈</span>
<span>{hasPrices ? 'Preishistorie' : 'Preishistorie (fehlt)'}</span>
</button>
<button
onClick={() => refreshHotelMetadata(hotel)}
@@ -1244,10 +1506,30 @@ export default function TravelDashboard() {
>
{scanningHotelId === hotel.id ? '🔄 Scanne...' : '🤖 KI-Review neu erfassen'}
</button>
<button
onClick={() => {
setSelectedTargetTripId('');
setCopyHotelTarget(hotel);
}}
style={{ background: '#f8fafc', color: '#0369a1', border: '1px solid #bae6fd', padding: '0.35rem 0.6rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem' }}
title="Dieses Hotel in einen anderen Trip kopieren / hinzufügen"
>
✈️ In Trip kopieren
</button>
<button
onClick={() => handleDeleteHotel(hotel)}
disabled={deletingHotelId === hotel.id}
style={{ background: '#fef2f2', color: '#dc2626', border: '1px solid #fecaca', padding: '0.35rem 0.55rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.75rem', opacity: deletingHotelId === hotel.id ? 0.6 : 1 }}
title="Hotel unwiderruflich aus der Datenbank löschen"
>
{deletingHotelId === hotel.id ? '🗑️...' : '🗑️ Löschen'}
</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>
);
})}
@@ -1294,7 +1576,7 @@ export default function TravelDashboard() {
}}
>
{/* Modal Header */}
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc' }}>
<div style={{ padding: '1.2rem 1.5rem', borderBottom: '1px solid #e2e8f0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', background: '#f8fafc', flexWrap: 'wrap', gap: '0.8rem' }}>
<div>
<h3 style={{ margin: 0, fontSize: '1.25rem', fontWeight: '800', color: '#0f172a' }}>
{selectedDetailHotel.name}
@@ -1303,14 +1585,36 @@ export default function TravelDashboard() {
🔗 Auf Booking.com öffnen ↗
</a>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.6rem' }}>
<button
type="button"
onClick={() => {
setSelectedTargetTripId('');
setCopyHotelTarget(selectedDetailHotel);
}}
style={{ background: '#f0f9ff', color: '#0369a1', border: '1px solid #bae6fd', padding: '0.4rem 0.75rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.8rem' }}
title="Dieses Hotel in einen anderen Trip kopieren"
>
✈️ In Trip kopieren
</button>
<button
type="button"
onClick={() => handleDeleteHotel(selectedDetailHotel)}
disabled={deletingHotelId === selectedDetailHotel.id}
style={{ background: '#fef2f2', color: '#dc2626', border: '1px solid #fecaca', padding: '0.4rem 0.75rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '700', fontSize: '0.8rem', opacity: deletingHotelId === selectedDetailHotel.id ? 0.6 : 1 }}
title="Hotel unwiderruflich löschen"
>
{deletingHotelId === selectedDetailHotel.id ? '🗑️...' : '🗑️ Löschen'}
</button>
<button
type="button"
onClick={() => setSelectedDetailHotel(null)}
style={{ background: '#e2e8f0', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem' }}
style={{ background: '#e2e8f0', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold', fontSize: '1rem', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
✕
</button>
</div>
</div>
{/* Tab Navigation mit dynamischer Review-Anzahl */}
<div style={{ display: 'flex', gap: '1rem', borderBottom: '2px solid #e2e8f0', padding: '0 1.5rem', background: '#f8fafc' }}>
@@ -1445,54 +1749,130 @@ export default function TravelDashboard() {
</div>
)}
{/* TAB 2: PREISHISTORIE (MIT WIEDERHERGESTELLTEM 2-KURVEN-CHART) */}
{/* TAB 2: PREISHISTORIE (7-TAGE RESTRIKTION, GESAMTTREND-TOGGLE & SAUBERES CHART) */}
{detailActiveTab === 'history' && (
<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 style={{ textAlign: 'center', padding: '2.5rem 1rem', background: '#f8fafc', borderRadius: '12px', border: '1px dashed #cbd5e1' }}>
<span style={{ fontSize: '2.5rem', display: 'block', marginBottom: '0.5rem' }}>📉</span>
<p style={{ margin: '0 0 0.5rem 0', fontWeight: '700', color: '#1e293b' }}>
Noch keine Preishistorie erfasst.
</p>
<p style={{ margin: '0 0 1rem 0', fontSize: '0.85rem', color: '#64748b' }}>
Klicke auf "Stammdaten aktualisieren" oder "Preise aktualisieren", um den ersten Messpunkt zu speichern.
</p>
</div>
) : (() => {
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 (
<div>
{/* ZEITRAUM FILTER TOGGLE BUTTONS */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1.2rem', flexWrap: 'wrap', gap: '0.6rem' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<span style={{ fontSize: '0.85rem', fontWeight: '800', color: '#0f172a' }}>
Ansicht:
</span>
<span style={{ fontSize: '0.8rem', color: '#64748b' }}>
({activePriceHistory.length} von {priceHistory.length} Messpunkten)
</span>
</div>
<div style={{ display: 'flex', gap: '0.35rem', background: '#f1f5f9', padding: '0.25rem', borderRadius: '8px', border: '1px solid #cbd5e1' }}>
<button
type="button"
onClick={() => setPriceHistoryRange('7d')}
style={{
padding: '0.35rem 0.8rem',
borderRadius: '6px',
border: 'none',
background: priceHistoryRange === '7d' ? '#2563eb' : 'transparent',
color: priceHistoryRange === '7d' ? '#ffffff' : '#475569',
fontWeight: '700',
fontSize: '0.8rem',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
📅 Letzte 7 Tage ({last7DaysPrices.length})
</button>
<button
type="button"
onClick={() => setPriceHistoryRange('all')}
style={{
padding: '0.35rem 0.8rem',
borderRadius: '6px',
border: 'none',
background: priceHistoryRange === 'all' ? '#2563eb' : 'transparent',
color: priceHistoryRange === 'all' ? '#ffffff' : '#475569',
fontWeight: '700',
fontSize: '0.8rem',
cursor: 'pointer',
transition: 'all 0.15s ease'
}}
>
📈 Gesamter Verlauf ({priceHistory.length})
</button>
</div>
</div>
{/* DUAL STATS CARDS */}
<div style={{ display: 'grid', gridTemplateColumns: hasAnyGeniusPrice ? '1fr 1fr' : '1fr', gap: '1rem', marginBottom: '1.5rem' }}>
<div style={{ display: 'grid', gridTemplateColumns: hasChartGenius ? '1fr 1fr' : '1fr', gap: '1rem', marginBottom: '1.5rem' }}>
<div style={{ background: '#f8fafc', padding: '0.9rem', borderRadius: '10px', border: '1px solid #cbd5e1' }}>
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#475569', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
🏷️ Regulärer Preis
🏷️ Regulärer Preis {priceHistoryRange === '7d' ? '(7 Tage)' : '(Gesamt)'}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.4rem', textAlign: 'center' }}>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<small style={{ color: '#166534', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Tiefst</small>
<strong style={{ color: '#15803d', fontSize: '1rem' }}>{minPrice} €</strong>
<strong style={{ color: '#15803d', fontSize: '1rem' }}>{chartMinPrice} €</strong>
</div>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<small style={{ color: '#1e40af', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Aktuell</small>
<strong style={{ color: '#1d4ed8', fontSize: '1rem' }}>{currentPrice} €</strong>
<strong style={{ color: '#1d4ed8', fontSize: '1rem' }}>{chartCurrentPrice} €</strong>
</div>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #e2e8f0' }}>
<small style={{ color: '#991b1b', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Höchst</small>
<strong style={{ color: '#b91c1c', fontSize: '1rem' }}>{maxPrice} €</strong>
<strong style={{ color: '#b91c1c', fontSize: '1rem' }}>{chartMaxPrice} €</strong>
</div>
</div>
</div>
{hasAnyGeniusPrice && (
{hasChartGenius && (
<div style={{ background: '#f0f7ff', padding: '0.9rem', borderRadius: '10px', border: '1.5px solid #93c5fd' }}>
<div style={{ fontSize: '0.78rem', fontWeight: '800', color: '#003580', textTransform: 'uppercase', marginBottom: '0.5rem' }}>
💙 Genius Vorteil (-10%)
💙 Genius Vorteil (-10%) {priceHistoryRange === '7d' ? '(7 Tage)' : '(Gesamt)'}
</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '0.4rem', textAlign: 'center' }}>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
<small style={{ color: '#003580', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Genius Min</small>
<strong style={{ color: '#003580', fontSize: '1rem' }}>{minGeniusPrice} € 🔥</strong>
<strong style={{ color: '#003580', fontSize: '1rem' }}>{chartMinGenius} € 🔥</strong>
</div>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
<small style={{ color: '#003580', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Aktuell</small>
<strong style={{ color: '#003580', fontSize: '1rem' }}>{currentGeniusPrice} €</strong>
<strong style={{ color: '#003580', fontSize: '1rem' }}>{chartCurrentGenius} €</strong>
</div>
<div style={{ background: '#fff', padding: '0.4rem', borderRadius: '6px', border: '1px solid #bfdbfe' }}>
<small style={{ color: '#64748b', fontWeight: '700', fontSize: '0.68rem', display: 'block' }}>Höchst</small>
<strong style={{ color: '#64748b', fontSize: '1rem' }}>{maxGeniusPrice} €</strong>
<strong style={{ color: '#64748b', fontSize: '1rem' }}>{chartMaxGenius} €</strong>
</div>
</div>
</div>
@@ -1500,46 +1880,46 @@ export default function TravelDashboard() {
</div>
{/* 2-KURVEN SVG-CHART */}
{priceHistory.length > 1 && (
{activePriceHistory.length > 1 ? (
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '10px', border: '1px solid #e2e8f0', marginBottom: '1.5rem' }}>
<div style={{ display: 'flex', gap: '1.2rem', justifyContent: 'center', marginBottom: '0.8rem', fontSize: '0.78rem', fontWeight: '700' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<div style={{ width: '14px', height: '3px', background: '#2563eb' }}></div>
<span style={{ color: '#2563eb' }}>Regulärer Preis</span>
</div>
{hasAnyGeniusPrice && (
{hasChartGenius && (
<div style={{ display: 'flex', alignItems: 'center', gap: '0.35rem' }}>
<div style={{ width: '14px', height: '3px', background: '#003580' }}></div>
<span style={{ color: '#003580' }}>Genius Preis (-10%)</span>
</div>
)}
</div>
<svg viewBox="0 0 500 160" style={{ width: '100%', height: '170px', overflow: 'visible' }}>
<svg viewBox="0 0 500 160" style={{ width: '100%', height: '180px', 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 allValues = [
...pricesList,
...geniusPricesList
...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 = priceHistory.map((p, idx) => {
const x = (idx / (priceHistory.length - 1)) * 500;
const y = 130 - ((Number(p.price) - chartMin) / range) * 110;
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 = priceHistory
const geniusPoints = activePriceHistory
.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 };
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 } => pt !== null);
.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(' ');
@@ -1547,15 +1927,26 @@ export default function TravelDashboard() {
<>
{/* Normal Line */}
<polyline fill="none" stroke="#2563eb" strokeWidth="2.5" strokeDasharray="4,3" points={normalPoints} />
{priceHistory.map((p, idx) => {
const x = (idx / (priceHistory.length - 1)) * 500;
const y = 130 - ((Number(p.price) - chartMin) / range) * 110;
{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 (
<g key={`norm-${p.id || idx}`}>
<circle cx={x} cy={y} r="4" fill="#2563eb" stroke="#ffffff" strokeWidth="1.5" />
<text x={x} y={y - 8} fontSize="9.5" textAnchor="middle" fill="#1e40af" fontWeight="bold">
<circle cx={x} cy={y} r={showLabel ? 4.5 : 3} fill="#2563eb" stroke="#ffffff" strokeWidth="1.5">
<title>{`${Number(p.price).toLocaleString('de-DE')} € am ${new Date(p.created_at).toLocaleString('de-DE')}`}</title>
</circle>
{showLabel && (
<text x={x} y={y - 8} fontSize="9" textAnchor="middle" fill="#1e40af" fontWeight="bold">
{p.price}€
</text>
)}
</g>
);
})}
@@ -1564,19 +1955,40 @@ export default function TravelDashboard() {
{geniusPoints.length > 1 && (
<polyline fill="none" stroke="#003580" strokeWidth="3" points={geniusPolylineStr} />
)}
{geniusPoints.map((pt, idx) => (
{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 (
<g key={`gen-${pt.id || idx}`}>
<circle cx={pt.x} cy={pt.y} r="4.5" fill="#003580" stroke="#ffffff" strokeWidth="1.5" />
<text x={pt.x} y={pt.y + 14} fontSize="9.5" textAnchor="middle" fill="#003580" fontWeight="900">
<circle cx={pt.x} cy={pt.y} r={showLabel ? 5 : 3.5} fill="#003580" stroke="#ffffff" strokeWidth="1.5">
<title>{`Genius: ${Number(pt.val).toLocaleString('de-DE')} € am ${new Date(pt.date).toLocaleString('de-DE')}`}</title>
</circle>
{showLabel && (
<text x={pt.x} y={pt.y + 14} fontSize="9" textAnchor="middle" fill="#003580" fontWeight="900">
{pt.val}€
</text>
)}
</g>
))}
);
})}
</>
);
})()}
</svg>
</div>
) : (
<div style={{ background: '#f8fafc', padding: '1rem 1.2rem', borderRadius: '10px', border: '1px dashed #cbd5e1', marginBottom: '1.5rem', textAlign: 'center' }}>
<p style={{ margin: 0, fontSize: '0.9rem', color: '#475569', fontWeight: '600' }}>
📌 1 Messpunkt vorhanden: <strong>{activePriceHistory[0]?.price} €</strong> ({new Date(activePriceHistory[0]?.created_at).toLocaleString('de-DE')})
</p>
<p style={{ margin: '0.3rem 0 0 0', fontSize: '0.8rem', color: '#94a3b8' }}>
Sobald weitere Scans durchgeführt werden, zeichnet das System automatisch den interaktiven Verlaufschart.
</p>
</div>
)}
{/* HISTORIEN TABELLE */}
@@ -1591,12 +2003,12 @@ export default function TravelDashboard() {
</tr>
</thead>
<tbody>
{priceHistory.slice().reverse().map((entry, idx) => {
{activePriceHistory.slice().reverse().map((entry, idx) => {
const hasGen = entry.genius_price !== null && entry.genius_price !== undefined;
return (
<tr key={entry.id || idx} style={{ borderBottom: '1px solid #f1f5f9' }}>
<td style={{ padding: '0.5rem 0.75rem', color: '#64748b' }}>
{new Date(entry.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
{new Date(entry.created_at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })}
</td>
<td style={{ padding: '0.5rem 0.75rem', fontWeight: '700', color: '#1e293b' }}>
{Number(entry.price).toLocaleString('de-DE')} €
@@ -1614,7 +2026,8 @@ export default function TravelDashboard() {
</table>
</div>
</div>
)}
);
})()}
</div>
)}
@@ -2069,11 +2482,61 @@ export default function TravelDashboard() {
</div>
)}
{/* MODAL: HOTEL HINZUFÜGEN */}
{/* MODAL: HOTEL HINZUFÜGEN (EINZELN ODER BULK) */}
{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>
<div style={{ background: '#fff', padding: '2rem', borderRadius: '16px', maxWidth: '560px', width: '100%', boxShadow: '0 25px 50px -12px rgba(0,0,0,0.35)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, fontSize: '1.35rem', fontWeight: '800', color: '#0f172a' }}>
🏨 Hotel(s) hinzufügen
</h2>
<button
type="button"
onClick={() => setShowAddHotelModal(false)}
style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}
>
✕
</button>
</div>
{/* TAB SELECTOR: EINZELN VS. BULK */}
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1.2rem', borderBottom: '1px solid #e2e8f0', paddingBottom: '0.5rem' }}>
<button
type="button"
onClick={() => setAddHotelMode('single')}
style={{
padding: '0.5rem 1rem',
borderRadius: '8px',
border: 'none',
background: addHotelMode === 'single' ? '#10b981' : '#f1f5f9',
color: addHotelMode === 'single' ? '#ffffff' : '#475569',
fontWeight: '700',
fontSize: '0.85rem',
cursor: 'pointer'
}}
>
➕ Einzelner Link
</button>
<button
type="button"
onClick={() => setAddHotelMode('bulk')}
style={{
padding: '0.5rem 1rem',
borderRadius: '8px',
border: 'none',
background: addHotelMode === 'bulk' ? '#10b981' : '#f1f5f9',
color: addHotelMode === 'bulk' ? '#ffffff' : '#475569',
fontWeight: '700',
fontSize: '0.85rem',
cursor: 'pointer'
}}
>
📋 Mehrere Links (Bulk Insert)
</button>
</div>
{/* FORMULAR: EINZELNES HOTEL */}
{addHotelMode === 'single' ? (
<form onSubmit={handleAddHotel} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Reise zuordnen</label>
@@ -2106,6 +2569,60 @@ export default function TravelDashboard() {
</button>
</div>
</form>
) : (
/* FORMULAR: BULK INSERT */
<form onSubmit={handleBulkAddHotels} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Reise zuordnen</label>
<select
value={newHotelTripId}
onChange={e => setNewHotelTripId(e.target.value)}
style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db', background: '#fff', fontSize: '0.95rem' }}
>
{trips.length === 0 && <option value="">Keine Reise vorhanden</option>}
{trips.map(trip => (
<option key={trip.id} value={String(trip.id)}>
🌍 {trip.name}
</option>
))}
<option value="">Keiner Reise zuordnen</option>
</select>
</div>
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.3rem' }}>
<label style={{ fontSize: '0.85rem', fontWeight: 'bold' }}>Booking.com Links (ein Link pro Zeile) *</label>
<span style={{ fontSize: '0.75rem', color: '#059669', fontWeight: '700' }}>
{bulkBookingUrls.split(/[\r\n]+/).filter(u => u.trim().includes('booking.com/hotel')).length} Links erkannt
</span>
</div>
<textarea
required
rows={6}
placeholder={`https://www.booking.com/hotel/in/hotel-1.html\nhttps://www.booking.com/hotel/in/hotel-2.html\nhttps://www.booking.com/hotel/in/hotel-3.html`}
value={bulkBookingUrls}
onChange={e => setBulkBookingUrls(e.target.value)}
style={{
width: '100%',
padding: '0.7rem',
borderRadius: '8px',
border: '1px solid #d1d5db',
fontFamily: 'monospace',
fontSize: '0.82rem',
lineHeight: '1.4'
}}
/>
<small style={{ color: '#64748b', fontSize: '0.75rem', marginTop: '0.2rem', display: 'block' }}>
💡 Tipp: Du kannst einfach mehrere Booking.com URLs untereinander einfügen. Name und Daten werden automatisch gescrapt.
</small>
</div>
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end', marginTop: '0.5rem' }}>
<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={isSubmittingBulk || !bulkBookingUrls.trim()} style={{ padding: '0.6rem 1.3rem', background: '#10b981', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold', opacity: (isSubmittingBulk || !bulkBookingUrls.trim()) ? 0.6 : 1 }}>
{isSubmittingBulk ? '⏳ Importiere...' : '🚀 Alle importieren & erfassen'}
</button>
</div>
</form>
)}
</div>
</div>
)}
@@ -2263,6 +2780,121 @@ export default function TravelDashboard() {
</div>
</div>
)}
{/* ======================================================== */}
{/* MODAL: HOTEL IN ANDEREN TRIP KOPIEREN */}
{/* ======================================================== */}
{copyHotelTarget && (
<div
onClick={() => setCopyHotelTarget(null)}
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: 10000,
padding: '1rem'
}}
>
<div
onClick={(e) => e.stopPropagation()}
style={{
background: '#ffffff',
borderRadius: '16px',
maxWidth: '480px',
width: '100%',
padding: '1.75rem',
boxShadow: '0 25px 50px -12px rgba(0,0,0,0.35)'
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3 style={{ margin: 0, fontSize: '1.2rem', color: '#0f172a', fontWeight: '800' }}>
✈️ Hotel in Trip kopieren
</h3>
<button
type="button"
onClick={() => setCopyHotelTarget(null)}
style={{ background: '#f1f5f9', border: 'none', borderRadius: '50%', width: '30px', height: '30px', cursor: 'pointer', fontWeight: 'bold' }}
>
✕
</button>
</div>
<p style={{ margin: '0 0 1.25rem 0', color: '#475569', fontSize: '0.9rem', lineHeight: '1.4' }}>
Wähle die Ziel-Reise aus, zu der <strong>{copyHotelTarget.name}</strong> hinzugefügt werden soll:
</p>
<form onSubmit={handleCopyHotelToTrip}>
<div style={{ marginBottom: '1.25rem' }}>
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: '700', color: '#334155', marginBottom: '0.4rem' }}>
Ziel-Reise / Trip *
</label>
<select
value={selectedTargetTripId}
onChange={(e) => setSelectedTargetTripId(e.target.value)}
required
style={{
width: '100%',
padding: '0.65rem 0.8rem',
borderRadius: '8px',
border: '1px solid #cbd5e1',
fontSize: '0.95rem',
background: '#ffffff',
color: '#0f172a'
}}
>
<option value="">-- Bitte Reise wählen --</option>
{trips.map(t => (
<option key={t.id} value={String(t.id)}>
🌍 {t.name} {String(t.id) === String(copyHotelTarget.trip_id) ? '(Bereits in diesem Trip)' : ''}
</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'flex-end' }}>
<button
type="button"
onClick={() => setCopyHotelTarget(null)}
style={{
background: '#f1f5f9',
color: '#475569',
border: '1px solid #cbd5e1',
padding: '0.6rem 1.1rem',
borderRadius: '8px',
fontWeight: '700',
cursor: 'pointer'
}}
>
Abbrechen
</button>
<button
type="submit"
disabled={isCopyingHotel || !selectedTargetTripId}
style={{
background: '#0284c7',
color: '#ffffff',
border: 'none',
padding: '0.6rem 1.3rem',
borderRadius: '8px',
fontWeight: '700',
cursor: 'pointer',
opacity: (isCopyingHotel || !selectedTargetTripId) ? 0.6 : 1
}}
>
{isCopyingHotel ? '⏳ Kopiere...' : '✈️ Zu Trip hinzufügen'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

111
src/lib/telegram.ts Normal file
View File

@@ -0,0 +1,111 @@
export interface PriceNotificationPayload {
hotelName: string;
tripName?: string | null;
oldPrice: number | null;
newPrice: number;
newGeniusPrice?: number | null;
allTimeBestPrice?: number | null;
isNewBestPrice: boolean;
isPriceDrop: boolean;
checkinDate?: string | null;
checkoutDate?: string | null;
nights?: number | null;
persons?: number | null;
bookingUrl: string;
}
export async function sendTelegramMessage(htmlText: string): Promise<{ success: boolean; error?: string }> {
const botToken = process.env.TELEGRAM_BOT_TOKEN;
const chatId = process.env.TELEGRAM_CHAT_ID;
if (!botToken || !chatId) {
console.log('[Telegram] Keine TELEGRAM_BOT_TOKEN oder TELEGRAM_CHAT_ID in .env gesetzt – überspringe Benachrichtigung.');
return { success: false, error: 'Telegram Bot Token oder Chat ID nicht konfiguriert.' };
}
try {
const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chat_id: chatId,
text: htmlText,
parse_mode: 'HTML',
disable_web_page_preview: false,
}),
});
const data = await response.json();
if (!data.ok) {
console.error('[Telegram] API Fehler:', data.description);
return { success: false, error: data.description };
}
console.log('[Telegram] ✅ Nachricht erfolgreich versendet!');
return { success: true };
} catch (err: any) {
console.error('[Telegram] Netzwerk- oder Request-Fehler:', err.message);
return { success: false, error: err.message };
}
}
export async function notifyPriceChange(payload: PriceNotificationPayload): Promise<{ success: boolean; error?: string }> {
const {
hotelName,
tripName,
oldPrice,
newPrice,
newGeniusPrice,
isNewBestPrice,
isPriceDrop,
checkinDate,
checkoutDate,
nights,
persons,
bookingUrl
} = payload;
let badge = '📉 <b>PREIS GESUNKEN!</b>';
if (isNewBestPrice) {
badge = '🔥 <b>NEUER ALLZEIT-TIEFSTPREIS!</b>';
}
let deltaStr = '';
if (oldPrice !== null && oldPrice > 0 && oldPrice !== newPrice) {
const diff = newPrice - oldPrice;
const pct = Math.round(((oldPrice - newPrice) / oldPrice) * 100);
deltaStr = `\n📉 <b>Ersparnis:</b> ${Math.abs(diff)} € (${pct}% günstiger! Vorher: ${oldPrice} €)`;
}
let geniusStr = '';
if (newGeniusPrice && newGeniusPrice > 0) {
geniusStr = `\n💙 <b>Genius-Preis (-10%):</b> <b>${newGeniusPrice} €</b>`;
}
let dateStr = '';
if (checkinDate && checkoutDate) {
const formatDate = (d: string) => {
try {
return new Date(d).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
} catch {
return d;
}
};
dateStr = `\n🗓️ <b>Reisezeitraum:</b> ${formatDate(checkinDate)} – ${formatDate(checkoutDate)}`;
if (nights) {
dateStr += ` (${nights} ${nights === 1 ? 'Nacht' : 'Nächte'}${persons ? ` · ${persons} Pers.` : ''})`;
}
}
const tripStr = tripName ? `\n🌍 <b>Reise:</b> ${tripName}` : '';
const message = `${badge}
🏨 <b>${hotelName}</b>${tripStr}
💰 <b>Neuer Preis:</b> <b>${newPrice} €</b>${geniusStr}${deltaStr}${dateStr}
<a href="${bookingUrl}">👉 Direkt zu Booking.com öffnen ↗</a>`;
return sendTelegramMessage(message);
}