Files
Reiseplanuing/public/app.js
2026-03-16 15:36:42 +01:00

553 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { useState, useRef } = React;
// ─────────────────────────────────────────────
// HILFSFUNKTIONEN
// ─────────────────────────────────────────────
function formatDateDE(dateStr) {
if (!dateStr) return '';
const [y, m, d] = dateStr.split('-');
return `${d}.${m}.${y}`;
}
// ─────────────────────────────────────────────
// HEADER
// ─────────────────────────────────────────────
function Header() {
return (
<header className="bg-navy shadow-lg">
<div className="max-w-5xl mx-auto px-6 py-4 flex items-center gap-3">
<div className="text-3xl"></div>
<div>
<h1 className="text-white text-2xl font-bold tracking-tight">TravelDesk</h1>
<p className="text-blue-200 text-sm">Intelligente Reiseplanung für Ihr Team</p>
</div>
</div>
</header>
);
}
// ─────────────────────────────────────────────
// LADEANIMATION
// ─────────────────────────────────────────────
const LOADING_STEPS = [
{ icon: '📍', text: 'Fabrikadresse wird ermittelt...' },
{ icon: '✈️', text: 'Flug-Link wird vorbereitet...' },
{ icon: '🏨', text: 'Hotel-Link wird vorbereitet...' },
{ icon: '🚗', text: 'Mietwagen wird geprüft...' },
];
function LoadingState({ currentStep }) {
return (
<div className="bg-white rounded-2xl shadow-md p-10 text-center mt-8">
<div className="flex justify-center mb-6">
<div className="spinner"></div>
</div>
<div className="space-y-3">
{LOADING_STEPS.map((step, i) => (
<div
key={i}
className={`flex items-center gap-3 justify-center text-sm transition-all duration-300 ${
i < currentStep
? 'text-green-600 font-medium'
: i === currentStep
? 'text-navy font-semibold text-base'
: 'text-gray-300'
}`}
>
<span>{step.icon}</span>
<span>{step.text}</span>
{i < currentStep && <span className="text-green-500"></span>}
</div>
))}
</div>
</div>
);
}
// ─────────────────────────────────────────────
// EINGABE-FORMULAR
// ─────────────────────────────────────────────
function SearchForm({ onSearch, loading }) {
const today = new Date().toISOString().split('T')[0];
const nextWeek = new Date(Date.now() + 7 * 86400000).toISOString().split('T')[0];
const nextWeek5 = new Date(Date.now() + 12 * 86400000).toISOString().split('T')[0];
const [form, setForm] = useState({
origin: 'Stuttgart, Deutschland',
destStreet: '',
destHouseNumber: '',
destPostalCode: '',
destCity: '',
destCountry: '',
checkin: nextWeek,
checkout: nextWeek5,
adults: 1,
refundableOnly: true,
directFlightsOnly: false,
hotelAddress: '',
specialRequirements: '',
});
const set = (field) => (e) => {
const val = e.target.type === 'checkbox' ? e.target.checked
: e.target.type === 'number' ? parseInt(e.target.value)
: e.target.value;
setForm((prev) => ({ ...prev, [field]: val }));
};
const handleSubmit = (e) => {
e.preventDefault();
if (!form.destCity.trim()) {
alert('Bitte gib mindestens die Zielstadt ein.');
return;
}
onSearch(form);
};
return (
<form onSubmit={handleSubmit} className="bg-white rounded-2xl shadow-md p-8 max-w-3xl mx-auto">
<h2 className="text-xl font-bold text-navy mb-6">Neue Reise planen</h2>
{/* Zeile 1: Abflugort */}
<div className="mb-4">
<label className="label">Abflugort</label>
<input type="text" value={form.origin} onChange={set('origin')}
placeholder="z.B. Stuttgart, Deutschland" className="input" required />
</div>
{/* Zeile 2: Fabrikadresse */}
<div className="mb-1">
<label className="label">Fabrikadresse (Zielort)</label>
</div>
<div className="grid grid-cols-3 gap-3 mb-2">
<div className="col-span-2">
<input type="text" value={form.destStreet} onChange={set('destStreet')}
placeholder="Straße" className="input" />
</div>
<div>
<input type="text" value={form.destHouseNumber} onChange={set('destHouseNumber')}
placeholder="Nr." className="input" />
</div>
</div>
<div className="grid grid-cols-3 gap-3 mb-4">
<div>
<input type="text" value={form.destPostalCode} onChange={set('destPostalCode')}
placeholder="PLZ" className="input" />
</div>
<div>
<input type="text" value={form.destCity} onChange={set('destCity')}
placeholder="Stadt *" className="input" required />
</div>
<div>
<input type="text" value={form.destCountry} onChange={set('destCountry')}
placeholder="Land" className="input" />
</div>
</div>
{/* Zeile 2 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-4">
<div>
<label className="label">Anreisedatum</label>
<input type="date" value={form.checkin} onChange={set('checkin')} min={today} className="input" required />
</div>
<div>
<label className="label">Abreisedatum</label>
<input type="date" value={form.checkout} onChange={set('checkout')} min={form.checkin} className="input" required />
</div>
<div>
<label className="label">Anzahl Personen</label>
<input type="number" value={form.adults} onChange={set('adults')} min="1" max="10" className="input" />
</div>
</div>
{/* Zeile 3 */}
<div className="mb-4">
<label className="label">Besondere Anforderungen <span className="text-gray-400 font-normal">(optional)</span></label>
<textarea value={form.specialRequirements} onChange={set('specialRequirements')}
rows="2" placeholder="z.B. Parkplatz, stornierbar, Einzelzimmer..." className="input resize-none" />
</div>
{/* Checkboxen */}
<div className="flex flex-wrap gap-6 mb-5">
{[
{ field: 'refundableOnly', label: 'Nur stornierbare Hotels' },
{ field: 'directFlightsOnly', label: 'Direktflüge bevorzugt' },
].map(({ field, label }) => (
<label key={field} className="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox" checked={form[field]} onChange={set(field)}
className="w-4 h-4 accent-orange-500 rounded" />
<span className="text-sm text-gray-700">{label}</span>
</label>
))}
</div>
{/* Mietwagen-Check Sektion */}
<div className="mb-5">
<div className="flex items-center gap-3 mb-3">
<div className="flex-1 border-t border-gray-200"></div>
<span className="text-sm font-semibold text-gray-500 whitespace-nowrap">🏨 Mietwagen-Check (optional)</span>
<div className="flex-1 border-t border-gray-200"></div>
</div>
<label className="label">Hotel-Adresse</label>
<input type="text" value={form.hotelAddress} onChange={set('hotelAddress')}
placeholder="z.B. ul. Marszałkowska 1, Warschau nach der Hotelbuchung hier eintragen"
className="input" />
<p className="text-xs text-gray-400 mt-1">
Füge die Adresse deines gebuchten Hotels ein. Das Tool berechnet automatisch ob ein Mietwagen zur Fabrik nötig ist (Grenzwert: 1,5 km).
</p>
</div>
{/* Info-Box */}
<div className="bg-gray-50 rounded-xl p-3 mb-4 text-sm text-gray-600 text-center">
Du wirst zu Booking.com weitergeleitet um Flug, Hotel und Auto selbst zu buchen. Das Tool füllt alle Felder automatisch vor.
</div>
<button type="submit" disabled={loading}
className="w-full bg-accent hover:bg-orange-600 disabled:opacity-60 disabled:cursor-not-allowed text-white font-bold py-4 rounded-xl text-lg transition-colors shadow-md">
{loading ? '⏳ Links werden generiert...' : '🔍 Reise planen'}
</button>
</form>
);
}
// ─────────────────────────────────────────────
// DISTANZ-CHECK WIDGET (inline im Auto-Block)
// ─────────────────────────────────────────────
function DistanceCheckWidget({ fabrikAddress, checkin, checkout, carLink }) {
const [hotelAddr, setHotelAddr] = useState('');
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState(null);
const [checkError, setCheckError] = useState('');
const handleCheck = async () => {
if (!hotelAddr.trim()) return;
setChecking(true);
setCheckResult(null);
setCheckError('');
try {
const res = await fetch('/api/check-distance', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hotelAddress: hotelAddr, fabrikAddress, checkin, checkout }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Fehler');
setCheckResult(data);
} catch (e) {
setCheckError(e.message || 'Adresse nicht gefunden. Bitte prüfe die Schreibweise.');
} finally {
setChecking(false);
}
};
return (
<div className="mt-3">
<div className="flex gap-2">
<input
type="text"
value={hotelAddr}
onChange={(e) => setHotelAddr(e.target.value)}
placeholder="Hotel-Adresse eintragen..."
className="input text-sm flex-1"
onKeyDown={(e) => e.key === 'Enter' && handleCheck()}
/>
<button
onClick={handleCheck}
disabled={checking || !hotelAddr.trim()}
className="bg-navy text-white text-sm font-semibold px-4 py-2 rounded-lg hover:bg-blue-800 disabled:opacity-50 whitespace-nowrap transition-colors"
>
{checking ? '...' : '📏 Prüfen'}
</button>
</div>
{checkError && (
<div className="mt-2 text-red-600 text-xs">{checkError}</div>
)}
{checkResult && (
<div className={`mt-2 rounded-lg p-3 text-sm font-medium flex items-center justify-between gap-3 ${
checkResult.carNeeded ? 'bg-orange-50 text-orange-800' : 'bg-green-50 text-green-800'
}`}>
<span>{checkResult.carMessage}</span>
{checkResult.carNeeded && (
<a href={checkResult.carLink} target="_blank" rel="noopener noreferrer"
className="btn-primary text-xs py-1.5 px-3 whitespace-nowrap">
Mietwagen
</a>
)}
</div>
)}
</div>
);
}
// ─────────────────────────────────────────────
// ERGEBNIS-BLÖCKE
// ─────────────────────────────────────────────
function FlugBlock({ data }) {
const { flightLink, originIATA, destIATA, iataFallbackUsed, iataError, meta } = data;
return (
<div className="result-block bg-navy text-white rounded-2xl p-6 shadow-lg">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className="text-4xl mt-1"></div>
<div>
<h3 className="text-xl font-bold mb-1">Flug buchen</h3>
{originIATA && destIATA ? (
<>
<div className="text-2xl font-bold text-orange-400 mb-1">
{originIATA} {destIATA}
</div>
<div className="text-blue-200 text-sm">
{formatDateDE(meta.checkin)} · {meta.adults} Person{meta.adults !== 1 ? 'en' : ''} · Economy
</div>
{iataFallbackUsed && (
<span className="inline-block mt-2 text-xs bg-blue-800 text-blue-200 px-2 py-0.5 rounded-full">
IATA via KI ermittelt
</span>
)}
</>
) : (
<div className="text-yellow-300 text-sm mt-1">
IATA-Code für{' '}
{[iataError?.origin, iataError?.dest].filter(Boolean).join(' / ')}{' '}
nicht gefunden bitte manuell auf Booking.com suchen
</div>
)}
</div>
</div>
<div className="text-center md:text-right shrink-0">
<a href={flightLink} target="_blank" rel="noopener noreferrer"
className="inline-block bg-accent hover:bg-orange-600 text-white font-bold py-3 px-6 rounded-xl transition-colors shadow-md text-base">
Flüge vergleichen
</a>
<div className="text-blue-300 text-xs mt-2">Öffnet Booking.com Flights</div>
</div>
</div>
</div>
);
}
function HotelBlock({ data }) {
const { hotelLink, meta } = data;
const nights = meta.nights;
return (
<div className="result-block bg-white border-2 border-navy rounded-2xl p-6 shadow-lg">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className="text-4xl mt-1">🏨</div>
<div>
<h3 className="text-xl font-bold text-navy mb-1">Hotel suchen</h3>
<div className="text-2xl font-bold text-navy mb-1">In {meta.destinationCity}</div>
<div className="text-gray-600 text-sm">
{formatDateDE(meta.checkin)} bis {formatDateDE(meta.checkout)} · {nights} Nacht{nights !== 1 ? 'e' : ''}
</div>
<div className="text-gray-400 text-xs mt-1">
Sortiert nach günstigstem Preis{data.refundableOnly ? ' · Nur kostenlos stornierbar' : ''}
</div>
</div>
</div>
<div className="text-center md:text-right shrink-0">
<a href={hotelLink} target="_blank" rel="noopener noreferrer"
className="inline-block bg-accent hover:bg-orange-600 text-white font-bold py-3 px-6 rounded-xl transition-colors shadow-md text-base">
🏨 Hotels vergleichen
</a>
<div className="text-gray-400 text-xs mt-2">Öffnet Booking.com Hotels</div>
</div>
</div>
</div>
);
}
function AutoBlock({ data }) {
const { carLink, carNeeded, carMessage, distanceMeters, hotelAddressUsed, meta } = data;
// Szenario A: Hotel angegeben + Mietwagen empfohlen
if (carNeeded === true) {
return (
<div className="result-block bg-white border-2 border-accent rounded-2xl p-6 shadow-lg">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className="text-4xl mt-1">🚗</div>
<div>
<h3 className="text-xl font-bold text-navy mb-1">Mietwagen empfohlen</h3>
<div className="text-gray-700 text-sm mb-3">{carMessage}</div>
<div className="bg-gray-50 rounded-lg p-3 text-xs text-gray-600 space-y-1">
<div>📍 <span className="font-medium">Hotel:</span> {hotelAddressUsed}</div>
<div>🏭 <span className="font-medium">Fabrik:</span> {meta.destination}</div>
<div>📏 <span className="font-medium">Entfernung:</span> {distanceMeters}m ({(distanceMeters / 1000).toFixed(1)} km)</div>
</div>
</div>
</div>
<div className="text-center md:text-right shrink-0">
<a href={carLink} target="_blank" rel="noopener noreferrer"
className="inline-block bg-accent hover:bg-orange-600 text-white font-bold py-3 px-6 rounded-xl transition-colors shadow-md text-base">
🚗 Mietwagen buchen
</a>
<div className="text-gray-400 text-xs mt-2">Öffnet Booking.com Cars</div>
</div>
</div>
</div>
);
}
// Szenario B: Hotel angegeben + kein Mietwagen nötig
if (carNeeded === false) {
return (
<div className="result-block bg-white border-2 border-green-400 rounded-2xl p-6 shadow-lg">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-4">
<div className="text-4xl mt-1"></div>
<div>
<h3 className="text-xl font-bold text-navy mb-1">Kein Mietwagen nötig</h3>
<div className="text-green-700 text-sm">{carMessage}</div>
<div className="text-gray-400 text-xs mt-1">{distanceMeters}m fußläufig erreichbar</div>
</div>
</div>
<div className="text-center md:text-right shrink-0">
<a href={carLink} target="_blank" rel="noopener noreferrer"
className="text-gray-400 hover:text-gray-600 text-sm underline">
Trotzdem Mietwagen suchen
</a>
</div>
</div>
</div>
);
}
// Szenario C: Kein Hotel angegeben
return (
<div className="result-block bg-white border-2 border-dashed border-gray-300 rounded-2xl p-6 shadow-md">
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
<div className="flex items-start gap-4">
<div className="text-4xl mt-1 opacity-50">🚗</div>
<div className="flex-1">
<h3 className="text-xl font-bold text-navy mb-1">Mietwagen noch unbekannt</h3>
<div className="text-gray-500 text-sm mb-3">{carMessage}</div>
<DistanceCheckWidget
fabrikAddress={meta.destination}
checkin={meta.checkin}
checkout={meta.checkout}
carLink={carLink}
/>
</div>
</div>
<div className="text-center md:text-right shrink-0 mt-2 md:mt-0">
<a href={carLink} target="_blank" rel="noopener noreferrer"
className="inline-block bg-accent hover:bg-orange-600 text-white font-semibold py-2.5 px-5 rounded-xl transition-colors shadow text-sm">
🚗 Mietwagen vorsorglich suchen
</a>
<div className="text-gray-400 text-xs mt-2">Öffnet Booking.com Cars</div>
</div>
</div>
</div>
);
}
// ─────────────────────────────────────────────
// ERGEBNIS-BEREICH
// ─────────────────────────────────────────────
function ResultSection({ data }) {
if (!data) return null;
return (
<section className="mt-10 max-w-3xl mx-auto space-y-4">
<FlugBlock data={data} />
<HotelBlock data={data} />
<AutoBlock data={data} />
</section>
);
}
// ─────────────────────────────────────────────
// HAUPT-APP
// ─────────────────────────────────────────────
function App() {
const [loading, setLoading] = useState(false);
const [loadingStep, setLoadingStep] = useState(0);
const [result, setResult] = useState(null);
const [error, setError] = useState(null);
const resultRef = useRef(null);
const handleSearch = async (formData) => {
setLoading(true);
setResult(null);
setError(null);
setLoadingStep(0);
const stepInterval = setInterval(() => {
setLoadingStep((s) => Math.min(s + 1, 4));
}, 1200);
try {
const res = await fetch('/api/search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
clearInterval(stepInterval);
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || `Server-Fehler: ${res.status}`);
}
const data = await res.json();
// refundableOnly an data anhängen für HotelBlock
data.refundableOnly = formData.refundableOnly;
setResult(data);
setLoadingStep(5);
setTimeout(() => {
resultRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, 100);
} catch (e) {
clearInterval(stepInterval);
setError(e.message || 'Ein unbekannter Fehler ist aufgetreten.');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-surface">
<Header />
<main className="max-w-6xl mx-auto px-4 py-10">
<SearchForm onSearch={handleSearch} loading={loading} />
{loading && <div className="max-w-3xl mx-auto"><LoadingState currentStep={loadingStep} /></div>}
{error && (
<div className="mt-6 bg-red-50 border border-red-200 rounded-xl p-5 max-w-3xl mx-auto text-red-700">
<strong>Fehler:</strong> {error}
</div>
)}
<div ref={resultRef}>
<ResultSection data={result} />
</div>
</main>
<footer className="text-center text-xs text-gray-400 py-8 mt-10 border-t border-gray-200">
TravelDesk · Reiseplanung für Firmenmonteure · Buchung direkt auf Booking.com
</footer>
</div>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);