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,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>