'use client'; /** * Travel Stepper — Поиск → Места → Маршрут → Отели → Билеты * docs/architecture: 01-perplexity-analogue-design.md §2.2.D * Состояние сохраняется в travel-svc (Redis) или sessionStorage (fallback) */ import { useCallback, useEffect, useMemo, useState } from 'react'; import { cn } from '@/lib/utils'; import { Search, MapPin, Route, Hotel, Plane, ChevronRight, ChevronLeft, X, } from 'lucide-react'; const STEPS = [ { id: 'search', label: 'Search', icon: Search }, { id: 'places', label: 'Places', icon: MapPin }, { id: 'route', label: 'Route', icon: Route }, { id: 'hotels', label: 'Hotels', icon: Hotel }, { id: 'tickets', label: 'Tickets', icon: Plane }, ] as const; export type TravelStepperStepId = (typeof STEPS)[number]['id']; export interface ItineraryDay { day: number; title: string; activities: string[]; tips?: string; } export interface TravelStepperState { step: TravelStepperStepId; searchQuery: string; places: string[]; route: string | null; itineraryDays: number; hotels: string[]; tickets: string | null; updatedAt: number; } const DEFAULT_STATE: TravelStepperState = { step: 'search', searchQuery: '', places: [], route: null, itineraryDays: 3, hotels: [], tickets: null, updatedAt: Date.now(), }; const STORAGE_KEY = 'gooseek_travel_stepper_session'; const API_PREFIX = '/api/v1/travel'; function getOrCreateSessionId(): string { if (typeof window === 'undefined') return ''; let id = sessionStorage.getItem(STORAGE_KEY); if (!id) { id = crypto.randomUUID(); sessionStorage.setItem(STORAGE_KEY, id); } return id; } async function saveState(sessionId: string, state: TravelStepperState): Promise { try { const res = await fetch(`${API_PREFIX}/stepper/state`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sessionId, state }), }); return res.ok; } catch { return false; } } async function fetchItinerary(query: string, days: number): Promise<{ days: ItineraryDay[]; summary?: string } | null> { try { const res = await fetch(`${API_PREFIX}/itinerary`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ query, days }), signal: AbortSignal.timeout(90000), }); const data = await res.json(); if (data?.days?.length) return data; return null; } catch { return null; } } async function loadState(sessionId: string): Promise { try { const res = await fetch(`${API_PREFIX}/stepper/state/${sessionId}`); const data = await res.json(); return data?.state ?? null; } catch { return null; } } interface TravelStepperProps { onClose: () => void; } export default function TravelStepper({ onClose }: TravelStepperProps) { const [sessionId, setSessionId] = useState(''); const [state, setState] = useState(DEFAULT_STATE); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [itineraryData, setItineraryData] = useState<{ days: ItineraryDay[]; summary?: string } | null>(null); const [itineraryLoading, setItineraryLoading] = useState(false); const [itineraryError, setItineraryError] = useState(null); const stepIndex = useMemo( () => STEPS.findIndex((s) => s.id === state.step), [state.step] ); const canPrev = stepIndex > 0; const canNext = stepIndex < STEPS.length - 1; const persistState = useCallback( async (next: TravelStepperState) => { if (!sessionId) return; setSaving(true); const ok = await saveState(sessionId, next); setSaving(false); if (!ok && typeof window !== 'undefined') { try { sessionStorage.setItem('gooseek_travel_stepper_state', JSON.stringify(next)); } catch { /* noop */ } } }, [sessionId] ); useEffect(() => { const id = getOrCreateSessionId(); setSessionId(id); let cancelled = false; (async () => { const loaded = await loadState(id); if (cancelled) return; if (loaded && typeof loaded.step === 'string') { setState({ ...DEFAULT_STATE, ...loaded, updatedAt: Date.now(), }); } else if (typeof window !== 'undefined') { try { const raw = sessionStorage.getItem('gooseek_travel_stepper_state'); if (raw) { const parsed = JSON.parse(raw) as Partial; if (parsed?.step) { setState({ ...DEFAULT_STATE, ...parsed }); } } } catch { /* noop */ } } setLoading(false); })(); return () => { cancelled = true; }; }, []); const goTo = useCallback( (step: TravelStepperStepId) => { const next = { ...state, step, updatedAt: Date.now() }; setState(next); persistState(next); }, [state, persistState] ); const goPrev = useCallback(() => { if (!canPrev) return; const prevStep = STEPS[stepIndex - 1].id; goTo(prevStep); }, [canPrev, stepIndex, goTo]); const goNext = useCallback(() => { if (!canNext) return; const nextStep = STEPS[stepIndex + 1].id; goTo(nextStep); }, [canNext, stepIndex, goTo]); useEffect(() => { if (state.step !== 'route' || !state.searchQuery.trim()) { setItineraryData(null); setItineraryError(null); return; } let cancelled = false; setItineraryLoading(true); setItineraryError(null); fetchItinerary(state.searchQuery, state.itineraryDays ?? 3) .then((data) => { if (!cancelled && data) setItineraryData(data); else if (!cancelled) setItineraryError('Could not generate itinerary. OPENAI_API_KEY may be missing.'); }) .catch(() => { if (!cancelled) setItineraryError('Request failed. Try again.'); }) .finally(() => { if (!cancelled) setItineraryLoading(false); }); return () => { cancelled = true; }; }, [state.step, state.searchQuery, state.itineraryDays]); const updateSearch = useCallback( (q: string) => { const next = { ...state, searchQuery: q, updatedAt: Date.now() }; setState(next); persistState(next); }, [state, persistState] ); const updateItineraryDays = useCallback( (d: number) => { const next = { ...state, itineraryDays: Math.min(14, Math.max(1, d)), updatedAt: Date.now() }; setState(next); persistState(next); setItineraryData(null); }, [state, persistState] ); if (loading) { return (
); } return (

Plan your trip

{STEPS.map((s, i) => { const Icon = s.icon; const isActive = s.id === state.step; const isDone = i < stepIndex; return ( ); })}
{state.step === 'search' && (
updateSearch(e.target.value)} placeholder="e.g. Paris, Japan, Iceland" className={cn( 'w-full px-4 py-3 rounded-xl border', 'bg-light-primary dark:bg-dark-primary border-light-200 dark:border-dark-200', 'focus:outline-none focus:ring-2 focus:ring-[#EA580C]/50' )} autoFocus />
)} {state.step === 'places' && (

Places step — search results for "{state.searchQuery || '...'}" will appear here.

Coming soon: integration with search and map APIs.

)} {state.step === 'route' && (
for "{state.searchQuery || '...'}"
{itineraryLoading ? (
) : itineraryError ? (

{itineraryError}

) : itineraryData?.days?.length ? (
{itineraryData.summary && (

{itineraryData.summary}

)} {itineraryData.days.map((d) => (

{d.title}

    {d.activities.map((a, i) => (
  • • {a}
  • ))}
{d.tips && (

{d.tips}

)}
))}
) : (

Enter a destination in the Search step first, then return here.

)}
)} {state.step === 'hotels' && (

Hotels — Tripadvisor and Selfbook integration.

Coming soon: hotel recommendations.

)} {state.step === 'tickets' && (

Tickets — flight and transport options.

Coming soon: booking integration.

)}
{saving && Saving...}
); }