From f522ae995314339f370f022e747339fe71150b20 Mon Sep 17 00:00:00 2001 From: gitea Date: Sun, 9 Aug 2026 20:31:03 +0000 Subject: [PATCH] Working state 1 --- .env | 11 + Dockerfile | 14 + docker-compose.yml | 9 + package.json | 26 ++ src/app/api/hotels/[id]/history/route.ts | 30 ++ src/app/api/hotels/[id]/route.ts | 26 ++ src/app/api/hotels/route.ts | 21 ++ src/app/api/jobs/trigger-daily-check/route.ts | 36 ++ src/app/api/trips/route.ts | 88 +++++ src/app/api/webhooks/scraper/route.ts | 90 +++++ src/app/layout.tsx | 20 + src/app/page.tsx | 347 ++++++++++++++++++ src/lib/db.ts | 7 + tsconfig.json | 27 ++ 14 files changed, 752 insertions(+) create mode 100644 .env create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 src/app/api/hotels/[id]/history/route.ts create mode 100644 src/app/api/hotels/[id]/route.ts create mode 100644 src/app/api/hotels/route.ts create mode 100644 src/app/api/jobs/trigger-daily-check/route.ts create mode 100644 src/app/api/trips/route.ts create mode 100644 src/app/api/webhooks/scraper/route.ts create mode 100644 src/app/layout.tsx create mode 100644 src/app/page.tsx create mode 100644 src/lib/db.ts create mode 100644 tsconfig.json diff --git a/.env b/.env new file mode 100644 index 0000000..f5f8f51 --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +# 1. Datenbank-Verbindung (Ersetze 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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fac5001 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..82f7b6e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + travel-app: + build: . + container_name: travel-app + restart: always + ports: + - "3002:3001" + env_file: + - .env diff --git a/package.json b/package.json new file mode 100644 index 0000000..66cbc29 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/src/app/api/hotels/[id]/history/route.ts b/src/app/api/hotels/[id]/history/route.ts new file mode 100644 index 0000000..1190d38 --- /dev/null +++ b/src/app/api/hotels/[id]/history/route.ts @@ -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 }); + } +} diff --git a/src/app/api/hotels/[id]/route.ts b/src/app/api/hotels/[id]/route.ts new file mode 100644 index 0000000..0e120dd --- /dev/null +++ b/src/app/api/hotels/[id]/route.ts @@ -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 }); + } +} diff --git a/src/app/api/hotels/route.ts b/src/app/api/hotels/route.ts new file mode 100644 index 0000000..425e236 --- /dev/null +++ b/src/app/api/hotels/route.ts @@ -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 }); + } +} diff --git a/src/app/api/jobs/trigger-daily-check/route.ts b/src/app/api/jobs/trigger-daily-check/route.ts new file mode 100644 index 0000000..be257a7 --- /dev/null +++ b/src/app/api/jobs/trigger-daily-check/route.ts @@ -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 }); + } +} diff --git a/src/app/api/trips/route.ts b/src/app/api/trips/route.ts new file mode 100644 index 0000000..85b9e35 --- /dev/null +++ b/src/app/api/trips/route.ts @@ -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 }); + } +} diff --git a/src/app/api/webhooks/scraper/route.ts b/src/app/api/webhooks/scraper/route.ts new file mode 100644 index 0000000..399174d --- /dev/null +++ b/src/app/api/webhooks/scraper/route.ts @@ -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 }); + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx new file mode 100644 index 0000000..71e4465 --- /dev/null +++ b/src/app/layout.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx new file mode 100644 index 0000000..3162b46 --- /dev/null +++ b/src/app/page.tsx @@ -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([]); + const [loading, setLoading] = useState(true); + + // Detail / History Modal State + const [selectedHotel, setSelectedHotel] = useState(null); + const [history, setHistory] = useState([]); + const [loadingHistory, setLoadingHistory] = useState(false); + + // Formular-States + const [newTripTitle, setNewTripTitle] = useState(''); + const [newTripDest, setNewTripDest] = useState(''); + const [selectedTripId, setSelectedTripId] = useState(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 ( +
+
+

✈️ Reiseplaner & Preis-Tracker

+ +
+ + {/* Neue Reise */} +
+

➕ Neue Reise planen

+
+ setNewTripTitle(e.target.value)} style={styles.input} /> + setNewTripDest(e.target.value)} style={styles.input} /> + +
+
+ + {/* Reisen Liste */} + {loading ?

Lade Reisen...

: trips.map((trip) => ( +
+
+
+

{trip.title}

+ 📍 {trip.destination || 'Kein Zielort angegeben'} +
+ +
+ + {selectedTripId === trip.id && ( +
+ setNewBookingUrl(e.target.value)} style={{ ...styles.input, width: '100%', marginBottom: '10px' }} /> + +
+ )} + +
+ {trip.hotels.length === 0 ? ( +

Noch keine Hotels angelegt.

+ ) : ( + 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 ( +
openHotelDetails(hotel)} + style={{ + ...styles.hotelRow, + opacity: hotel.is_active ? 1 : 0.6, + backgroundColor: hotel.is_active ? '#fff' : '#f9f9f9', + cursor: 'pointer' + }} + > +
+ {hotel.image_url && ( + + )} +
+ {hotel.name || 'Unbekanntes Hotel'} +
{hotel.address || 'Keine Adresse'}
+ +
+ {hotel.rating && ⭐ {hotel.rating} ({hotel.review_count || 0} Bew.)} + {hotel.room_size_m2 && 📐 {hotel.room_size_m2} m²} + + e.stopPropagation()} + style={styles.bookingLinkBtn} + > + 🔗 Auf Booking.com ↗ + +
+
+
+ +
+
+ {pReg !== null ? ( +
+
+ Regulär: {pReg.toFixed(2)} € +
+
+ Genius + {pGenius ? `${pGenius.toFixed(2)} €` : '-'} +
+ +
+
{pNight} € / Nacht (Zimmer)
+
{pPerson} € / Pers. & Nacht
+
+ ({nights} {nights === 1 ? 'Nacht' : 'Nächte'}, {persons} Pers.) +
+
+
+ ) : ( +
Kein Preis
+ )} +
+ + + + +
+
+ ); + }) + )} +
+
+ ))} + + {/* Preishistorie Modal */} + {selectedHotel && ( +
setSelectedHotel(null)}> +
e.stopPropagation()}> +
+
+

📈 Preishistorie: {selectedHotel.name}

+ + 🔗 Auf Booking.com öffnen ↗ + +
+ +
+ + {loadingHistory ?

Lade Historie...

: ( + + + + + + + + + + + + + {history.length === 0 ? ( + + ) : ( + 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 ( + + + + + + + + + ); + }) + )} + +
DatumRegulärGenius (-10%)Pro NachtPro Person/NachtGesamt/Person
Keine Daten vorhanden.
{new Date(entry.tracked_at).toLocaleString('de-DE')}{pReg.toFixed(2)} €{pGenius.toFixed(2)} €{entry.price_per_night ? `${entry.price_per_night} €` : '-'}{entry.price_per_person ? `${entry.price_per_person} €` : '-'}{totalPerPerson} €
+ )} +
+
+ )} +
+ ); +} + +const styles: Record = { + 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' } +}; diff --git a/src/lib/db.ts b/src/lib/db.ts new file mode 100644 index 0000000..5bf017f --- /dev/null +++ b/src/lib/db.ts @@ -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); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bada8ed --- /dev/null +++ b/tsconfig.json @@ -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"] +}