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

141
app/pages/explore.vue Normal file
View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { InspirationItem } from '~/server/utils/flightics'
const route = useRoute()
const router = useRouter()
const { fetchInspirations } = useFlightSearch()
const { airports, loadAirports } = useLocations()
const origin = ref((route.query.dep as string) || 'MAD')
const budget = ref<number | null>(route.query.budget ? Number(route.query.budget) : null)
const directOnly = ref(false)
const inspirations = ref<InspirationItem[]>([])
const loadingInsp = ref(false)
const { prefetch, getImage } = useDestinationImages()
useSeoMeta({ title: 'Vuelato - Explorar destinos' })
// Load airports for map
onMounted(() => loadAirports())
async function loadInspirations() {
loadingInsp.value = true
try {
const data = await fetchInspirations(origin.value)
inspirations.value = data.items || []
} catch {
inspirations.value = []
} finally {
loadingInsp.value = false
}
}
watch(origin, () => loadInspirations(), { immediate: true })
const filteredInspirations = computed(() => {
let items = inspirations.value
if (directOnly.value) items = items.filter(i => i.minStops === 0)
if (budget.value) items = items.filter(i => i.minPrice <= budget.value!)
return items
})
function cityName(iata: string): string {
const a = airports.value.find(ap => ap.iata === iata)
return a?.city_name || iata
}
// Prefetch images when inspirations change
watch(filteredInspirations, (items) => {
const cities = items.slice(0, 20)
.map(i => cityName(i.to[0]))
.filter(c => c.length > 2)
if (cities.length) prefetch(cities)
})
// Map airports (only those with lat/lon)
const mapAirports = computed(() =>
airports.value
.filter(a => a.lat && a.lon)
.map(a => ({ iata: a.iata, name: a.name, lat: a.lat, lon: a.lon, city_name: a.city_name }))
)
function onSelectOrigin(iata: string) {
origin.value = iata
}
function onSelectDestination(iata: string) {
router.push(`/route/${origin.value}-${iata}`)
}
</script>
<template>
<div>
<UPageHero title="Explorar destinos" description="Descubre vuelos baratos en el mapa" />
<UPageSection>
<div class="space-y-4">
<MapControls
v-model:origin="origin"
v-model:budget="budget"
v-model:direct-only="directOnly"
:inspiration-count="filteredInspirations.length"
/>
<ClientOnly>
<MapFlightMap
:airports="mapAirports"
:origin="origin"
:inspirations="filteredInspirations"
:budget="budget"
@select-origin="onSelectOrigin"
@select-destination="onSelectDestination"
/>
<template #fallback>
<USkeleton class="h-[500px] w-full rounded-lg" />
</template>
</ClientOnly>
<!-- Destination list below map -->
<div v-if="filteredInspirations.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div
v-for="item in filteredInspirations.slice(0, 20)"
:key="item.to[0]"
class="relative overflow-hidden rounded-lg cursor-pointer group h-40"
@click="onSelectDestination(item.to[0])"
>
<img
v-if="getImage(cityName(item.to[0]))?.thumb_url"
:src="getImage(cityName(item.to[0]))!.thumb_url"
:alt="cityName(item.to[0])"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
>
<div v-else class="absolute inset-0 bg-gradient-to-br from-primary-500 to-primary-700" />
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-3 text-white">
<p class="font-bold text-sm truncate">{{ cityName(item.to[0]) }}</p>
<div class="flex items-center justify-between mt-0.5">
<p class="text-xs text-white/80">
{{ item.minStops === 0 ? 'Directo' : `${item.minStops} escala(s)` }}
</p>
<p class="text-lg font-bold">
{{ item.minPrice.toFixed(0) }}<span class="text-xs">&euro;</span>
</p>
</div>
</div>
<!-- Unsplash attribution -->
<a
v-if="getImage(cityName(item.to[0]))?.photographer"
:href="getImage(cityName(item.to[0]))!.photographer_url + '?utm_source=vuelato&utm_medium=referral'"
class="absolute top-1 right-1 text-[9px] text-white/50 hover:text-white/80 transition-colors"
target="_blank"
rel="noopener"
@click.stop
>
{{ getImage(cityName(item.to[0]))!.photographer }}
</a>
</div>
</div>
</div>
</UPageSection>
</div>
</template>