first commit
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
const GenerateSchema = z.object({
|
||||
destination: z.string().min(2),
|
||||
startDate: z.string(),
|
||||
numDays: z.number().int().min(1).max(14),
|
||||
budget: z.number().positive(),
|
||||
currency: z.string(),
|
||||
experienceMode: z.string(),
|
||||
preferences: z.array(z.string()),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const data = GenerateSchema.parse(body);
|
||||
|
||||
// Stub response (Phase 3 will replace with real Geoapify + LLM)
|
||||
const days = Array.from({ length: data.numDays }, (_, i) => ({
|
||||
day: i + 1,
|
||||
activities: [
|
||||
{
|
||||
time: '09:30',
|
||||
title: `Highlight ${i + 1} (${data.experienceMode})`,
|
||||
type: data.experienceMode === 'foodie' ? 'restaurant' : 'attraction',
|
||||
cost: Math.round(data.budget / (data.numDays * 3)),
|
||||
},
|
||||
{
|
||||
time: '13:30',
|
||||
title: 'Recommended meal',
|
||||
type: 'restaurant',
|
||||
cost: 22,
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
tripId: 'trip_' + Date.now(),
|
||||
destination: data.destination,
|
||||
numDays: data.numDays,
|
||||
experienceMode: data.experienceMode,
|
||||
summary: `Stub plan for ${data.destination} — ${data.numDays} days in ${data.experienceMode} mode.`,
|
||||
days,
|
||||
estimatedCost: Math.round(data.budget * 0.82),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return NextResponse.json({ error: 'Invalid input' }, { status: 400 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
const envCheck = {
|
||||
hasGeoapify: !!process.env.GEOAPIFY_API_KEY,
|
||||
hasTicketmaster: !!process.env.TICKETMASTER_API_KEY,
|
||||
hasLLM: !!process.env.LLM_API_KEY,
|
||||
hasDb: !!process.env.DATABASE_URL,
|
||||
};
|
||||
|
||||
return NextResponse.json({
|
||||
status: 'ok',
|
||||
env: envCheck,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
+2
-2
@@ -13,8 +13,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Otto — Travel Activity Planner",
|
||||
description: "Generate real, budget-aware itineraries using live APIs. Pick an experience mode and number of days.",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
|
||||
+256
-58
@@ -1,65 +1,263 @@
|
||||
import Image from "next/image";
|
||||
'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);
|
||||
}
|
||||
};
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
<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 we’ll fit everything into your days and budget.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
</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"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user