Compare commits
2 Commits
f522ae9953
...
6b80d628f3
| Author | SHA1 | Date | |
|---|---|---|---|
| 6b80d628f3 | |||
| fe9dfa4519 |
11
.env
11
.env
@@ -1,11 +0,0 @@
|
|||||||
# 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
|
|
||||||
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# ==========================================
|
||||||
|
# 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?
|
||||||
6
next.config.js
Normal file
6
next.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
serverExternalPackages: ['bullmq', 'ioredis'],
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = nextConfig;
|
||||||
6
next.config.mjs
Normal file
6
next.config.mjs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
serverExternalPackages: ['bullmq', 'ioredis'],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default nextConfig;
|
||||||
2353
package-lock.json
generated
Normal file
2353
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -8,19 +8,21 @@
|
|||||||
"start": "next start -p 3001"
|
"start": "next start -p 3001"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.7.0",
|
||||||
|
"bullmq": "^6.0.9",
|
||||||
|
"ioredis": "^6.0.0",
|
||||||
"next": "^14.2.0",
|
"next": "^14.2.0",
|
||||||
"react": "^18.3.0",
|
|
||||||
"react-dom": "^18.3.0",
|
|
||||||
"pg": "^8.11.5",
|
"pg": "^8.11.5",
|
||||||
"axios": "^1.7.0"
|
"react": "^18.3.0",
|
||||||
|
"react-dom": "^18.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.0.0",
|
"@types/node": "^20.0.0",
|
||||||
"@types/react": "^18.3.0",
|
|
||||||
"@types/pg": "^8.11.0",
|
"@types/pg": "^8.11.0",
|
||||||
"typescript": "^5.0.0",
|
"@types/react": "^18.3.0",
|
||||||
"tailwindcss": "^3.4.0",
|
|
||||||
"autoprefixer": "^10.4.0",
|
"autoprefixer": "^10.4.0",
|
||||||
"postcss": "^8.4.0"
|
"postcss": "^8.4.0",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"typescript": "^5.0.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/app/api/hotels/[id]/review-summary/route.ts
Normal file
28
src/app/api/hotels/[id]/review-summary/route.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 } = 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,
|
||||||
|
reviews_updated_at = NOW()
|
||||||
|
WHERE id = $4`,
|
||||||
|
[summary_positive, summary_passable, summary_negative, 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,36 +1,67 @@
|
|||||||
import { NextResponse } from 'next/server';
|
import { NextResponse } from 'next/server';
|
||||||
import { query } from '@/lib/db';
|
import { query } from '@/lib/db';
|
||||||
import axios from 'axios';
|
|
||||||
|
export const runtime = 'nodejs';
|
||||||
|
export const dynamic = 'force-dynamic';
|
||||||
|
|
||||||
export async function POST() {
|
export async function POST() {
|
||||||
try {
|
try {
|
||||||
// 1. Alle aktiven Hotels aus der Datenbank holen
|
const bullmq = typeof window === 'undefined' ? eval('require')('bullmq') : null;
|
||||||
const result = await query(
|
if (!bullmq || !bullmq.Queue) {
|
||||||
'SELECT id, booking_url FROM hotels WHERE is_active = true'
|
throw new Error('BullMQ Queue Klasse konnte nicht geladen werden.');
|
||||||
);
|
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const n8nBaseUrl = process.env.APP_WEBHOOK_URL || 'https://n8n.prinz-pilaw.de/webhook-test/scrape-reviews';
|
||||||
|
|
||||||
|
for (const hotel of activeHotels) {
|
||||||
|
const n8nWebhookUrl = n8nBaseUrl.includes('?')
|
||||||
|
? `${n8nBaseUrl}&hotelId=${hotel.id}`
|
||||||
|
: `${n8nBaseUrl}?hotelId=${hotel.id}`;
|
||||||
|
|
||||||
|
// Direkt-URL zur Next.js App für Preishistorie
|
||||||
|
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, // Für Preishistorie in Postgres
|
||||||
|
webhookUrl: n8nWebhookUrl, // Für n8n KI-Reviews
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await scrapeQueue.close();
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
message: `${triggeredCount} Scrape-Jobs an den Scraper übergeben.`
|
message: `${activeHotels.length} Scrape-Jobs erfolgreich gestartet.`,
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Trigger Job Error:', error);
|
console.error('Fehler beim Starten der Jobs:', error);
|
||||||
return NextResponse.json({ success: false, error: error.message }, { status: 500 });
|
return NextResponse.json(
|
||||||
|
{ success: false, error: error.message },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
135
src/app/page.tsx
135
src/app/page.tsx
@@ -18,6 +18,10 @@ interface Hotel {
|
|||||||
room_size_m2: number | null;
|
room_size_m2: number | null;
|
||||||
address: string | null;
|
address: string | null;
|
||||||
image_url: string | null;
|
image_url: string | null;
|
||||||
|
review_summary_positive?: string | null;
|
||||||
|
review_summary_passable?: string | null;
|
||||||
|
review_summary_negative?: string | null;
|
||||||
|
reviews_updated_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Trip {
|
interface Trip {
|
||||||
@@ -42,12 +46,13 @@ export default function Dashboard() {
|
|||||||
const [trips, setTrips] = useState<Trip[]>([]);
|
const [trips, setTrips] = useState<Trip[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Detail / History Modal State
|
// Detail / Modal State
|
||||||
const [selectedHotel, setSelectedHotel] = useState<Hotel | null>(null);
|
const [selectedHotel, setSelectedHotel] = useState<Hotel | null>(null);
|
||||||
|
const [activeTab, setActiveTab] = useState<'history' | 'reviews'>('history');
|
||||||
const [history, setHistory] = useState<PriceHistoryEntry[]>([]);
|
const [history, setHistory] = useState<PriceHistoryEntry[]>([]);
|
||||||
const [loadingHistory, setLoadingHistory] = useState(false);
|
const [loadingHistory, setLoadingHistory] = useState(false);
|
||||||
|
|
||||||
// Formular-States
|
// Forms
|
||||||
const [newTripTitle, setNewTripTitle] = useState('');
|
const [newTripTitle, setNewTripTitle] = useState('');
|
||||||
const [newTripDest, setNewTripDest] = useState('');
|
const [newTripDest, setNewTripDest] = useState('');
|
||||||
const [selectedTripId, setSelectedTripId] = useState<string | null>(null);
|
const [selectedTripId, setSelectedTripId] = useState<string | null>(null);
|
||||||
@@ -68,9 +73,9 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
useEffect(() => { loadData(); }, []);
|
useEffect(() => { loadData(); }, []);
|
||||||
|
|
||||||
// Preishistorie Modal öffnen
|
|
||||||
const openHotelDetails = async (hotel: Hotel) => {
|
const openHotelDetails = async (hotel: Hotel) => {
|
||||||
setSelectedHotel(hotel);
|
setSelectedHotel(hotel);
|
||||||
|
setActiveTab('history');
|
||||||
setHistory([]);
|
setHistory([]);
|
||||||
setLoadingHistory(true);
|
setLoadingHistory(true);
|
||||||
try {
|
try {
|
||||||
@@ -127,9 +132,9 @@ export default function Dashboard() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const triggerScrape = async () => {
|
const triggerScrape = async () => {
|
||||||
alert('Scrape-Prozess gestartet. Preise werden im Hintergrund geladen...');
|
alert('Scrape & Review-Analyse gestartet...');
|
||||||
await fetch('/api/jobs/trigger-daily-check', { method: 'POST' });
|
await fetch('/api/jobs/trigger-daily-check', { method: 'POST' });
|
||||||
setTimeout(() => loadData(), 10000);
|
setTimeout(() => loadData(), 12000);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -176,20 +181,12 @@ export default function Dashboard() {
|
|||||||
trip.hotels.map((hotel) => {
|
trip.hotels.map((hotel) => {
|
||||||
const pReg = hotel.latest_price !== null ? parseFloat(String(hotel.latest_price)) : null;
|
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 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 nights = hotel.nights ? parseInt(String(hotel.nights), 10) : 1;
|
||||||
const persons = hotel.persons ? parseInt(String(hotel.persons), 10) : 2;
|
const persons = hotel.persons ? parseInt(String(hotel.persons), 10) : 2;
|
||||||
|
|
||||||
const activePrice = pGenius || pReg;
|
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 pNight = hotel.price_per_night
|
const pPerson = hotel.price_per_person ? parseFloat(String(hotel.price_per_person)).toFixed(2) : (activePrice ? (activePrice / (nights * persons)).toFixed(2) : '-');
|
||||||
? 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -214,14 +211,8 @@ export default function Dashboard() {
|
|||||||
{hotel.rating && <small style={styles.badge}>⭐ {hotel.rating} ({hotel.review_count || 0} Bew.)</small>}
|
{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>}
|
{hotel.room_size_m2 && <small style={styles.badge}>📐 {hotel.room_size_m2} m²</small>}
|
||||||
|
|
||||||
<a
|
<a href={hotel.booking_url} target="_blank" rel="noopener noreferrer" onClick={(e) => e.stopPropagation()} style={styles.bookingLinkBtn}>
|
||||||
href={hotel.booking_url}
|
🔗 Booking.com ↗
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
style={styles.bookingLinkBtn}
|
|
||||||
>
|
|
||||||
🔗 Auf Booking.com ↗
|
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -231,9 +222,7 @@ export default function Dashboard() {
|
|||||||
<div style={{ textAlign: 'right', minWidth: '170px' }}>
|
<div style={{ textAlign: 'right', minWidth: '170px' }}>
|
||||||
{pReg !== null ? (
|
{pReg !== null ? (
|
||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: '0.78rem', color: '#888', textDecoration: 'line-through' }}>
|
<div style={{ fontSize: '0.78rem', color: '#888', textDecoration: 'line-through' }}>Regulär: {pReg.toFixed(2)} €</div>
|
||||||
Regulär: {pReg.toFixed(2)} €
|
|
||||||
</div>
|
|
||||||
<div style={{ fontSize: '1.25rem', fontWeight: 'bold', color: '#2e7d32' }}>
|
<div style={{ fontSize: '1.25rem', fontWeight: 'bold', color: '#2e7d32' }}>
|
||||||
<span style={styles.geniusTag}>Genius</span>
|
<span style={styles.geniusTag}>Genius</span>
|
||||||
{pGenius ? `${pGenius.toFixed(2)} €` : '-'}
|
{pGenius ? `${pGenius.toFixed(2)} €` : '-'}
|
||||||
@@ -242,9 +231,7 @@ export default function Dashboard() {
|
|||||||
<div style={{ fontSize: '0.75rem', color: '#555', marginTop: '2px', lineHeight: '1.3' }}>
|
<div style={{ fontSize: '0.75rem', color: '#555', marginTop: '2px', lineHeight: '1.3' }}>
|
||||||
<div><strong>{pNight} €</strong> / Nacht (Zimmer)</div>
|
<div><strong>{pNight} €</strong> / Nacht (Zimmer)</div>
|
||||||
<div><strong>{pPerson} €</strong> / Pers. & Nacht</div>
|
<div><strong>{pPerson} €</strong> / Pers. & Nacht</div>
|
||||||
<div style={{ fontSize: '0.7rem', color: '#888' }}>
|
<div style={{ fontSize: '0.7rem', color: '#888' }}>({nights} {nights === 1 ? 'Nacht' : 'Nächte'}, {persons} Pers.)</div>
|
||||||
({nights} {nights === 1 ? 'Nacht' : 'Nächte'}, {persons} Pers.)
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -267,13 +254,13 @@ export default function Dashboard() {
|
|||||||
</section>
|
</section>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Preishistorie Modal */}
|
{/* Detail Modal (Historie & KI Reviews) */}
|
||||||
{selectedHotel && (
|
{selectedHotel && (
|
||||||
<div style={styles.modalOverlay} onClick={() => setSelectedHotel(null)}>
|
<div style={styles.modalOverlay} onClick={() => setSelectedHotel(null)}>
|
||||||
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
<div style={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
|
||||||
<div>
|
<div>
|
||||||
<h2 style={{ margin: 0 }}>📈 Preishistorie: {selectedHotel.name}</h2>
|
<h2 style={{ margin: 0 }}>{selectedHotel.name}</h2>
|
||||||
<a href={selectedHotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.85rem', color: '#0070f3' }}>
|
<a href={selectedHotel.booking_url} target="_blank" rel="noopener noreferrer" style={{ fontSize: '0.85rem', color: '#0070f3' }}>
|
||||||
🔗 Auf Booking.com öffnen ↗
|
🔗 Auf Booking.com öffnen ↗
|
||||||
</a>
|
</a>
|
||||||
@@ -281,7 +268,25 @@ export default function Dashboard() {
|
|||||||
<button onClick={() => setSelectedHotel(null)} style={styles.btnSecondary}>✕ Schließen</button>
|
<button onClick={() => setSelectedHotel(null)} style={styles.btnSecondary}>✕ Schließen</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loadingHistory ? <p>Lade Historie...</p> : (
|
{/* Tab Navigation */}
|
||||||
|
<div style={{ display: 'flex', gap: '1rem', borderBottom: '2px solid #eee', marginBottom: '1.5rem' }}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('history')}
|
||||||
|
style={{ ...styles.tabBtn, borderBottom: activeTab === 'history' ? '3px solid #0070f3' : 'none', fontWeight: activeTab === 'history' ? 'bold' : 'normal' }}
|
||||||
|
>
|
||||||
|
📈 Preishistorie
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('reviews')}
|
||||||
|
style={{ ...styles.tabBtn, borderBottom: activeTab === 'reviews' ? '3px solid #0070f3' : 'none', fontWeight: activeTab === 'reviews' ? 'bold' : 'normal' }}
|
||||||
|
>
|
||||||
|
🤖 KI-Bewertungsanalyse (80 Reviews)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* TAB 1: PREISHISTORIE */}
|
||||||
|
{activeTab === 'history' && (
|
||||||
|
loadingHistory ? <p>Lade Historie...</p> : (
|
||||||
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.88rem' }}>
|
<table style={{ width: '100%', borderCollapse: 'collapse', textAlign: 'left', fontSize: '0.88rem' }}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr style={{ borderBottom: '2px solid #ccc', background: '#f5f5f5' }}>
|
<tr style={{ borderBottom: '2px solid #ccc', background: '#f5f5f5' }}>
|
||||||
@@ -290,33 +295,64 @@ export default function Dashboard() {
|
|||||||
<th style={{ padding: '8px' }}>Genius (-10%)</th>
|
<th style={{ padding: '8px' }}>Genius (-10%)</th>
|
||||||
<th style={{ padding: '8px' }}>Pro Nacht</th>
|
<th style={{ padding: '8px' }}>Pro Nacht</th>
|
||||||
<th style={{ padding: '8px' }}>Pro Person/Nacht</th>
|
<th style={{ padding: '8px' }}>Pro Person/Nacht</th>
|
||||||
<th style={{ padding: '8px' }}>Gesamt/Person</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{history.length === 0 ? (
|
{history.map((entry) => (
|
||||||
<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' }}>
|
<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' }}>{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', textDecoration: 'line-through', color: '#888' }}>{entry.price_numeric} €</td>
|
||||||
<td style={{ padding: '8px', fontWeight: 'bold', color: '#2e7d32' }}>{pGenius.toFixed(2)} €</td>
|
<td style={{ padding: '8px', fontWeight: 'bold', color: '#2e7d32' }}>{entry.genius_price_numeric || (entry.price_numeric * 0.9).toFixed(2)} €</td>
|
||||||
<td style={{ padding: '8px' }}>{entry.price_per_night ? `${entry.price_per_night} €` : '-'}</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' }}>{entry.price_per_person ? `${entry.price_per_person} €` : '-'}</td>
|
||||||
<td style={{ padding: '8px', fontWeight: 'bold' }}>{totalPerPerson} €</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
);
|
))}
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAB 2: KI REVIEWS */}
|
||||||
|
{activeTab === 'reviews' && (
|
||||||
|
<div>
|
||||||
|
{!selectedHotel.review_summary_positive ? (
|
||||||
|
<p style={{ color: '#888', fontStyle: 'italic', textAlign: 'center', padding: '2rem' }}>
|
||||||
|
Noch keine KI-Analyse vorhanden. Klicke oben auf "Alle Hotels scannen", um die Bewertungen zu scrapen und auszuwerten.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '1.2rem' }}>
|
||||||
|
{/* 1. Bestnoten */}
|
||||||
|
<div style={{ background: '#e8f5e9', padding: '1.2rem', borderRadius: '8px', borderLeft: '5px solid #2e7d32' }}>
|
||||||
|
<h4 style={{ margin: '0 0 0.5rem 0', color: '#1b5e20' }}>💚 Bestnoten & Highlights (Positiv)</h4>
|
||||||
|
<div style={{ whiteSpace: 'pre-line', fontSize: '0.9rem', color: '#2e7d32' }}>
|
||||||
|
{selectedHotel.review_summary_positive}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 2. Passabel */}
|
||||||
|
<div style={{ background: '#fffde7', padding: '1.2rem', borderRadius: '8px', borderLeft: '5px solid #fbc02d' }}>
|
||||||
|
<h4 style={{ margin: '0 0 0.5rem 0', color: '#f57f17' }}>🟡 Passabel & Neutrales</h4>
|
||||||
|
<div style={{ whiteSpace: 'pre-line', fontSize: '0.9rem', color: '#f57f17' }}>
|
||||||
|
{selectedHotel.review_summary_passable}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 3. Schlechteste */}
|
||||||
|
<div style={{ background: '#ffebee', padding: '1.2rem', borderRadius: '8px', borderLeft: '5px solid #c62828' }}>
|
||||||
|
<h4 style={{ margin: '0 0 0.5rem 0', color: '#b71c1c' }}>🔴 Schlechteste & Mängel (Kritik)</h4>
|
||||||
|
<div style={{ whiteSpace: 'pre-line', fontSize: '0.9rem', color: '#c62828' }}>
|
||||||
|
{selectedHotel.review_summary_negative}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedHotel.reviews_updated_at && (
|
||||||
|
<small style={{ color: '#888', textAlign: 'right' }}>
|
||||||
|
Zuletzt analysiert: {new Date(selectedHotel.reviews_updated_at).toLocaleString('de-DE')}
|
||||||
|
</small>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,6 +372,7 @@ const styles: Record<string, React.CSSProperties> = {
|
|||||||
btn: { padding: '0.6rem 1.2rem', background: '#333', color: '#fff', border: 'none', borderRadius: '4px', cursor: 'pointer' },
|
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' },
|
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' },
|
btnSecondary: { padding: '0.4rem 0.8rem', background: '#e0e0e0', border: 'none', borderRadius: '4px', cursor: 'pointer' },
|
||||||
|
tabBtn: { padding: '0.6rem 1rem', background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.95rem' },
|
||||||
dangerBtn: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.1rem' },
|
dangerBtn: { background: 'none', border: 'none', cursor: 'pointer', fontSize: '1.1rem' },
|
||||||
addHotelBox: { background: '#f8f9fa', padding: '1rem', borderRadius: '6px', marginTop: '1rem', border: '1px solid #e9ecef' },
|
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' },
|
hotelRow: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '1rem', borderRadius: '6px', marginBottom: '0.5rem', border: '1px solid #eee', transition: 'all 0.2s' },
|
||||||
|
|||||||
Reference in New Issue
Block a user