first commit
This commit is contained in:
552
public/app.js
Normal file
552
public/app.js
Normal file
@@ -0,0 +1,552 @@
|
||||
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 />);
|
||||
41
public/index.html
Normal file
41
public/index.html
Normal file
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TravelDesk – Intelligente Reiseplanung</title>
|
||||
|
||||
<!-- Tailwind CSS via CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
navy: '#1E3A5F',
|
||||
accent: '#F97316',
|
||||
surface: '#F8FAFC',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
</head>
|
||||
<body class="bg-surface min-h-screen font-sans">
|
||||
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- React + ReactDOM via CDN -->
|
||||
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
|
||||
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
|
||||
|
||||
<!-- Babel für JSX im Browser -->
|
||||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||||
|
||||
<!-- App -->
|
||||
<script type="text/babel" src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
150
public/style.css
Normal file
150
public/style.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* ─────────────────────────────────────────
|
||||
TravelDesk – Custom Styles
|
||||
───────────────────────────────────────── */
|
||||
|
||||
:root {
|
||||
--navy: #1E3A5F;
|
||||
--accent: #F97316;
|
||||
--surface: #F8FAFC;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
|
||||
background-color: var(--surface);
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* ── Formular-Elemente ─────────────────── */
|
||||
|
||||
.label {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
margin-bottom: 0.375rem;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1.5px solid #e2e8f0;
|
||||
border-radius: 0.625rem;
|
||||
font-size: 0.9375rem;
|
||||
color: #1a1a2e;
|
||||
background: #fff;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(249, 115, 22, 0.12);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: #a0aec0;
|
||||
}
|
||||
|
||||
/* ── Karten ────────────────────────────── */
|
||||
|
||||
.card {
|
||||
background: #ffffff;
|
||||
border-radius: 1rem;
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.07), 0 4px 16px rgba(0,0,0,0.05);
|
||||
padding: 1.25rem;
|
||||
transition: box-shadow 0.2s, transform 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: 0 4px 16px rgba(30,58,95,0.12), 0 8px 32px rgba(30,58,95,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* ── Buttons ───────────────────────────── */
|
||||
|
||||
.btn-primary {
|
||||
display: inline-block;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
border-radius: 0.625rem;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, transform 0.1s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #ea6c0a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
/* ── Ergebnis-Blöcke ───────────────────── */
|
||||
|
||||
.result-block {
|
||||
animation: fadeInUp 0.35s ease both;
|
||||
transition: box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.result-block:hover {
|
||||
box-shadow: 0 8px 32px rgba(30,58,95,0.12);
|
||||
}
|
||||
|
||||
/* ── Lade-Spinner ──────────────────────── */
|
||||
|
||||
.spinner {
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 4px solid #e2e8f0;
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* ── Range Input ───────────────────────── */
|
||||
|
||||
input[type="range"] {
|
||||
height: 6px;
|
||||
cursor: pointer;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────── */
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.grid-cols-3 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Animationen ───────────────────────── */
|
||||
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(16px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
animation: fadeInUp 0.3s ease both;
|
||||
}
|
||||
Reference in New Issue
Block a user