1405 lines
52 KiB
Plaintext
1405 lines
52 KiB
Plaintext
========================================
|
||
PROJEKTSTRUKTUR
|
||
========================================
|
||
(Ermittelt via Git unter Berücksichtigung von .gitignore)
|
||
copyChat.sh
|
||
llm_context.txt
|
||
src/app/api/hotels/[id]/prices/route.ts
|
||
src/app/api/hotels/[id]/scan/route.ts
|
||
.gitignore
|
||
Dockerfile
|
||
README.md
|
||
docker-compose.yml
|
||
next.config.js
|
||
next.config.mjs
|
||
package-lock.json
|
||
package.json
|
||
src/app/api/hotels/[id]/history/route.ts
|
||
src/app/api/hotels/[id]/review-summary/route.ts
|
||
src/app/api/hotels/[id]/route.ts
|
||
src/app/api/hotels/route.ts
|
||
src/app/api/jobs/trigger-daily-check/route.ts
|
||
src/app/api/trips/route.ts
|
||
src/app/api/webhooks/scraper/route.ts
|
||
src/app/layout.tsx
|
||
src/app/page.tsx
|
||
src/lib/db.ts
|
||
tsconfig.json
|
||
|
||
========================================
|
||
DATEIINHALTE
|
||
========================================
|
||
|
||
--- DATEI: .gitignore ---
|
||
```
|
||
# ==========================================
|
||
# 1. Lokale Umgebungs- & Geheimdateien
|
||
# ==========================================
|
||
.env
|
||
.env*.local
|
||
.env.development.local
|
||
.env.test.local
|
||
.env.production.local
|
||
|
||
# ==========================================
|
||
# 2. Abhängigkeiten & Paketmanager
|
||
# ==========================================
|
||
node_modules/
|
||
.pnpm-store/
|
||
|
||
# ==========================================
|
||
# 3. Next.js Build- & Output-Dateien
|
||
# ==========================================
|
||
.next/
|
||
out/
|
||
build/
|
||
dist/
|
||
|
||
# ==========================================
|
||
# 4. Logs & temporäre Dateien
|
||
# ==========================================
|
||
npm-debug.log*
|
||
yarn-debug.log*
|
||
yarn-error.log*
|
||
pnpm-debug.log*
|
||
.pnpm-debug.log*
|
||
*.log
|
||
|
||
# ==========================================
|
||
# 5. IDEs & Betriebssystem
|
||
# ==========================================
|
||
.DS_Store
|
||
*.pem
|
||
.vscode/*
|
||
!.vscode/settings.json
|
||
!.vscode/tasks.json
|
||
!.vscode/launch.json
|
||
!.vscode/extensions.json
|
||
.idea/
|
||
*.suo
|
||
*.ntvs*
|
||
*.njsproj
|
||
*.sln
|
||
*.sw?
|
||
|
||
```
|
||
|
||
--- DATEI: Dockerfile ---
|
||
```
|
||
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"]
|
||
|
||
```
|
||
|
||
--- DATEI: README.md ---
|
||
```
|
||
|
||
```
|
||
|
||
--- DATEI: copyChat.sh ---
|
||
```
|
||
#!/usr/bin/env bash
|
||
|
||
OUTPUT_FILE="${1:-llm_context.txt}"
|
||
ROOT_DIR="${2:-.}"
|
||
|
||
# Absolute Pfade auflösen
|
||
OUTPUT_PATH="$(cd "$(dirname "$OUTPUT_FILE")" 2>/dev/null && pwd)/$(basename "$OUTPUT_FILE")"
|
||
|
||
cd "$ROOT_DIR" || exit 1
|
||
|
||
# Ausgabedatei initialisieren / leeren
|
||
> "$OUTPUT_PATH"
|
||
|
||
echo "========================================" >> "$OUTPUT_PATH"
|
||
echo "PROJEKTSTRUKTUR" >> "$OUTPUT_PATH"
|
||
echo "========================================" >> "$OUTPUT_PATH"
|
||
|
||
# Prüfen, ob Git verfügbar und das Verzeichnis ein Git-Repo ist
|
||
IS_GIT_REPO=false
|
||
if command -v git &> /dev/null && git rev-parse --is-inside-work-tree &> /dev/null; then
|
||
IS_GIT_REPO=true
|
||
fi
|
||
|
||
if [ "$IS_GIT_REPO" = true ]; then
|
||
echo "(Ermittelt via Git unter Berücksichtigung von .gitignore)" >> "$OUTPUT_PATH"
|
||
# Zeigt alle getrackten + ungetrackten Dateien an, ignoriert .gitignore-Treffer
|
||
git ls-files -co --exclude-standard >> "$OUTPUT_PATH"
|
||
else
|
||
if command -v tree &> /dev/null; then
|
||
tree -I 'node_modules|.git|venv|.venv|__pycache__|dist|build|.next|target' >> "$OUTPUT_PATH"
|
||
else
|
||
find . -maxdepth 3 -not -path '*/.*' -not -path '*/node_modules*' -not -path '*/venv*' >> "$OUTPUT_PATH"
|
||
fi
|
||
fi
|
||
|
||
echo -e "\n========================================" >> "$OUTPUT_PATH"
|
||
echo "DATEIINHALTE" >> "$OUTPUT_PATH"
|
||
echo "========================================" >> "$OUTPUT_PATH"
|
||
|
||
# Dateiliste ermitteln
|
||
get_file_list() {
|
||
if [ "$IS_GIT_REPO" = true ]; then
|
||
# -c = cached/tracked, -o = others/untracked, --exclude-standard = .gitignore beachten
|
||
git ls-files -co --exclude-standard
|
||
else
|
||
find . -type f \
|
||
-not -path '*/.git/*' \
|
||
-not -path '*/node_modules/*' \
|
||
-not -path '*/venv/*' \
|
||
-not -path '*/.venv/*' \
|
||
-not -path '*/__pycache__/*' \
|
||
-not -path '*/dist/*' \
|
||
-not -path '*/build/*' \
|
||
-not -path '*/.next/*'
|
||
fi
|
||
}
|
||
|
||
# Dateien verarbeiten
|
||
get_file_list | sort | while IFS= read -r file; do
|
||
# Überspringe leere Pfade, die Zieldatei selbst oder typische Binär-Endungen
|
||
[ -z "$file" ] && continue
|
||
[ "$file" = "$OUTPUT_FILE" ] && continue
|
||
[ "$(realpath "$file" 2>/dev/null)" = "$OUTPUT_PATH" ] && continue
|
||
|
||
case "$file" in
|
||
*.png|*.jpg|*.jpeg|*.gif|*.ico|*.pdf|*.zip|*.tar.gz|*.lock|package-lock.json|yarn.lock)
|
||
continue
|
||
;;
|
||
esac
|
||
|
||
# Prüfen, ob die Datei existiert und lesbarer Text ist
|
||
if [ -f "$file" ] && (grep -Iq . "$file" 2>/dev/null || [ ! -s "$file" ]); then
|
||
echo -e "\n--- DATEI: $file ---" >> "$OUTPUT_PATH"
|
||
echo '```' >> "$OUTPUT_PATH"
|
||
cat "$file" >> "$OUTPUT_PATH"
|
||
echo -e "\n"'```' >> "$OUTPUT_PATH"
|
||
fi
|
||
done
|
||
|
||
echo "Fertig! Projektkontext liegt in: $OUTPUT_PATH"
|
||
```
|
||
|
||
--- DATEI: docker-compose.yml ---
|
||
```
|
||
services:
|
||
travel-app:
|
||
build: .
|
||
container_name: travel-app
|
||
restart: always
|
||
ports:
|
||
- "3002:3001"
|
||
env_file:
|
||
- .env
|
||
|
||
```
|
||
|
||
--- DATEI: next.config.js ---
|
||
```
|
||
/** @type {import('next').NextConfig} */
|
||
const nextConfig = {
|
||
serverExternalPackages: ['bullmq', 'ioredis'],
|
||
};
|
||
|
||
module.exports = nextConfig;
|
||
|
||
```
|
||
|
||
--- DATEI: next.config.mjs ---
|
||
```
|
||
/** @type {import('next').NextConfig} */
|
||
const nextConfig = {
|
||
serverExternalPackages: ['bullmq', 'ioredis'],
|
||
};
|
||
|
||
export default nextConfig;
|
||
|
||
```
|
||
|
||
--- DATEI: package.json ---
|
||
```
|
||
{
|
||
"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": {
|
||
"axios": "^1.7.0",
|
||
"bullmq": "^6.0.9",
|
||
"ioredis": "^6.0.0",
|
||
"next": "^14.2.0",
|
||
"pg": "^8.11.5",
|
||
"react": "^18.3.0",
|
||
"react-dom": "^18.3.0"
|
||
},
|
||
"devDependencies": {
|
||
"@types/node": "^20.0.0",
|
||
"@types/pg": "^8.11.0",
|
||
"@types/react": "^18.3.0",
|
||
"autoprefixer": "^10.4.0",
|
||
"postcss": "^8.4.0",
|
||
"tailwindcss": "^3.4.0",
|
||
"typescript": "^5.0.0"
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/hotels/[id]/history/route.ts ---
|
||
```
|
||
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 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/hotels/[id]/prices/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
export async function GET(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
const { id } = params;
|
||
|
||
const res = await query(
|
||
`SELECT
|
||
id,
|
||
price_numeric::float AS price,
|
||
price_per_night::float AS price_per_night,
|
||
price_per_person::float AS price_per_person,
|
||
nights,
|
||
persons,
|
||
tracked_at AS created_at
|
||
FROM hotel_price_history
|
||
WHERE hotel_id::text = $1::text
|
||
ORDER BY tracked_at ASC`,
|
||
[id]
|
||
);
|
||
|
||
return NextResponse.json({ success: true, prices: res.rows });
|
||
} catch (error: any) {
|
||
console.error('Fehler beim Laden der Preishistorie:', error.message);
|
||
return NextResponse.json({ success: false, error: error.message, prices: [] }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/hotels/[id]/review-summary/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export async function POST(req: NextRequest, { params }: { params: { id: string } }) {
|
||
try {
|
||
const { id } = params;
|
||
const { summary_positive, summary_passable, summary_negative, summary_bathroom, summary_count } = await req.json();
|
||
|
||
if (!id) {
|
||
return NextResponse.json({ error: 'Hotel-ID fehlt' }, { status: 400 });
|
||
}
|
||
|
||
await query(
|
||
`UPDATE hotels
|
||
SET review_summary_positive = $1,
|
||
review_summary_passable = $2,
|
||
review_summary_negative = $3,
|
||
review_summary_bathroom = $4,
|
||
review_summary_count = $5,
|
||
reviews_updated_at = NOW()
|
||
WHERE id = $6`,
|
||
[summary_positive, summary_passable, summary_negative, summary_bathroom || null, summary_count || null, id]
|
||
);
|
||
|
||
return NextResponse.json({ success: true, message: 'Review-Zusammenfassungen gespeichert.' });
|
||
} catch (error: any) {
|
||
console.error('Fehler beim Speichern der Review-Zusammenfassung:', error);
|
||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/hotels/[id]/route.ts ---
|
||
```
|
||
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 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/hotels/[id]/scan/route.ts ---
|
||
```
|
||
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,
|
||
{ params }: { params: { id: string } }
|
||
) {
|
||
try {
|
||
const { id } = params;
|
||
|
||
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||
if (!bullmq || !bullmq.Queue) {
|
||
throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.');
|
||
}
|
||
|
||
const { Queue } = bullmq;
|
||
|
||
const connection = {
|
||
host: process.env.REDIS_HOST || '172.17.0.1',
|
||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||
connectTimeout: 5000,
|
||
maxRetriesPerRequest: 3,
|
||
};
|
||
|
||
const scrapeQueue = new Queue('scrape-jobs', { connection });
|
||
|
||
// Nur dieses eine Hotel laden
|
||
const res = await query('SELECT id, booking_url, name FROM hotels WHERE id = $1', [id]);
|
||
if (res.rows.length === 0) {
|
||
await scrapeQueue.close();
|
||
return NextResponse.json({ error: 'Hotel nicht gefunden' }, { status: 404 });
|
||
}
|
||
|
||
const hotel = res.rows[0];
|
||
|
||
const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
|
||
const n8nWebhookUrl = n8nBaseUrl.includes('?')
|
||
? `${n8nBaseUrl}&hotelId=${hotel.id}`
|
||
: `${n8nBaseUrl}?hotelId=${hotel.id}`;
|
||
|
||
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`;
|
||
|
||
// Nur DIESEN einzelnen Job in die Queue schreiben
|
||
await scrapeQueue.add('scrape-hotel', {
|
||
hotelId: hotel.id,
|
||
url: hotel.booking_url,
|
||
appWebhookUrl,
|
||
webhookUrl: n8nWebhookUrl,
|
||
});
|
||
|
||
await scrapeQueue.close();
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: `Scrape-Job für "${hotel.name}" erfolgreich gestartet.`,
|
||
});
|
||
} catch (error: any) {
|
||
console.error('Fehler beim Starten des Einzel-Scrapes:', error);
|
||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/hotels/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
export async function GET() {
|
||
try {
|
||
const res = await query(`
|
||
SELECT
|
||
h.*,
|
||
t.name AS trip_name,
|
||
ph.price_numeric::float AS latest_price,
|
||
ph.price_per_night::float AS latest_price_per_night,
|
||
ph.price_per_person::float AS latest_price_per_person,
|
||
ph.nights AS latest_nights,
|
||
ph.persons AS latest_persons
|
||
FROM hotels h
|
||
LEFT JOIN trips t ON h.trip_id = t.id
|
||
LEFT JOIN LATERAL (
|
||
SELECT price_numeric, price_per_night, price_per_person, nights, persons
|
||
FROM hotel_price_history
|
||
WHERE hotel_id::text = h.id::text
|
||
ORDER BY tracked_at DESC NULLS LAST, id DESC
|
||
LIMIT 1
|
||
) ph ON true
|
||
ORDER BY h.checkin_date ASC NULLS LAST, h.id ASC
|
||
`);
|
||
|
||
return NextResponse.json({ success: true, hotels: res.rows });
|
||
} catch (error: any) {
|
||
console.error('Fehler in GET /api/hotels:', error.message);
|
||
try {
|
||
const fallback = await query(`SELECT * FROM hotels ORDER BY id ASC`);
|
||
return NextResponse.json({ success: true, hotels: fallback.rows });
|
||
} catch (fallbackErr: any) {
|
||
return NextResponse.json({ success: false, error: error.message, hotels: [] }, { status: 500 });
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function POST(req: NextRequest) {
|
||
try {
|
||
const { name, booking_url, trip_id } = await req.json();
|
||
|
||
if (!booking_url) {
|
||
return NextResponse.json({ error: 'Booking.com URL erforderlich' }, { status: 400 });
|
||
}
|
||
|
||
let checkin_date: string | null = null;
|
||
let checkout_date: string | null = null;
|
||
|
||
try {
|
||
const parsedUrl = new URL(booking_url);
|
||
checkin_date = parsedUrl.searchParams.get('checkin');
|
||
checkout_date = parsedUrl.searchParams.get('checkout');
|
||
} catch (e) {}
|
||
|
||
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 *`,
|
||
[name || 'Neues Hotel', booking_url, checkin_date, checkout_date, trip_id || null]
|
||
);
|
||
|
||
return NextResponse.json({ success: true, hotel: res.rows[0] });
|
||
} catch (error: any) {
|
||
console.error('Fehler in POST /api/hotels:', error.message);
|
||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/jobs/trigger-daily-check/route.ts ---
|
||
```
|
||
import { NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const runtime = 'nodejs';
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
export async function POST() {
|
||
try {
|
||
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||
if (!bullmq || !bullmq.Queue) {
|
||
throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.');
|
||
}
|
||
|
||
const { Queue } = bullmq;
|
||
|
||
const connection = {
|
||
host: process.env.REDIS_HOST || '172.17.0.1',
|
||
port: parseInt(process.env.REDIS_PORT || '6379', 10),
|
||
connectTimeout: 5000,
|
||
maxRetriesPerRequest: 3,
|
||
};
|
||
|
||
const scrapeQueue = new Queue('scrape-jobs', { connection });
|
||
|
||
const res = await query('SELECT id, booking_url FROM hotels WHERE is_active = true');
|
||
const activeHotels = res.rows;
|
||
|
||
if (activeHotels.length === 0) {
|
||
await scrapeQueue.close();
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: 'Keine aktiven Hotels zum Scrapen gefunden.',
|
||
});
|
||
}
|
||
|
||
// Für ALLE aktiven Hotels nur den Preis-Scan anwerfen (OHNE n8n / OHNE LLM)
|
||
for (const hotel of activeHotels) {
|
||
const appWebhookUrl = `http://172.17.0.1:3002/api/webhooks/scraper?hotelId=${hotel.id}`;
|
||
|
||
await scrapeQueue.add('scrape-hotel', {
|
||
hotelId: hotel.id,
|
||
url: hotel.booking_url,
|
||
appWebhookUrl, // Speichert den Preis in Postgres
|
||
webhookUrl: null, // KEIN n8n -> KOSTENLOS!
|
||
});
|
||
}
|
||
|
||
await scrapeQueue.close();
|
||
|
||
return NextResponse.json({
|
||
success: true,
|
||
message: `Preis-Scan für ${activeHotels.length} Hotels gestartet.`,
|
||
});
|
||
} catch (error: any) {
|
||
console.error('Fehler beim Starten des Preis-Scans:', error);
|
||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/trips/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
export const dynamic = 'force-dynamic';
|
||
|
||
export async function GET() {
|
||
try {
|
||
const res = await query(`SELECT * FROM trips ORDER BY id ASC`);
|
||
return NextResponse.json({ success: true, trips: res.rows });
|
||
} catch (error: any) {
|
||
return NextResponse.json({ success: false, error: error.message, trips: [] }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
export async function POST(req: NextRequest) {
|
||
try {
|
||
const { name } = await req.json();
|
||
if (!name) {
|
||
return NextResponse.json({ error: 'Name der Reise erforderlich' }, { status: 400 });
|
||
}
|
||
|
||
const res = await query(
|
||
`INSERT INTO trips (name) VALUES ($1) RETURNING *`,
|
||
[name]
|
||
);
|
||
|
||
return NextResponse.json({ success: true, trip: res.rows[0] });
|
||
} catch (error: any) {
|
||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/api/webhooks/scraper/route.ts ---
|
||
```
|
||
import { NextRequest, NextResponse } from 'next/server';
|
||
import { query } from '@/lib/db';
|
||
|
||
function parseGermanPrice(priceStr: any): number | null {
|
||
if (!priceStr) return null;
|
||
if (typeof priceStr === 'number') return Math.round(priceStr);
|
||
|
||
let cleaned = String(priceStr).replace(/[^0-9.,]/g, '').trim();
|
||
if (!cleaned) return null;
|
||
|
||
if (cleaned.includes('.') && !cleaned.includes(',')) {
|
||
cleaned = cleaned.replace(/\./g, '');
|
||
} else if (cleaned.includes('.') && cleaned.includes(',')) {
|
||
cleaned = cleaned.replace(/\./g, '').replace(',', '.');
|
||
} else if (cleaned.includes(',')) {
|
||
cleaned = cleaned.replace(',', '.');
|
||
}
|
||
|
||
const parsed = parseFloat(cleaned);
|
||
return isNaN(parsed) ? null : Math.round(parsed);
|
||
}
|
||
|
||
export async function POST(req: NextRequest) {
|
||
try {
|
||
const body = await req.json().catch(() => ({}));
|
||
const { searchParams } = new URL(req.url);
|
||
const hotelId = searchParams.get('hotelId') || body.hotelId;
|
||
|
||
if (!hotelId) {
|
||
return NextResponse.json({ success: false, error: 'hotelId fehlt' }, { status: 400 });
|
||
}
|
||
|
||
const numericPrice = parseGermanPrice(body.price);
|
||
|
||
// 1. In hotel_price_history eintragen mit echten Spalten
|
||
if (numericPrice !== null && numericPrice > 0) {
|
||
try {
|
||
await query(
|
||
`INSERT INTO hotel_price_history (hotel_id, price_numeric, price_raw, currency, nights, persons, tracked_at)
|
||
VALUES ($1::uuid, $2, $3, 'EUR', $4, $5, NOW())`,
|
||
[hotelId, numericPrice, String(body.price || ''), body.nights || 1, body.persons || 2]
|
||
);
|
||
} catch (err: any) {
|
||
console.error('DB Insert Error (hotel_price_history):', err.message);
|
||
}
|
||
}
|
||
|
||
// 2. Stammdaten aktualisieren
|
||
try {
|
||
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),
|
||
updated_at = NOW()
|
||
WHERE id = $7::uuid`,
|
||
[
|
||
body.hotelName || null,
|
||
body.rating || null,
|
||
body.reviewCount || null,
|
||
body.address || null,
|
||
body.image || null,
|
||
body.roomSizeM2 || null,
|
||
hotelId
|
||
]
|
||
);
|
||
} catch (err: any) {
|
||
console.error('DB Update Error (hotels):', err.message);
|
||
}
|
||
|
||
return NextResponse.json({ success: true, hotelId, parsedPrice: numericPrice });
|
||
} catch (error: any) {
|
||
console.error('Allgemeiner Fehler im Webhook:', error.message);
|
||
return NextResponse.json({ success: false, error: error.message }, { status: 200 });
|
||
}
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/layout.tsx ---
|
||
```
|
||
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>
|
||
);
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/app/page.tsx ---
|
||
```
|
||
'use client';
|
||
|
||
import { useState, useEffect } from 'react';
|
||
|
||
interface Trip {
|
||
id: string;
|
||
name: string;
|
||
}
|
||
|
||
interface PriceEntry {
|
||
id: string;
|
||
price: number;
|
||
price_per_night?: number | null;
|
||
price_per_person?: number | null;
|
||
nights: number;
|
||
persons: number;
|
||
created_at: string;
|
||
}
|
||
|
||
interface Hotel {
|
||
id: string;
|
||
name: string;
|
||
booking_url: string;
|
||
is_active: boolean;
|
||
trip_id?: string | null;
|
||
trip_name?: string | null;
|
||
latest_price?: number | null;
|
||
latest_price_per_night?: number | null;
|
||
latest_price_per_person?: number | null;
|
||
latest_nights?: number | null;
|
||
latest_persons?: number | null;
|
||
rating?: number | null;
|
||
review_count?: number | null;
|
||
image_url?: string | null;
|
||
address?: string | null;
|
||
room_size_m2?: number | null;
|
||
review_summary_positive?: string | null;
|
||
review_summary_passable?: string | null;
|
||
review_summary_negative?: string | null;
|
||
review_summary_bathroom?: string | null;
|
||
review_summary_count?: number | null;
|
||
reviews_updated_at?: string | null;
|
||
checkin_date?: string | null;
|
||
checkout_date?: string | null;
|
||
}
|
||
|
||
export default function TravelDashboard() {
|
||
const [trips, setTrips] = useState<Trip[]>([]);
|
||
const [activeTripId, setActiveTripId] = useState<string>('ALL');
|
||
const [hotels, setHotels] = useState<Hotel[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
const [scanningAllPrices, setScanningAllPrices] = useState(false);
|
||
const [scanningHotelId, setScanningHotelId] = useState<string | null>(null);
|
||
|
||
// Preishistorie Modal
|
||
const [selectedPriceHotel, setSelectedPriceHotel] = useState<Hotel | null>(null);
|
||
const [priceHistory, setPriceHistory] = useState<PriceEntry[]>([]);
|
||
const [loadingPrices, setLoadingPrices] = useState(false);
|
||
|
||
// Modals
|
||
const [showAddTripModal, setShowAddTripModal] = useState(false);
|
||
const [newTripName, setNewTripName] = useState('');
|
||
|
||
const [showAddHotelModal, setShowAddHotelModal] = useState(false);
|
||
const [newBookingUrl, setNewBookingUrl] = useState('');
|
||
const [newHotelName, setNewHotelName] = useState('');
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
|
||
useEffect(() => {
|
||
loadData();
|
||
}, []);
|
||
|
||
const formatDateGerman = (dateStr?: string | null) => {
|
||
if (!dateStr) return '';
|
||
const date = new Date(dateStr);
|
||
return isNaN(date.getTime()) ? dateStr : date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
|
||
};
|
||
|
||
const calculateNights = (checkin?: string | null, checkout?: string | null) => {
|
||
if (!checkin || !checkout) return null;
|
||
const start = new Date(checkin).getTime();
|
||
const end = new Date(checkout).getTime();
|
||
if (isNaN(start) || isNaN(end)) return null;
|
||
const diffDays = Math.round((end - start) / (1000 * 60 * 60 * 24));
|
||
return diffDays > 0 ? diffDays : null;
|
||
};
|
||
|
||
const loadData = async () => {
|
||
setLoading(true);
|
||
try {
|
||
const resTrips = await fetch('/api/trips');
|
||
const dataTrips = await resTrips.json();
|
||
setTrips(dataTrips.trips || []);
|
||
|
||
const resHotels = await fetch('/api/hotels');
|
||
const dataHotels = await resHotels.json();
|
||
setHotels(dataHotels.hotels || []);
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden:', e);
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const openPriceHistoryModal = async (hotel: Hotel) => {
|
||
setSelectedPriceHotel(hotel);
|
||
setLoadingPrices(true);
|
||
setPriceHistory([]);
|
||
try {
|
||
const res = await fetch(`/api/hotels/${hotel.id}/prices`);
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
setPriceHistory(data.prices || []);
|
||
}
|
||
} catch (e) {
|
||
console.error('Fehler beim Laden der Preise:', e);
|
||
} finally {
|
||
setLoadingPrices(false);
|
||
}
|
||
};
|
||
|
||
const handleAddTrip = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newTripName.trim()) return;
|
||
|
||
try {
|
||
const res = await fetch('/api/trips', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: newTripName.trim() })
|
||
});
|
||
const data = await res.json();
|
||
if (data.success) {
|
||
setTrips([...trips, data.trip]);
|
||
setActiveTripId(String(data.trip.id));
|
||
setNewTripName('');
|
||
setShowAddTripModal(false);
|
||
}
|
||
} catch (e: any) {
|
||
alert('Fehler: ' + e.message);
|
||
}
|
||
};
|
||
|
||
const handleAddHotel = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!newBookingUrl) return alert('Bitte URL eingeben');
|
||
|
||
setIsSubmitting(true);
|
||
try {
|
||
const targetTripId = activeTripId === 'ALL' ? (trips[0]?.id || null) : activeTripId;
|
||
const res = await fetch('/api/hotels', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
name: newHotelName || 'Neues Hotel',
|
||
booking_url: newBookingUrl,
|
||
trip_id: targetTripId
|
||
})
|
||
});
|
||
|
||
const data = await res.json();
|
||
if (res.ok || data.success) {
|
||
setNewBookingUrl('');
|
||
setNewHotelName('');
|
||
setShowAddHotelModal(false);
|
||
loadData();
|
||
}
|
||
} catch (err: any) {
|
||
alert('Fehler: ' + err.message);
|
||
} finally {
|
||
setIsSubmitting(false);
|
||
}
|
||
};
|
||
|
||
const toggleHotelActive = async (hotel: Hotel) => {
|
||
try {
|
||
const updatedActive = !hotel.is_active;
|
||
await fetch(`/api/hotels/${hotel.id}`, {
|
||
method: 'PATCH',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ is_active: updatedActive })
|
||
});
|
||
setHotels(hotels.map(h => h.id === hotel.id ? { ...h, is_active: updatedActive } : h));
|
||
} catch (e) {}
|
||
};
|
||
|
||
const deleteHotel = async (id: string, name: string) => {
|
||
if (!confirm(`Hotel "${name}" löschen?`)) return;
|
||
await fetch(`/api/hotels/${id}`, { method: 'DELETE' });
|
||
setHotels(hotels.filter(h => h.id !== id));
|
||
};
|
||
|
||
const triggerBatchPriceScan = async () => {
|
||
setScanningAllPrices(true);
|
||
try {
|
||
const res = await fetch('/api/jobs/trigger-daily-check', { method: 'POST' });
|
||
const data = await res.json();
|
||
alert(data.success ? '📊 Preis-Scan gestartet!' : 'Fehler: ' + data.error);
|
||
setTimeout(loadData, 3000);
|
||
} finally {
|
||
setScanningAllPrices(false);
|
||
}
|
||
};
|
||
|
||
const scanSingleHotel = async (hotelId: string) => {
|
||
setScanningHotelId(hotelId);
|
||
try {
|
||
const res = await fetch(`/api/hotels/${hotelId}/scan`, { method: 'POST' });
|
||
const data = await res.json();
|
||
alert(data.message || data.error);
|
||
} finally {
|
||
setScanningHotelId(null);
|
||
}
|
||
};
|
||
|
||
const currentHotels = hotels
|
||
.filter(h => {
|
||
if (activeTripId === 'ALL') return true;
|
||
if (!h.trip_id) return true;
|
||
return String(h.trip_id).toLowerCase() === String(activeTripId).toLowerCase();
|
||
})
|
||
.sort((a, b) => {
|
||
if (!a.checkin_date && !b.checkin_date) return 0;
|
||
if (!a.checkin_date) return 1;
|
||
if (!b.checkin_date) return -1;
|
||
return new Date(a.checkin_date).getTime() - new Date(b.checkin_date).getTime();
|
||
});
|
||
|
||
const pricesList = priceHistory.map(p => Number(p.price));
|
||
const minPrice = pricesList.length > 0 ? Math.min(...pricesList) : 0;
|
||
const maxPrice = pricesList.length > 0 ? Math.max(...pricesList) : 0;
|
||
const currentPrice = pricesList.length > 0 ? pricesList[pricesList.length - 1] : 0;
|
||
|
||
return (
|
||
<div style={{ maxWidth: '950px', margin: '0 auto', padding: '2rem 1rem', fontFamily: 'system-ui, sans-serif', color: '#1f2937' }}>
|
||
|
||
<header style={{ marginBottom: '2rem', textAlign: 'center' }}>
|
||
<h1 style={{ fontSize: '2.2rem', marginBottom: '0.5rem', fontWeight: '800' }}>📍 Reise-Ablauf & Timeline</h1>
|
||
<p style={{ color: '#6b7280', margin: 0 }}>Routenplanung, Preishistorie & KI-Bewertungen</p>
|
||
|
||
<div style={{ display: 'flex', gap: '1rem', justifyContent: 'center', marginTop: '1.5rem', flexWrap: 'wrap' }}>
|
||
<button
|
||
onClick={() => setShowAddHotelModal(true)}
|
||
style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.75rem 1.4rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer' }}
|
||
>
|
||
➕ Neues Hotel hinzufügen
|
||
</button>
|
||
|
||
<button
|
||
onClick={triggerBatchPriceScan}
|
||
disabled={scanningAllPrices}
|
||
style={{ background: '#2563eb', color: '#fff', border: 'none', padding: '0.75rem 1.4rem', borderRadius: '8px', fontWeight: '600', cursor: 'pointer', opacity: scanningAllPrices ? 0.7 : 1 }}
|
||
>
|
||
{scanningAllPrices ? '🔄 Scanne...' : '📊 Preise aller Hotels aktualisieren'}
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{/* TABS */}
|
||
<div style={{ display: 'flex', gap: '0.5rem', borderBottom: '2px solid #e5e7eb', marginBottom: '2rem', overflowX: 'auto', paddingBottom: '0.5rem' }}>
|
||
<button
|
||
onClick={() => setActiveTripId('ALL')}
|
||
style={{
|
||
padding: '0.6rem 1.2rem',
|
||
borderRadius: '8px',
|
||
border: 'none',
|
||
background: activeTripId === 'ALL' ? '#2563eb' : '#f3f4f6',
|
||
color: activeTripId === 'ALL' ? '#ffffff' : '#4b5563',
|
||
fontWeight: 'bold',
|
||
cursor: 'pointer',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
>
|
||
🌐 Alle Hotels ({hotels.length})
|
||
</button>
|
||
|
||
{trips.map(trip => (
|
||
<button
|
||
key={trip.id}
|
||
onClick={() => setActiveTripId(String(trip.id))}
|
||
style={{
|
||
padding: '0.6rem 1.2rem',
|
||
borderRadius: '8px',
|
||
border: 'none',
|
||
background: String(activeTripId).toLowerCase() === String(trip.id).toLowerCase() ? '#2563eb' : '#f3f4f6',
|
||
color: String(activeTripId).toLowerCase() === String(trip.id).toLowerCase() ? '#ffffff' : '#4b5563',
|
||
fontWeight: 'bold',
|
||
cursor: 'pointer',
|
||
whiteSpace: 'nowrap'
|
||
}}
|
||
>
|
||
🌍 {trip.name}
|
||
</button>
|
||
))}
|
||
|
||
<button
|
||
onClick={() => setShowAddTripModal(true)}
|
||
style={{ padding: '0.6rem 1rem', borderRadius: '8px', border: '1px dashed #2563eb', background: '#eff6ff', color: '#2563eb', fontWeight: 'bold', cursor: 'pointer' }}
|
||
>
|
||
➕ Neue Reise
|
||
</button>
|
||
</div>
|
||
|
||
{/* PREISHISTORIE MODAL */}
|
||
{selectedPriceHotel && (
|
||
<div style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, background: 'rgba(0,0,0,0.6)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000, padding: '1rem' }}>
|
||
<div style={{ background: '#fff', padding: '1.8rem', borderRadius: '12px', maxWidth: '650px', width: '100%', boxShadow: '0 20px 25px -5px rgba(0,0,0,0.1)' }}>
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||
<div>
|
||
<h3 style={{ margin: 0, fontSize: '1.3rem' }}>📈 Preishistorie: {selectedPriceHotel.name}</h3>
|
||
<p style={{ margin: 0, color: '#6b7280', fontSize: '0.85rem' }}>Entwicklung aller erfassten Preis-Scans</p>
|
||
</div>
|
||
<button onClick={() => setSelectedPriceHotel(null)} style={{ background: '#f3f4f6', border: 'none', borderRadius: '50%', width: '32px', height: '32px', cursor: 'pointer', fontWeight: 'bold' }}>✕</button>
|
||
</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. Klicke oben auf "Preise aller Hotels aktualisieren".</p>
|
||
) : (
|
||
<div>
|
||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '1rem', marginBottom: '1.5rem', textAlign: 'center' }}>
|
||
<div style={{ background: '#f0fdf4', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bbf7d0' }}>
|
||
<small style={{ color: '#166534', fontWeight: 'bold' }}>Tiefstpreis</small>
|
||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#15803d' }}>{minPrice} €</div>
|
||
</div>
|
||
<div style={{ background: '#eff6ff', padding: '0.8rem', borderRadius: '8px', border: '1px solid #bfdbfe' }}>
|
||
<small style={{ color: '#1e40af', fontWeight: 'bold' }}>Aktuellster Preis</small>
|
||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#1d4ed8' }}>{currentPrice} €</div>
|
||
</div>
|
||
<div style={{ background: '#fef2f2', padding: '0.8rem', borderRadius: '8px', border: '1px solid #fecaca' }}>
|
||
<small style={{ color: '#991b1b', fontWeight: 'bold' }}>Höchstpreis</small>
|
||
<div style={{ fontSize: '1.3rem', fontWeight: 'bold', color: '#b91c1c' }}>{maxPrice} €</div>
|
||
</div>
|
||
</div>
|
||
|
||
{priceHistory.length > 1 && (
|
||
<div style={{ background: '#fafafa', padding: '1rem', borderRadius: '8px', border: '1px solid #f3f4f6', marginBottom: '1.5rem' }}>
|
||
<svg viewBox="0 0 500 150" style={{ width: '100%', height: '160px', 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 range = maxPrice - minPrice || 1;
|
||
const points = priceHistory.map((p, idx) => {
|
||
const x = (idx / (priceHistory.length - 1)) * 500;
|
||
const y = 130 - ((p.price - minPrice) / range) * 110;
|
||
return `${x},${y}`;
|
||
}).join(' ');
|
||
|
||
return (
|
||
<>
|
||
<polyline fill="none" stroke="#2563eb" strokeWidth="3" points={points} />
|
||
{priceHistory.map((p, idx) => {
|
||
const x = (idx / (priceHistory.length - 1)) * 500;
|
||
const y = 130 - ((p.price - minPrice) / range) * 110;
|
||
return (
|
||
<g key={p.id}>
|
||
<circle cx={x} cy={y} r="5" fill="#2563eb" stroke="#ffffff" strokeWidth="2" />
|
||
<text x={x} y={y - 10} fontSize="10" textAnchor="middle" fill="#374151" fontWeight="bold">{p.price}€</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</>
|
||
);
|
||
})()}
|
||
</svg>
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
|
||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '0.85rem' }}>
|
||
<thead>
|
||
<tr style={{ background: '#f3f4f6', textAlign: 'left' }}>
|
||
<th style={{ padding: '0.5rem' }}>Datum & Uhrzeit</th>
|
||
<th style={{ padding: '0.5rem' }}>Preis</th>
|
||
<th style={{ padding: '0.5rem' }}>Details</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{priceHistory.map(p => (
|
||
<tr key={p.id} style={{ borderBottom: '1px solid #f3f4f6' }}>
|
||
<td style={{ padding: '0.5rem' }}>{new Date(p.created_at).toLocaleString('de-DE')}</td>
|
||
<td style={{ padding: '0.5rem', fontWeight: 'bold', color: '#2563eb' }}>{p.price} €</td>
|
||
<td style={{ padding: '0.5rem', color: '#6b7280' }}>{p.nights || 1} Nächte, {p.persons || 2} Personen</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL: NEUE REISE */}
|
||
{showAddTripModal && (
|
||
<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: '400px', width: '100%' }}>
|
||
<h3 style={{ marginTop: 0 }}>🌍 Neue Reise anlegen</h3>
|
||
<form onSubmit={handleAddTrip}>
|
||
<input
|
||
type="text"
|
||
required
|
||
placeholder="z. B. Indien 2026 oder Japan 2027"
|
||
value={newTripName}
|
||
onChange={e => setNewTripName(e.target.value)}
|
||
style={{ width: '100%', padding: '0.7rem', borderRadius: '6px', border: '1px solid #d1d5db', marginBottom: '1rem' }}
|
||
/>
|
||
<div style={{ display: 'flex', gap: '0.5rem', justifyContent: 'flex-end' }}>
|
||
<button type="button" onClick={() => setShowAddTripModal(false)} style={{ padding: '0.6rem 1rem', background: '#e5e7eb', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>Abbrechen</button>
|
||
<button type="submit" style={{ padding: '0.6rem 1rem', background: '#2563eb', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>Anlegen</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* MODAL: HOTEL HINZUFÜGEN */}
|
||
{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>
|
||
<p style={{ fontSize: '0.85rem', color: '#6b7280', marginBottom: '1.2rem' }}>Check-in/Check-out Datum wird automatisch aus dem Link gelesen.</p>
|
||
|
||
<form onSubmit={handleAddHotel} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Hotel Name (optional)</label>
|
||
<input type="text" placeholder="z. B. Abad Fort" value={newHotelName} onChange={e => setNewHotelName(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||
</div>
|
||
|
||
<div>
|
||
<label style={{ display: 'block', fontSize: '0.85rem', fontWeight: 'bold', marginBottom: '0.3rem' }}>Booking.com URL *</label>
|
||
<input type="url" required placeholder="https://www.booking.com/hotel/..." value={newBookingUrl} onChange={e => setNewBookingUrl(e.target.value)} style={{ width: '100%', padding: '0.6rem', borderRadius: '6px', border: '1px solid #d1d5db' }} />
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '0.8rem', justifyContent: 'flex-end', marginTop: '1rem' }}>
|
||
<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={isSubmitting} style={{ padding: '0.6rem 1.2rem', background: '#10b981', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontWeight: 'bold' }}>
|
||
{isSubmitting ? 'Speichere...' : 'Speichern'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* TIMELINE */}
|
||
{loading ? (
|
||
<p style={{ textAlign: 'center', color: '#6b7280' }}>Lade Daten...</p>
|
||
) : currentHotels.length === 0 ? (
|
||
<div style={{ textAlign: 'center', padding: '3rem', background: '#f9fafb', borderRadius: '12px', border: '1px dashed #d1d5db' }}>
|
||
<p style={{ color: '#6b7280', marginBottom: '1rem' }}>Keine Hotels für diese Auswahl gefunden.</p>
|
||
<button onClick={() => setShowAddHotelModal(true)} style={{ background: '#10b981', color: '#fff', border: 'none', padding: '0.6rem 1.2rem', borderRadius: '6px', cursor: 'pointer' }}>
|
||
Erstes Hotel hinzufügen
|
||
</button>
|
||
</div>
|
||
) : (
|
||
<div style={{ position: 'relative', paddingLeft: '35px', borderLeft: '4px solid #3b82f6', marginLeft: '15px' }}>
|
||
{currentHotels.map((hotel, index) => {
|
||
const nights = calculateNights(hotel.checkin_date, hotel.checkout_date) || hotel.latest_nights || 1;
|
||
const persons = hotel.latest_persons || 2;
|
||
const totalPrice = hotel.latest_price !== null && hotel.latest_price !== undefined ? Number(hotel.latest_price) : null;
|
||
|
||
// Bevorzuge vorberechnete Werte aus der DB, sonst dyn. Fallback
|
||
const pricePerNight = hotel.latest_price_per_night
|
||
? Math.round(Number(hotel.latest_price_per_night))
|
||
: (totalPrice ? Math.round(totalPrice / nights) : null);
|
||
|
||
const pricePerPersonPerNight = hotel.latest_price_per_person
|
||
? Math.round(Number(hotel.latest_price_per_person))
|
||
: (totalPrice ? Math.round(totalPrice / (nights * persons)) : null);
|
||
|
||
return (
|
||
<div key={hotel.id} style={{ position: 'relative', marginBottom: '2.5rem', opacity: hotel.is_active ? 1 : 0.5 }}>
|
||
<div style={{ position: 'absolute', left: '-47px', top: '2px', width: '20px', height: '20px', borderRadius: '50%', background: hotel.is_active ? '#3b82f6' : '#9ca3af', border: '4px solid #ffffff', boxShadow: '0 0 0 2px #3b82f6' }} />
|
||
|
||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||
<div style={{ fontSize: '0.9rem', fontWeight: 'bold', color: '#2563eb', display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||
<span>📍 Stop #{index + 1}</span>
|
||
{hotel.trip_name && <span style={{ background: '#f3f4f6', color: '#4b5563', padding: '0.2rem 0.5rem', borderRadius: '6px', fontSize: '0.75rem' }}>{hotel.trip_name}</span>}
|
||
{hotel.checkin_date ? (
|
||
<span style={{ background: '#dbeafe', color: '#1e40af', padding: '0.2rem 0.6rem', borderRadius: '12px', fontSize: '0.8rem' }}>
|
||
🗓️ {formatDateGerman(hotel.checkin_date)} – {formatDateGerman(hotel.checkout_date) || '?'} ({nights} {nights === 1 ? 'Nacht' : 'Nächte'})
|
||
</span>
|
||
) : (
|
||
<span style={{ color: '#9ca3af', fontSize: '0.8rem', fontStyle: 'italic' }}>(Warte auf Scan für Datum)</span>
|
||
)}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '0.8rem', alignItems: 'center' }}>
|
||
<label style={{ fontSize: '0.8rem', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '0.3rem', color: '#4b5563' }}>
|
||
<input type="checkbox" checked={hotel.is_active} onChange={() => toggleHotelActive(hotel)} />
|
||
{hotel.is_active ? 'Aktiv' : 'Inaktiv'}
|
||
</label>
|
||
<button onClick={() => deleteHotel(hotel.id, hotel.name)} style={{ background: 'none', border: 'none', color: '#ef4444', cursor: 'pointer', fontSize: '0.85rem' }}>
|
||
🗑️ Löschen
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div style={{ background: '#ffffff', border: '1px solid #e5e7eb', borderRadius: '12px', padding: '1.2rem', boxShadow: '0 4px 6px -1px rgba(0,0,0,0.05)', display: 'flex', gap: '1.2rem', flexWrap: 'wrap' }}>
|
||
{hotel.image_url && <img src={hotel.image_url} alt={hotel.name} style={{ width: '130px', height: '100px', objectFit: 'cover', borderRadius: '8px' }} />}
|
||
|
||
<div style={{ flex: 1, minWidth: '220px' }}>
|
||
<h3 style={{ margin: '0 0 0.4rem 0', fontSize: '1.2rem', color: '#1f2937' }}>{hotel.name}</h3>
|
||
<p style={{ margin: '0 0 0.5rem 0', fontSize: '0.85rem', color: '#6b7280' }}>📍 {hotel.address || 'Keine Adresse hinterlegt'}</p>
|
||
|
||
{/* ANZEIGE DER DREI PREIS-WERTE */}
|
||
{totalPrice !== null ? (
|
||
<div style={{ display: 'flex', gap: '0.6rem', flexWrap: 'wrap', marginBottom: '0.8rem', background: '#ecfdf5', padding: '0.6rem 0.8rem', borderRadius: '8px', border: '1px solid #a7f3d0', alignItems: 'center' }}>
|
||
<span style={{ fontWeight: '800', color: '#047857', fontSize: '0.95rem' }}>
|
||
💰 Gesamt: {totalPrice.toLocaleString('de-DE')} €
|
||
</span>
|
||
<span style={{ color: '#059669', fontSize: '0.85rem', fontWeight: '600' }}>
|
||
| 🌙 {pricePerNight?.toLocaleString('de-DE')} € / Nacht
|
||
</span>
|
||
<span style={{ color: '#059669', fontSize: '0.85rem' }}>
|
||
| 👤 {pricePerPersonPerNight?.toLocaleString('de-DE')} € / P. & Nacht
|
||
</span>
|
||
</div>
|
||
) : (
|
||
<div style={{ marginBottom: '0.8rem', fontSize: '0.85rem', color: '#6b7280', fontStyle: 'italic' }}>
|
||
💰 Noch kein Preis erfasst
|
||
</div>
|
||
)}
|
||
|
||
<div style={{ display: 'flex', gap: '1rem', fontSize: '0.85rem', marginBottom: '1rem', color: '#374151', flexWrap: 'wrap', alignItems: 'center' }}>
|
||
<span>⭐ <strong>{hotel.rating || 'N/A'}</strong>/10</span>
|
||
<span>💬 {hotel.review_count || 0} Bewertungen</span>
|
||
{hotel.room_size_m2 && <span>📐 {hotel.room_size_m2} m²</span>}
|
||
</div>
|
||
|
||
<div style={{ display: 'flex', gap: '0.8rem', flexWrap: 'wrap' }}>
|
||
<button
|
||
onClick={() => openPriceHistoryModal(hotel)}
|
||
style={{ background: '#0284c7', color: '#ffffff', border: 'none', padding: '0.5rem 1rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.85rem' }}
|
||
>
|
||
📈 Preishistorie anzeigen
|
||
</button>
|
||
|
||
<button
|
||
onClick={() => scanSingleHotel(hotel.id)}
|
||
disabled={scanningHotelId === hotel.id || !hotel.is_active}
|
||
style={{ background: '#8b5cf6', color: '#ffffff', border: 'none', padding: '0.5rem 1rem', borderRadius: '6px', cursor: 'pointer', fontWeight: '600', fontSize: '0.85rem', opacity: (!hotel.is_active || scanningHotelId === hotel.id) ? 0.6 : 1 }}
|
||
>
|
||
{scanningHotelId === hotel.id ? '🔄 Scanne KI-Reviews...' : '🤖 KI-Reviews scannen'}
|
||
</button>
|
||
|
||
<a href={hotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ color: '#2563eb', textDecoration: 'none', fontSize: '0.85rem', alignSelf: 'center', fontWeight: '500' }}>
|
||
🔗 Booking.com ↗
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{(hotel.review_summary_positive || hotel.review_summary_bathroom) && (
|
||
<div style={{ marginTop: '0.8rem', paddingLeft: '0.2rem' }}>
|
||
<details style={{ background: '#f9fafb', padding: '0.8rem 1rem', borderRadius: '8px', border: '1px solid #e5e7eb' }}>
|
||
<summary style={{ cursor: 'pointer', fontWeight: 'bold', color: '#374151', fontSize: '0.9rem' }}>
|
||
📋 KI-Zusammenfassung
|
||
<span style={{ fontWeight: 'normal', color: '#6b7280', marginLeft: '0.5rem' }}>
|
||
(Basierend auf <strong>{hotel.review_summary_count || 'mehreren'}</strong> analysierten Bewertungen)
|
||
</span>
|
||
</summary>
|
||
|
||
<div style={{ marginTop: '1rem', fontSize: '0.88rem', display: 'grid', gap: '0.8rem' }}>
|
||
{hotel.review_summary_positive && (
|
||
<div style={{ background: '#e8f5e9', padding: '0.8rem', borderRadius: '6px', borderLeft: '4px solid #2e7d32' }}>
|
||
<strong style={{ color: '#1b5e20' }}>💚 Bestnoten & Highlights:</strong>
|
||
<div style={{ whiteSpace: 'pre-line', color: '#2e7d32', marginTop: '0.3rem' }}>{hotel.review_summary_positive}</div>
|
||
</div>
|
||
)}
|
||
{hotel.review_summary_passable && (
|
||
<div style={{ background: '#fffde7', padding: '0.8rem', borderRadius: '6px', borderLeft: '4px solid #fbc02d' }}>
|
||
<strong style={{ color: '#f57f17' }}>🟡 Passabel & Neutrales:</strong>
|
||
<div style={{ whiteSpace: 'pre-line', color: '#f57f17', marginTop: '0.3rem' }}>{hotel.review_summary_passable}</div>
|
||
</div>
|
||
)}
|
||
{hotel.review_summary_negative && (
|
||
<div style={{ background: '#ffebee', padding: '0.8rem', borderRadius: '6px', borderLeft: '4px solid #c62828' }}>
|
||
<strong style={{ color: '#b71c1c' }}>🔴 Schlechteste & Mängel:</strong>
|
||
<div style={{ whiteSpace: 'pre-line', color: '#c62828', marginTop: '0.3rem' }}>{hotel.review_summary_negative}</div>
|
||
</div>
|
||
)}
|
||
<div style={{ background: '#e0f7fa', padding: '0.8rem', borderRadius: '6px', borderLeft: '4px solid #00acc1' }}>
|
||
<strong style={{ color: '#006064' }}>🚽 Bad & WC Fokus:</strong>
|
||
<div style={{ whiteSpace: 'pre-line', color: '#006064', marginTop: '0.3rem' }}>
|
||
{hotel.review_summary_bathroom || 'Keine spezifischen Anmerkungen zum Bad/WC gefunden.'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
```
|
||
|
||
--- DATEI: src/lib/db.ts ---
|
||
```
|
||
import { Pool } from 'pg';
|
||
|
||
const pool = new Pool({
|
||
connectionString: process.env.DATABASE_URL,
|
||
});
|
||
|
||
export const query = (text: string, params?: any[]) => pool.query(text, params);
|
||
|
||
```
|
||
|
||
--- DATEI: tsconfig.json ---
|
||
```
|
||
{
|
||
"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"]
|
||
}
|
||
|
||
```
|