feat: default locale Russian, geo determines language for other countries
- 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>
This commit is contained in:
445
services/web-svc/src/components/TravelStepper.tsx
Normal file
445
services/web-svc/src/components/TravelStepper.tsx
Normal file
@@ -0,0 +1,445 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user