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

View 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 }
}

View 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 }
}

View 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 }
}

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

View 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 }
}

View 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 }
}

View 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 }
}

View 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
}
}

View 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 }
}

View 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 }
}

View 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 }
}

View 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 }
}