Files
2026-07-08 19:17:05 +02:00

264 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
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.
'use client';
import { useState } from 'react';
import { EXPERIENCE_MODES, type ExperienceMode } from '@/lib/modes';
interface FormData {
destination: string;
startDate: string;
numDays: number;
budget: number;
currency: string;
experienceMode: ExperienceMode;
preferences: string[];
}
const PREFERENCE_OPTIONS = [
'Culture & History',
'Gastronomy',
'Bars & Nightlife',
'Concerts & Music',
'Theater & Shows',
'Parks & Nature',
'Art & Museums',
];
export default function OttoPlanner() {
const [form, setForm] = useState<FormData>({
destination: '',
startDate: '',
numDays: 3,
budget: 200,
currency: 'EUR',
experienceMode: 'balanced',
preferences: [],
});
const [isSubmitting, setIsSubmitting] = useState(false);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const [result, setResult] = useState<any>(null); // TODO: proper Trip type after Phase 3 (stub)
const togglePreference = (pref: string) => {
setForm((prev) => ({
...prev,
preferences: prev.preferences.includes(pref)
? prev.preferences.filter((p) => p !== pref)
: [...prev.preferences, pref],
}));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.destination || !form.startDate) {
alert('Please provide destination and start date');
return;
}
setIsSubmitting(true);
try {
// TODO Phase 3: call real /api/generate
const res = await fetch('/api/generate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(form),
});
if (!res.ok) throw new Error('Generation failed');
const data = await res.json();
setResult(data);
} catch {
// Stub response for early development (will be replaced)
setResult({
tripId: 'stub-' + Date.now(),
destination: form.destination,
numDays: form.numDays,
experienceMode: form.experienceMode,
summary: `Stub itinerary for ${form.destination} (${form.numDays} days, ${form.experienceMode} mode). Budget: ${form.budget} ${form.currency}.`,
days: Array.from({ length: form.numDays }, (_, i) => ({
day: i + 1,
activities: [
{ time: '09:30', title: 'Top cultural sight', type: 'attraction', cost: 12 },
{ time: '13:00', title: 'Local lunch', type: 'restaurant', cost: 25 },
],
})),
estimatedCost: Math.round(form.budget * 0.85),
});
} finally {
setIsSubmitting(false);
}
};
return (
<div className="min-h-screen bg-zinc-50 dark:bg-zinc-950 text-zinc-900 dark:text-zinc-100">
<div className="max-w-3xl mx-auto px-6 py-12">
<header className="mb-10">
<h1 className="text-4xl font-semibold tracking-tight">Otto</h1>
<p className="mt-2 text-lg text-zinc-600 dark:text-zinc-400">
Real itineraries powered by live APIs. Pick a mode and well fit everything into your days and budget.
</p>
</header>
<form onSubmit={handleSubmit} className="space-y-8">
{/* Destination + Dates + Days */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1.5">Destination</label>
<input
type="text"
value={form.destination}
onChange={(e) => setForm({ ...form, destination: e.target.value })}
placeholder="Barcelona, London, Mexico City..."
className="w-full rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1.5">Start date</label>
<input
type="date"
value={form.startDate}
onChange={(e) => setForm({ ...form, startDate: e.target.value })}
className="w-full rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
required
/>
</div>
<div>
<label className="block text-sm font-medium mb-1.5">Number of days</label>
<input
type="number"
min={1}
max={14}
value={form.numDays}
onChange={(e) => setForm({ ...form, numDays: parseInt(e.target.value) || 1 })}
className="w-full rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
/>
</div>
</div>
</div>
{/* Budget */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium mb-1.5">Total budget for activities & meals</label>
<div className="flex gap-3">
<input
type="number"
value={form.budget}
onChange={(e) => setForm({ ...form, budget: parseInt(e.target.value) || 0 })}
className="flex-1 rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
/>
<select
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
className="rounded-xl border border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 px-4 py-3"
>
<option value="EUR">EUR</option>
<option value="USD">USD</option>
<option value="GBP">GBP</option>
</select>
</div>
</div>
</div>
{/* Experience Mode */}
<div>
<label className="block text-sm font-medium mb-3">Experience Mode</label>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{EXPERIENCE_MODES.map((mode) => (
<button
type="button"
key={mode.id}
onClick={() => setForm({ ...form, experienceMode: mode.id as ExperienceMode })}
className={`rounded-2xl border p-4 text-left transition-all ${
form.experienceMode === mode.id
? 'border-black dark:border-white bg-zinc-100 dark:bg-zinc-900 ring-1 ring-black/10'
: 'border-zinc-200 dark:border-zinc-800 hover:bg-zinc-50 dark:hover:bg-zinc-900'
}`}
>
<div className="text-2xl mb-1">{mode.icon}</div>
<div className="font-semibold">{mode.label}</div>
<div className="text-sm text-zinc-600 dark:text-zinc-400 mt-0.5">{mode.description}</div>
</button>
))}
</div>
</div>
{/* Preferences */}
<div>
<label className="block text-sm font-medium mb-2">Additional interests (optional)</label>
<div className="flex flex-wrap gap-2">
{PREFERENCE_OPTIONS.map((pref) => {
const active = form.preferences.includes(pref);
return (
<button
type="button"
key={pref}
onClick={() => togglePreference(pref)}
className={`px-4 py-1.5 rounded-full text-sm border transition ${
active
? 'bg-black text-white border-black dark:bg-white dark:text-black'
: 'border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-900'
}`}
>
{pref}
</button>
);
})}
</div>
</div>
<button
type="submit"
disabled={isSubmitting}
className="w-full md:w-auto rounded-2xl bg-black text-white dark:bg-white dark:text-black px-8 py-4 font-medium disabled:opacity-60"
>
{isSubmitting ? 'Generating your itinerary…' : 'Generate itinerary'}
</button>
</form>
{/* Stub / Result area */}
{result && (
<div className="mt-12 border border-zinc-200 dark:border-zinc-800 rounded-3xl p-8 bg-white dark:bg-zinc-900">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-sm text-zinc-500">Itinerary for</div>
<div className="text-2xl font-semibold">{result.destination}</div>
</div>
<div className="text-right text-sm">
{result.numDays} days {result.experienceMode} mode<br />
~{result.estimatedCost} {form.currency}
</div>
</div>
<div className="space-y-6">
{result.days?.map((day: any, idx: number) => ( // eslint-disable-line @typescript-eslint/no-explicit-any
<div key={idx} className="border-l-2 border-zinc-200 dark:border-zinc-800 pl-4">
<div className="font-semibold mb-2">Day {day.day}</div>
<ul className="space-y-2 text-sm">
{day.activities.map((a: any, i: number) => ( // eslint-disable-line @typescript-eslint/no-explicit-any
<li key={i} className="flex justify-between">
<span>{a.time} {a.title}</span>
<span className="text-zinc-500">{a.cost} {form.currency}</span>
</li>
))}
</ul>
</div>
))}
</div>
<p className="mt-6 text-xs text-zinc-500">
This is a development stub. Real API + LLM generation coming in Phase 3.
</p>
</div>
)}
<footer className="mt-16 text-xs text-zinc-500">
Data from Geoapify and Ticketmaster when live. Always verify opening hours and prices directly.
</footer>
</div>
</div>
);
}