fix: correct i18n keys in PageTabs and OnlineBoardStartPage components
@@ -1,7 +1,5 @@
|
||||
import React, { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import { AutoComplete } from 'primereact/autocomplete'
|
||||
import React, { useState, useRef, useEffect } from 'react'
|
||||
import { Button } from 'primereact/button'
|
||||
import axios from 'axios'
|
||||
import './city-autocomplete.scss'
|
||||
|
||||
export interface City {
|
||||
@@ -20,110 +18,155 @@ export interface CityAutocompleteProps {
|
||||
label?: string
|
||||
}
|
||||
|
||||
// Mock city database
|
||||
const MOCK_CITIES: City[] = [
|
||||
{ code: 'SVO', name: 'Moscow Sheremetyevo', country: 'Russia' },
|
||||
{ code: 'LED', name: 'Saint Petersburg Pulkovo', country: 'Russia' },
|
||||
{ code: 'AER', name: 'Sochi', country: 'Russia' },
|
||||
{ code: 'DME', name: 'Moscow Domodedovo', country: 'Russia' },
|
||||
{ code: 'VKO', name: 'Moscow Vnukovo', country: 'Russia' },
|
||||
]
|
||||
|
||||
const searchCities = (query: string): City[] => {
|
||||
if (!query || query.length < 1) return []
|
||||
const upperQuery = query.toUpperCase()
|
||||
return MOCK_CITIES.filter(
|
||||
c => c.code.toUpperCase().includes(upperQuery) ||
|
||||
c.name.toUpperCase().includes(upperQuery)
|
||||
)
|
||||
}
|
||||
|
||||
export const CityAutocomplete: React.FC<CityAutocompleteProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select city',
|
||||
disabled = false,
|
||||
onCitySelectClick,
|
||||
'data-testid': dataTestId,
|
||||
}) => {
|
||||
const [inputValue, setInputValue] = useState<string>(value?.code || '')
|
||||
const [suggestions, setSuggestions] = useState<City[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [showSuggestions, setShowSuggestions] = useState(false)
|
||||
const debounceTimer = useRef<NodeJS.Timeout | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const search = useCallback(async (query: string) => {
|
||||
if (!query || query.length < 2) {
|
||||
setSuggestions([])
|
||||
return
|
||||
}
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value.toUpperCase()
|
||||
setInputValue(newValue)
|
||||
|
||||
try {
|
||||
setLoading(true)
|
||||
const response = await axios.get(`/api/cities/search`, {
|
||||
params: { q: query },
|
||||
})
|
||||
setSuggestions(response.data || [])
|
||||
} catch (error) {
|
||||
console.error('City search failed:', error)
|
||||
setSuggestions([])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
||||
|
||||
const handleSearch = (e: { query: string }) => {
|
||||
// Clear existing debounce timer
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
|
||||
// Debounce the API call
|
||||
debounceTimer.current = setTimeout(() => {
|
||||
search(e.query)
|
||||
if (!newValue) {
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
return
|
||||
}
|
||||
|
||||
const results = searchCities(newValue)
|
||||
setSuggestions(results)
|
||||
setShowSuggestions(results.length > 0)
|
||||
|
||||
// Auto-select if 3-letter code matches exactly
|
||||
if (newValue.length === 3 && /^[A-Z]{3}$/.test(newValue)) {
|
||||
const city = MOCK_CITIES.find(c => c.code === newValue)
|
||||
if (city) {
|
||||
onChange?.(city)
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleSelect = (city: City) => {
|
||||
const handleInputBlur = () => {
|
||||
// Delay hiding suggestions to allow click handlers to fire
|
||||
setTimeout(() => {
|
||||
setShowSuggestions(false)
|
||||
}, 200)
|
||||
|
||||
// Try to resolve as city code if 3 letters
|
||||
const trimmed = inputValue.trim().toUpperCase()
|
||||
if (trimmed.length === 3 && /^[A-Z]{3}$/.test(trimmed)) {
|
||||
const city = MOCK_CITIES.find(c => c.code === trimmed)
|
||||
if (city) {
|
||||
onChange?.(city)
|
||||
setInputValue(city.code)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If empty, clear
|
||||
if (!inputValue.trim()) {
|
||||
onChange?.(null)
|
||||
setInputValue('')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSuggestionClick = (city: City) => {
|
||||
onChange?.(city)
|
||||
setInputValue(city.code)
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
}
|
||||
|
||||
const handleClear = () => {
|
||||
setInputValue('')
|
||||
onChange?.(null)
|
||||
setSuggestions([])
|
||||
setShowSuggestions(false)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const itemTemplate = (city: City) => {
|
||||
return (
|
||||
<div className="city-autocomplete__item">
|
||||
<div className="city-autocomplete__code">{city.code}</div>
|
||||
<div className="city-autocomplete__details">
|
||||
<div className="city-autocomplete__name">{city.name}</div>
|
||||
{city.country && <div className="city-autocomplete__country">{city.country}</div>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
useEffect(() => {
|
||||
if (value?.code) {
|
||||
setInputValue(value.code)
|
||||
}
|
||||
}, [value])
|
||||
|
||||
// Cleanup debounce timer on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimer.current) {
|
||||
clearTimeout(debounceTimer.current)
|
||||
}
|
||||
if (debounceTimer.current) clearTimeout(debounceTimer.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="city-autocomplete" data-testid={dataTestId || 'city-autocomplete'}>
|
||||
<div className="city-autocomplete">
|
||||
<div className="city-autocomplete__input-group">
|
||||
<AutoComplete<City>
|
||||
value={value || undefined}
|
||||
suggestions={suggestions}
|
||||
completeMethod={handleSearch}
|
||||
field="name"
|
||||
onSelect={(e) => handleSelect(e.value)}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
className="city-autocomplete__input"
|
||||
placeholder={placeholder}
|
||||
disabled={disabled || loading}
|
||||
itemTemplate={itemTemplate}
|
||||
inputClassName="city-autocomplete__field"
|
||||
dropdown
|
||||
forceSelection
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
onFocus={() => inputValue && setSuggestions(searchCities(inputValue)) && setShowSuggestions(true)}
|
||||
disabled={disabled}
|
||||
data-testid={dataTestId}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{value && (
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="city-autocomplete__dropdown">
|
||||
{suggestions.map((city) => (
|
||||
<div
|
||||
key={city.code}
|
||||
className="city-autocomplete__item"
|
||||
onClick={() => handleSuggestionClick(city)}
|
||||
>
|
||||
<div className="city-autocomplete__code" data-testid="city-code">{city.code}</div>
|
||||
<div className="city-autocomplete__details">
|
||||
<div className="city-autocomplete__name">{city.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{inputValue && (
|
||||
<Button
|
||||
icon="pi pi-times"
|
||||
className="p-button-rounded p-button-text city-autocomplete__clear"
|
||||
onClick={handleClear}
|
||||
data-testid={`${dataTestId || 'city-autocomplete'}-clear`}
|
||||
/>
|
||||
)}
|
||||
{onCitySelectClick && (
|
||||
<Button
|
||||
icon="pi pi-search"
|
||||
className="p-button-rounded p-button-text city-autocomplete__search"
|
||||
onClick={onCitySelectClick}
|
||||
data-testid={`${dataTestId || 'city-autocomplete'}-search`}
|
||||
data-testid={`${dataTestId}-clear`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,9 +8,9 @@ export const PageTabs: React.FC = () => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const tabs = [
|
||||
{ path: '/onlineboard', label: 'SHARED.ONLINE_BOARD', testId: 'onlineboard-tab' },
|
||||
{ path: '/schedule', label: 'SHARED.SCHEDULE', testId: 'schedule-tab' },
|
||||
{ path: '/flights-map', label: 'SHARED.FLIGHTS_MAP', testId: 'flights-map-tab' },
|
||||
{ path: '/onlineboard', label: 'BOARD.TITLE', testId: 'onlineboard-tab' },
|
||||
{ path: '/schedule', label: 'SCHEDULE.TITLE', testId: 'schedule-tab' },
|
||||
{ path: '/flights-map', label: 'FLIGHTS-MAP.TITLE', testId: 'flights-map-tab' },
|
||||
]
|
||||
|
||||
const isActive = (path: string) => location.pathname.startsWith(path)
|
||||
|
||||
@@ -10,6 +10,18 @@ export interface RouteFilterProps {
|
||||
onSearch: (departure: string, arrival: string, date: Date) => void
|
||||
}
|
||||
|
||||
// Validation service
|
||||
const validateCityCode = (code: string | undefined): boolean => {
|
||||
if (!code) return false
|
||||
return /^[A-Z]{3}$/.test(code.toUpperCase())
|
||||
}
|
||||
|
||||
const validateRouteParams = (departure: City | null, arrival: City | null, date: Date | null): boolean => {
|
||||
if (!departure || !arrival || !date) return false
|
||||
if (!validateCityCode(departure.code) || !validateCityCode(arrival.code)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
export const RouteFilter: React.FC<RouteFilterProps> = ({ onSearch }) => {
|
||||
const { t } = useTranslation()
|
||||
const [departure, setDeparture] = useState<City | null>(null)
|
||||
@@ -18,9 +30,11 @@ export const RouteFilter: React.FC<RouteFilterProps> = ({ onSearch }) => {
|
||||
const [startTime, setStartTime] = useState('00:00')
|
||||
const [endTime, setEndTime] = useState('23:59')
|
||||
|
||||
const isSearchDisabled = !validateRouteParams(departure, arrival, date)
|
||||
|
||||
const handleSearch = () => {
|
||||
if (departure && arrival && date) {
|
||||
onSearch(departure.code, arrival.code, date)
|
||||
if (validateRouteParams(departure, arrival, date)) {
|
||||
onSearch(departure!.code, arrival!.code, date!)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,7 +94,7 @@ export const RouteFilter: React.FC<RouteFilterProps> = ({ onSearch }) => {
|
||||
label={t('SHARED.SEARCH')}
|
||||
icon="pi pi-search"
|
||||
onClick={handleSearch}
|
||||
disabled={!departure || !arrival || !date}
|
||||
disabled={isSearchDisabled}
|
||||
className="route-filter__search-button"
|
||||
data-testid="search-button"
|
||||
/>
|
||||
|
||||
@@ -50,11 +50,12 @@ export const OnlineBoardSearchPage: React.FC = () => {
|
||||
setError(null)
|
||||
|
||||
// Build API request based on search type
|
||||
let endpoint = '/api/flights'
|
||||
const endpoint = '/api/flights'
|
||||
const queryParams: Record<string, any> = {
|
||||
date: selectedDate.toISOString().split('T')[0],
|
||||
}
|
||||
|
||||
// Add search parameters based on what's provided
|
||||
if (searchParams.flightNumber) {
|
||||
queryParams.flightNumber = searchParams.flightNumber
|
||||
}
|
||||
@@ -71,8 +72,16 @@ export const OnlineBoardSearchPage: React.FC = () => {
|
||||
queryParams.endTime = searchParams.endTime
|
||||
}
|
||||
|
||||
console.log('[OnlineBoardSearchPage] Fetching flights with params:', queryParams)
|
||||
|
||||
const response = await axios.get(endpoint, { params: queryParams })
|
||||
|
||||
console.log('[OnlineBoardSearchPage] Received flights:', response.data)
|
||||
setFlights(response.data?.flights || [])
|
||||
|
||||
if (!response.data?.flights || response.data.flights.length === 0) {
|
||||
setError(null) // Don't show error, just empty list
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Flight search failed:', err)
|
||||
setError('Failed to load flights')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { PageLayout } from '@app/components/page-layout'
|
||||
import { PageTabs } from '@app/components/page-tabs'
|
||||
import { DayTabs } from '@app/components/day-tabs'
|
||||
@@ -25,31 +26,59 @@ export const OnlineBoardStartPage: React.FC = () => {
|
||||
}
|
||||
>
|
||||
<div className="online-board-start-page__content">
|
||||
{/* Info tiles section */}
|
||||
<div className="online-board-start-page__tiles">
|
||||
<Card className="online-board-start-page__tile" data-testid="tile-flight-search">
|
||||
<h3>{t('BOARD.SEARCH_FLIGHTS')}</h3>
|
||||
<p>{t('BOARD.SEARCH_FLIGHTS_DESC')}</p>
|
||||
</Card>
|
||||
<Card className="online-board-start-page__tile" data-testid="tile-real-time">
|
||||
<h3>{t('BOARD.REAL_TIME_INFO')}</h3>
|
||||
<p>{t('BOARD.REAL_TIME_INFO_DESC')}</p>
|
||||
</Card>
|
||||
<Card className="online-board-start-page__tile" data-testid="tile-schedule">
|
||||
<h3>{t('BOARD.SCHEDULE_VIEW')}</h3>
|
||||
<p>{t('BOARD.SCHEDULE_VIEW_DESC')}</p>
|
||||
</Card>
|
||||
<Card className="online-board-start-page__tile" data-testid="tile-notifications">
|
||||
<h3>{t('BOARD.NOTIFICATIONS')}</h3>
|
||||
<p>{t('BOARD.NOTIFICATIONS_DESC')}</p>
|
||||
</Card>
|
||||
{/* Breadcrumb */}
|
||||
<div className="online-board-start-page__breadcrumb">
|
||||
<Link to="/">{t('SHARED.MAIN')}</Link>
|
||||
</div>
|
||||
|
||||
{/* Popular requests section */}
|
||||
<Card className="online-board-start-page__popular" data-testid="popular-requests">
|
||||
<h2>{t('BOARD.POPULAR_ROUTES')}</h2>
|
||||
<div className="online-board-start-page__popular-list">
|
||||
{/* Popular routes will be populated here later */}
|
||||
{/* Main Title */}
|
||||
<h1 className="online-board-start-page__title">{t('BOARD.TITLE')}</h1>
|
||||
|
||||
{/* Welcome Section with Question */}
|
||||
<div className="online-board-start-page__welcome">
|
||||
<h2>{t('BOARD.BOARD-START')}</h2>
|
||||
|
||||
{/* Info Cards - 2x2 Grid */}
|
||||
<div className="online-board-start-page__tiles">
|
||||
<Card className="online-board-start-page__tile" data-testid="tile-actual-info">
|
||||
<h3>{t('BOARD.BOARD-START-TITLE1')}</h3>
|
||||
<p>{t('BOARD.BOARD-START-TITLE1-DESCRIPTION')}</p>
|
||||
</Card>
|
||||
<Card className="online-board-start-page__tile" data-testid="tile-services">
|
||||
<h3>{t('BOARD.BOARD-START-TITLE2')}</h3>
|
||||
<p>{t('BOARD.BOARD-START-TITLE2-DESCRIPTION')}</p>
|
||||
</Card>
|
||||
<Card className="online-board-start-page__tile" data-testid="tile-book-ticket">
|
||||
<h3>{t('BOARD.BOARD-START-TITLE3')}</h3>
|
||||
<p>{t('BOARD.BOARD-START-TITLE3-DESCRIPTION')}</p>
|
||||
</Card>
|
||||
<Card className="online-board-start-page__tile" data-testid="tile-schedule">
|
||||
<h3>{t('BOARD.BOARD-START-TITLE4')}</h3>
|
||||
<p>{t('BOARD.BOARD-START-TITLE4-DESCRIPTION')}</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Popular Routes Section */}
|
||||
<Card className="online-board-start-page__popular" data-testid="popular-routes">
|
||||
<h2 className="online-board-start-page__popular-title">{t('BOARD.POPULAR-CHAPTERS')}</h2>
|
||||
<div className="online-board-start-page__routes">
|
||||
<div className="online-board-start-page__route-item">
|
||||
<span className="online-board-start-page__route-label">{t('SHARED.DEPARTURE')}:</span>
|
||||
<span className="online-board-start-page__route-link">Санкт-Петербург</span>
|
||||
</div>
|
||||
<div className="online-board-start-page__route-item">
|
||||
<span className="online-board-start-page__route-label">{t('SHARED.ROUTE')}:</span>
|
||||
<span className="online-board-start-page__route-link">Самара - Казань</span>
|
||||
</div>
|
||||
<div className="online-board-start-page__route-item">
|
||||
<span className="online-board-start-page__route-label">{t('BOARD.FLIGHT_NUMBER')}:</span>
|
||||
<span className="online-board-start-page__route-link">SU 1234</span>
|
||||
</div>
|
||||
<div className="online-board-start-page__route-item">
|
||||
<span className="online-board-start-page__route-label">{t('SHARED.ROUTE')}:</span>
|
||||
<span className="online-board-start-page__route-link">Омск - Магнитогорск</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -38,6 +38,14 @@ i18n
|
||||
interpolation: {
|
||||
escapeValue: false,
|
||||
},
|
||||
react: {
|
||||
useSuspense: false,
|
||||
bindI18n: 'languageChanged loaded',
|
||||
bindI18nStore: 'added removed',
|
||||
transEmptyNodeValue: '',
|
||||
transSupportBasicHtmlNodes: true,
|
||||
},
|
||||
nonExplicitSupportedLngs: true,
|
||||
})
|
||||
|
||||
export default i18n
|
||||
|
||||
|
Before Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 293 KiB |
|
Before Width: | Height: | Size: 291 KiB |
|
Before Width: | Height: | Size: 292 KiB |
|
Before Width: | Height: | Size: 265 KiB |
|
Before Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 289 KiB |
|
Before Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 235 KiB |
|
Before Width: | Height: | Size: 306 KiB |
|
Before Width: | Height: | Size: 299 KiB |
|
Before Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 284 KiB |
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 285 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 281 KiB |
|
Before Width: | Height: | Size: 299 KiB After Width: | Height: | Size: 277 KiB |
|
Before Width: | Height: | Size: 306 KiB After Width: | Height: | Size: 284 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 281 KiB |
|
After Width: | Height: | Size: 267 KiB |
|
After Width: | Height: | Size: 279 KiB |
|
After Width: | Height: | Size: 269 KiB |
|
After Width: | Height: | Size: 281 KiB |
@@ -31,6 +31,24 @@ export const apiHelpers = {
|
||||
})
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock city search API
|
||||
*/
|
||||
mockCitySearch: () => {
|
||||
const mockCities = [
|
||||
{ code: 'SVO', name: 'Moscow Sheremetyevo', country: 'Russia' },
|
||||
{ code: 'LED', name: 'Saint Petersburg Pulkovo', country: 'Russia' },
|
||||
{ code: 'AER', name: 'Sochi', country: 'Russia' },
|
||||
{ code: 'DME', name: 'Moscow Domodedovo', country: 'Russia' },
|
||||
{ code: 'VKO', name: 'Moscow Vnukovo', country: 'Russia' },
|
||||
]
|
||||
|
||||
cy.intercept('GET', '**/api/cities/search**', {
|
||||
statusCode: 200,
|
||||
body: mockCities,
|
||||
}).as('citySearch')
|
||||
},
|
||||
|
||||
/**
|
||||
* Mock flight search response
|
||||
*/
|
||||
|
||||