- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
446 lines
15 KiB
TypeScript
446 lines
15 KiB
TypeScript
'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<boolean> {
|
|
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<TravelStepperState | null> {
|
|
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<TravelStepperState>(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<string | null>(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<TravelStepperState>;
|
|
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 (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
|
|
<div className="rounded-2xl bg-light-secondary dark:bg-dark-secondary p-8 animate-pulse">
|
|
<div className="h-6 w-48 bg-light-200 dark:bg-dark-200 rounded mb-4" />
|
|
<div className="h-4 w-32 bg-light-200 dark:bg-dark-200 rounded" />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/40">
|
|
<div
|
|
className={cn(
|
|
'w-full max-w-2xl rounded-2xl shadow-xl',
|
|
'bg-light-secondary dark:bg-dark-secondary border border-light-200/20 dark:border-dark-200/20',
|
|
'overflow-hidden'
|
|
)}
|
|
>
|
|
<div className="flex items-center justify-between p-4 border-b border-light-200/20 dark:border-dark-200/20">
|
|
<h2 className="text-lg font-semibold">Plan your trip</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 rounded-lg hover:bg-light-200/50 dark:hover:bg-dark-200/50 transition"
|
|
aria-label="Close"
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex gap-1 p-4 overflow-x-auto">
|
|
{STEPS.map((s, i) => {
|
|
const Icon = s.icon;
|
|
const isActive = s.id === state.step;
|
|
const isDone = i < stepIndex;
|
|
return (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => goTo(s.id)}
|
|
className={cn(
|
|
'flex items-center gap-1.5 px-3 py-2 rounded-lg text-sm whitespace-nowrap transition',
|
|
isActive && 'bg-[#EA580C]/20 text-[#EA580C]',
|
|
isDone && !isActive && 'text-black/60 dark:text-white/60',
|
|
!isActive && !isDone && 'text-black/40 dark:text-white/40 hover:bg-light-200/50 dark:hover:bg-dark-200/50'
|
|
)}
|
|
>
|
|
<Icon size={16} />
|
|
{s.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className="p-6 min-h-[200px]">
|
|
{state.step === 'search' && (
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">Where would you like to go?</label>
|
|
<input
|
|
type="text"
|
|
value={state.searchQuery}
|
|
onChange={(e) => 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
|
|
/>
|
|
</div>
|
|
)}
|
|
{state.step === 'places' && (
|
|
<div>
|
|
<p className="text-black/60 dark:text-white/60 text-sm">
|
|
Places step — search results for "{state.searchQuery || '...'}" will appear here.
|
|
</p>
|
|
<p className="text-sm mt-2 text-black/40 dark:text-white/40">
|
|
Coming soon: integration with search and map APIs.
|
|
</p>
|
|
</div>
|
|
)}
|
|
{state.step === 'route' && (
|
|
<div>
|
|
<div className="flex items-center gap-3 mb-4">
|
|
<label className="text-sm font-medium">Duration:</label>
|
|
<select
|
|
value={state.itineraryDays ?? 3}
|
|
onChange={(e) => updateItineraryDays(parseInt(e.target.value, 10))}
|
|
className="px-3 py-2 rounded-lg border bg-light-primary dark:bg-dark-primary border-light-200 dark:border-dark-200 text-sm"
|
|
>
|
|
{[1, 3, 5, 7, 10, 14].map((d) => (
|
|
<option key={d} value={d}>{d} day{d > 1 ? 's' : ''}</option>
|
|
))}
|
|
</select>
|
|
<span className="text-black/50 dark:text-white/50 text-sm">
|
|
for "{state.searchQuery || '...'}"
|
|
</span>
|
|
</div>
|
|
{itineraryLoading ? (
|
|
<div className="space-y-3 animate-pulse">
|
|
<div className="h-4 w-3/4 bg-light-200 dark:bg-dark-200 rounded" />
|
|
<div className="h-3 w-full bg-light-200 dark:bg-dark-200 rounded" />
|
|
<div className="h-3 w-2/3 bg-light-200 dark:bg-dark-200 rounded" />
|
|
</div>
|
|
) : itineraryError ? (
|
|
<div>
|
|
<p className="text-sm text-red-600 dark:text-red-400">{itineraryError}</p>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setItineraryError(null);
|
|
setItineraryLoading(true);
|
|
fetchItinerary(state.searchQuery, state.itineraryDays ?? 3)
|
|
.then((data) => {
|
|
if (data) setItineraryData(data);
|
|
else setItineraryError('Could not generate itinerary.');
|
|
})
|
|
.catch(() => setItineraryError('Request failed. Try again.'))
|
|
.finally(() => setItineraryLoading(false));
|
|
}}
|
|
className="mt-2 text-sm text-[#EA580C] hover:underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
) : itineraryData?.days?.length ? (
|
|
<div className="space-y-4 max-h-[280px] overflow-y-auto">
|
|
{itineraryData.summary && (
|
|
<p className="text-sm text-black/70 dark:text-white/70">{itineraryData.summary}</p>
|
|
)}
|
|
{itineraryData.days.map((d) => (
|
|
<div
|
|
key={d.day}
|
|
className="p-3 rounded-xl bg-light-primary dark:bg-dark-primary border border-light-200/50 dark:border-dark-200/50"
|
|
>
|
|
<h4 className="font-medium text-sm">{d.title}</h4>
|
|
<ul className="mt-2 space-y-1 text-sm text-black/70 dark:text-white/70">
|
|
{d.activities.map((a, i) => (
|
|
<li key={i}>• {a}</li>
|
|
))}
|
|
</ul>
|
|
{d.tips && (
|
|
<p className="mt-2 text-xs text-[#EA580C]">{d.tips}</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-black/50 dark:text-white/50">
|
|
Enter a destination in the Search step first, then return here.
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
{state.step === 'hotels' && (
|
|
<div>
|
|
<p className="text-black/60 dark:text-white/60 text-sm">Hotels — Tripadvisor and Selfbook integration.</p>
|
|
<p className="text-sm mt-2 text-black/40 dark:text-white/40">Coming soon: hotel recommendations.</p>
|
|
</div>
|
|
)}
|
|
{state.step === 'tickets' && (
|
|
<div>
|
|
<p className="text-black/60 dark:text-white/60 text-sm">Tickets — flight and transport options.</p>
|
|
<p className="text-sm mt-2 text-black/40 dark:text-white/40">Coming soon: booking integration.</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between p-4 border-t border-light-200/20 dark:border-dark-200/20">
|
|
<button
|
|
onClick={goPrev}
|
|
disabled={!canPrev}
|
|
className={cn(
|
|
'flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium transition',
|
|
canPrev
|
|
? 'hover:bg-light-200/50 dark:hover:bg-dark-200/50'
|
|
: 'opacity-40 cursor-not-allowed'
|
|
)}
|
|
>
|
|
<ChevronLeft size={18} />
|
|
Back
|
|
</button>
|
|
{saving && <span className="text-xs text-black/40 dark:text-white/40">Saving...</span>}
|
|
<button
|
|
onClick={goNext}
|
|
disabled={!canNext}
|
|
className={cn(
|
|
'flex items-center gap-1 px-4 py-2 rounded-lg text-sm font-medium bg-[#EA580C] text-white transition hover:opacity-90',
|
|
!canNext && 'opacity-50 cursor-not-allowed'
|
|
)}
|
|
>
|
|
Next
|
|
<ChevronRight size={18} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|