diff --git a/apps/react/src/app/components/city-autocomplete/city-autocomplete.scss b/apps/react/src/app/components/city-autocomplete/city-autocomplete.scss index f34ad81aa..8b1bb23af 100644 --- a/apps/react/src/app/components/city-autocomplete/city-autocomplete.scss +++ b/apps/react/src/app/components/city-autocomplete/city-autocomplete.scss @@ -1,184 +1,82 @@ -$space-m: 1rem; -$gray: #666; -$border-input: #ddd; -$red: #d32f2f; -$white: #fff; -$buttons-width: 45px; -$border-radius: 4px; -$standard-button-height: 42px; - .city-autocomplete { - display: flex; - flex-direction: column; - - &__labels-container { - justify-content: space-between; - margin-bottom: $space-m; - width: 100%; - display: flex; - align-items: center; - } - - &__label { - font-size: 0.875rem; - color: $gray; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - - &:last-child { - text-align: right; - } - } - - &__error { - font-size: 0.75rem; - color: $red; - margin-bottom: 0.5rem; - } - - &__input { - display: flex; - flex-direction: row; - position: relative; - align-items: center; - width: 100%; - border: 1px solid $border-input; - border-radius: $border-radius; - background-color: $white; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); - } - - &__field { - flex: 1; - border: none; - padding: 0.75rem; - font-size: 1rem; - outline: none; - background: transparent; - - &::placeholder { - color: #999; - } - } - - &__clear-button { - width: $buttons-width !important; - min-width: $buttons-width; - border: none !important; - background-color: transparent !important; - height: $standard-button-height - 2px; - cursor: pointer; - font-size: 1.5rem; - padding: 0; - display: none; - - &:hover { - color: $red; - } - } - - &__search-button { - width: $buttons-width !important; - min-width: $buttons-width; - border-radius: 0 $border-radius $border-radius 0 !important; - border: none !important; - border-left: 1px solid $white !important; - background-color: transparent !important; - height: $standard-button-height - 2px; - cursor: pointer; - font-size: 0.75rem; - padding: 0; - transition: all 0.2s ease; - - &:hover { - background-color: $white !important; - border-left-color: $border-input !important; - } - - &--opened { - background-color: #f5f5f5 !important; - border-left-color: $border-input !important; - } - } - - &__input--has-value { - .city-autocomplete__clear-button { - display: block !important; - } - - .city-autocomplete__search-button { - border-left: 1px solid $border-input !important; - } - } - - &__input--has-error { - border-color: $red; - } - - &__suggestions { - position: absolute; - top: 100%; - left: 0; - right: 0; - background-color: $white; - border: 1px solid $border-input; - border-top: none; - border-radius: 0 0 $border-radius $border-radius; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); - max-height: 300px; - overflow-y: auto; - z-index: 1000; - } - - &__suggestion-item { - padding: 0.75rem; - display: flex; - justify-content: space-between; - align-items: center; - cursor: pointer; - transition: background-color 0.2s ease; - - &:hover { - background-color: #f5f5f5; - } - } - - &__suggestion-name { - flex: 1; - } - - &__suggestion-code { - font-weight: bold; - color: $gray; - margin-left: 1rem; - } + width: 100%; } -.city-autocomplete-popup-wrapper { - position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; - background-color: rgba(0, 0, 0, 0.5); - z-index: 2000; +.city-autocomplete__input-group { display: flex; align-items: center; - justify-content: center; + gap: 8px; + position: relative; + + :global { + .p-autocomplete { + flex: 1; + } + + .p-autocomplete-input { + width: 100%; + padding: 8px 12px; + border: 1px solid #e0e0e0; + border-radius: 4px; + font-size: 14px; + + &:focus { + border-color: #1976d2; + outline: none; + } + } + + .p-autocomplete-panel { + max-height: 300px; + + .p-autocomplete-list { + max-height: 300px; + } + } + } } -.city-autocomplete-popup { - background-color: $white; - border-radius: $border-radius; - padding: 2rem; - max-width: 600px; - width: 90%; - max-height: 80vh; - overflow-y: auto; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2); +.city-autocomplete__item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px; } -.button-clear { - display: none !important; +.city-autocomplete__code { + font-weight: 600; + color: #1976d2; + min-width: 40px; +} + +.city-autocomplete__details { + flex: 1; +} + +.city-autocomplete__name { + font-weight: 500; + color: #333; +} + +.city-autocomplete__country { + font-size: 12px; + color: #999; +} + +.city-autocomplete__clear { + :global { + &.p-button-rounded { + width: 36px; + height: 36px; + } + } +} + +.city-autocomplete__search { + :global { + &.p-button-rounded { + width: 36px; + height: 36px; + } + } } diff --git a/apps/react/src/app/components/city-autocomplete/city-autocomplete.tsx b/apps/react/src/app/components/city-autocomplete/city-autocomplete.tsx index 2d80bbb13..8e8259212 100644 --- a/apps/react/src/app/components/city-autocomplete/city-autocomplete.tsx +++ b/apps/react/src/app/components/city-autocomplete/city-autocomplete.tsx @@ -1,212 +1,132 @@ -import React, { useState, useRef, useEffect, forwardRef, useCallback } from 'react' +import React, { useState, useCallback, useRef, useEffect } from 'react' +import { AutoComplete } from 'primereact/autocomplete' +import { Button } from 'primereact/button' +import axios from 'axios' import './city-autocomplete.scss' export interface City { code: string name: string + country?: string } -export interface CityAutocompleteProps - extends Omit, 'data-testid' | 'onChange' | 'onSelect'> { - label?: string +export interface CityAutocompleteProps { + value?: City | null + onChange?: (city: City | null) => void placeholder?: string - error?: string - onErrorChange?: (error?: string) => void - onChange?: (value?: string) => void - onSelect?: (city: City) => void + disabled?: boolean + onCitySelectClick?: () => void 'data-testid'?: string + label?: string } -export const CityAutocomplete = forwardRef( - ({ - label, - placeholder = 'Enter city or airport', - error, - onErrorChange, - onChange, - onSelect, - className = '', - 'data-testid': dataTestId = 'city-autocomplete-input', - ...props - }, ref) => { - const [city, setCity] = useState(null) - const [inputValue, setInputValue] = useState('') - const [suggestions, setSuggestions] = useState([]) - const [showSuggestions, setShowSuggestions] = useState(false) - const [openedPopup, setOpenedPopup] = useState(false) - const containerRef = useRef(null) - const debounceTimer = useRef(null) +export const CityAutocomplete: React.FC = ({ + value, + onChange, + placeholder = 'Select city', + disabled = false, + onCitySelectClick, + 'data-testid': dataTestId, +}) => { + const [suggestions, setSuggestions] = useState([]) + const [loading, setLoading] = useState(false) + const debounceTimer = useRef(null) - // Simulate fetching cities (in real app, would call an API) - const filterCities = useCallback((query: string) => { - if (!query.trim()) { - setSuggestions([]) - return - } - - // Mock cities data - const mockCities: City[] = [ - { code: 'MOW', name: 'Moscow' }, - { code: 'LED', name: 'Saint Petersburg' }, - { code: 'SVO', name: 'Sheremetyevo' }, - { code: 'DME', name: 'Domodedovo' }, - { code: 'NYC', name: 'New York' }, - { code: 'JFK', name: 'John F. Kennedy' }, - { code: 'PAR', name: 'Paris' }, - { code: 'CDG', name: 'Charles de Gaulle' }, - ] - - const filtered = mockCities.filter( - c => - c.name.toLowerCase().includes(query.toLowerCase()) || - c.code.toUpperCase().includes(query.toUpperCase()) - ) - - setSuggestions(filtered) - setShowSuggestions(filtered.length > 0) - }, []) - - // Debounced input handler - const handleInputChange = (e: React.ChangeEvent) => { - const value = e.target.value - setInputValue(value) - - if (debounceTimer.current) { - clearTimeout(debounceTimer.current) - } - - debounceTimer.current = setTimeout(() => { - filterCities(value) - }, 300) - - resetError() - } - - const handleSelectCity = (selectedCity: City) => { - setCity(selectedCity) - setInputValue(selectedCity.name) + const search = useCallback(async (query: string) => { + if (!query || query.length < 2) { setSuggestions([]) - setShowSuggestions(false) - onChange?.(selectedCity.code) - onSelect?.(selectedCity) - resetError() + return } - const handleClearInput = () => { - setCity(null) - setInputValue('') + 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([]) - setShowSuggestions(false) - onChange?.(undefined) - resetError() + } finally { + setLoading(false) + } + }, []) + + const handleSearch = (e: { query: string }) => { + // Clear existing debounce timer + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) } - const handlePopupToggle = () => { - setOpenedPopup(!openedPopup) - resetError() - } + // Debounce the API call + debounceTimer.current = setTimeout(() => { + search(e.query) + }, 300) + } - const resetError = () => { - onErrorChange?.(undefined) - } + const handleSelect = (city: City) => { + onChange?.(city) + } - // Close popup when clicking outside - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (containerRef.current && !containerRef.current.contains(event.target as Node)) { - setShowSuggestions(false) - setOpenedPopup(false) - } - } - - document.addEventListener('click', handleClickOutside) - return () => { - document.removeEventListener('click', handleClickOutside) - } - }, []) + const handleClear = () => { + onChange?.(null) + setSuggestions([]) + } + const itemTemplate = (city: City) => { return ( -
-
- {label && } - {city && } +
+
{city.code}
+
+
{city.name}
+ {city.country &&
{city.country}
}
- - {error && ( -
- {error} -
- )} - -
- - - {city && ( - - )} - - - - {showSuggestions && suggestions.length > 0 && ( -
- {suggestions.map(suggestion => ( -
handleSelectCity(suggestion)} - data-testid={`suggestion-${suggestion.code}`} - > - {suggestion.name} - {suggestion.code} -
- ))} -
- )} -
- - {openedPopup && ( -
- {/* City selector popup would go here */} -
-

City selector popup

-
-
- )}
) } -) -CityAutocomplete.displayName = 'CityAutocomplete' + // Cleanup debounce timer on unmount + useEffect(() => { + return () => { + if (debounceTimer.current) { + clearTimeout(debounceTimer.current) + } + } + }, []) + + return ( +
+
+ + value={value || undefined} + suggestions={suggestions} + completeMethod={handleSearch} + field="name" + onSelect={(e) => handleSelect(e.value)} + placeholder={placeholder} + disabled={disabled || loading} + itemTemplate={itemTemplate} + inputClassName="city-autocomplete__field" + dropdown + forceSelection + /> + {value && ( +
+
+ ) +} diff --git a/apps/react/src/app/pages/components/OnlineBoardSearch.tsx b/apps/react/src/app/pages/components/OnlineBoardSearch.tsx index 965c15761..bd07ef738 100644 --- a/apps/react/src/app/pages/components/OnlineBoardSearch.tsx +++ b/apps/react/src/app/pages/components/OnlineBoardSearch.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react' -import { CityAutocomplete } from '../../components/city-autocomplete' +import { CityAutocomplete, City } from '../../components/city-autocomplete' import { Button } from '../../components/button' import { DatePicker } from '../../components/datepicker' import { Flight } from '../OnlineBoard' @@ -63,8 +63,8 @@ const OnlineBoardSearch: React.FC = ({ setSearchError(undefined) } - const handleDestinationChange = (cityCode?: string) => { - setDestination(cityCode) + const handleDestinationChange = (city: City | null) => { + setDestination(city?.code) } return ( @@ -72,6 +72,7 @@ const OnlineBoardSearch: React.FC = ({