Initial commit: Vuelato - buscador de vuelos
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:
Alejandro Martinez
2026-04-10 23:37:06 +02:00
commit b8906efc80
122 changed files with 37809 additions and 0 deletions

View 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>

View 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 }}&euro;</span>
</div>
<URange v-model="model" :min="20" :max="2000" :step="10" />
</div>
</template>

View 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>

View 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>

View 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>

View 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>