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:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View 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 &quot;{state.searchQuery || '...'}&quot; 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 &quot;{state.searchQuery || '...'}&quot;
</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>
);
}