Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos, inspiraciones, watchlist, tracking de precios y mapa interactivo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
189
app/components/search/AirportInput.vue
Normal file
189
app/components/search/AirportInput.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
multiple?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { loadAirports, searchAirports } = useLocations()
|
||||
const query = ref('')
|
||||
const results = ref<ReturnType<typeof searchAirports>>([])
|
||||
const open = ref(false)
|
||||
const highlightIndex = ref(0)
|
||||
|
||||
// Selected codes as array
|
||||
const selected = ref<string[]>([])
|
||||
|
||||
// Init from modelValue
|
||||
function parseModelValue(v: string) {
|
||||
return v.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||
}
|
||||
|
||||
selected.value = parseModelValue(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
const parsed = parseModelValue(v)
|
||||
if (parsed.join(',') !== selected.value.join(',')) {
|
||||
selected.value = parsed
|
||||
}
|
||||
})
|
||||
|
||||
function emitValue() {
|
||||
emit('update:modelValue', selected.value.join(','))
|
||||
}
|
||||
|
||||
onMounted(() => loadAirports())
|
||||
|
||||
watch(query, (q) => {
|
||||
results.value = searchAirports(q)
|
||||
// Don't show already-selected airports
|
||||
if (props.multiple) {
|
||||
results.value = results.value.filter(a => !selected.value.includes(a.iata))
|
||||
}
|
||||
open.value = results.value.length > 0
|
||||
highlightIndex.value = 0
|
||||
})
|
||||
|
||||
function select(airport: (typeof results.value)[0]) {
|
||||
if (props.multiple) {
|
||||
if (!selected.value.includes(airport.iata)) {
|
||||
selected.value.push(airport.iata)
|
||||
}
|
||||
query.value = ''
|
||||
} else {
|
||||
selected.value = [airport.iata]
|
||||
query.value = airport.iata
|
||||
}
|
||||
emitValue()
|
||||
open.value = false
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeCode(code: string) {
|
||||
selected.value = selected.value.filter(c => c !== code)
|
||||
emitValue()
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Backspace' && !query.value && props.multiple && selected.value.length) {
|
||||
e.preventDefault()
|
||||
selected.value.pop()
|
||||
emitValue()
|
||||
return
|
||||
}
|
||||
|
||||
if (!open.value || results.value.length === 0) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
highlightIndex.value = Math.min(highlightIndex.value + 1, results.value.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
select(results.value[highlightIndex.value])
|
||||
} else if (e.key === 'Escape') {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// If there's text typed and it looks like a code, add it
|
||||
const val = query.value.trim().toUpperCase()
|
||||
if (val && props.multiple) {
|
||||
if (val.length >= 2) {
|
||||
if (!selected.value.includes(val)) {
|
||||
selected.value.push(val)
|
||||
emitValue()
|
||||
}
|
||||
}
|
||||
query.value = ''
|
||||
} else if (val && !props.multiple) {
|
||||
selected.value = [val]
|
||||
query.value = val
|
||||
emitValue()
|
||||
}
|
||||
setTimeout(() => { open.value = false; results.value = [] }, 150)
|
||||
}
|
||||
|
||||
// For single mode, keep query in sync
|
||||
watch(selected, (codes) => {
|
||||
if (!props.multiple && codes.length) {
|
||||
query.value = codes[0]
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Multiple mode: badges + input -->
|
||||
<div
|
||||
v-if="multiple"
|
||||
class="flex flex-wrap items-center gap-1 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1.5 focus-within:ring-2 focus-within:ring-primary-500 transition-shadow"
|
||||
>
|
||||
<UBadge
|
||||
v-for="code in selected"
|
||||
:key="code"
|
||||
:label="code"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
class="cursor-pointer"
|
||||
@click="removeCode(code)"
|
||||
>
|
||||
<template #trailing>
|
||||
<UIcon name="i-lucide-x" class="text-xs" />
|
||||
</template>
|
||||
</UBadge>
|
||||
<input
|
||||
v-model="query"
|
||||
:placeholder="selected.length ? '' : placeholder || 'Buscar aeropuerto...'"
|
||||
class="flex-1 min-w-24 bg-transparent outline-none text-sm py-0.5"
|
||||
autocomplete="off"
|
||||
@focus="results = searchAirports(query); open = results.length > 0"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Single mode -->
|
||||
<UInput
|
||||
v-else
|
||||
v-model="query"
|
||||
:placeholder="placeholder || 'Buscar aeropuerto...'"
|
||||
:icon="icon || 'i-lucide-plane'"
|
||||
autocomplete="off"
|
||||
@focus="results = searchAirports(query); open = results.length > 0"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
v-if="open && results.length"
|
||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="(apt, i) in results"
|
||||
:key="apt.iata"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 flex items-center justify-between text-sm"
|
||||
:class="i === highlightIndex ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'"
|
||||
@mousedown.prevent="select(apt)"
|
||||
@mouseenter="highlightIndex = i"
|
||||
>
|
||||
<span>
|
||||
<span class="font-semibold">{{ apt.iata }}</span>
|
||||
<span class="text-muted ml-2">{{ apt.name }}</span>
|
||||
</span>
|
||||
<span v-if="apt.city_name" class="text-xs text-muted">{{ apt.city_name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
13
app/components/search/BudgetSlider.vue
Normal file
13
app/components/search/BudgetSlider.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<number>({ default: 500 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted">Presupuesto max</span>
|
||||
<span class="font-semibold">{{ model }}€</span>
|
||||
</div>
|
||||
<URange v-model="model" :min="20" :max="2000" :step="10" />
|
||||
</div>
|
||||
</template>
|
||||
27
app/components/search/DateRangePicker.vue
Normal file
27
app/components/search/DateRangePicker.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
const dateFrom = defineModel<string>('dateFrom', { default: '' })
|
||||
const dateTo = defineModel<string>('dateTo', { default: '' })
|
||||
|
||||
defineProps<{
|
||||
singleDate?: boolean
|
||||
}>()
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
watch(dateFrom, (from) => {
|
||||
if (from && dateTo.value && dateTo.value < from) {
|
||||
dateTo.value = from
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField :label="singleDate ? 'Fecha' : 'Desde'">
|
||||
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" required />
|
||||
</UFormField>
|
||||
<UFormField v-if="!singleDate" label="Hasta">
|
||||
<UInput v-model="dateTo" type="date" :min="dateFrom || today" required />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
24
app/components/search/MaxStopsFilter.vue
Normal file
24
app/components/search/MaxStopsFilter.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<number | null>({ default: null })
|
||||
|
||||
const options = [
|
||||
{ label: 'Cualquiera', value: null },
|
||||
{ label: 'Directo', value: 0 },
|
||||
{ label: 'Max 1', value: 1 },
|
||||
{ label: 'Max 2', value: 2 }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in options"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:color="model === opt.value ? 'primary' : 'neutral'"
|
||||
:variant="model === opt.value ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="model = opt.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
26
app/components/search/SearchModeTabs.vue
Normal file
26
app/components/search/SearchModeTabs.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>({ default: 'roundtrip' })
|
||||
|
||||
const modes = [
|
||||
{ value: 'roundtrip', label: 'Ida y vuelta', icon: 'i-lucide-repeat' },
|
||||
{ value: 'oneway', label: 'Solo ida', icon: 'i-lucide-arrow-right' },
|
||||
{ value: 'multicity', label: 'Multi-ciudad', icon: 'i-lucide-route' },
|
||||
{ value: 'weekend', label: 'Finde', icon: 'i-lucide-calendar-days' },
|
||||
{ value: 'explore', label: 'Explorar', icon: 'i-lucide-compass' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<UButton
|
||||
v-for="m in modes"
|
||||
:key="m.value"
|
||||
:label="m.label"
|
||||
:icon="m.icon"
|
||||
:color="model === m.value ? 'primary' : 'neutral'"
|
||||
:variant="model === m.value ? 'solid' : 'ghost'"
|
||||
size="sm"
|
||||
@click="model = m.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
15
app/components/search/StayDurationPicker.vue
Normal file
15
app/components/search/StayDurationPicker.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
const minDays = defineModel<number>('minDays', { default: 2 })
|
||||
const maxDays = defineModel<number>('maxDays', { default: 6 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Estancia min (dias)">
|
||||
<UInput v-model.number="minDays" type="number" :min="1" :max="maxDays" />
|
||||
</UFormField>
|
||||
<UFormField label="Estancia max (dias)">
|
||||
<UInput v-model.number="maxDays" type="number" :min="minDays" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user