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

149
app/pages/route/[slug].vue Normal file
View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
const route = useRoute()
const router = useRouter()
const { trips, loading, error, fetchRouteFlights } = useRouteFlights()
const slug = computed(() => route.params.slug as string)
const from = computed(() => slug.value.split('-')[0]?.toUpperCase() || '')
const to = computed(() => slug.value.split('-')[1]?.toUpperCase() || '')
useSeoMeta({ title: () => `Vuelato - ${from.value}${to.value}` })
onMounted(() => {
if (from.value && to.value) {
fetchRouteFlights(from.value, to.value)
}
})
function selectTrip(trip: Trip) {
router.push({
path: `/detail/${encodeURIComponent(trip.bookingToken)}`,
query: { price: String(trip.totalCost), adults: '1', children: '0', infants: '0' }
})
}
function formatTime(d: string) {
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'short' })
}
function legLabel(index: number, total: number) {
if (total === 1) return 'Solo ida'
return index === 0 ? 'Ida' : 'Vuelta'
}
function legStopsText(leg: Trip['legs'][0]) {
const n = leg.segments.length - 1
if (n === 0) return 'Directo'
return `${n} escala${n > 1 ? 's' : ''}`
}
function legRoute(leg: Trip['legs'][0]) {
const codes = leg.segments.map(s => s.departureCode)
codes.push(leg.segments.at(-1)!.arrivalCode)
return codes.join(' → ')
}
// Group by airline
const airlineGroups = computed(() => {
const groups = new Map<string, Trip[]>()
for (const trip of trips.value) {
const code = trip.legs[0]?.segments[0]?.company?.code || 'Otros'
const name = trip.legs[0]?.segments[0]?.company?.name || code
const key = `${code} - ${name}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(trip)
}
return [...groups.entries()].sort((a, b) => {
const minA = Math.min(...a[1].map(t => t.totalCost))
const minB = Math.min(...b[1].map(t => t.totalCost))
return minA - minB
})
})
</script>
<template>
<div>
<UPageHero
:title="`${from} → ${to}`"
description="Vuelos disponibles en esta ruta (ida y vuelta)"
/>
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<UButton label="Volver" icon="i-lucide-arrow-left" variant="ghost" @click="$router.back()" />
<div v-if="loading" class="space-y-3">
<USkeleton v-for="i in 5" :key="i" class="h-24 w-full" />
</div>
<UAlert v-else-if="error" color="error" icon="i-lucide-alert-circle" :title="error" />
<div v-else-if="trips.length === 0" class="text-center py-12">
<UIcon name="i-lucide-plane" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No se encontraron vuelos para esta ruta</p>
</div>
<template v-else>
<p class="text-sm text-muted">{{ trips.length }} opcion{{ trips.length !== 1 ? 'es' : '' }} encontrada{{ trips.length !== 1 ? 's' : '' }}</p>
<div v-for="[airline, airlineTrips] in airlineGroups" :key="airline" class="space-y-2">
<h3 class="font-semibold text-sm flex items-center gap-2">
<UIcon name="i-lucide-plane" class="text-muted" />
{{ airline }}
<UBadge :label="`desde ${Math.min(...airlineTrips.map(t => t.totalCost)).toFixed(0)}€`" color="primary" size="xs" />
</h3>
<UCard
v-for="(trip, i) in airlineTrips.slice(0, 10)"
:key="i"
class="hover:ring-primary-400 cursor-pointer transition-all"
@click="selectTrip(trip)"
>
<div class="flex items-center justify-between gap-4">
<div class="flex-1 space-y-2">
<div v-for="(leg, li) in trip.legs" :key="li" class="flex items-center gap-3 text-sm">
<UBadge
:label="legLabel(li, trip.legs.length)"
:color="li === 0 ? 'primary' : 'info'"
variant="subtle"
size="xs"
/>
<span class="font-medium">
{{ formatTime(leg.segments[0]?.departureDate) }}
{{ formatTime(leg.segments.at(-1)?.arrivalDate || '') }}
</span>
<span class="text-muted">
{{ formatDate(leg.segments[0]?.departureDate) }}
</span>
<span class="text-xs text-muted">
{{ legStopsText(leg) }}
</span>
<span v-if="leg.segments.length > 1" class="text-xs text-muted hidden sm:inline">
{{ legRoute(leg) }}
</span>
</div>
</div>
<div class="shrink-0 text-right">
<p class="text-xl font-bold text-primary-600 dark:text-primary-400">
{{ trip.totalCost.toFixed(0) }}<span class="text-sm">&euro;</span>
</p>
<p class="text-xs text-muted">
{{ trip.legs.length > 1 ? 'ida+vuelta' : 'solo ida' }}
</p>
</div>
</div>
</UCard>
</div>
</template>
</div>
</UPageSection>
</div>
</template>