Files
service-finder/frontend_admin/components/SmartLocationInput.vue
2026-07-02 11:52:22 +00:00

324 lines
9.2 KiB
Vue

<template>
<div class="relative">
<label v-if="label" class="block text-sm font-medium text-slate-300 mb-1">{{ label }}</label>
<div class="relative">
<input
ref="inputRef"
:value="displayText"
@input="onInput"
@focus="onFocus"
@blur="onBlur"
@keydown.down.prevent="highlightNext"
@keydown.up.prevent="highlightPrev"
@keydown.enter.prevent="selectHighlighted"
@keydown.escape="closeDropdown"
:placeholder="placeholder"
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
/>
<!-- Loading spinner -->
<svg
v-if="searching"
class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 animate-spin text-slate-400"
fill="none" stroke="currentColor" viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
<!-- Clear button -->
<button
v-if="selectedItem && !searching"
@click="clearSelection"
class="absolute right-3 top-1/2 -translate-y-1/2 text-slate-500 hover:text-white transition"
type="button"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<!-- Dropdown -->
<div
v-if="showDropdown && filteredItems.length > 0"
class="absolute z-50 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl max-h-60 overflow-y-auto"
>
<button
v-for="(item, index) in filteredItems"
:key="item.id"
@mousedown.prevent="selectItem(item)"
class="w-full text-left px-3 py-2 text-sm transition flex items-center gap-2"
:class="index === highlightedIndex
? 'bg-indigo-500/20 text-white'
: 'text-slate-300 hover:bg-slate-700'"
>
<span class="font-mono text-indigo-400">{{ item.zip_code }}</span>
<span>{{ item.city }}</span>
<span class="text-xs text-slate-500 ml-auto">{{ item.country_code }}</span>
</button>
</div>
<!-- No results -->
<div
v-if="showDropdown && query.length >= minChars && filteredItems.length === 0 && !searching"
class="absolute z-50 mt-1 w-full bg-slate-800 border border-slate-600 rounded-lg shadow-xl p-3 text-sm text-slate-400"
>
Nincs találat
</div>
</div>
</template>
<script setup lang="ts">
/**
* SmartLocationInput.vue
* ======================
*
* Autocomplete/combobox component for selecting a city/zip code pair.
* Calls the backend GET /api/v1/locations/autocomplete endpoint.
*
* Usage:
* <SmartLocationInput v-model="selected" />
*
* The v-model emits the selected GeoPostalCode object:
* { id: number, zip_code: string, city: string, country_code: string }
*
* Props:
* modelValue: The currently selected location (null or the object)
* label: Optional label text above the input
* placeholder: Placeholder text
* minChars: Minimum characters before triggering search (default: 2)
* countryCode: Filter by country code (default: 'HU')
* debounceMs: Debounce delay in ms (default: 300)
*/
import { ref, computed, watch, nextTick } from 'vue'
interface LocationItem {
id: number
zip_code: string
city: string
country_code: string
}
const props = withDefaults(defineProps<{
modelValue?: LocationItem | null
label?: string
placeholder?: string
minChars?: number
countryCode?: string
debounceMs?: number
}>(), {
modelValue: null,
label: '',
placeholder: 'Kezdj el gépelni (irányítószám vagy város)...',
minChars: 2,
countryCode: 'HU',
debounceMs: 300,
})
const emit = defineEmits<{
'update:modelValue': [value: LocationItem | null]
'select': [value: LocationItem]
}>()
// ── State ──────────────────────────────────────────────
const inputRef = ref<HTMLInputElement | null>(null)
const query = ref('')
const selectedItem = ref<LocationItem | null>(props.modelValue)
const items = ref<LocationItem[]>([])
const searching = ref(false)
const showDropdown = ref(false)
const highlightedIndex = ref(0)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
// ── Computed ───────────────────────────────────────────
/**
* The display text shown in the input.
* If a location is selected, shows "zip_code city".
* Otherwise shows the raw query text.
*/
const displayText = computed(() => {
if (selectedItem.value) {
return `${selectedItem.value.zip_code} ${selectedItem.value.city}`
}
return query.value
})
/**
* Filtered items based on the current query.
* The backend already filters, but we keep client-side filtering
* for instant feedback when the user types more after results load.
*/
const filteredItems = computed(() => {
if (!query.value || query.value.length < props.minChars) return []
const q = query.value.toLowerCase()
return items.value.filter(
(item) =>
item.zip_code.toLowerCase().includes(q) ||
item.city.toLowerCase().includes(q)
)
})
// ── Watch modelValue from parent ───────────────────────
watch(
() => props.modelValue,
(val) => {
selectedItem.value = val
}
)
// ── Methods ────────────────────────────────────────────
/**
* Handle input events from the text field.
* Starts debounced search when the user types.
*/
function onInput(event: Event) {
const target = event.target as HTMLInputElement
query.value = target.value
// If the user is typing, clear the selected item
if (selectedItem.value) {
selectedItem.value = null
emit('update:modelValue', null)
}
// Debounced search
if (debounceTimer) clearTimeout(debounceTimer)
if (query.value.length >= props.minChars) {
debounceTimer = setTimeout(() => {
searchLocations(query.value)
}, props.debounceMs)
} else {
items.value = []
showDropdown.value = false
}
}
/**
* Call the backend autocomplete API.
*/
async function searchLocations(searchQuery: string) {
if (!searchQuery || searchQuery.length < props.minChars) return
searching.value = true
try {
const res = await $fetch('/api/v1/locations/autocomplete', {
params: {
q: searchQuery,
country_code: props.countryCode,
limit: 15,
},
headers: getHeaders(),
})
items.value = (res as LocationItem[]) || []
highlightedIndex.value = 0
showDropdown.value = items.value.length > 0
} catch (err: any) {
console.error('Location autocomplete error:', err)
items.value = []
} finally {
searching.value = false
}
}
/**
* Get auth headers for API calls.
*/
function getHeaders(): Record<string, string> {
const token = useCookie('access_token').value
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
return headers
}
/**
* Select an item from the dropdown.
*/
function selectItem(item: LocationItem) {
selectedItem.value = item
query.value = ''
showDropdown.value = false
emit('update:modelValue', item)
emit('select', item)
}
/**
* Clear the current selection.
*/
function clearSelection() {
selectedItem.value = null
query.value = ''
items.value = []
showDropdown.value = false
emit('update:modelValue', null)
nextTick(() => {
inputRef.value?.focus()
})
}
/**
* Close the dropdown.
*/
function closeDropdown() {
showDropdown.value = false
}
/**
* Handle focus - show dropdown if there are results.
*/
function onFocus() {
if (filteredItems.value.length > 0) {
showDropdown.value = true
}
}
/**
* Handle blur - close dropdown with a delay to allow click.
*/
function onBlur() {
// Delay to allow mousedown on dropdown items
setTimeout(() => {
showDropdown.value = false
}, 200)
}
/**
* Highlight the next item in the dropdown.
*/
function highlightNext() {
if (filteredItems.value.length > 0) {
highlightedIndex.value = (highlightedIndex.value + 1) % filteredItems.value.length
}
}
/**
* Highlight the previous item in the dropdown.
*/
function highlightPrev() {
if (filteredItems.value.length > 0) {
highlightedIndex.value = (highlightedIndex.value - 1 + filteredItems.value.length) % filteredItems.value.length
}
}
/**
* Select the currently highlighted item.
*/
function selectHighlighted() {
if (filteredItems.value.length > 0 && highlightedIndex.value >= 0) {
selectItem(filteredItems.value[highlightedIndex.value])
}
}
</script>
<style scoped>
.overflow-y-auto::-webkit-scrollbar {
width: 6px;
}
.overflow-y-auto::-webkit-scrollbar-track {
background: transparent;
}
.overflow-y-auto::-webkit-scrollbar-thumb {
background-color: rgba(100, 116, 139, 0.5);
border-radius: 3px;
}
</style>