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,145 @@
<script setup lang="ts">
const props = defineProps<{
flightCode: string
}>()
const info = ref<any>(null)
const fr24Url = ref<string | null>(null)
const loading = ref(false)
const loaded = ref(false)
async function loadInfo() {
if (loaded.value) return
loading.value = true
try {
const data = await $fetch<any>('/api/flight-info', {
query: { flightno: props.flightCode }
})
fr24Url.value = data.fr24Url || `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
if (data.found) info.value = data.flight
} catch {
fr24Url.value = `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
} finally {
loading.value = false
loaded.value = true
}
}
function formatAltitude(ft: number) {
return `${Math.round(ft * 0.3048)}m (FL${Math.round(ft / 100)})`
}
function formatSpeed(knots: number) {
return `${Math.round(knots * 1.852)} km/h`
}
function formatDelay(min: number | null) {
if (min == null || min === 0) return null
if (min > 0) return `+${min} min`
return `${min} min`
}
</script>
<template>
<div>
<!-- Toggle button -->
<UButton
:label="loading ? 'Cargando...' : (info ? 'Info del vuelo' : 'Ver info en vivo')"
:icon="info ? 'i-lucide-radar' : 'i-lucide-radio'"
color="neutral"
variant="ghost"
size="xs"
:loading="loading"
@click="loadInfo"
/>
<!-- Flight info panel -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-96"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 max-h-96"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="info" class="mt-2 overflow-hidden">
<div class="rounded-lg bg-neutral-50 dark:bg-neutral-800/50 p-3 space-y-2 text-sm">
<!-- Status -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="info.onGround ? 'bg-amber-500' : info.altitude > 0 ? 'bg-green-500 animate-pulse' : 'bg-neutral-400'"
/>
<span class="font-medium">{{ info.status }}</span>
</div>
<a
:href="info.fr24Url"
target="_blank"
class="text-xs text-primary-500 hover:underline"
>
Flightradar24
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
</a>
</div>
<!-- Aircraft & airline -->
<div class="grid grid-cols-2 gap-2 text-xs">
<div v-if="info.aircraft">
<span class="text-muted">Avion</span>
<p class="font-medium">{{ info.aircraftAge || info.aircraft }}</p>
</div>
<div v-if="info.registration">
<span class="text-muted">Matricula</span>
<p class="font-medium">{{ info.registration }}</p>
</div>
</div>
<!-- Live data (if in flight) -->
<div v-if="info.altitude > 0 && !info.onGround" class="grid grid-cols-3 gap-2 text-xs">
<div>
<span class="text-muted">Altitud</span>
<p class="font-medium">{{ formatAltitude(info.altitude) }}</p>
</div>
<div>
<span class="text-muted">Velocidad</span>
<p class="font-medium">{{ formatSpeed(info.speed) }}</p>
</div>
<div>
<span class="text-muted">Rumbo</span>
<p class="font-medium">{{ info.heading }}°</p>
</div>
</div>
<!-- Delays -->
<div v-if="formatDelay(info.departureDelay) || formatDelay(info.arrivalDelay)" class="flex gap-4 text-xs">
<div v-if="formatDelay(info.departureDelay)">
<span class="text-muted">Retraso salida</span>
<p class="font-medium" :class="info.departureDelay > 0 ? 'text-red-500' : 'text-green-500'">
{{ formatDelay(info.departureDelay) }}
</p>
</div>
<div v-if="formatDelay(info.arrivalDelay)">
<span class="text-muted">Retraso llegada</span>
<p class="font-medium" :class="info.arrivalDelay > 0 ? 'text-red-500' : 'text-green-500'">
{{ formatDelay(info.arrivalDelay) }}
</p>
</div>
</div>
</div>
</div>
</Transition>
<!-- Not found link to FR24 anyway -->
<div v-if="loaded && !info && !loading" class="mt-1">
<a
:href="fr24Url"
target="_blank"
class="text-xs text-muted hover:text-primary-500 transition-colors"
>
No hay datos en vivo · Ver historial en Flightradar24
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
defineProps<{ trip: Trip }>()
function formatFullDate(d: string) {
return new Date(d).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'long' })
}
function legDuration(leg: Trip['legs'][0]) {
const segs = leg.segments
if (!segs.length) return ''
const depMs = segs[0].departureTimestamp * 1000
const arrMs = segs[segs.length - 1].arrivalTimestamp * 1000
const diffMin = Math.round((arrMs - depMs) / 60000)
const h = Math.floor(diffMin / 60)
const m = diffMin % 60
return `${h}h ${m}m`
}
</script>
<template>
<div class="space-y-4">
<UCard v-for="(leg, i) in trip.legs" :key="i">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UBadge :label="i === 0 ? 'Ida' : 'Vuelta'" :color="i === 0 ? 'primary' : 'info'" variant="subtle" />
<span class="text-sm text-neutral-500">{{ formatFullDate(leg.segments[0].departureDate) }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-muted">
<UIcon name="i-lucide-timer" class="text-xs" />
{{ legDuration(leg) }}
<template v-if="leg.segments.length > 1">
· {{ leg.segments.length - 1 }} escala{{ leg.segments.length > 2 ? 's' : '' }}
</template>
</div>
</div>
</template>
<DetailSegmentCard
v-for="(seg, j) in leg.segments"
:key="j"
:segment="seg"
:show-divider="j > 0"
/>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { PassengersCount } from '~/server/utils/flightics'
const props = defineProps<{
bookingToken: string
originalPrice: number
passengers: PassengersCount
}>()
const { checkPrice } = useFlightSearch()
const checkedPrice = ref<number | null>(null)
const checking = ref(false)
async function verify() {
checking.value = true
try {
const data = await checkPrice(props.bookingToken, props.passengers)
checkedPrice.value = data.trip.totalCost
} catch {
checkedPrice.value = -1
} finally {
checking.value = false
}
}
</script>
<template>
<UCard>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">Verificar precio actual</h3>
<p class="text-sm text-neutral-500">Comprueba si el precio sigue disponible</p>
</div>
<div class="flex items-center gap-3">
<template v-if="checkedPrice !== null">
<UBadge v-if="checkedPrice === -1" label="No disponible" color="error" />
<UBadge v-else-if="checkedPrice <= originalPrice" :label="`${checkedPrice.toFixed(0)}€`" color="success" />
<UBadge v-else :label="`${checkedPrice.toFixed(0)}€ (subio)`" color="warning" />
</template>
<UButton label="Verificar" icon="i-lucide-refresh-cw" :loading="checking" @click="verify" />
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
const props = defineProps<{
from: string
to: string
}>()
const { trips, loading, fetchRouteFlights } = useRouteFlights()
onMounted(() => {
if (props.from && props.to) {
fetchRouteFlights(props.from, props.to)
}
})
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', { day: 'numeric', month: 'short' })
}
</script>
<template>
<div v-if="loading || trips.length > 0">
<h3 class="font-semibold mb-3">Otros vuelos {{ from }} {{ to }}</h3>
<div v-if="loading" class="space-y-2">
<USkeleton v-for="i in 3" :key="i" class="h-12" />
</div>
<div v-else class="space-y-2">
<div
v-for="(trip, i) in trips.slice(0, 5)"
:key="i"
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm"
>
<div class="flex items-center gap-4">
<span class="font-medium">
{{ formatTime(trip.legs[0]?.segments[0]?.departureDate) }}
{{ formatTime(trip.legs[0]?.segments.at(-1)?.arrivalDate || '') }}
</span>
<span class="text-muted">
{{ formatDate(trip.legs[0]?.segments[0]?.departureDate) }}
</span>
<span class="text-xs text-muted">
{{ trip.legs[0]?.segments[0]?.company?.code }}{{ trip.legs[0]?.segments[0]?.number }}
</span>
</div>
<span class="font-bold text-primary-600 dark:text-primary-400">
{{ trip.totalCost.toFixed(0) }}&euro;
</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import type { Segment } from '~/server/utils/flightics'
const props = defineProps<{
segment: Segment
showDivider?: boolean
}>()
const { resolve } = useAirlineNames()
const { getBookingUrl, getAirlineWebsite } = useBookingUrl()
function formatTime(d: string) {
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
}
const segmentDate = computed(() => props.segment.departureDate.slice(0, 10))
const segmentLink = computed(() => ({
path: '/results',
query: {
mode: 'oneway',
dep: props.segment.departureCode,
dest: props.segment.arrivalCode,
from: segmentDate.value,
to: segmentDate.value,
adults: '1'
}
}))
const flightCode = computed(() => `${props.segment.company.code}${props.segment.number}`)
const trackerUrl = computed(() => `https://www.flightradar24.com/data/flights/${flightCode.value.toLowerCase()}`)
const airlineName = computed(() => resolve(props.segment.company.code, props.segment.company.name))
const bookingUrl = computed(() => getBookingUrl({
airlineCode: props.segment.company.code,
origin: props.segment.departureCode,
destination: props.segment.arrivalCode,
date: props.segment.departureDate
}))
const airlineWebsite = computed(() => getAirlineWebsite(props.segment.company.code))
// Fetch cheapest price for this specific route
const segmentPrice = ref<number | null>(null)
const loadingPrice = ref(false)
onMounted(async () => {
loadingPrice.value = true
try {
const date = props.segment.departureDate.slice(0, 10)
const data = await $fetch<any>('/api/search', {
method: 'POST',
body: {
departures: [props.segment.departureCode],
local: 'en',
departureDateInterval: {
begin: `${date}T00:00:00+00:00`,
end: `${date}T00:00:00+00:00`
},
stops: [{
locations: [props.segment.arrivalCode],
stayRange: { min: 0, max: 0 },
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
continueFromAny: false
}],
endInSameLocation: false,
maxStops: 0,
fixStopsOrder: false,
stopLength: { min: 0, max: 0, isSet: false },
maxResults: 1,
passengersCount: { adult: 1, child: 0, infant: 0 }
}
})
if (data.trips?.length) {
segmentPrice.value = data.trips[0].totalCost
}
} catch {
// silently fail
} finally {
loadingPrice.value = false
}
})
</script>
<template>
<div
class="flex items-center gap-4 py-3 -mx-2 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
:class="{ 'border-t border-neutral-100 dark:border-neutral-800': showDivider }"
>
<!-- Departure -->
<div class="text-center w-20">
<p class="text-xl font-semibold">{{ formatTime(segment.departureDate) }}</p>
<p class="text-sm font-medium">{{ segment.departureCode }}</p>
<p class="text-xs text-neutral-400">{{ segment.departureCity }}</p>
</div>
<!-- Flight info -->
<div class="flex-1 flex flex-col items-center gap-1">
<div class="flex items-center gap-2">
<a
:href="trackerUrl"
target="_blank"
class="text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:text-primary-500 transition-colors"
title="Ver en Flightradar24"
@click.stop
>
<span v-if="airlineName" class="font-medium">{{ airlineName }} · </span>
<span>{{ segment.company.code }} {{ segment.number }}</span>
<UIcon name="i-lucide-radar" class="inline ml-0.5 text-[10px] opacity-50" />
</a>
</div>
<div class="w-full h-px bg-neutral-200 dark:bg-neutral-700 relative">
<UIcon name="i-lucide-plane" class="absolute -top-2 left-1/2 -translate-x-1/2 text-primary-500" />
</div>
</div>
<!-- Arrival -->
<div class="text-center w-20">
<p class="text-xl font-semibold">{{ formatTime(segment.arrivalDate) }}</p>
<p class="text-sm font-medium">{{ segment.arrivalCode }}</p>
<p class="text-xs text-neutral-400">{{ segment.arrivalCity }}</p>
</div>
<!-- Price (links to one-way search) -->
<NuxtLink :to="segmentLink" class="shrink-0 text-right w-16 hover:opacity-80 transition-opacity" @click.stop>
<template v-if="loadingPrice">
<USkeleton class="h-5 w-12 ml-auto" />
</template>
<template v-else-if="segmentPrice != null">
<p class="text-sm font-bold text-primary-600 dark:text-primary-400">
{{ segmentPrice.toFixed(0) }}&euro;
</p>
<p class="text-xs text-muted">solo ida</p>
</template>
</NuxtLink>
</div>
<!-- Flight tracker (expandable) -->
<div class="pl-24 -mt-1 mb-1">
<DetailFlightTracker :flight-code="flightCode" />
</div>
<!-- Booking links -->
<div class="pl-24 -mt-1 mb-2 flex items-center gap-2">
<a
:href="bookingUrl"
target="_blank"
class="inline-flex items-center gap-1 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
@click.stop
>
<UIcon name="i-lucide-ticket" class="text-sm" />
Reservar vuelo
</a>
<a
v-if="airlineWebsite"
:href="airlineWebsite"
target="_blank"
class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
@click.stop
>
<UIcon name="i-lucide-globe" class="text-sm" />
Web de {{ airlineName || segment.company.code }}
</a>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
const props = defineProps<{
title: string
price: number
}>()
const copied = ref(false)
async function share() {
const url = window.location.href
const text = `${props.title} - ${props.price.toFixed(0)}`
if (navigator.share) {
await navigator.share({ title: text, url })
} else {
await navigator.clipboard.writeText(`${text}\n${url}`)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
}
</script>
<template>
<UButton
:label="copied ? 'Copiado!' : 'Compartir'"
:icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'"
:color="copied ? 'success' : 'neutral'"
variant="outline"
@click="share"
/>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
const props = defineProps<{
bookingToken: string
routeSummary: string
departureCode: string
arrivalCode: string
departureDate: string
price: number
passengers: { adult: number; child: number; infant: number }
}>()
const user = useSupabaseUser()
const { isWatched, getWatchedItem, add, remove } = useWatchlist()
const toggling = ref(false)
const watched = computed(() => isWatched(props.bookingToken))
async function toggle() {
if (!user.value) {
await navigateTo('/auth')
return
}
toggling.value = true
if (watched.value) {
const item = getWatchedItem(props.bookingToken)
if (item) await remove(item.id)
} else {
await add({
bookingToken: props.bookingToken,
routeSummary: props.routeSummary,
departureCode: props.departureCode,
arrivalCode: props.arrivalCode,
departureDate: props.departureDate,
price: props.price,
passengers: props.passengers
})
}
toggling.value = false
}
</script>
<template>
<UButton
:icon="watched ? 'i-lucide-heart' : 'i-lucide-heart'"
:color="watched ? 'error' : 'neutral'"
:variant="watched ? 'soft' : 'ghost'"
:loading="toggling"
size="sm"
@click.stop="toggle"
/>
</template>