refaktor címjegyzék
This commit is contained in:
123
frontend_admin/components/RawDataViewer.vue
Normal file
123
frontend_admin/components/RawDataViewer.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Empty state -->
|
||||
<div v-if="!data || isEmpty(data)" class="text-center py-8">
|
||||
<svg class="w-10 h-10 mx-auto mb-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<p class="text-sm text-slate-500">Nincs nyers adat</p>
|
||||
</div>
|
||||
|
||||
<!-- JSON tree view -->
|
||||
<div v-else class="space-y-1 font-mono text-xs">
|
||||
<div
|
||||
v-for="(value, key) in data"
|
||||
:key="key"
|
||||
class="border-b border-slate-700/50 last:border-0"
|
||||
>
|
||||
<div class="flex items-start gap-3 py-2">
|
||||
<!-- Key -->
|
||||
<span class="text-indigo-400 font-semibold whitespace-nowrap min-w-[140px] flex-shrink-0">
|
||||
{{ key }}
|
||||
</span>
|
||||
|
||||
<!-- Value -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<!-- Null -->
|
||||
<span v-if="value === null || value === undefined" class="text-slate-500 italic">null</span>
|
||||
|
||||
<!-- String -->
|
||||
<span v-else-if="typeof value === 'string'" class="text-emerald-300 break-words">
|
||||
"{{ value }}"
|
||||
</span>
|
||||
|
||||
<!-- Number / Boolean -->
|
||||
<span v-else-if="typeof value === 'number' || typeof value === 'boolean'" class="text-amber-300">
|
||||
{{ value }}
|
||||
</span>
|
||||
|
||||
<!-- Array -->
|
||||
<div v-else-if="Array.isArray(value)">
|
||||
<template v-if="value.length === 0">
|
||||
<span class="text-slate-500 italic">[]</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-slate-400 hover:text-slate-300 list-none flex items-center gap-1.5">
|
||||
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="text-slate-400">Array [{{ value.length }}]</span>
|
||||
</summary>
|
||||
<div class="ml-4 mt-1 space-y-1 border-l border-slate-700 pl-3">
|
||||
<div v-for="(item, idx) in value" :key="idx" class="py-1">
|
||||
<span class="text-slate-500 mr-2">[{{ idx }}]</span>
|
||||
<span v-if="typeof item === 'object' && item !== null" class="text-slate-400">
|
||||
<RawDataViewer :data="item" />
|
||||
</span>
|
||||
<span v-else-if="typeof item === 'string'" class="text-emerald-300">"{{ item }}"</span>
|
||||
<span v-else class="text-amber-300">{{ item }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Object (nested JSON) -->
|
||||
<div v-else-if="typeof value === 'object'">
|
||||
<details class="group">
|
||||
<summary class="cursor-pointer text-slate-400 hover:text-slate-300 list-none flex items-center gap-1.5">
|
||||
<svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="text-slate-400">Object {{ objectKeys(value) }}</span>
|
||||
</summary>
|
||||
<div class="ml-4 mt-1 border-l border-slate-700 pl-3">
|
||||
<RawDataViewer :data="value" />
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Fallback -->
|
||||
<span v-else class="text-slate-400">{{ String(value) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* RawDataViewer.vue
|
||||
*
|
||||
* Egy Vue 3 komponens, amely egy JSON/objektum adatot jelenít meg
|
||||
* strukturált, fa-szerű nézetben. Támogatja a beágyazott objektumokat
|
||||
* és tömböket összecsukható (details/summary) elemekkel.
|
||||
*
|
||||
* Props:
|
||||
* data - Object | null - A megjelenítendő nyers adat (JSON)
|
||||
*/
|
||||
defineProps<{
|
||||
data: Record<string, any> | null
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Ellenőrzi, hogy egy objektum üres-e (nincs saját enumerable tulajdonsága).
|
||||
*/
|
||||
function isEmpty(obj: Record<string, any>): boolean {
|
||||
if (!obj || typeof obj !== 'object') return true
|
||||
return Object.keys(obj).length === 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Visszaadja az objektum kulcsainak számát szöveges formában,
|
||||
* pl. "{ 3 kulcs }" vagy "{ üres }"
|
||||
*/
|
||||
function objectKeys(obj: Record<string, any>): string {
|
||||
if (!obj || typeof obj !== 'object') return '{ }'
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length === 0) return '{ üres }'
|
||||
return `{ ${keys.length} kulcs }`
|
||||
}
|
||||
</script>
|
||||
229
frontend_admin/components/SharedAddressForm.vue
Normal file
229
frontend_admin/components/SharedAddressForm.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="bg-slate-800 rounded-xl border border-slate-700 p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-white mb-4">Cím adatok</h2>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Smart Location Input (zip + city autocomplete) -->
|
||||
<div class="md:col-span-2">
|
||||
<SmartLocationInput
|
||||
:model-value="selectedLocation"
|
||||
@update:model-value="onLocationSelected"
|
||||
label="Irányítószám / Város"
|
||||
placeholder="Kezdj el gépelni (pl. 1011 vagy Budapest)..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Street name -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Utca neve</label>
|
||||
<input
|
||||
:value="modelValue.street_name"
|
||||
@input="updateField('street_name', ($event.target as HTMLInputElement).value)"
|
||||
placeholder="pl. Egressy"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Street type -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Közterület jellege</label>
|
||||
<select
|
||||
:value="modelValue.street_type"
|
||||
@change="updateField('street_type', ($event.target as HTMLSelectElement).value)"
|
||||
class="w-full px-3 py-2 bg-slate-700 border border-slate-600 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-indigo-500/50 focus:border-indigo-500 text-sm"
|
||||
>
|
||||
<option value="">Válassz...</option>
|
||||
<option value="utca">utca</option>
|
||||
<option value="út">út</option>
|
||||
<option value="tér">tér</option>
|
||||
<option value="körút">körút</option>
|
||||
<option value="sugárút">sugárút</option>
|
||||
<option value="köz">köz</option>
|
||||
<option value="sétány">sétány</option>
|
||||
<option value="lakópark">lakópark</option>
|
||||
<option value="liget">liget</option>
|
||||
<option value="erdő">erdő</option>
|
||||
<option value="dűlő">dűlő</option>
|
||||
<option value="major">major</option>
|
||||
<option value="puszta">puszta</option>
|
||||
<option value="sziget">sziget</option>
|
||||
<option value="telep">telep</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- House number -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Házszám</label>
|
||||
<input
|
||||
:value="modelValue.house_number"
|
||||
@input="updateField('house_number', ($event.target as HTMLInputElement).value)"
|
||||
placeholder="pl. 4/b"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stairwell -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Lépcsőház</label>
|
||||
<input
|
||||
:value="modelValue.stairwell"
|
||||
@input="updateField('stairwell', ($event.target as HTMLInputElement).value)"
|
||||
placeholder="pl. 1"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Floor -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Emelet</label>
|
||||
<input
|
||||
:value="modelValue.floor"
|
||||
@input="updateField('floor', ($event.target as HTMLInputElement).value)"
|
||||
placeholder="pl. 3"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Door -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Ajtó</label>
|
||||
<input
|
||||
:value="modelValue.door"
|
||||
@input="updateField('door', ($event.target as HTMLInputElement).value)"
|
||||
placeholder="pl. A"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Parcel ID (hrsz) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-slate-300 mb-1">Helyrajzi szám (hrsz)</label>
|
||||
<input
|
||||
:value="modelValue.parcel_id"
|
||||
@input="updateField('parcel_id', ($event.target as HTMLInputElement).value)"
|
||||
placeholder="pl. 12345/6"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SharedAddressForm.vue
|
||||
* ======================
|
||||
*
|
||||
* Reusable address form component that uses SmartLocationInput for
|
||||
* zip/city autocomplete and provides all other address fields.
|
||||
*
|
||||
* Usage:
|
||||
* <SharedAddressForm v-model="form.address" />
|
||||
*
|
||||
* The v-model expects an object matching the AddressIn schema:
|
||||
* {
|
||||
* zip?: string
|
||||
* city?: string
|
||||
* street_name?: string
|
||||
* street_type?: string
|
||||
* house_number?: string
|
||||
* stairwell?: string
|
||||
* floor?: string
|
||||
* door?: string
|
||||
* parcel_id?: string
|
||||
* full_address_text?: string
|
||||
* latitude?: number
|
||||
* longitude?: number
|
||||
* }
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface AddressForm {
|
||||
zip?: string | null
|
||||
city?: string | null
|
||||
street_name?: string | null
|
||||
street_type?: string | null
|
||||
house_number?: string | null
|
||||
stairwell?: string | null
|
||||
floor?: string | null
|
||||
door?: string | null
|
||||
parcel_id?: string | null
|
||||
full_address_text?: string | null
|
||||
latitude?: number | null
|
||||
longitude?: number | null
|
||||
}
|
||||
|
||||
interface LocationItem {
|
||||
id: number
|
||||
zip_code: string
|
||||
city: string
|
||||
country_code: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue?: AddressForm
|
||||
}>(), {
|
||||
modelValue: () => ({
|
||||
zip: null,
|
||||
city: null,
|
||||
street_name: null,
|
||||
street_type: null,
|
||||
house_number: null,
|
||||
stairwell: null,
|
||||
floor: null,
|
||||
door: null,
|
||||
parcel_id: null,
|
||||
full_address_text: null,
|
||||
latitude: null,
|
||||
longitude: null,
|
||||
}),
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: AddressForm]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* Computed property for the SmartLocationInput selected value.
|
||||
* Converts the address zip/city to a LocationItem if both are present.
|
||||
*/
|
||||
const selectedLocation = computed<LocationItem | null>(() => {
|
||||
if (props.modelValue?.zip && props.modelValue?.city) {
|
||||
return {
|
||||
id: 0, // We don't have the DB id here, but SmartLocationInput handles this
|
||||
zip_code: props.modelValue.zip,
|
||||
city: props.modelValue.city,
|
||||
country_code: 'HU',
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
|
||||
/**
|
||||
* Handle location selection from SmartLocationInput.
|
||||
* Updates the zip and city fields in the address model.
|
||||
*/
|
||||
function onLocationSelected(location: LocationItem | null) {
|
||||
const updated = { ...props.modelValue }
|
||||
if (location) {
|
||||
updated.zip = location.zip_code
|
||||
updated.city = location.city
|
||||
} else {
|
||||
updated.zip = null
|
||||
updated.city = null
|
||||
}
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a single field in the address model.
|
||||
*/
|
||||
function updateField(field: keyof AddressForm, value: string) {
|
||||
const updated = { ...props.modelValue }
|
||||
;(updated as any)[field] = value || null
|
||||
emit('update:modelValue', updated)
|
||||
}
|
||||
</script>
|
||||
323
frontend_admin/components/SmartLocationInput.vue
Normal file
323
frontend_admin/components/SmartLocationInput.vue
Normal file
@@ -0,0 +1,323 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user