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,206 @@
import type { SearchResponse, DetailResponse, PassengersCount, InspirationsResponse, Trip } from '~/server/utils/flightics'
interface SearchFormData {
departures: string[]
destination: string[]
dateFrom: string
dateTo: string
stayMinDays: number
stayMaxDays: number
passengers: PassengersCount
maxResults?: number
maxStops?: number | null
multiCityStops?: string[]
}
export function useFlightSearch() {
const trips = ref<Trip[]>([])
const loading = ref(false)
const polling = ref(false)
const error = ref<string | null>(null)
const searchMeta = ref<{ notComplete: boolean, responseId: string, pollCount: number }>({
notComplete: false,
responseId: '',
pollCount: 0
})
let abortController: AbortController | null = null
function buildPayload(form: SearchFormData) {
// Default dates: from today, to +30 days if not provided
const now = new Date()
const defaultFrom = now.toISOString().slice(0, 10)
const defaultTo = new Date(now.getTime() + 30 * 86400000).toISOString().slice(0, 10)
const dateFrom = form.dateFrom || defaultFrom
const dateTo = form.dateTo || defaultTo
const isOneWay = dateFrom === dateTo
const isMultiCity = form.multiCityStops && form.multiCityStops.length > 0
const defaultStayRange = { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' }
let stops
if (isMultiCity) {
// Each intermediate city gets a stay, then return to origin
stops = [
...form.multiCityStops!.map(code => ({
locations: [code],
stayRange: { min: form.stayMinDays * 24, max: form.stayMaxDays * 24 },
stayDateRange: defaultStayRange,
continueFromAny: true
})),
{
locations: form.departures,
stayRange: { min: 0, max: 0 },
stayDateRange: defaultStayRange,
continueFromAny: false
}
]
} else if (isOneWay) {
stops = [{
locations: form.destination,
stayRange: { min: 0, max: 0 },
stayDateRange: defaultStayRange,
continueFromAny: false
}]
} else {
stops = [
{
locations: form.destination,
stayRange: { min: form.stayMinDays * 24, max: form.stayMaxDays * 24 },
stayDateRange: defaultStayRange,
continueFromAny: true
},
{
locations: form.departures,
stayRange: { min: 0, max: 0 },
stayDateRange: defaultStayRange,
continueFromAny: false
}
]
}
return {
departures: form.departures,
local: 'en',
departureDateInterval: {
begin: `${dateFrom}T00:00:00+00:00`,
end: `${dateTo}T00:00:00+00:00`
},
stops,
endInSameLocation: isMultiCity || !isOneWay,
maxStops: form.maxStops ?? null,
fixStopsOrder: false,
stopLength: { min: 0, max: 0, isSet: false },
maxResults: form.maxResults || 45,
passengersCount: form.passengers
}
}
const { learn: learnAirline } = useAirlineNames()
function learnAirlineNames(tripList: Trip[]) {
for (const trip of tripList) {
for (const leg of trip.legs) {
for (const seg of leg.segments) {
if (seg.company?.name && seg.company.code) {
learnAirline(seg.company.code, seg.company.name)
}
}
}
}
}
// Deduplicate trips by bookingToken
function mergeTrips(existing: Trip[], incoming: Trip[]): Trip[] {
learnAirlineNames(incoming)
const seen = new Set(existing.map(t => t.bookingToken))
const newTrips = incoming.filter(t => !seen.has(t.bookingToken))
return [...existing, ...newTrips]
}
async function search(form: SearchFormData, maxPolls = 3) {
// Abort any ongoing search
abortController?.abort()
abortController = new AbortController()
loading.value = true
error.value = null
trips.value = []
searchMeta.value = { notComplete: false, responseId: '', pollCount: 0 }
const payload = buildPayload(form)
try {
// Initial search
const data = await $fetch<SearchResponse>('/api/search', {
method: 'POST',
body: payload,
signal: abortController.signal
})
const initialTrips = data.trips || []
learnAirlineNames(initialTrips)
trips.value = initialTrips
searchMeta.value = { notComplete: data.notComplete, responseId: data.responseId, pollCount: 1 }
loading.value = false
// Progressive polling if more results available
if (data.notComplete && maxPolls > 1) {
polling.value = true
for (let i = 1; i < maxPolls; i++) {
if (abortController.signal.aborted) break
await new Promise(r => setTimeout(r, 800))
const more = await $fetch<SearchResponse>('/api/search', {
method: 'POST',
body: payload,
signal: abortController.signal
})
trips.value = mergeTrips(trips.value, more.trips || [])
searchMeta.value = {
notComplete: more.notComplete,
responseId: more.responseId,
pollCount: i + 1
}
if (!more.notComplete) break
}
polling.value = false
}
} catch (e: any) {
if (e.name === 'AbortError') return
error.value = e?.data?.message || e?.message || 'Error searching flights'
loading.value = false
polling.value = false
}
}
function stopPolling() {
abortController?.abort()
polling.value = false
}
async function getDetail(bookingToken: string, passengers: PassengersCount) {
return $fetch<DetailResponse>('/api/detail', {
method: 'POST',
body: { bookingToken, local: 'en', passengersCount: passengers }
})
}
async function checkPrice(bookingToken: string, passengers: PassengersCount) {
return $fetch<DetailResponse>('/api/check', {
method: 'POST',
body: { bookingToken, local: 'en', passengersCount: passengers }
})
}
async function fetchInspirations(from: string, take: number = 100) {
return $fetch<InspirationsResponse>('/api/inspirations', {
query: { from, take, locale: 'en' }
})
}
return { trips, loading, polling, error, searchMeta, search, stopPolling, getDetail, checkPrice, fetchInspirations, buildPayload }
}