Working state 1
This commit is contained in:
11
.env
Normal file
11
.env
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
# 1. Datenbank-Verbindung (Ersetze <POSTGRES_IP> und das Passwort)
|
||||||
|
DATABASE_URL=postgres://travel_user:B8NJUD0tu46wPiGpyspoU6D0Cx1NWg@192.168.178.103:5432/travel_data
|
||||||
|
|
||||||
|
# 2. URL deines Scraper-Microservices (Port 3000)
|
||||||
|
SCRAPER_API_URL=http://192.168.178.133:3000/scrape
|
||||||
|
|
||||||
|
# 3. Webhook-URL deiner Web-App für Rückmeldungen (Port 3001)
|
||||||
|
APP_WEBHOOK_URL=http://192.168.178.133:3002/api/webhooks/scraper
|
||||||
|
|
||||||
|
# 4. Port der Web-App
|
||||||
|
PORT=3002
|
||||||
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 3001
|
||||||
|
ENV PORT=3001
|
||||||
|
|
||||||
|
CMD ["npm", "start"]
|
||||||
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
services:
|
||||||
|
travel-app:
|
||||||
|
build: .
|
||||||
|
container_name: travel-app
|
||||||
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "3002:3001"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "travel-planner-app",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "next dev -p 3001",
|
||||||
|
"build": "next build",
|
||||||
|
"start": "next start -p 3001"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"next": "^14.2.0",
|
||||||
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0",
|
||||||
|
"pg": "^8.11.5",
|
||||||
|
"axios": "^1.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^20.0.0",
|
||||||
|
"@types/react": "^18.3.0",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
|
"typescript": "^5.0.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"autoprefixer": "^10.4.0",
|
||||||
|
"postcss": "^8.4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/app/api/hotels/[id]/history/route.ts
Normal file
30
src/app/api/hotels/[id]/history/route.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
const res = await query(
|
||||||
|
`SELECT * FROM hotel_price_history
|
||||||
|
WHERE hotel_id = $1
|
||||||
|
ORDER BY tracked_at DESC`,
|
||||||
|
[id]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Alle SQL-NUMERIC Strings in echte JS-Numbers umwandeln
|
||||||
|
const parsedHistory = res.rows.map((entry) => ({
|
||||||
|
...entry,
|
||||||
|
price_numeric: entry.price_numeric ? parseFloat(entry.price_numeric) : 0,
|
||||||
|
genius_price_numeric: entry.genius_price_numeric ? parseFloat(entry.genius_price_numeric) : null,
|
||||||
|
price_per_night: entry.price_per_night ? parseFloat(entry.price_per_night) : null,
|
||||||
|
price_per_person: entry.price_per_person ? parseFloat(entry.price_per_person) : null,
|
||||||
|
nights: entry.nights ? parseInt(entry.nights, 10) : 1,
|
||||||
|
persons: entry.persons ? parseInt(entry.persons, 10) : 2,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, history: parsedHistory });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error fetching history:', e);
|
||||||
|
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
26
src/app/api/hotels/[id]/route.ts
Normal file
26
src/app/api/hotels/[id]/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
// PATCH: Tracking aktivieren / deaktivieren (is_active)
|
||||||
|
export async function PATCH(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
const { is_active } = await req.json();
|
||||||
|
|
||||||
|
await query('UPDATE hotels SET is_active = $1 WHERE id = $2', [is_active, id]);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE: Hotel entfernen
|
||||||
|
export async function DELETE(req: NextRequest, { params }: { params: { id: string } }) {
|
||||||
|
try {
|
||||||
|
const { id } = params;
|
||||||
|
await query('DELETE FROM hotels WHERE id = $1', [id]);
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/app/api/hotels/route.ts
Normal file
21
src/app/api/hotels/route.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { trip_id, booking_url, name } = await req.json();
|
||||||
|
if (!trip_id || !booking_url) {
|
||||||
|
return NextResponse.json({ error: 'trip_id und booking_url sind erforderlich' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await query(
|
||||||
|
`INSERT INTO hotels (trip_id, booking_url, name, is_active)
|
||||||
|
VALUES ($1, $2, $3, true) RETURNING *`,
|
||||||
|
[trip_id, booking_url, name || 'Booking Hotel']
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, hotel: res.rows[0] });
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/app/api/jobs/trigger-daily-check/route.ts
Normal file
36
src/app/api/jobs/trigger-daily-check/route.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
try {
|
||||||
|
// 1. Alle aktiven Hotels aus der Datenbank holen
|
||||||
|
const result = await query(
|
||||||
|
'SELECT id, booking_url FROM hotels WHERE is_active = true'
|
||||||
|
);
|
||||||
|
const activeHotels = result.rows;
|
||||||
|
|
||||||
|
const scraperUrl = process.env.SCRAPER_API_URL || 'http://localhost:3000/scrape';
|
||||||
|
const webhookUrl = process.env.APP_WEBHOOK_URL || 'http://localhost:3001/api/webhooks/scraper';
|
||||||
|
|
||||||
|
let triggeredCount = 0;
|
||||||
|
|
||||||
|
// 2. Pro Hotel einen Scrape-Job an den Scraper-Microservice feuern
|
||||||
|
for (const hotel of activeHotels) {
|
||||||
|
await axios.post(scraperUrl, {
|
||||||
|
url: hotel.booking_url,
|
||||||
|
webhookUrl: `${webhookUrl}?hotelId=${hotel.id}` // Hotel-ID als URL-Param übergeben
|
||||||
|
});
|
||||||
|
triggeredCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
success: true,
|
||||||
|
message: `${triggeredCount} Scrape-Jobs an den Scraper übergeben.`
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Trigger Job Error:', error);
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/app/api/trips/route.ts
Normal file
88
src/app/api/trips/route.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const tripsRes = await query('SELECT * FROM trips ORDER BY created_at DESC');
|
||||||
|
const trips = tripsRes.rows;
|
||||||
|
|
||||||
|
const hotelsRes = await query(`
|
||||||
|
SELECT h.*,
|
||||||
|
ph.price_numeric as latest_price,
|
||||||
|
ph.genius_price_numeric as latest_genius_price,
|
||||||
|
ph.price_per_night,
|
||||||
|
ph.price_per_person,
|
||||||
|
ph.nights,
|
||||||
|
ph.persons,
|
||||||
|
ph.tracked_at as last_tracked
|
||||||
|
FROM hotels h
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT * FROM hotel_price_history
|
||||||
|
WHERE hotel_id = h.id
|
||||||
|
ORDER BY tracked_at DESC LIMIT 1
|
||||||
|
) ph ON true
|
||||||
|
ORDER BY h.created_at DESC
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Zahlen-Typen parsen & fehlende Raten bei älteren Einträgen live berechnen
|
||||||
|
const parsedHotels = hotelsRes.rows.map((h) => {
|
||||||
|
const latest_price = h.latest_price ? parseFloat(h.latest_price) : null;
|
||||||
|
let latest_genius_price = h.latest_genius_price ? parseFloat(h.latest_genius_price) : null;
|
||||||
|
if (!latest_genius_price && latest_price) {
|
||||||
|
latest_genius_price = parseFloat((latest_price * 0.90).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const nights = h.nights ? parseInt(h.nights, 10) : 1;
|
||||||
|
const persons = h.persons ? parseInt(h.persons, 10) : 2;
|
||||||
|
|
||||||
|
const activePrice = latest_genius_price || latest_price;
|
||||||
|
|
||||||
|
// Fallback-Berechnung falls DB-Feld NULL ist
|
||||||
|
let price_per_night = h.price_per_night ? parseFloat(h.price_per_night) : null;
|
||||||
|
if (!price_per_night && activePrice && nights > 0) {
|
||||||
|
price_per_night = parseFloat((activePrice / nights).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
let price_per_person = h.price_per_person ? parseFloat(h.price_per_person) : null;
|
||||||
|
if (!price_per_person && activePrice && nights > 0 && persons > 0) {
|
||||||
|
price_per_person = parseFloat((activePrice / (nights * persons)).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...h,
|
||||||
|
rating: h.rating ? parseFloat(h.rating) : null,
|
||||||
|
latest_price,
|
||||||
|
latest_genius_price,
|
||||||
|
price_per_night,
|
||||||
|
price_per_person,
|
||||||
|
nights,
|
||||||
|
persons,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const tripsWithHotels = trips.map((trip) => ({
|
||||||
|
...trip,
|
||||||
|
hotels: parsedHotels.filter((h) => h.trip_id === trip.id),
|
||||||
|
}));
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true, trips: tripsWithHotels });
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error('Error in GET /api/trips:', e);
|
||||||
|
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { title, destination } = await req.json();
|
||||||
|
if (!title) return NextResponse.json({ error: 'Titel ist erforderlich' }, { status: 400 });
|
||||||
|
|
||||||
|
const res = await query(
|
||||||
|
'INSERT INTO trips (title, destination) VALUES ($1, $2) RETURNING *',
|
||||||
|
[title, destination || null]
|
||||||
|
);
|
||||||
|
return NextResponse.json({ success: true, trip: res.rows[0] });
|
||||||
|
} catch (e: any) {
|
||||||
|
return NextResponse.json({ success: false, error: e.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
90
src/app/api/webhooks/scraper/route.ts
Normal file
90
src/app/api/webhooks/scraper/route.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { query } from '@/lib/db';
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const hotelId = searchParams.get('hotelId');
|
||||||
|
const body = await req.json();
|
||||||
|
|
||||||
|
const {
|
||||||
|
price,
|
||||||
|
roomSizeM2,
|
||||||
|
hotelName,
|
||||||
|
rating,
|
||||||
|
reviewCount,
|
||||||
|
address,
|
||||||
|
image,
|
||||||
|
nights = 1,
|
||||||
|
persons = 2,
|
||||||
|
} = body;
|
||||||
|
|
||||||
|
if (!hotelId) {
|
||||||
|
return NextResponse.json({ error: 'hotelId fehlt' }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseNum = (val: any): number | null => {
|
||||||
|
if (typeof val === 'number') return val;
|
||||||
|
if (!val || typeof val !== 'string') return null;
|
||||||
|
const match = val.replace(',', '.').match(/\d+(\.\d+)?/);
|
||||||
|
return match ? parseFloat(match[0]) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const numPrice = parseNum(price);
|
||||||
|
|
||||||
|
// Automatisch 10 % Genius Rabatt berechnen
|
||||||
|
let numGenius: number | null = null;
|
||||||
|
if (numPrice !== null) {
|
||||||
|
numGenius = parseFloat((numPrice * 0.90).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ratenberechnung auf Basis des Genius-Preises
|
||||||
|
const basePrice = numGenius || numPrice;
|
||||||
|
let pricePerNight = null;
|
||||||
|
let pricePerPersonPerNight = null;
|
||||||
|
|
||||||
|
if (basePrice !== null && nights > 0 && persons > 0) {
|
||||||
|
// 1. Zimmerpreis pro Nacht
|
||||||
|
pricePerNight = parseFloat((basePrice / nights).toFixed(2));
|
||||||
|
// 2. Preis pro Person & Nacht
|
||||||
|
pricePerPersonPerNight = parseFloat((basePrice / (nights * persons)).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. In Preishistorie eintragen
|
||||||
|
if (numPrice !== null) {
|
||||||
|
await query(
|
||||||
|
`INSERT INTO hotel_price_history
|
||||||
|
(hotel_id, price_numeric, genius_price_numeric, price_raw, price_per_night, price_per_person, nights, persons)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
||||||
|
[
|
||||||
|
hotelId,
|
||||||
|
numPrice,
|
||||||
|
numGenius,
|
||||||
|
price ? String(price) : null,
|
||||||
|
pricePerNight,
|
||||||
|
pricePerPersonPerNight,
|
||||||
|
nights,
|
||||||
|
persons,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Hotel Stammdaten aktualisieren
|
||||||
|
await query(
|
||||||
|
`UPDATE hotels
|
||||||
|
SET name = COALESCE($1, name),
|
||||||
|
rating = COALESCE($2, rating),
|
||||||
|
review_count = COALESCE($3, review_count),
|
||||||
|
address = COALESCE($4, address),
|
||||||
|
image_url = COALESCE($5, image_url),
|
||||||
|
room_size_m2 = COALESCE($6, room_size_m2)
|
||||||
|
WHERE id = $7`,
|
||||||
|
[hotelName, rating, reviewCount, address, image, roomSizeM2, hotelId]
|
||||||
|
);
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Webhook Error:', error);
|
||||||
|
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/app/layout.tsx
Normal file
20
src/app/layout.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: 'Reiseplaner App',
|
||||||
|
description: 'Hotelpreise und Reisen tracken',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<html lang="de">
|
||||||
|
<body style={{ margin: 0, padding: 0, backgroundColor: '#fafafa' }}>
|
||||||
|
{children}
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
347
src/app/page.tsx
Normal file
347
src/app/page.tsx
Normal file
@@ -0,0 +1,347 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
interface Hotel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
booking_url: string;
|
||||||
|
is_active: boolean;
|
||||||
|
latest_price: number | string | null;
|
||||||
|
latest_genius_price: number | string | null;
|
||||||
|
price_per_night: number | string | null;
|
||||||
|
price_per_person: number | string | null;
|
||||||
|
nights: number | null;
|
||||||
|
persons: number | null;
|
||||||
|
rating: number | string | null;
|
||||||
|
review_count: number | null;
|
||||||
|
room_size_m2: number | null;
|
||||||
|
address: string | null;
|
||||||
|
image_url: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Trip {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
destination: string | null;
|
||||||
|
hotels: Hotel[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PriceHistoryEntry {
|
||||||
|
id: string;
|
||||||
|
price_numeric: number;
|
||||||
|
genius_price_numeric: number | null;
|
||||||
|
price_per_night: number | null;
|
||||||
|
price_per_person: number | null;
|
||||||
|
nights: number | null;
|
||||||
|
persons: number | null;
|
||||||
|
tracked_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const [trips, setTrips] = useState<Trip[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
// Detail / History Modal State
|
||||||
|
const [selectedHotel, setSelectedHotel] = useState<Hotel | null>(null);
|
||||||
|
const [history, setHistory] = useState<PriceHistoryEntry[]>([]);
|
||||||
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
|
||||||
|
// Formular-States
|
||||||
|
const [newTripTitle, setNewTripTitle] = useState('');
|
||||||
|
const [newTripDest, setNewTripDest] = useState('');
|
||||||
|
const [selectedTripId, setSelectedTripId] = useState<string | null>(null);
|
||||||
|
const [newBookingUrl, setNewBookingUrl] = useState('');
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/trips');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) setTrips(data.trips);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden:', e);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => { loadData(); }, []);
|
||||||
|
|
||||||
|
// Preishistorie Modal öffnen
|
||||||
|
const openHotelDetails = async (hotel: Hotel) => {
|
||||||
|
setSelectedHotel(hotel);
|
||||||
|
setHistory([]);
|
||||||
|
setLoadingHistory(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/hotels/${hotel.id}/history`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) setHistory(data.history);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Fehler beim Laden der Historie:', e);
|
||||||
|
} finally {
|
||||||
|
setLoadingHistory(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateTrip = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!newTripTitle) return;
|
||||||
|
await fetch('/api/trips', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ title: newTripTitle, destination: newTripDest })
|
||||||
|
});
|
||||||
|
setNewTripTitle('');
|
||||||
|
setNewTripDest('');
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddHotel = async (tripId: string) => {
|
||||||
|
if (!newBookingUrl) return alert('Bitte Booking.com URL eingeben');
|
||||||
|
await fetch('/api/hotels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ trip_id: tripId, booking_url: newBookingUrl })
|
||||||
|
});
|
||||||
|
setNewBookingUrl('');
|
||||||
|
setSelectedTripId(null);
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleHotelActive = async (e: React.SyntheticEvent, hotelId: string, currentStatus: boolean) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
await fetch(`/api/hotels/${hotelId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ is_active: !currentStatus })
|
||||||
|
});
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteHotel = async (e: React.MouseEvent, hotelId: string) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!confirm('Hotel wirklich löschen?')) return;
|
||||||
|
await fetch(`/api/hotels/${hotelId}`, { method: 'DELETE' });
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerScrape = async () => {
|
||||||
|
alert('Scrape-Prozess gestartet. Preise werden im Hintergrund geladen...');
|
||||||
|
await fetch('/api/jobs/trigger-daily-check', { method: 'POST' });
|
||||||
|
setTimeout(() => loadData(), 10000);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={styles.container}>
|
||||||
|
<header style={styles.header}>
|
||||||
|
<h1>✈️ Reiseplaner & Preis-Tracker</h1>
|
||||||
|
<button onClick={triggerScrape} style={styles.primaryBtn}>🔄 Alle Hotels scannen</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Neue Reise */}
|
||||||
|
<section style={styles.card}>
|
||||||
|
<h3>➕ Neue Reise planen</h3>
|
||||||
|
<form onSubmit={handleCreateTrip} style={styles.inlineForm}>
|
||||||
|
<input type="text" placeholder="Titel (z.B. Italien 2026)" value={newTripTitle} onChange={(e) => setNewTripTitle(e.target.value)} style={styles.input} />
|
||||||
|
<input type="text" placeholder="Zielort (z.B. Rom)" value={newTripDest} onChange={(e) => setNewTripDest(e.target.value)} style={styles.input} />
|
||||||
|
<button type="submit" style={styles.btn}>Reise erstellen</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Reisen Liste */}
|
||||||
|
{loading ? <p>Lade Reisen...</p> : trips.map((trip) => (
|
||||||
|
<section key={trip.id} style={styles.tripCard}>
|
||||||
|
<div style={styles.tripHeader}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0 }}>{trip.title}</h2>
|
||||||
|
<small style={{ color: '#666' }}>📍 {trip.destination || 'Kein Zielort angegeben'}</small>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedTripId(selectedTripId === trip.id ? null : trip.id)} style={styles.btnSecondary}>
|
||||||
|
+ Hotel hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTripId === trip.id && (
|
||||||
|
<div style={styles.addHotelBox}>
|
||||||
|
<input type="text" placeholder="Booking.com URL..." value={newBookingUrl} onChange={(e) => setNewBookingUrl(e.target.value)} style={{ ...styles.input, width: '100%', marginBottom: '10px' }} />
|
||||||
|
<button onClick={() => handleAddHotel(trip.id)} style={styles.primaryBtn}>Speichern</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ marginTop: '1rem' }}>
|
||||||
|
{trip.hotels.length === 0 ? (
|
||||||
|
<p style={{ color: '#999', fontStyle: 'italic' }}>Noch keine Hotels angelegt.</p>
|
||||||
|
) : (
|
||||||
|
trip.hotels.map((hotel) => {
|
||||||
|
const pReg = hotel.latest_price !== null ? parseFloat(String(hotel.latest_price)) : null;
|
||||||
|
const pGenius = hotel.latest_genius_price !== null ? parseFloat(String(hotel.latest_genius_price)) : (pReg ? pReg * 0.9 : null);
|
||||||
|
|
||||||
|
const nights = hotel.nights ? parseInt(String(hotel.nights), 10) : 1;
|
||||||
|
const persons = hotel.persons ? parseInt(String(hotel.persons), 10) : 2;
|
||||||
|
|
||||||
|
const activePrice = pGenius || pReg;
|
||||||
|
|
||||||
|
// Dynamische Raten-Berechnung als Fallback
|
||||||
|
const pNight = hotel.price_per_night
|
||||||
|
? parseFloat(String(hotel.price_per_night)).toFixed(2)
|
||||||
|
: (activePrice ? (activePrice / nights).toFixed(2) : '-');
|
||||||
|
|
||||||
|
const pPerson = hotel.price_per_person
|
||||||
|
? parseFloat(String(hotel.price_per_person)).toFixed(2)
|
||||||
|
: (activePrice ? (activePrice / (nights * persons)).toFixed(2) : '-');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={hotel.id}
|
||||||
|
onClick={() => openHotelDetails(hotel)}
|
||||||
|
style={{
|
||||||
|
...styles.hotelRow,
|
||||||
|
opacity: hotel.is_active ? 1 : 0.6,
|
||||||
|
backgroundColor: hotel.is_active ? '#fff' : '#f9f9f9',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
|
||||||
|
{hotel.image_url && (
|
||||||
|
<img src={hotel.image_url} alt="" style={{ width: '75px', height: '75px', borderRadius: '6px', objectFit: 'cover' }} />
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<strong style={{ fontSize: '1.05rem' }}>{hotel.name || 'Unbekanntes Hotel'}</strong>
|
||||||
|
<div><small style={{ color: '#666' }}>{hotel.address || 'Keine Adresse'}</small></div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '6px', alignItems: 'center' }}>
|
||||||
|
{hotel.rating && <small style={styles.badge}>⭐ {hotel.rating} ({hotel.review_count || 0} Bew.)</small>}
|
||||||
|
{hotel.room_size_m2 && <small style={styles.badge}>📐 {hotel.room_size_m2} m²</small>}
|
||||||
|
|
||||||
|
<a
|
||||||
|
href={hotel.booking_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
style={styles.bookingLinkBtn}
|
||||||
|
>
|
||||||
|
🔗 Auf Booking.com ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '1.5rem' }}>
|
||||||
|
<div style={{ textAlign: 'right', minWidth: '170px' }}>
|
||||||
|
{pReg !== null ? (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '0.78rem', color: '#888', textDecoration: 'line-through' }}>
|
||||||
|
Regulär: {pReg.toFixed(2)} €
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '1.25rem', fontWeight: 'bold', color: '#2e7d32' }}>
|
||||||
|
<span style={styles.geniusTag}>Genius</span>
|
||||||
|
{pGenius ? `${pGenius.toFixed(2)} €` : '-'}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ fontSize: '0.75rem', color: '#555', marginTop: '2px', lineHeight: '1.3' }}>
|
||||||
|
<div><strong>{pNight} €</strong> / Nacht (Zimmer)</div>
|
||||||
|
<div><strong>{pPerson} €</strong> / Pers. & Nacht</div>
|
||||||
|
<div style={{ fontSize: '0.7rem', color: '#888' }}>
|
||||||
|
({nights} {nights === 1 ? 'Nacht' : 'Nächte'}, {persons} Pers.)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{ fontSize: '1.1rem', fontWeight: 'bold', color: '#999' }}>Kein Preis</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label style={{ cursor: 'pointer' }} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<input type="checkbox" checked={hotel.is_active} onChange={(e) => toggleHotelActive(e, hotel.id, hotel.is_active)} />
|
||||||
|
<small style={{ marginLeft: '4px' }}>Aktiv</small>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button onClick={(e) => deleteHotel(e, hotel.id)} style={styles.dangerBtn}>🗑️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Preishistorie Modal */}
|
||||||
|
{selectedHotel && (
|
||||||
|
<div style={styles.modalOverlay} onClick={() => setSelectedHotel(null)}>
|
||||||
|
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
|
<div>
|
||||||
|
<h2 style={{ margin: 0 }}>📈 Preishistorie: {selectedHotel.name}</h2>
|
||||||
|
<a href={selectedHotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.85rem', color: '#0070f3' }}>
|
||||||
|
🔗 Auf Booking.com öffnen ↗
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setSelectedHotel(null)} style={styles.btnSecondary}>✕ Schließen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingHistory ? <p>Lade Historie...</p> : (
|
||||||
|
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.88rem' }}>
|
||||||
|
<thead>
|
||||||
|
<tr style={{ borderBottom: '2px solid #ccc', background: '#f5f5f5' }}>
|
||||||
|
<th style={{ padding: '8px' }}>Datum</th>
|
||||||
|
<th style={{ padding: '8px' }}>Regulär</th>
|
||||||
|
<th style={{ padding: '8px' }}>Genius (-10%)</th>
|
||||||
|
<th style={{ padding: '8px' }}>Pro Nacht</th>
|
||||||
|
<th style={{ padding: '8px' }}>Pro Person/Nacht</th>
|
||||||
|
<th style={{ padding: '8px' }}>Gesamt/Person</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} style={{ padding: '1rem', textAlign: 'center', color: '#888' }}>Keine Daten vorhanden.</td></tr>
|
||||||
|
) : (
|
||||||
|
history.map((entry) => {
|
||||||
|
const pReg = parseFloat(String(entry.price_numeric || '0'));
|
||||||
|
const pGenius = entry.genius_price_numeric ? parseFloat(String(entry.genius_price_numeric)) : pReg * 0.9;
|
||||||
|
const persons = entry.persons || 2;
|
||||||
|
const totalPerPerson = (pGenius / persons).toFixed(2);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={entry.id} style={{ borderBottom: '1px solid #eee' }}>
|
||||||
|
<td style={{ padding: '8px' }}>{new Date(entry.tracked_at).toLocaleString('de-DE')}</td>
|
||||||
|
<td style={{ padding: '8px', textDecoration: 'line-through', color: '#888' }}>{pReg.toFixed(2)} €</td>
|
||||||
|
<td style={{ padding: '8px', fontWeight: 'bold', color: '#2e7d32' }}>{pGenius.toFixed(2)} €</td>
|
||||||
|
<td style={{ padding: '8px' }}>{entry.price_per_night ? `${entry.price_per_night} €` : '-'}</td>
|
||||||
|
<td style={{ padding: '8px' }}>{entry.price_per_person ? `${entry.price_per_person} €` : '-'}</td>
|
||||||
|
<td style={{ padding: '8px', fontWeight: 'bold' }}>{totalPerPerson} €</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles: Record<string, React.CSSProperties> = {
|
||||||
|
container: { maxWidth: '980px', margin: '0 auto', padding: '2rem', fontFamily: 'system-ui, sans-serif' },
|
||||||
|
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '2rem' },
|
||||||
|
card: { background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.05)', marginBottom: '2rem' },
|
||||||
|
tripCard: { background: '#fff', padding: '1.5rem', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.08)', marginBottom: '1.5rem' },
|
||||||
|
tripHeader: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', borderBottom: '1px solid #eee', paddingBottom: '0.8rem' },
|
||||||
|
inlineForm: { display: 'flex', gap: '1rem', marginTop: '1rem' },
|
||||||
|
input: { padding: '0.6rem 1rem', borderRadius: '4px', border: '1px solid #ccc', flex: 1 },
|
||||||
|
btn: { padding: '0.6rem 1.2rem', background: '#333', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' },
|
||||||
|
primaryBtn: { padding: '0.6rem 1.2rem', background: '#0070f3', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer', fontWeight: 'bold' },
|
||||||
|
btnSecondary: { padding: '0.4rem 0.8rem', background: '#e0e0e0', border: 'none', borderRadius: '4px', cursor: 'pointer' },
|
||||||
|
dangerBtn: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.1rem' },
|
||||||
|
addHotelBox: { background: '#f8f9fa', padding: '1rem', borderRadius: '6px', marginTop: '1rem', border: '1px solid #e9ecef' },
|
||||||
|
hotelRow: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem', borderRadius: '6px', marginBottom: '0.5rem', border: '1px solid #eee', transition: 'all 0.2s' },
|
||||||
|
badge: { background: '#f0f0f0', padding: '3px 7px', borderRadius: '4px', color: '#444', fontSize: '0.75rem' },
|
||||||
|
bookingLinkBtn: { background: '#003580', color: '#fff', padding: '3px 8px', borderRadius: '4px', fontSize: '0.75rem', textDecoration: 'none', fontWeight: 'bold' },
|
||||||
|
geniusTag: { background: '#003580', color: '#fff', fontSize: '0.7rem', padding: '2px 6px', borderRadius: '4px', marginRight: '6px', fontWeight: 'bold' },
|
||||||
|
modalOverlay: { position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', justifyContent: 'center', alignItems: 'center', zIndex: 9999 },
|
||||||
|
modalContent: { background: '#fff', padding: '2rem', borderRadius: '8px', width: '90%', maxWidth: '850px', maxHeight: '80vh', overflowY: 'auto' }
|
||||||
|
};
|
||||||
7
src/lib/db.ts
Normal file
7
src/lib/db.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Pool } from 'pg';
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const query = (text: string, params?: any[]) => pool.query(text, params);
|
||||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2017",
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "node",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user