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

197
app/pages/index.vue Normal file
View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { InspirationItem, MultiCityInspirationItem } from '~/server/utils/flightics'
const router = useRouter()
const user = useSupabaseUser()
const { fetchInspirations } = useFlightSearch()
const { searches, saveSearch } = useRecentSearches()
const { homeAirports } = useUserPreferences()
const inspirations = ref<InspirationItem[]>([])
const inspirationFrom = ref('MAD')
const loadingInspirations = ref(false)
// Multi-city inspirations
const multiCityItems = ref<MultiCityInspirationItem[]>([])
const loadingMultiCity = ref(false)
async function loadInspirations() {
loadingInspirations.value = true
try {
const data = await fetchInspirations(inspirationFrom.value)
inspirations.value = data.items || []
} catch {
inspirations.value = []
} finally {
loadingInspirations.value = false
}
}
async function loadMultiCity() {
const codes = quickAirports.value.slice(0, 3)
if (codes.length === 0) return
loadingMultiCity.value = true
try {
const data = await $fetch<any>('/api/multi-city-inspirations', {
method: 'POST',
body: { startLocationsCodes: codes, locale: 'en', take: 8 }
})
multiCityItems.value = data.items || []
} catch {
multiCityItems.value = []
} finally {
loadingMultiCity.value = false
}
}
// Use home airports from profile, fallback to defaults
const quickAirports = computed(() => {
if (homeAirports.value.length) return homeAirports.value
return ['MAD', 'BCN', 'AGP', 'SVQ', 'VLC', 'PMI']
})
// Set initial origin from user preferences
watch(homeAirports, (airports) => {
if (airports.length) inspirationFrom.value = airports[0]
}, { immediate: true })
onMounted(() => {
loadInspirations()
loadMultiCity()
})
function onSearch(data: any) {
const dep = data.departures.join(',')
const dest = data.destination.join(',')
const summary = dest ? `${dep} > ${dest}` : `${dep} > Explorar`
if (user.value) saveSearch(data, summary, data.mode)
if (data.mode === 'explore') {
router.push({ path: '/explore', query: { dep, budget: data.budget } })
return
}
router.push({
path: '/results',
query: {
mode: data.mode,
dep,
dest,
from: data.dateFrom,
to: data.dateTo,
smin: data.stayMinDays,
smax: data.stayMaxDays,
adults: data.passengers.adult,
children: data.passengers.child,
infants: data.passengers.infant,
maxStops: data.maxStops ?? undefined,
budget: data.budget ?? undefined
}
})
}
function replaySearch(s: any) {
const p = s.search_params
router.push({
path: '/results',
query: {
mode: p.mode || s.search_mode,
dep: p.departures?.join(','),
dest: Array.isArray(p.destination) ? p.destination.join(',') : p.destination,
from: p.dateFrom,
to: p.dateTo,
smin: p.stayMinDays,
smax: p.stayMaxDays,
adults: p.passengers?.adult,
children: p.passengers?.child,
infants: p.passengers?.infant
}
})
}
function goToRoute(iata: string) {
router.push(`/route/${inspirationFrom.value}-${iata}`)
}
</script>
<template>
<div>
<UPageHero
title="Vuelato"
description="Busca vuelos baratos con fechas flexibles"
/>
<!-- Search -->
<UPageSection>
<UCard class="max-w-2xl mx-auto">
<SearchForm compact @search="onSearch" />
</UCard>
</UPageSection>
<!-- Recent searches -->
<UPageSection v-if="user && searches.length" title="Busquedas recientes">
<div class="flex gap-2 flex-wrap">
<UButton
v-for="s in searches.slice(0, 5)"
:key="s.id"
:label="s.route_summary"
icon="i-lucide-history"
color="neutral"
variant="soft"
size="sm"
@click="replaySearch(s)"
/>
</div>
</UPageSection>
<!-- Inspirations -->
<UPageSection title="Vuelos baratos" description="Los mejores precios desde tu aeropuerto">
<div class="flex gap-2 mb-4 flex-wrap">
<UButton
v-for="apt in quickAirports"
:key="apt"
:label="apt"
:color="inspirationFrom === apt ? 'primary' : 'neutral'"
:variant="inspirationFrom === apt ? 'solid' : 'outline'"
size="sm"
@click="inspirationFrom = apt; loadInspirations()"
/>
</div>
<div v-if="loadingInspirations" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<USkeleton v-for="i in 12" :key="i" class="h-16" />
</div>
<InspirationGrid v-else :items="inspirations" :from="inspirationFrom" />
</UPageSection>
<!-- Budget explorer -->
<UPageSection>
<InspirationBudgetExplorer
:items="inspirations"
:from="inspirationFrom"
:loading="loadingInspirations"
@select="goToRoute"
/>
</UPageSection>
<!-- Multi-city carousel -->
<UPageSection title="Inspiracion multi-ciudad" description="Itinerarios con varias paradas">
<div v-if="loadingMultiCity" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<USkeleton v-for="i in 4" :key="i" class="h-20" />
</div>
<div v-else-if="multiCityItems.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<InspirationMultiCityCard
v-for="(item, i) in multiCityItems"
:key="i"
:item="item"
@select="router.push('/multi-city')"
/>
</div>
<div v-else class="text-center py-6">
<UButton to="/multi-city" label="Explorar multi-ciudad" variant="outline" icon="i-lucide-route" />
</div>
</UPageSection>
</div>
</template>