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:
36
app/composables/useAirlineNames.ts
Normal file
36
app/composables/useAirlineNames.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Well-known airline codes
|
||||
const KNOWN_AIRLINES: Record<string, string> = {
|
||||
'2W': 'World2Fly', AA: 'American Airlines', AC: 'Air Canada', AF: 'Air France',
|
||||
AI: 'Air India', AM: 'Aeromexico', AR: 'Aerolineas Argentinas', AT: 'Royal Air Maroc',
|
||||
AV: 'Avianca', AY: 'Finnair', AZ: 'ITA Airways', BA: 'British Airways',
|
||||
CM: 'Copa Airlines', CX: 'Cathay Pacific', DL: 'Delta', DY: 'Norwegian',
|
||||
EI: 'Aer Lingus', EK: 'Emirates', ET: 'Ethiopian Airlines', EW: 'Eurowings',
|
||||
EY: 'Etihad', FB: 'Bulgaria Air', FI: 'Icelandair', FR: 'Ryanair',
|
||||
HA: 'Hawaiian Airlines', HU: 'Hainan Airlines', IB: 'Iberia', JL: 'Japan Airlines',
|
||||
JU: 'Air Serbia', KE: 'Korean Air', KL: 'KLM', LA: 'LATAM',
|
||||
LH: 'Lufthansa', LO: 'LOT Polish', LX: 'Swiss', MH: 'Malaysia Airlines',
|
||||
MS: 'EgyptAir', NH: 'ANA', NK: 'Spirit Airlines', OS: 'Austrian',
|
||||
OZ: 'Asiana Airlines', PC: 'Pegasus', QF: 'Qantas', QR: 'Qatar Airways',
|
||||
RO: 'TAROM', SK: 'SAS', SN: 'Brussels Airlines', SQ: 'Singapore Airlines',
|
||||
SU: 'Aeroflot', TK: 'Turkish Airlines', TP: 'TAP Air Portugal', U2: 'easyJet',
|
||||
UA: 'United Airlines', UX: 'Air Europa', VB: 'VivaAerobus', VY: 'Vueling',
|
||||
W6: 'Wizz Air', WS: 'WestJet', X1: 'Hahn Air', ZI: 'Aigle Azur',
|
||||
}
|
||||
|
||||
// Global cache of airline code → name, learned from API responses
|
||||
const airlineNames = reactive(new Map<string, string>(Object.entries(KNOWN_AIRLINES)))
|
||||
|
||||
export function useAirlineNames() {
|
||||
function learn(code: string, name: string) {
|
||||
if (name && !airlineNames.has(code)) {
|
||||
airlineNames.set(code, name)
|
||||
}
|
||||
}
|
||||
|
||||
function resolve(code: string, name?: string | null): string {
|
||||
if (name) return name
|
||||
return airlineNames.get(code) || ''
|
||||
}
|
||||
|
||||
return { airlineNames, learn, resolve }
|
||||
}
|
||||
43
app/composables/useAuth.ts
Normal file
43
app/composables/useAuth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export function useAuth() {
|
||||
const supabase = useSupabaseClient()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const { error: err } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (err) error.value = err.message
|
||||
loading.value = false
|
||||
return !err
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const { error: err } = await supabase.auth.signUp({ email, password })
|
||||
if (err) error.value = err.message
|
||||
loading.value = false
|
||||
return !err
|
||||
}
|
||||
|
||||
async function loginWithGoogle() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const { error: err } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: { redirectTo: `${window.location.origin}/auth/confirm` }
|
||||
})
|
||||
if (err) error.value = err.message
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await supabase.auth.signOut()
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
return { user, loading, error, login, register, loginWithGoogle, logout }
|
||||
}
|
||||
107
app/composables/useBookingUrl.ts
Normal file
107
app/composables/useBookingUrl.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
interface BookingParams {
|
||||
airlineCode: string
|
||||
origin: string
|
||||
destination: string
|
||||
date: string // ISO date string
|
||||
passengers?: number
|
||||
}
|
||||
|
||||
// Direct booking URL templates for major airlines
|
||||
// date format helpers applied per-airline
|
||||
const BOOKING_TEMPLATES: Record<string, (p: BookingParams & { d: string; ymd: string }) => string> = {
|
||||
FR: p => `https://www.ryanair.com/es/es/trip/flights/select?adults=${p.passengers}&teens=0&children=0&infants=0&dateOut=${p.ymd}&originIata=${p.origin}&destinationIata=${p.destination}&isReturn=false`,
|
||||
U2: p => `https://www.easyjet.com/es/search?origin=${p.origin}&destination=${p.destination}&outboundDate=${p.ymd}&adults=${p.passengers}`,
|
||||
VY: () => `https://www.vueling.com/es/reserva-tu-vuelo/busca-tu-vuelo`,
|
||||
LH: p => `https://www.lufthansa.com/es/es/offer/search?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&paxAdult=${p.passengers}&cabinClass=ECONOMY&tripType=O`,
|
||||
IB: p => `https://www.iberia.com/es/?language=es&market=ES&origin=${p.origin}&destination=${p.destination}&outbound=${p.ymd}&adults=${p.passengers}&cabin=ECONOMY`,
|
||||
W6: p => `https://wizzair.com/es-es#/booking/select-flight/${p.origin}/${p.destination}/${p.ymd}/null/${p.passengers}/0/0/null`,
|
||||
NK: p => `https://www.spirit.com/book/flights?origStation=${p.origin}&destStation=${p.destination}&date=${p.ymd}&adt=${p.passengers}&chd=0&inf=0&promoCode=&tripType=OW`,
|
||||
KL: p => `https://www.klm.es/search?pax=${p.passengers}:0:0:0:0:0:0:0&cabinClass=ECONOMY&connections=${p.origin}:C%3E${p.destination}:C&bookingFlow=LEISURE`,
|
||||
AF: p => `https://www.airfrance.es/search?pax=${p.passengers}:0:0:0:0:0:0:0&cabinClass=ECONOMY&connections=${p.origin}:C%3E${p.destination}:C&bookingFlow=LEISURE`,
|
||||
TK: p => `https://www.turkishairlines.com/es-es/flights/?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&adult=${p.passengers}&child=0&infant=0&tripType=O`,
|
||||
AA: p => `https://www.aa.com/booking/find-flights?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&pax=${p.passengers}&tripType=OneWay&locale=es_ES`,
|
||||
}
|
||||
|
||||
interface AirlineData {
|
||||
website: string | null
|
||||
bookingUrl: string | null
|
||||
bookingUrlTemplate: string | null
|
||||
}
|
||||
|
||||
// Airline data cache (loaded once from API)
|
||||
const airlineData = ref<Map<string, AirlineData> | null>(null)
|
||||
let loadingPromise: Promise<void> | null = null
|
||||
|
||||
async function loadAirlineData() {
|
||||
if (airlineData.value) return
|
||||
if (loadingPromise) return loadingPromise
|
||||
|
||||
loadingPromise = $fetch<{ airlines: { iata: string; website: string | null; booking_url: string | null; booking_url_template: string | null }[] }>('/api/airlines')
|
||||
.then(res => {
|
||||
const map = new Map<string, AirlineData>()
|
||||
for (const a of res.airlines) {
|
||||
map.set(a.iata, {
|
||||
website: a.website,
|
||||
bookingUrl: a.booking_url,
|
||||
bookingUrlTemplate: a.booking_url_template,
|
||||
})
|
||||
}
|
||||
airlineData.value = map
|
||||
})
|
||||
.catch(() => {
|
||||
airlineData.value = new Map()
|
||||
})
|
||||
|
||||
return loadingPromise
|
||||
}
|
||||
|
||||
function applyTemplate(template: string, params: BookingParams, pax: number, ymd: string): string {
|
||||
return template
|
||||
.replace(/\{origin\}/gi, params.origin)
|
||||
.replace(/\{destination\}/gi, params.destination)
|
||||
.replace(/\{date\}/gi, ymd)
|
||||
.replace(/\{passengers\}/gi, String(pax))
|
||||
}
|
||||
|
||||
function buildGoogleFlightsUrl(p: BookingParams): string {
|
||||
const ymd = p.date.slice(0, 10)
|
||||
return `https://www.google.com/travel/flights?hl=es&curr=EUR&q=flights+from+${p.origin}+to+${p.destination}+on+${ymd}+one+way+${p.passengers}+passenger`
|
||||
}
|
||||
|
||||
export function useBookingUrl() {
|
||||
// Start loading on first use
|
||||
loadAirlineData()
|
||||
|
||||
function getBookingUrl(params: BookingParams): string {
|
||||
const pax = params.passengers || 1
|
||||
const ymd = params.date.slice(0, 10)
|
||||
const d = ymd.replace(/-/g, '')
|
||||
|
||||
// 1. Hardcoded templates (most reliable, manually verified)
|
||||
const hardcoded = BOOKING_TEMPLATES[params.airlineCode]
|
||||
if (hardcoded) {
|
||||
return hardcoded({ ...params, passengers: pax, d, ymd })
|
||||
}
|
||||
|
||||
const data = airlineData.value?.get(params.airlineCode)
|
||||
|
||||
// 2. Auto-discovered template with placeholders
|
||||
if (data?.bookingUrlTemplate) {
|
||||
return applyTemplate(data.bookingUrlTemplate, params, pax, ymd)
|
||||
}
|
||||
|
||||
// 3. Discovered booking page URL (no params, but lands on the right page)
|
||||
if (data?.bookingUrl) {
|
||||
return data.bookingUrl
|
||||
}
|
||||
|
||||
// 4. Google Flights as universal fallback
|
||||
return buildGoogleFlightsUrl({ ...params, passengers: pax })
|
||||
}
|
||||
|
||||
function getAirlineWebsite(code: string): string | null {
|
||||
return airlineData.value?.get(code)?.website || null
|
||||
}
|
||||
|
||||
return { getBookingUrl, getAirlineWebsite }
|
||||
}
|
||||
32
app/composables/useDestinationImages.ts
Normal file
32
app/composables/useDestinationImages.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const imageCache = reactive<Record<string, { thumb_url: string; image_url: string; photographer: string; photographer_url: string } | null>>({})
|
||||
const pending = new Set<string>()
|
||||
|
||||
export function useDestinationImages() {
|
||||
async function fetchImage(cityName: string) {
|
||||
const key = cityName.trim().toLowerCase()
|
||||
if (key in imageCache || pending.has(key)) return
|
||||
pending.add(key)
|
||||
|
||||
try {
|
||||
const data = await $fetch<{ thumb_url: string; image_url: string; photographer: string; photographer_url: string } | null>('/api/destination-image', {
|
||||
query: { city: cityName },
|
||||
})
|
||||
imageCache[key] = data
|
||||
} catch {
|
||||
imageCache[key] = null
|
||||
} finally {
|
||||
pending.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function getImage(cityName: string) {
|
||||
const key = cityName.trim().toLowerCase()
|
||||
return imageCache[key] ?? null
|
||||
}
|
||||
|
||||
function prefetch(cityNames: string[]) {
|
||||
cityNames.forEach(name => fetchImage(name))
|
||||
}
|
||||
|
||||
return { fetchImage, getImage, prefetch, imageCache }
|
||||
}
|
||||
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 }
|
||||
}
|
||||
67
app/composables/useLocations.ts
Normal file
67
app/composables/useLocations.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
interface AirportResult {
|
||||
iata: string
|
||||
name: string
|
||||
city_name: string
|
||||
country_code: string
|
||||
country_name: string
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
}
|
||||
|
||||
export function useLocations() {
|
||||
const supabase = useSupabaseClient()
|
||||
|
||||
const airports = ref<AirportResult[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
async function loadAirports() {
|
||||
if (loaded.value) return
|
||||
const { data } = await supabase
|
||||
.from('airports')
|
||||
.select('iata, name, city_name, country_code, country_name, lat, lon')
|
||||
.order('iata')
|
||||
.limit(10000)
|
||||
if (data && data.length > 0) {
|
||||
airports.value = data as AirportResult[]
|
||||
loaded.value = true
|
||||
} else {
|
||||
// Fallback: fetch from Flightics API and cache in Supabase
|
||||
await syncLocations()
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLocations() {
|
||||
try {
|
||||
const data = await $fetch('/api/sync/locations', { method: 'POST' })
|
||||
if (data?.count) {
|
||||
// Reload from Supabase after sync
|
||||
const { data: fresh } = await supabase
|
||||
.from('airports')
|
||||
.select('iata, name, city_name, country_code, country_name, lat, lon')
|
||||
.order('iata')
|
||||
airports.value = (fresh as AirportResult[]) || []
|
||||
loaded.value = true
|
||||
}
|
||||
} catch {
|
||||
// Silently fail, user can retry
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function searchAirports(query: string): AirportResult[] {
|
||||
if (!query || query.length < 2) return []
|
||||
const q = normalize(query)
|
||||
return airports.value
|
||||
.filter(a =>
|
||||
normalize(a.iata).includes(q) ||
|
||||
normalize(a.name).includes(q) ||
|
||||
normalize(a.city_name || '').includes(q)
|
||||
)
|
||||
.slice(0, 20)
|
||||
}
|
||||
|
||||
return { airports, loaded, loadAirports, searchAirports, syncLocations }
|
||||
}
|
||||
18
app/composables/useOriginTime.ts
Normal file
18
app/composables/useOriginTime.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function useOriginTime() {
|
||||
const cookie = useCookie('showOriginTime', { default: () => false, watch: true })
|
||||
const { profile, updateProfile } = useUserPreferences()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const showOriginTime = computed({
|
||||
get: () => user.value && profile.value ? profile.value.show_origin_time : cookie.value,
|
||||
set: (v: boolean) => {
|
||||
cookie.value = v
|
||||
if (user.value && profile.value) {
|
||||
profile.value.show_origin_time = v
|
||||
updateProfile({ show_origin_time: v })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { showOriginTime }
|
||||
}
|
||||
44
app/composables/useRecentSearches.ts
Normal file
44
app/composables/useRecentSearches.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
interface RecentSearch {
|
||||
id: string
|
||||
search_params: Record<string, any>
|
||||
route_summary: string
|
||||
search_mode: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useRecentSearches() {
|
||||
const supabase = useSupabaseClient()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const searches = ref<RecentSearch[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchRecent(limit = 10) {
|
||||
if (!user.value) return
|
||||
loading.value = true
|
||||
const { data } = await supabase
|
||||
.from('recent_searches')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
searches.value = (data as RecentSearch[]) || []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function saveSearch(params: Record<string, any>, routeSummary: string, mode: string) {
|
||||
if (!user.value) return
|
||||
await supabase.from('recent_searches').insert({
|
||||
user_id: user.value.id,
|
||||
search_params: params,
|
||||
route_summary: routeSummary,
|
||||
search_mode: mode
|
||||
})
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u) fetchRecent()
|
||||
else searches.value = []
|
||||
}, { immediate: true })
|
||||
|
||||
return { searches, loading, fetchRecent, saveSearch }
|
||||
}
|
||||
144
app/composables/useResultFilters.ts
Normal file
144
app/composables/useResultFilters.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
export type SortKey = 'price' | 'duration' | 'departure' | 'stops'
|
||||
|
||||
interface Filters {
|
||||
maxPrice: number | null
|
||||
maxStops: number | null
|
||||
airlines: string[]
|
||||
departureTimeRange: [number, number] // hours 0-24
|
||||
}
|
||||
|
||||
function getTripDuration(trip: Trip): number {
|
||||
let total = 0
|
||||
for (const leg of trip.legs) {
|
||||
for (const seg of leg.segments) {
|
||||
total += (seg.arrivalUtcTimestamp ?? 0) - (seg.departureUtcTimestamp ?? 0)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function getTripStops(trip: Trip): number {
|
||||
return trip.legs.reduce((sum, leg) => sum + Math.max(0, leg.segments.length - 1), 0)
|
||||
}
|
||||
|
||||
function getTripAirlines(trip: Trip): string[] {
|
||||
const codes = new Set<string>()
|
||||
for (const leg of trip.legs) {
|
||||
for (const seg of leg.segments) {
|
||||
if (seg.company?.code) codes.add(seg.company.code)
|
||||
}
|
||||
}
|
||||
return [...codes]
|
||||
}
|
||||
|
||||
function getDepartureHour(trip: Trip): number {
|
||||
const dep = trip.legs[0]?.segments[0]?.departureDate
|
||||
return dep ? new Date(dep).getHours() : 0
|
||||
}
|
||||
|
||||
export function useResultFilters(trips: Ref<Trip[]>) {
|
||||
const sortBy = ref<SortKey>('price')
|
||||
const filters = reactive<Filters>({
|
||||
maxPrice: null,
|
||||
maxStops: null,
|
||||
airlines: [],
|
||||
departureTimeRange: [0, 24]
|
||||
})
|
||||
const viewMode = ref<'full' | 'compact'>('full')
|
||||
|
||||
// Extract available airlines from results
|
||||
const { resolve: resolveAirline } = useAirlineNames()
|
||||
const availableAirlines = computed(() => {
|
||||
const codes = new Set<string>()
|
||||
for (const trip of trips.value) {
|
||||
for (const leg of trip.legs) {
|
||||
for (const seg of leg.segments) {
|
||||
if (seg.company?.code) codes.add(seg.company.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...codes].sort().map(code => ({ code, name: resolveAirline(code) }))
|
||||
})
|
||||
|
||||
// Price range in results
|
||||
const priceRange = computed(() => {
|
||||
if (!trips.value.length) return { min: 0, max: 1000 }
|
||||
const prices = trips.value.map(t => t.totalCost)
|
||||
return { min: Math.floor(Math.min(...prices)), max: Math.ceil(Math.max(...prices)) }
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
let result = [...trips.value]
|
||||
|
||||
// Filter by price
|
||||
if (filters.maxPrice != null) {
|
||||
result = result.filter(t => t.totalCost <= filters.maxPrice!)
|
||||
}
|
||||
|
||||
// Filter by stops
|
||||
if (filters.maxStops != null) {
|
||||
result = result.filter(t => getTripStops(t) <= filters.maxStops!)
|
||||
}
|
||||
|
||||
// Filter by airlines (exclusive: all airlines in the trip must be in the selected set)
|
||||
if (filters.airlines.length > 0) {
|
||||
result = result.filter(t => {
|
||||
const tripAirlines = getTripAirlines(t)
|
||||
return tripAirlines.every(a => filters.airlines.includes(a))
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by departure time
|
||||
if (filters.departureTimeRange[0] > 0 || filters.departureTimeRange[1] < 24) {
|
||||
result = result.filter(t => {
|
||||
const hour = getDepartureHour(t)
|
||||
return hour >= filters.departureTimeRange[0] && hour <= filters.departureTimeRange[1]
|
||||
})
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortBy.value === 'price') {
|
||||
result.sort((a, b) => a.totalCost - b.totalCost)
|
||||
} else if (sortBy.value === 'departure') {
|
||||
result.sort((a, b) => {
|
||||
const aTime = a.legs[0]?.segments[0]?.departureTimestamp ?? 0
|
||||
const bTime = b.legs[0]?.segments[0]?.departureTimestamp ?? 0
|
||||
return aTime - bTime
|
||||
})
|
||||
} else if (sortBy.value === 'duration') {
|
||||
result.sort((a, b) => getTripDuration(a) - getTripDuration(b))
|
||||
} else if (sortBy.value === 'stops') {
|
||||
result.sort((a, b) => getTripStops(a) - getTripStops(b))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
filters.maxPrice = null
|
||||
filters.maxStops = null
|
||||
filters.airlines = []
|
||||
filters.departureTimeRange = [0, 24]
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
filters.maxPrice != null ||
|
||||
filters.maxStops != null ||
|
||||
filters.airlines.length > 0 ||
|
||||
filters.departureTimeRange[0] > 0 ||
|
||||
filters.departureTimeRange[1] < 24
|
||||
)
|
||||
|
||||
return {
|
||||
sortBy,
|
||||
filters,
|
||||
viewMode,
|
||||
filtered,
|
||||
availableAirlines,
|
||||
priceRange,
|
||||
hasActiveFilters,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
26
app/composables/useRouteFlights.ts
Normal file
26
app/composables/useRouteFlights.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { RouteFlightsResponse, PassengersCount, Trip } from '~/server/utils/flightics'
|
||||
|
||||
export function useRouteFlights() {
|
||||
const trips = ref<Trip[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchRouteFlights(from: string, to: string, passengers?: PassengersCount) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
trips.value = []
|
||||
try {
|
||||
const data = await $fetch<RouteFlightsResponse>('/api/route-flights', {
|
||||
method: 'POST',
|
||||
body: { from, to, locale: 'en', passengersCount: passengers }
|
||||
})
|
||||
trips.value = data.returnResults?.flatMap(r => r.trips) || []
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.message || e?.message || 'Error loading route flights'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { trips, loading, error, fetchRouteFlights }
|
||||
}
|
||||
115
app/composables/useTrackedSearches.ts
Normal file
115
app/composables/useTrackedSearches.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export interface TrackedSearch {
|
||||
id: string
|
||||
name: string
|
||||
search_params: Record<string, unknown>
|
||||
route_summary: string
|
||||
interval_hours: number
|
||||
is_active: boolean
|
||||
next_run_at: string | null
|
||||
last_run_at: string | null
|
||||
run_count: number
|
||||
last_error: string | null
|
||||
expires_at: string | null
|
||||
created_at: string
|
||||
latest_snapshot: PriceSnapshot | null
|
||||
}
|
||||
|
||||
export interface PriceSnapshot {
|
||||
cheapest_price: number
|
||||
avg_price: number | null
|
||||
median_price: number | null
|
||||
total_results: number
|
||||
recorded_at: string
|
||||
}
|
||||
|
||||
export interface TopTrip {
|
||||
price: number
|
||||
currency: string
|
||||
bookingToken: string
|
||||
legs: Array<{ from: string; to: string; departure: string; airlines: string[] }>
|
||||
}
|
||||
|
||||
export interface SearchRun {
|
||||
id: string
|
||||
tracked_search_id: string
|
||||
status: string
|
||||
cheapest_price: number | null
|
||||
total_trips_found: number
|
||||
top_trips: TopTrip[] | null
|
||||
from_cache: boolean
|
||||
error_message: string | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useTrackedSearches() {
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const trackedSearches = ref<TrackedSearch[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchAll() {
|
||||
if (!user.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await $fetch<TrackedSearch[]>('/api/tracking')
|
||||
trackedSearches.value = data || []
|
||||
} catch {
|
||||
trackedSearches.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(params: {
|
||||
name: string
|
||||
searchParams: Record<string, unknown>
|
||||
routeSummary: string
|
||||
intervalHours?: number
|
||||
expiresAt?: string
|
||||
}) {
|
||||
const data = await $fetch<TrackedSearch>('/api/tracking', {
|
||||
method: 'POST',
|
||||
body: params
|
||||
})
|
||||
await fetchAll()
|
||||
return data
|
||||
}
|
||||
|
||||
async function update(id: string, patch: Partial<Pick<TrackedSearch, 'name' | 'interval_hours' | 'is_active' | 'expires_at' | 'search_params' | 'route_summary'>>) {
|
||||
const data = await $fetch<TrackedSearch>(`/api/tracking/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: patch
|
||||
})
|
||||
const idx = trackedSearches.value.findIndex(s => s.id === id)
|
||||
if (idx >= 0) {
|
||||
trackedSearches.value[idx] = { ...trackedSearches.value[idx], ...data }
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
await $fetch(`/api/tracking/${id}`, { method: 'DELETE' })
|
||||
trackedSearches.value = trackedSearches.value.filter(s => s.id !== id)
|
||||
}
|
||||
|
||||
async function getHistory(id: string, days = 30): Promise<PriceSnapshot[]> {
|
||||
return $fetch<PriceSnapshot[]>(`/api/tracking/${id}/history`, {
|
||||
query: { days }
|
||||
})
|
||||
}
|
||||
|
||||
async function getRuns(id: string, limit = 20): Promise<SearchRun[]> {
|
||||
return $fetch<SearchRun[]>(`/api/tracking/${id}/runs`, {
|
||||
query: { limit }
|
||||
})
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u) fetchAll()
|
||||
else trackedSearches.value = []
|
||||
}, { immediate: true })
|
||||
|
||||
return { trackedSearches, loading, fetchAll, create, update, remove, getHistory, getRuns }
|
||||
}
|
||||
62
app/composables/useUserPreferences.ts
Normal file
62
app/composables/useUserPreferences.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
interface UserProfile {
|
||||
home_airports: string[]
|
||||
default_adults: number
|
||||
default_children: number
|
||||
default_infants: number
|
||||
locale: string
|
||||
show_origin_time: boolean
|
||||
}
|
||||
|
||||
export function useUserPreferences() {
|
||||
const user = useSupabaseUser()
|
||||
const profile = useState<UserProfile | null>('user-profile', () => null)
|
||||
const loading = useState<boolean>('user-profile-loading', () => false)
|
||||
|
||||
let fetchPromise: Promise<void> | null = null
|
||||
|
||||
async function fetchProfile() {
|
||||
if (!user.value) return
|
||||
if (fetchPromise) return fetchPromise
|
||||
loading.value = true
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const data = await $fetch<UserProfile>('/api/profile')
|
||||
profile.value = data
|
||||
} catch {
|
||||
profile.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
fetchPromise = null
|
||||
}
|
||||
})()
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
async function updateProfile(updates: Partial<UserProfile>) {
|
||||
if (!user.value) return
|
||||
try {
|
||||
const data = await $fetch<UserProfile>('/api/profile', {
|
||||
method: 'PATCH',
|
||||
body: updates
|
||||
})
|
||||
profile.value = data
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
const homeAirports = computed(() => profile.value?.home_airports ?? [])
|
||||
|
||||
const defaultPassengers = computed(() => ({
|
||||
adult: profile.value?.default_adults ?? 1,
|
||||
child: profile.value?.default_children ?? 0,
|
||||
infant: profile.value?.default_infants ?? 0
|
||||
}))
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u) fetchProfile()
|
||||
else profile.value = null
|
||||
}, { immediate: true })
|
||||
|
||||
return { profile, loading, homeAirports, defaultPassengers, fetchProfile, updateProfile }
|
||||
}
|
||||
135
app/composables/useWatchlist.ts
Normal file
135
app/composables/useWatchlist.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
interface WatchlistItem {
|
||||
id: string
|
||||
booking_token: string
|
||||
route_summary: string
|
||||
departure_code: string
|
||||
arrival_code: string
|
||||
departure_date: string
|
||||
original_price: number
|
||||
current_price: number | null
|
||||
price_status: string
|
||||
passengers_adult: number
|
||||
passengers_child: number
|
||||
passengers_infant: number
|
||||
last_checked_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useWatchlist() {
|
||||
const supabase = useSupabaseClient()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const items = ref<WatchlistItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchAll() {
|
||||
if (!user.value) return
|
||||
loading.value = true
|
||||
const { data } = await supabase
|
||||
.from('watchlist')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
items.value = (data as WatchlistItem[]) || []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function add(params: {
|
||||
bookingToken: string
|
||||
routeSummary: string
|
||||
departureCode: string
|
||||
arrivalCode: string
|
||||
departureDate: string
|
||||
price: number
|
||||
passengers: { adult: number; child: number; infant: number }
|
||||
}) {
|
||||
if (!user.value) return false
|
||||
const { error } = await supabase.from('watchlist').insert({
|
||||
user_id: user.value.id,
|
||||
booking_token: params.bookingToken,
|
||||
route_summary: params.routeSummary,
|
||||
departure_code: params.departureCode,
|
||||
arrival_code: params.arrivalCode,
|
||||
departure_date: params.departureDate,
|
||||
original_price: params.price,
|
||||
current_price: params.price,
|
||||
passengers_adult: params.passengers.adult,
|
||||
passengers_child: params.passengers.child,
|
||||
passengers_infant: params.passengers.infant
|
||||
})
|
||||
if (!error) await fetchAll()
|
||||
return !error
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
const { error } = await supabase.from('watchlist').delete().eq('id', id)
|
||||
if (!error) items.value = items.value.filter(i => i.id !== id)
|
||||
return !error
|
||||
}
|
||||
|
||||
async function checkPrice(item: WatchlistItem) {
|
||||
try {
|
||||
const data = await $fetch<any>('/api/check', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
bookingToken: item.booking_token,
|
||||
local: 'en',
|
||||
passengersCount: {
|
||||
adult: item.passengers_adult,
|
||||
child: item.passengers_child,
|
||||
infant: item.passengers_infant
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const newPrice = data.trip.totalCost
|
||||
let status = 'available'
|
||||
if (newPrice < item.original_price) status = 'price_down'
|
||||
else if (newPrice > item.original_price) status = 'price_up'
|
||||
|
||||
await supabase.from('watchlist').update({
|
||||
current_price: newPrice,
|
||||
price_status: status,
|
||||
last_checked_at: new Date().toISOString()
|
||||
}).eq('id', item.id)
|
||||
|
||||
// Update local state
|
||||
const idx = items.value.findIndex(i => i.id === item.id)
|
||||
if (idx >= 0) {
|
||||
items.value[idx] = { ...items.value[idx], current_price: newPrice, price_status: status, last_checked_at: new Date().toISOString() }
|
||||
}
|
||||
return { price: newPrice, status }
|
||||
} catch {
|
||||
await supabase.from('watchlist').update({
|
||||
price_status: 'unavailable',
|
||||
last_checked_at: new Date().toISOString()
|
||||
}).eq('id', item.id)
|
||||
|
||||
const idx = items.value.findIndex(i => i.id === item.id)
|
||||
if (idx >= 0) {
|
||||
items.value[idx] = { ...items.value[idx], price_status: 'unavailable', last_checked_at: new Date().toISOString() }
|
||||
}
|
||||
return { price: null, status: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAll() {
|
||||
for (const item of items.value) {
|
||||
await checkPrice(item)
|
||||
}
|
||||
}
|
||||
|
||||
function isWatched(bookingToken: string) {
|
||||
return items.value.some(i => i.booking_token === bookingToken)
|
||||
}
|
||||
|
||||
function getWatchedItem(bookingToken: string) {
|
||||
return items.value.find(i => i.booking_token === bookingToken)
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u) fetchAll()
|
||||
else items.value = []
|
||||
}, { immediate: true })
|
||||
|
||||
return { items, loading, fetchAll, add, remove, checkPrice, checkAll, isWatched, getWatchedItem }
|
||||
}
|
||||
Reference in New Issue
Block a user