Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
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:
206
app/composables/useFlightSearch.ts
Normal file
206
app/composables/useFlightSearch.ts
Normal 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user