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>
207 lines
6.2 KiB
TypeScript
207 lines
6.2 KiB
TypeScript
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 }
|
|
}
|