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

211
app/pages/multi-city.vue Normal file
View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import type { MultiCityInspirationItem, MultiCityInspirationsResponse } from '~/server/utils/flightics'
const router = useRouter()
const { airports, loadAirports } = useLocations()
const { homeAirports } = useUserPreferences()
const origins = ref('')
const loading = ref(false)
const items = ref<MultiCityInspirationItem[]>([])
const currency = ref('')
const includeCountries = ref<string[]>([])
const excludeCountries = ref<string[]>([])
const excludeRest = ref(false)
const sortBy = ref<'price' | 'stops'>('price')
useSeoMeta({ title: 'Vuelato - Multi-ciudad' })
// Map IATA code -> country name
const airportCountryMap = computed(() => {
const map = new Map<string, string>()
for (const a of airports.value) {
if (a.country_name) map.set(a.iata, a.country_name)
}
return map
})
// All unique countries present in current results (stops only, not origin)
const availableCountries = computed(() => {
const names = new Set<string>()
for (const item of items.value) {
for (const stop of item.stops) {
const name = airportCountryMap.value.get(stop)
if (name) names.add(name)
}
}
return Array.from(names).sort((a, b) => a.localeCompare(b))
})
// Filtered and sorted items
const filteredItems = computed(() => {
const filtered = items.value.filter((item) => {
const stopCountries = item.stops
.map(s => airportCountryMap.value.get(s))
.filter(Boolean) as string[]
if (includeCountries.value.length > 0) {
if (!includeCountries.value.some(c => stopCountries.includes(c))) return false
if (excludeRest.value) {
if (stopCountries.some(c => !includeCountries.value.includes(c))) return false
}
}
if (excludeCountries.value.length > 0) {
if (excludeCountries.value.some(c => stopCountries.includes(c))) return false
}
return true
})
return filtered.sort((a, b) => {
if (sortBy.value === 'price') return a.minPrice - b.minPrice
return a.stops.length - b.stops.length
})
})
function resolveCountryName(iata: string): string {
return airportCountryMap.value.get(iata) || ''
}
async function search() {
const codes = origins.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
if (codes.length === 0) return
loading.value = true
try {
const data = await $fetch<MultiCityInspirationsResponse>('/api/multi-city-inspirations', {
method: 'POST',
body: { startLocationsCodes: codes, locale: 'en', take: 30 }
})
items.value = data.items || []
currency.value = data.currency?.symbol || '€'
// Reset filters on new search
includeCountries.value = []
excludeCountries.value = []
excludeRest.value = false
} catch {
items.value = []
} finally {
loading.value = false
}
}
// Initialize origins from user's home airports and trigger search
watch(homeAirports, (airports) => {
if (airports.length && !origins.value) {
origins.value = airports.join(',')
}
}, { immediate: true })
onMounted(async () => {
await loadAirports()
if (!origins.value) {
origins.value = 'MAD,BCN'
}
search()
})
function onSelect(item: MultiCityInspirationItem) {
router.push({
path: '/results',
query: {
mode: 'multicity',
dep: item.from,
stops: item.stops.join(','),
from: '',
to: '',
adults: '1'
}
})
}
const hasFilters = computed(() => includeCountries.value.length > 0 || excludeCountries.value.length > 0 || excludeRest.value)
</script>
<template>
<div>
<UPageHero title="Multi-ciudad" description="Inspiracion para itinerarios con varias paradas" />
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<UCard>
<form class="flex items-end gap-3" @submit.prevent="search">
<UFormField label="Aeropuertos origen" class="flex-1">
<SearchAirportInput v-model="origins" placeholder="MAD, BCN..." icon="i-lucide-plane-takeoff" multiple />
<template #hint>
<span class="text-xs text-muted">Codigos IATA separados por coma</span>
</template>
</UFormField>
<UButton type="submit" label="Buscar" icon="i-lucide-search" :loading="loading" />
</form>
</UCard>
<!-- Filters -->
<div v-if="!loading && items.length > 0 && availableCountries.length > 0" class="flex flex-wrap items-end gap-3">
<UFormField label="Incluir paises" class="flex-1 min-w-40">
<USelectMenu
v-model="includeCountries"
:items="availableCountries"
multiple
placeholder="Todos"
class="w-full"
/>
</UFormField>
<UFormField label="Excluir paises" class="flex-1 min-w-40">
<USelectMenu
v-model="excludeCountries"
:items="availableCountries"
multiple
placeholder="Ninguno"
class="w-full"
/>
</UFormField>
<UFormField label="Ordenar por" class="w-36">
<USelectMenu
v-model="sortBy"
:items="[
{ label: 'Precio', value: 'price' },
{ label: 'Paradas', value: 'stops' }
]"
value-key="value"
class="w-full"
/>
</UFormField>
<div v-if="includeCountries.length > 0" class="flex items-center gap-2 pb-1">
<UCheckbox v-model="excludeRest" />
<span class="text-xs text-muted whitespace-nowrap">Excluir el resto</span>
</div>
<UButton
v-if="hasFilters"
icon="i-lucide-x"
variant="ghost"
size="sm"
@click="includeCountries = []; excludeCountries = []; excludeRest = false"
/>
</div>
<p v-if="hasFilters && !loading" class="text-xs text-muted">
{{ filteredItems.length }} de {{ items.length }} itinerarios
</p>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<USkeleton v-for="i in 6" :key="i" class="h-20" />
</div>
<div v-else-if="filteredItems.length === 0 && !loading" class="text-center py-12">
<UIcon name="i-lucide-route" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">{{ hasFilters ? 'Ningun itinerario coincide con los filtros' : 'No se encontraron itinerarios' }}</p>
<UButton v-if="hasFilters" label="Limpiar filtros" class="mt-3" variant="outline" @click="includeCountries = []; excludeCountries = []; excludeRest = false" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
<InspirationMultiCityCard
v-for="(item, i) in filteredItems"
:key="i"
:item="item"
:resolve-country="resolveCountryName"
@select="onSelect"
/>
</div>
</div>
</UPageSection>
</div>
</template>