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

16
app/pages/auth.vue Normal file
View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const user = useSupabaseUser()
// Redirect if already logged in
watch(user, (u) => {
if (u) navigateTo('/')
}, { immediate: true })
useSeoMeta({ title: 'Vuelato - Iniciar sesion' })
</script>
<template>
<UPageSection>
<AuthLoginForm />
</UPageSection>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
// OAuth callback page - Supabase handles the token exchange automatically
const user = useSupabaseUser()
watch(user, (u) => {
if (u) navigateTo('/')
}, { immediate: true })
</script>
<template>
<UPageSection>
<div class="text-center">
<UIcon name="i-lucide-loader" class="animate-spin text-2xl" />
<p class="mt-2 text-muted">Verificando...</p>
</div>
</UPageSection>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
const route = useRoute()
const { getDetail } = useFlightSearch()
const token = computed(() => route.params.token as string)
const originalPrice = computed(() => Number(route.query.price) || 0)
const passengers = computed(() => ({
adult: Number(route.query.adults) || 1,
child: Number(route.query.children) || 0,
infant: Number(route.query.infants) || 0
}))
const trip = ref<Trip | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const routeSummary = computed(() => {
if (!trip.value) return ''
const legs = trip.value.legs
const codes = legs.map(l => l.segments[0]?.departureCode).filter(Boolean)
const lastArr = legs[legs.length - 1]?.segments.at(-1)?.arrivalCode
if (lastArr) codes.push(lastArr)
return codes.join(' > ')
})
const departureCode = computed(() => trip.value?.legs[0]?.segments[0]?.departureCode ?? '')
const arrivalCode = computed(() => trip.value?.legs[0]?.segments.at(-1)?.arrivalCode ?? '')
const departureDate = computed(() => trip.value?.legs[0]?.segments[0]?.departureDate ?? '')
useSeoMeta({ title: () => `Vuelato - ${routeSummary.value || 'Detalle'}` })
onMounted(async () => {
try {
const data = await getDetail(token.value, passengers.value)
trip.value = data.trip
} catch (e: any) {
error.value = e?.data?.message || 'Error loading detail'
} finally {
loading.value = false
}
})
</script>
<template>
<UPageSection>
<div class="max-w-3xl mx-auto">
<UButton label="Volver" icon="i-lucide-arrow-left" variant="ghost" class="mb-4" @click="$router.back()" />
<div v-if="loading" class="space-y-4">
<USkeleton class="h-48 w-full" />
<USkeleton class="h-48 w-full" />
</div>
<UAlert v-else-if="error" color="error" icon="i-lucide-alert-circle" :title="error" />
<template v-else-if="trip">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">{{ routeSummary }}</h1>
<p class="text-sm text-muted">
{{ passengers.adult + passengers.child + passengers.infant }} pasajero(s)
</p>
</div>
<div class="flex items-center gap-3">
<div class="text-right">
<p class="text-3xl font-bold text-primary-600 dark:text-primary-400">
{{ originalPrice.toFixed(0) }}&euro;
</p>
</div>
<DetailWatchlistToggle
:booking-token="token"
:route-summary="routeSummary"
:departure-code="departureCode"
:arrival-code="arrivalCode"
:departure-date="departureDate"
:price="originalPrice"
:passengers="passengers"
/>
<DetailShareButton :title="routeSummary" :price="originalPrice" />
</div>
</div>
<!-- Itinerary -->
<DetailItineraryTimeline :trip="trip" />
<!-- Price verifier -->
<div class="mt-6">
<DetailPriceVerifier
:booking-token="token"
:original-price="originalPrice"
:passengers="passengers"
/>
</div>
<!-- Booking CTA -->
<div v-if="trip.deepLink" class="mt-4">
<UButton
:to="trip.deepLink"
target="_blank"
label="Reservar en aerolinea"
icon="i-lucide-external-link"
size="lg"
block
/>
</div>
<!-- Related flights -->
<div v-if="departureCode && arrivalCode" class="mt-6">
<DetailRelatedFlights :from="departureCode" :to="arrivalCode" />
</div>
</template>
</div>
</UPageSection>
</template>

141
app/pages/explore.vue Normal file
View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { InspirationItem } from '~/server/utils/flightics'
const route = useRoute()
const router = useRouter()
const { fetchInspirations } = useFlightSearch()
const { airports, loadAirports } = useLocations()
const origin = ref((route.query.dep as string) || 'MAD')
const budget = ref<number | null>(route.query.budget ? Number(route.query.budget) : null)
const directOnly = ref(false)
const inspirations = ref<InspirationItem[]>([])
const loadingInsp = ref(false)
const { prefetch, getImage } = useDestinationImages()
useSeoMeta({ title: 'Vuelato - Explorar destinos' })
// Load airports for map
onMounted(() => loadAirports())
async function loadInspirations() {
loadingInsp.value = true
try {
const data = await fetchInspirations(origin.value)
inspirations.value = data.items || []
} catch {
inspirations.value = []
} finally {
loadingInsp.value = false
}
}
watch(origin, () => loadInspirations(), { immediate: true })
const filteredInspirations = computed(() => {
let items = inspirations.value
if (directOnly.value) items = items.filter(i => i.minStops === 0)
if (budget.value) items = items.filter(i => i.minPrice <= budget.value!)
return items
})
function cityName(iata: string): string {
const a = airports.value.find(ap => ap.iata === iata)
return a?.city_name || iata
}
// Prefetch images when inspirations change
watch(filteredInspirations, (items) => {
const cities = items.slice(0, 20)
.map(i => cityName(i.to[0]))
.filter(c => c.length > 2)
if (cities.length) prefetch(cities)
})
// Map airports (only those with lat/lon)
const mapAirports = computed(() =>
airports.value
.filter(a => a.lat && a.lon)
.map(a => ({ iata: a.iata, name: a.name, lat: a.lat, lon: a.lon, city_name: a.city_name }))
)
function onSelectOrigin(iata: string) {
origin.value = iata
}
function onSelectDestination(iata: string) {
router.push(`/route/${origin.value}-${iata}`)
}
</script>
<template>
<div>
<UPageHero title="Explorar destinos" description="Descubre vuelos baratos en el mapa" />
<UPageSection>
<div class="space-y-4">
<MapControls
v-model:origin="origin"
v-model:budget="budget"
v-model:direct-only="directOnly"
:inspiration-count="filteredInspirations.length"
/>
<ClientOnly>
<MapFlightMap
:airports="mapAirports"
:origin="origin"
:inspirations="filteredInspirations"
:budget="budget"
@select-origin="onSelectOrigin"
@select-destination="onSelectDestination"
/>
<template #fallback>
<USkeleton class="h-[500px] w-full rounded-lg" />
</template>
</ClientOnly>
<!-- Destination list below map -->
<div v-if="filteredInspirations.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div
v-for="item in filteredInspirations.slice(0, 20)"
:key="item.to[0]"
class="relative overflow-hidden rounded-lg cursor-pointer group h-40"
@click="onSelectDestination(item.to[0])"
>
<img
v-if="getImage(cityName(item.to[0]))?.thumb_url"
:src="getImage(cityName(item.to[0]))!.thumb_url"
:alt="cityName(item.to[0])"
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
>
<div v-else class="absolute inset-0 bg-gradient-to-br from-primary-500 to-primary-700" />
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
<div class="absolute bottom-0 left-0 right-0 p-3 text-white">
<p class="font-bold text-sm truncate">{{ cityName(item.to[0]) }}</p>
<div class="flex items-center justify-between mt-0.5">
<p class="text-xs text-white/80">
{{ item.minStops === 0 ? 'Directo' : `${item.minStops} escala(s)` }}
</p>
<p class="text-lg font-bold">
{{ item.minPrice.toFixed(0) }}<span class="text-xs">&euro;</span>
</p>
</div>
</div>
<!-- Unsplash attribution -->
<a
v-if="getImage(cityName(item.to[0]))?.photographer"
:href="getImage(cityName(item.to[0]))!.photographer_url + '?utm_source=vuelato&utm_medium=referral'"
class="absolute top-1 right-1 text-[9px] text-white/50 hover:text-white/80 transition-colors"
target="_blank"
rel="noopener"
@click.stop
>
{{ getImage(cityName(item.to[0]))!.photographer }}
</a>
</div>
</div>
</div>
</UPageSection>
</div>
</template>

197
app/pages/index.vue Normal file
View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { InspirationItem, MultiCityInspirationItem } from '~/server/utils/flightics'
const router = useRouter()
const user = useSupabaseUser()
const { fetchInspirations } = useFlightSearch()
const { searches, saveSearch } = useRecentSearches()
const { homeAirports } = useUserPreferences()
const inspirations = ref<InspirationItem[]>([])
const inspirationFrom = ref('MAD')
const loadingInspirations = ref(false)
// Multi-city inspirations
const multiCityItems = ref<MultiCityInspirationItem[]>([])
const loadingMultiCity = ref(false)
async function loadInspirations() {
loadingInspirations.value = true
try {
const data = await fetchInspirations(inspirationFrom.value)
inspirations.value = data.items || []
} catch {
inspirations.value = []
} finally {
loadingInspirations.value = false
}
}
async function loadMultiCity() {
const codes = quickAirports.value.slice(0, 3)
if (codes.length === 0) return
loadingMultiCity.value = true
try {
const data = await $fetch<any>('/api/multi-city-inspirations', {
method: 'POST',
body: { startLocationsCodes: codes, locale: 'en', take: 8 }
})
multiCityItems.value = data.items || []
} catch {
multiCityItems.value = []
} finally {
loadingMultiCity.value = false
}
}
// Use home airports from profile, fallback to defaults
const quickAirports = computed(() => {
if (homeAirports.value.length) return homeAirports.value
return ['MAD', 'BCN', 'AGP', 'SVQ', 'VLC', 'PMI']
})
// Set initial origin from user preferences
watch(homeAirports, (airports) => {
if (airports.length) inspirationFrom.value = airports[0]
}, { immediate: true })
onMounted(() => {
loadInspirations()
loadMultiCity()
})
function onSearch(data: any) {
const dep = data.departures.join(',')
const dest = data.destination.join(',')
const summary = dest ? `${dep} > ${dest}` : `${dep} > Explorar`
if (user.value) saveSearch(data, summary, data.mode)
if (data.mode === 'explore') {
router.push({ path: '/explore', query: { dep, budget: data.budget } })
return
}
router.push({
path: '/results',
query: {
mode: data.mode,
dep,
dest,
from: data.dateFrom,
to: data.dateTo,
smin: data.stayMinDays,
smax: data.stayMaxDays,
adults: data.passengers.adult,
children: data.passengers.child,
infants: data.passengers.infant,
maxStops: data.maxStops ?? undefined,
budget: data.budget ?? undefined
}
})
}
function replaySearch(s: any) {
const p = s.search_params
router.push({
path: '/results',
query: {
mode: p.mode || s.search_mode,
dep: p.departures?.join(','),
dest: Array.isArray(p.destination) ? p.destination.join(',') : p.destination,
from: p.dateFrom,
to: p.dateTo,
smin: p.stayMinDays,
smax: p.stayMaxDays,
adults: p.passengers?.adult,
children: p.passengers?.child,
infants: p.passengers?.infant
}
})
}
function goToRoute(iata: string) {
router.push(`/route/${inspirationFrom.value}-${iata}`)
}
</script>
<template>
<div>
<UPageHero
title="Vuelato"
description="Busca vuelos baratos con fechas flexibles"
/>
<!-- Search -->
<UPageSection>
<UCard class="max-w-2xl mx-auto">
<SearchForm compact @search="onSearch" />
</UCard>
</UPageSection>
<!-- Recent searches -->
<UPageSection v-if="user && searches.length" title="Busquedas recientes">
<div class="flex gap-2 flex-wrap">
<UButton
v-for="s in searches.slice(0, 5)"
:key="s.id"
:label="s.route_summary"
icon="i-lucide-history"
color="neutral"
variant="soft"
size="sm"
@click="replaySearch(s)"
/>
</div>
</UPageSection>
<!-- Inspirations -->
<UPageSection title="Vuelos baratos" description="Los mejores precios desde tu aeropuerto">
<div class="flex gap-2 mb-4 flex-wrap">
<UButton
v-for="apt in quickAirports"
:key="apt"
:label="apt"
:color="inspirationFrom === apt ? 'primary' : 'neutral'"
:variant="inspirationFrom === apt ? 'solid' : 'outline'"
size="sm"
@click="inspirationFrom = apt; loadInspirations()"
/>
</div>
<div v-if="loadingInspirations" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<USkeleton v-for="i in 12" :key="i" class="h-16" />
</div>
<InspirationGrid v-else :items="inspirations" :from="inspirationFrom" />
</UPageSection>
<!-- Budget explorer -->
<UPageSection>
<InspirationBudgetExplorer
:items="inspirations"
:from="inspirationFrom"
:loading="loadingInspirations"
@select="goToRoute"
/>
</UPageSection>
<!-- Multi-city carousel -->
<UPageSection title="Inspiracion multi-ciudad" description="Itinerarios con varias paradas">
<div v-if="loadingMultiCity" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<USkeleton v-for="i in 4" :key="i" class="h-20" />
</div>
<div v-else-if="multiCityItems.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<InspirationMultiCityCard
v-for="(item, i) in multiCityItems"
:key="i"
:item="item"
@select="router.push('/multi-city')"
/>
</div>
<div v-else class="text-center py-6">
<UButton to="/multi-city" label="Explorar multi-ciudad" variant="outline" icon="i-lucide-route" />
</div>
</UPageSection>
</div>
</template>

211
app/pages/multi-city.vue Normal file
View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import type { MultiCityInspirationItem, MultiCityInspirationsResponse } from '~/server/utils/flightics'
const router = useRouter()
const { airports, loadAirports } = useLocations()
const { homeAirports } = useUserPreferences()
const origins = ref('')
const loading = ref(false)
const items = ref<MultiCityInspirationItem[]>([])
const currency = ref('')
const includeCountries = ref<string[]>([])
const excludeCountries = ref<string[]>([])
const excludeRest = ref(false)
const sortBy = ref<'price' | 'stops'>('price')
useSeoMeta({ title: 'Vuelato - Multi-ciudad' })
// Map IATA code -> country name
const airportCountryMap = computed(() => {
const map = new Map<string, string>()
for (const a of airports.value) {
if (a.country_name) map.set(a.iata, a.country_name)
}
return map
})
// All unique countries present in current results (stops only, not origin)
const availableCountries = computed(() => {
const names = new Set<string>()
for (const item of items.value) {
for (const stop of item.stops) {
const name = airportCountryMap.value.get(stop)
if (name) names.add(name)
}
}
return Array.from(names).sort((a, b) => a.localeCompare(b))
})
// Filtered and sorted items
const filteredItems = computed(() => {
const filtered = items.value.filter((item) => {
const stopCountries = item.stops
.map(s => airportCountryMap.value.get(s))
.filter(Boolean) as string[]
if (includeCountries.value.length > 0) {
if (!includeCountries.value.some(c => stopCountries.includes(c))) return false
if (excludeRest.value) {
if (stopCountries.some(c => !includeCountries.value.includes(c))) return false
}
}
if (excludeCountries.value.length > 0) {
if (excludeCountries.value.some(c => stopCountries.includes(c))) return false
}
return true
})
return filtered.sort((a, b) => {
if (sortBy.value === 'price') return a.minPrice - b.minPrice
return a.stops.length - b.stops.length
})
})
function resolveCountryName(iata: string): string {
return airportCountryMap.value.get(iata) || ''
}
async function search() {
const codes = origins.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
if (codes.length === 0) return
loading.value = true
try {
const data = await $fetch<MultiCityInspirationsResponse>('/api/multi-city-inspirations', {
method: 'POST',
body: { startLocationsCodes: codes, locale: 'en', take: 30 }
})
items.value = data.items || []
currency.value = data.currency?.symbol || '€'
// Reset filters on new search
includeCountries.value = []
excludeCountries.value = []
excludeRest.value = false
} catch {
items.value = []
} finally {
loading.value = false
}
}
// Initialize origins from user's home airports and trigger search
watch(homeAirports, (airports) => {
if (airports.length && !origins.value) {
origins.value = airports.join(',')
}
}, { immediate: true })
onMounted(async () => {
await loadAirports()
if (!origins.value) {
origins.value = 'MAD,BCN'
}
search()
})
function onSelect(item: MultiCityInspirationItem) {
router.push({
path: '/results',
query: {
mode: 'multicity',
dep: item.from,
stops: item.stops.join(','),
from: '',
to: '',
adults: '1'
}
})
}
const hasFilters = computed(() => includeCountries.value.length > 0 || excludeCountries.value.length > 0 || excludeRest.value)
</script>
<template>
<div>
<UPageHero title="Multi-ciudad" description="Inspiracion para itinerarios con varias paradas" />
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<UCard>
<form class="flex items-end gap-3" @submit.prevent="search">
<UFormField label="Aeropuertos origen" class="flex-1">
<SearchAirportInput v-model="origins" placeholder="MAD, BCN..." icon="i-lucide-plane-takeoff" multiple />
<template #hint>
<span class="text-xs text-muted">Codigos IATA separados por coma</span>
</template>
</UFormField>
<UButton type="submit" label="Buscar" icon="i-lucide-search" :loading="loading" />
</form>
</UCard>
<!-- Filters -->
<div v-if="!loading && items.length > 0 && availableCountries.length > 0" class="flex flex-wrap items-end gap-3">
<UFormField label="Incluir paises" class="flex-1 min-w-40">
<USelectMenu
v-model="includeCountries"
:items="availableCountries"
multiple
placeholder="Todos"
class="w-full"
/>
</UFormField>
<UFormField label="Excluir paises" class="flex-1 min-w-40">
<USelectMenu
v-model="excludeCountries"
:items="availableCountries"
multiple
placeholder="Ninguno"
class="w-full"
/>
</UFormField>
<UFormField label="Ordenar por" class="w-36">
<USelectMenu
v-model="sortBy"
:items="[
{ label: 'Precio', value: 'price' },
{ label: 'Paradas', value: 'stops' }
]"
value-key="value"
class="w-full"
/>
</UFormField>
<div v-if="includeCountries.length > 0" class="flex items-center gap-2 pb-1">
<UCheckbox v-model="excludeRest" />
<span class="text-xs text-muted whitespace-nowrap">Excluir el resto</span>
</div>
<UButton
v-if="hasFilters"
icon="i-lucide-x"
variant="ghost"
size="sm"
@click="includeCountries = []; excludeCountries = []; excludeRest = false"
/>
</div>
<p v-if="hasFilters && !loading" class="text-xs text-muted">
{{ filteredItems.length }} de {{ items.length }} itinerarios
</p>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<USkeleton v-for="i in 6" :key="i" class="h-20" />
</div>
<div v-else-if="filteredItems.length === 0 && !loading" class="text-center py-12">
<UIcon name="i-lucide-route" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">{{ hasFilters ? 'Ningun itinerario coincide con los filtros' : 'No se encontraron itinerarios' }}</p>
<UButton v-if="hasFilters" label="Limpiar filtros" class="mt-3" variant="outline" @click="includeCountries = []; excludeCountries = []; excludeRest = false" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
<InspirationMultiCityCard
v-for="(item, i) in filteredItems"
:key="i"
:item="item"
:resolve-country="resolveCountryName"
@select="onSelect"
/>
</div>
</div>
</UPageSection>
</div>
</template>

202
app/pages/results.vue Normal file
View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const { trips, loading, polling, error, searchMeta, search, stopPolling } = useFlightSearch()
const { sortBy, filters, viewMode, filtered, availableAirlines, priceRange, hasActiveFilters, resetFilters } = useResultFilters(trips)
const { create: createTracking } = useTrackedSearches()
const user = useSupabaseUser()
const trackingName = ref('')
const showTrackingForm = ref(false)
const creatingTracking = ref(false)
const searchMode = computed(() => (route.query.mode as string) || 'roundtrip')
const showFilters = ref(false)
const passengers = computed(() => ({
adult: Number(route.query.adults) || 1,
child: Number(route.query.children) || 0,
infant: Number(route.query.infants) || 0
}))
const multiCityStops = computed(() =>
(route.query.stops as string)?.split(',').filter(Boolean) || []
)
const searchParams = computed(() => ({
departures: (route.query.dep as string)?.split(',') || [],
destination: (route.query.dest as string)?.split(',').filter(Boolean) || [],
dateFrom: (route.query.from as string) || '',
dateTo: (route.query.to as string) || '',
stayMinDays: Number(route.query.smin) || 2,
stayMaxDays: Number(route.query.smax) || 6,
passengers: passengers.value,
maxStops: route.query.maxStops != null ? Number(route.query.maxStops) : null,
multiCityStops: multiCityStops.value.length > 0 ? multiCityStops.value : undefined
}))
// Apply budget from query as initial filter
onMounted(() => {
if (route.query.budget) {
filters.maxPrice = Number(route.query.budget)
}
const hasDestination = searchParams.value.destination.length > 0 || (searchParams.value.multiCityStops && searchParams.value.multiCityStops.length > 0)
if (searchParams.value.departures.length && hasDestination) {
search(searchParams.value)
}
})
function selectTrip(trip: any) {
router.push({
path: `/detail/${encodeURIComponent(trip.bookingToken)}`,
query: {
adults: String(passengers.value.adult),
children: String(passengers.value.child),
infants: String(passengers.value.infant),
price: String(trip.totalCost)
}
})
}
const modeLabels: Record<string, string> = {
roundtrip: 'Ida y vuelta',
oneway: 'Solo ida',
multicity: 'Multi-ciudad',
weekend: 'Finde'
}
const routeSummary = computed(() => {
if (multiCityStops.value.length > 0) {
return `${searchParams.value.departures.join(',')} > ${multiCityStops.value.join(' > ')}`
}
return `${searchParams.value.departures.join(',')} > ${searchParams.value.destination.join(',')}`
})
async function onCreateTracking() {
if (!trackingName.value) return
creatingTracking.value = true
try {
const { buildPayload } = useFlightSearch()
await createTracking({
name: trackingName.value,
searchParams: buildPayload(searchParams.value),
routeSummary: routeSummary.value,
intervalHours: 24
})
showTrackingForm.value = false
trackingName.value = ''
} finally {
creatingTracking.value = false
}
}
</script>
<template>
<div>
<UPageHero
:title="multiCityStops.length > 0
? `${searchParams.departures.join(', ')} → ${multiCityStops.join(' → ')}`
: `${searchParams.departures.join(', ')} → ${searchParams.destination.join(', ')}`"
:description="`${modeLabels[searchMode] || searchMode} · ${searchParams.dateFrom || 'Flexible'} al ${searchParams.dateTo || 'Flexible'} · ${passengers.adult + passengers.child + passengers.infant} pasajero(s)`"
/>
<UPageSection>
<div class="max-w-4xl mx-auto space-y-4">
<ResultsToolbar
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
:count="filtered.length"
:has-active-filters="hasActiveFilters"
@toggle-filters="showFilters = !showFilters"
@reset-filters="resetFilters"
/>
<!-- Tracking button -->
<div v-if="user && !loading && filtered.length > 0 && !showTrackingForm" class="flex justify-end">
<UButton
label="Hacer seguimiento"
icon="i-lucide-bell-plus"
variant="outline"
size="sm"
@click="showTrackingForm = true"
/>
</div>
<UCard v-if="showTrackingForm" class="border-primary-200">
<div class="flex items-end gap-3">
<UFormField label="Nombre del seguimiento" class="flex-1">
<UInput v-model="trackingName" :placeholder="`Ej: ${routeSummary}`" class="w-full" />
</UFormField>
<UButton
label="Crear"
icon="i-lucide-plus"
size="sm"
:loading="creatingTracking"
:disabled="!trackingName"
@click="onCreateTracking"
/>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
@click="showTrackingForm = false"
/>
</div>
</UCard>
<!-- Filters panel -->
<ResultsFilters
v-if="showFilters"
v-model:max-price="filters.maxPrice"
v-model:max-stops="filters.maxStops"
v-model:airlines="filters.airlines"
v-model:departure-time-range="filters.departureTimeRange"
:available-airlines="availableAirlines"
:price-range="priceRange"
/>
<!-- Loading -->
<div v-if="loading" class="space-y-4">
<USkeleton v-for="i in 5" :key="i" class="h-32 w-full" />
</div>
<UAlert v-else-if="error" color="error" icon="i-lucide-alert-circle" :title="error" />
<template v-else>
<div v-if="filtered.length === 0" class="text-center py-12">
<UIcon name="i-lucide-plane" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No se encontraron vuelos</p>
<UButton v-if="hasActiveFilters" label="Limpiar filtros" class="mt-3" variant="outline" @click="resetFilters" />
</div>
<!-- Full view -->
<template v-if="viewMode === 'full'">
<TripCard
v-for="(trip, i) in filtered"
:key="trip.bookingToken || i"
:trip="trip"
@select="selectTrip"
/>
</template>
<!-- Compact view -->
<template v-else>
<ResultsTripCardCompact
v-for="(trip, i) in filtered"
:key="trip.bookingToken || i"
:trip="trip"
@select="selectTrip"
/>
</template>
</template>
<!-- Polling indicator -->
<ResultsLoadingMore
:polling="polling"
:poll-count="searchMeta.pollCount"
@stop="stopPolling"
/>
</div>
</UPageSection>
</div>
</template>

149
app/pages/route/[slug].vue Normal file
View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
const route = useRoute()
const router = useRouter()
const { trips, loading, error, fetchRouteFlights } = useRouteFlights()
const slug = computed(() => route.params.slug as string)
const from = computed(() => slug.value.split('-')[0]?.toUpperCase() || '')
const to = computed(() => slug.value.split('-')[1]?.toUpperCase() || '')
useSeoMeta({ title: () => `Vuelato - ${from.value}${to.value}` })
onMounted(() => {
if (from.value && to.value) {
fetchRouteFlights(from.value, to.value)
}
})
function selectTrip(trip: Trip) {
router.push({
path: `/detail/${encodeURIComponent(trip.bookingToken)}`,
query: { price: String(trip.totalCost), adults: '1', children: '0', infants: '0' }
})
}
function formatTime(d: string) {
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
}
function formatDate(d: string) {
return new Date(d).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'short' })
}
function legLabel(index: number, total: number) {
if (total === 1) return 'Solo ida'
return index === 0 ? 'Ida' : 'Vuelta'
}
function legStopsText(leg: Trip['legs'][0]) {
const n = leg.segments.length - 1
if (n === 0) return 'Directo'
return `${n} escala${n > 1 ? 's' : ''}`
}
function legRoute(leg: Trip['legs'][0]) {
const codes = leg.segments.map(s => s.departureCode)
codes.push(leg.segments.at(-1)!.arrivalCode)
return codes.join(' → ')
}
// Group by airline
const airlineGroups = computed(() => {
const groups = new Map<string, Trip[]>()
for (const trip of trips.value) {
const code = trip.legs[0]?.segments[0]?.company?.code || 'Otros'
const name = trip.legs[0]?.segments[0]?.company?.name || code
const key = `${code} - ${name}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(trip)
}
return [...groups.entries()].sort((a, b) => {
const minA = Math.min(...a[1].map(t => t.totalCost))
const minB = Math.min(...b[1].map(t => t.totalCost))
return minA - minB
})
})
</script>
<template>
<div>
<UPageHero
:title="`${from} → ${to}`"
description="Vuelos disponibles en esta ruta (ida y vuelta)"
/>
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<UButton label="Volver" icon="i-lucide-arrow-left" variant="ghost" @click="$router.back()" />
<div v-if="loading" class="space-y-3">
<USkeleton v-for="i in 5" :key="i" class="h-24 w-full" />
</div>
<UAlert v-else-if="error" color="error" icon="i-lucide-alert-circle" :title="error" />
<div v-else-if="trips.length === 0" class="text-center py-12">
<UIcon name="i-lucide-plane" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No se encontraron vuelos para esta ruta</p>
</div>
<template v-else>
<p class="text-sm text-muted">{{ trips.length }} opcion{{ trips.length !== 1 ? 'es' : '' }} encontrada{{ trips.length !== 1 ? 's' : '' }}</p>
<div v-for="[airline, airlineTrips] in airlineGroups" :key="airline" class="space-y-2">
<h3 class="font-semibold text-sm flex items-center gap-2">
<UIcon name="i-lucide-plane" class="text-muted" />
{{ airline }}
<UBadge :label="`desde ${Math.min(...airlineTrips.map(t => t.totalCost)).toFixed(0)}€`" color="primary" size="xs" />
</h3>
<UCard
v-for="(trip, i) in airlineTrips.slice(0, 10)"
:key="i"
class="hover:ring-primary-400 cursor-pointer transition-all"
@click="selectTrip(trip)"
>
<div class="flex items-center justify-between gap-4">
<div class="flex-1 space-y-2">
<div v-for="(leg, li) in trip.legs" :key="li" class="flex items-center gap-3 text-sm">
<UBadge
:label="legLabel(li, trip.legs.length)"
:color="li === 0 ? 'primary' : 'info'"
variant="subtle"
size="xs"
/>
<span class="font-medium">
{{ formatTime(leg.segments[0]?.departureDate) }}
{{ formatTime(leg.segments.at(-1)?.arrivalDate || '') }}
</span>
<span class="text-muted">
{{ formatDate(leg.segments[0]?.departureDate) }}
</span>
<span class="text-xs text-muted">
{{ legStopsText(leg) }}
</span>
<span v-if="leg.segments.length > 1" class="text-xs text-muted hidden sm:inline">
{{ legRoute(leg) }}
</span>
</div>
</div>
<div class="shrink-0 text-right">
<p class="text-xl font-bold text-primary-600 dark:text-primary-400">
{{ trip.totalCost.toFixed(0) }}<span class="text-sm">&euro;</span>
</p>
<p class="text-xs text-muted">
{{ trip.legs.length > 1 ? 'ida+vuelta' : 'solo ida' }}
</p>
</div>
</div>
</UCard>
</div>
</template>
</div>
</UPageSection>
</div>
</template>

59
app/pages/search.vue Normal file
View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
const router = useRouter()
const user = useSupabaseUser()
const { saveSearch } = useRecentSearches()
useSeoMeta({ title: 'Vuelato - Buscar vuelos' })
function onSearch(data: any) {
// Build route summary
const dep = data.departures.join(',')
const dest = data.destination.join(',')
const summary = dest
? `${dep} > ${dest}`
: `${dep} > Explorar`
// Save to recent searches if logged in
if (user.value) {
saveSearch(data, summary, data.mode)
}
if (data.mode === 'explore') {
router.push({
path: '/explore',
query: { dep, budget: data.budget }
})
return
}
router.push({
path: '/results',
query: {
mode: data.mode,
dep,
dest,
from: data.dateFrom,
to: data.dateTo,
smin: data.stayMinDays,
smax: data.stayMaxDays,
adults: data.passengers.adult,
children: data.passengers.child,
infants: data.passengers.infant,
maxStops: data.maxStops ?? undefined,
budget: data.budget ?? undefined
}
})
}
</script>
<template>
<div>
<UPageHero title="Buscar vuelos" description="5 modos de busqueda para encontrar el vuelo perfecto" />
<UPageSection>
<UCard class="max-w-2xl mx-auto">
<SearchForm @search="onSearch" />
</UCard>
</UPageSection>
</div>
</template>

132
app/pages/settings.vue Normal file
View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
const user = useSupabaseUser()
const { profile, loading, updateProfile, fetchProfile } = useUserPreferences()
const { showOriginTime } = useOriginTime()
useSeoMeta({ title: 'Vuelato - Preferencias' })
watch(user, (u) => {
if (!u) navigateTo('/auth')
}, { immediate: true })
const homeAirports = ref('')
const defaultAdults = ref(1)
const defaultChildren = ref(0)
const defaultInfants = ref(0)
const saving = ref(false)
const saved = ref(false)
watch(profile, (p) => {
if (!p) return
homeAirports.value = p.home_airports?.join(',') || ''
defaultAdults.value = p.default_adults ?? 1
defaultChildren.value = p.default_children ?? 0
defaultInfants.value = p.default_infants ?? 0
}, { immediate: true })
async function save() {
saving.value = true
saved.value = false
await updateProfile({
home_airports: homeAirports.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
default_adults: defaultAdults.value,
default_children: defaultChildren.value,
default_infants: defaultInfants.value,
})
saving.value = false
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
</script>
<template>
<div v-if="user">
<UPageHero title="Preferencias" description="Configura tu experiencia en Vuelato" />
<UPageSection>
<div class="max-w-2xl mx-auto space-y-6">
<!-- Loading -->
<div v-if="loading" class="space-y-4">
<USkeleton class="h-12 w-full" />
<USkeleton class="h-12 w-full" />
<USkeleton class="h-12 w-full" />
</div>
<template v-else>
<!-- Busquedas -->
<UCard>
<template #header>
<h3 class="font-semibold">Busquedas</h3>
</template>
<div class="space-y-4">
<UFormField label="Aeropuertos de origen habituales">
<SearchAirportInput v-model="homeAirports" placeholder="MAD, BCN..." icon="i-lucide-plane-takeoff" multiple />
<template #hint>
<span class="text-xs text-muted">Se usaran como origen por defecto en las busquedas</span>
</template>
</UFormField>
<USeparator />
<p class="text-sm font-medium">Pasajeros por defecto</p>
<div class="grid grid-cols-3 gap-3">
<UFormField label="Adultos">
<UInput v-model.number="defaultAdults" type="number" :min="1" :max="9" class="w-full" />
</UFormField>
<UFormField label="Menores">
<UInput v-model.number="defaultChildren" type="number" :min="0" :max="9" class="w-full" />
</UFormField>
<UFormField label="Bebes">
<UInput v-model.number="defaultInfants" type="number" :min="0" :max="9" class="w-full" />
</UFormField>
</div>
</div>
<template #footer>
<div class="flex items-center gap-2">
<UButton label="Guardar" icon="i-lucide-save" size="sm" :loading="saving" @click="save" />
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<span v-if="saved" class="text-sm text-green-600">Guardado</span>
</Transition>
</div>
</template>
</UCard>
<!-- Visualizacion -->
<UCard>
<template #header>
<h3 class="font-semibold">Visualizacion</h3>
</template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Mostrar hora en origen</p>
<p class="text-xs text-muted">Muestra entre parentesis la hora equivalente en tu aeropuerto de salida</p>
</div>
<USwitch v-model="showOriginTime" />
</div>
</div>
</UCard>
<!-- Cuenta -->
<UCard>
<template #header>
<h3 class="font-semibold">Cuenta</h3>
</template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Email</p>
<p class="text-sm text-muted">{{ user.email }}</p>
</div>
</div>
</div>
</UCard>
</template>
</div>
</UPageSection>
</div>
</template>

159
app/pages/tracking/[id].vue Normal file
View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
const user = useSupabaseUser()
const route = useRoute()
const router = useRouter()
const { trackedSearches, getHistory, getRuns, remove, fetchAll, update } = useTrackedSearches()
const id = route.params.id as string
useSeoMeta({ title: 'Vuelato - Detalle seguimiento' })
watch(user, (u) => {
if (!u) navigateTo('/auth')
}, { immediate: true })
const search = computed(() => trackedSearches.value.find(s => s.id === id))
const snapshots = ref<PriceSnapshot[]>([])
const runs = ref<SearchRun[]>([])
const loadingHistory = ref(true)
const loadError = ref(false)
const days = ref(30)
async function loadData() {
loadingHistory.value = true
loadError.value = false
try {
const [h, r] = await Promise.all([
getHistory(id, days.value),
getRuns(id, 20)
])
snapshots.value = h
runs.value = r
} catch {
loadError.value = true
} finally {
loadingHistory.value = false
}
}
watch(days, () => loadData())
onMounted(async () => {
await fetchAll()
loadData()
})
// Stats computadas
const stats = computed(() => {
if (snapshots.value.length === 0) return null
const prices = snapshots.value.map(s => s.cheapest_price)
const min = Math.min(...prices)
const max = Math.max(...prices)
const avg = Math.round(prices.reduce((s, p) => s + p, 0) / prices.length)
const current = prices[prices.length - 1] ?? 0
const previous = prices.length > 1 ? (prices[prices.length - 2] ?? current) : current
const trend = current - previous
return { current, min, max, avg, trend }
})
async function onRemove() {
await remove(id)
router.push('/tracking')
}
async function onConfigUpdated() {
await fetchAll()
loadData()
}
</script>
<template>
<div v-if="user">
<!-- Loading si aun no cargo la search -->
<div v-if="!search" class="max-w-3xl mx-auto py-12">
<USkeleton class="h-8 w-64 mb-4" />
<USkeleton class="h-72 w-full" />
</div>
<template v-else>
<UPageHero :title="search.name" :description="search.route_summary">
<template #actions>
<div class="flex gap-2">
<UButton label="Volver" icon="i-lucide-arrow-left" variant="outline" color="neutral" to="/tracking" />
<UButton label="Eliminar" icon="i-lucide-trash-2" variant="outline" color="error" @click="onRemove" />
</div>
</template>
</UPageHero>
<UPageSection>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Stats -->
<div v-if="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<UCard class="text-center">
<p class="text-xs text-muted mb-1">Precio actual</p>
<p class="text-xl font-bold">{{ stats.current?.toFixed(0) }}&euro;</p>
<p v-if="stats.trend !== 0" class="text-xs" :class="stats.trend < 0 ? 'text-green-600' : 'text-red-500'">
<UIcon :name="stats.trend < 0 ? 'i-lucide-trending-down' : 'i-lucide-trending-up'" class="text-xs" />
{{ stats.trend > 0 ? '+' : '' }}{{ stats.trend.toFixed(0) }}&euro;
</p>
</UCard>
<UCard class="text-center">
<p class="text-xs text-muted mb-1">Minimo historico</p>
<p class="text-xl font-bold text-green-600">{{ stats.min?.toFixed(0) }}&euro;</p>
</UCard>
<UCard class="text-center">
<p class="text-xs text-muted mb-1">Maximo historico</p>
<p class="text-xl font-bold text-red-500">{{ stats.max?.toFixed(0) }}&euro;</p>
</UCard>
<UCard class="text-center">
<p class="text-xs text-muted mb-1">Precio medio</p>
<p class="text-xl font-bold">{{ stats.avg?.toFixed(0) }}&euro;</p>
</UCard>
</div>
<!-- Chart -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">Evolucion de precios</h3>
<div class="flex gap-1">
<UButton
v-for="d in [7, 14, 30, 60]"
:key="d"
:label="`${d}d`"
size="xs"
:variant="days === d ? 'solid' : 'ghost'"
:color="days === d ? 'primary' : 'neutral'"
@click="days = d"
/>
</div>
</div>
</template>
<div v-if="loadingHistory" class="flex justify-center py-12">
<USkeleton class="h-64 w-full" />
</div>
<div v-else-if="loadError" class="text-center py-8">
<p class="text-sm text-red-500">Error al cargar datos</p>
<UButton label="Reintentar" variant="outline" size="sm" class="mt-2" @click="loadData" />
</div>
<ClientOnly v-else>
<TrackingPriceChart :snapshots="snapshots" />
</ClientOnly>
</UCard>
<!-- Config + Runs side by side on desktop -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="lg:col-span-1">
<TrackingConfig :search="search" @updated="onConfigUpdated" />
</div>
<div class="lg:col-span-2">
<TrackingRunHistory :runs="runs" />
</div>
</div>
</div>
</UPageSection>
</template>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
const user = useSupabaseUser()
const { trackedSearches, loading, update, remove, fetchAll } = useTrackedSearches()
const router = useRouter()
const showCreateForm = ref(false)
useSeoMeta({ title: 'Vuelato - Seguimiento de precios' })
watch(user, (u) => {
if (!u) navigateTo('/auth')
}, { immediate: true })
async function onToggle(id: string, active: boolean) {
await update(id, { is_active: active })
}
async function onRemove(id: string) {
await remove(id)
}
function onDetail(id: string) {
router.push(`/tracking/${id}`)
}
function onCreated() {
showCreateForm.value = false
fetchAll()
}
const activeCount = computed(() => trackedSearches.value.filter(s => s.is_active).length)
</script>
<template>
<div v-if="user">
<UPageHero title="Seguimiento de precios" description="Busquedas automaticas que monitorizan fluctuaciones de precio" />
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<!-- Actions -->
<div class="flex items-center justify-between">
<p class="text-sm text-muted">
{{ trackedSearches.length }} seguimiento{{ trackedSearches.length !== 1 ? 's' : '' }}
<span v-if="activeCount > 0"> ({{ activeCount }} activo{{ activeCount !== 1 ? 's' : '' }})</span>
</p>
<UButton
v-if="!showCreateForm"
label="Nuevo seguimiento"
icon="i-lucide-plus"
size="sm"
@click="showCreateForm = true"
/>
</div>
<!-- Create form -->
<TrackingCreateTrackingForm
v-if="showCreateForm"
@created="onCreated"
@cancel="showCreateForm = false"
/>
<!-- Loading -->
<div v-if="loading" class="space-y-3">
<USkeleton v-for="i in 3" :key="i" class="h-28 w-full" />
</div>
<!-- Empty -->
<div v-else-if="trackedSearches.length === 0 && !showCreateForm" class="text-center py-12">
<UIcon name="i-lucide-bell" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No tienes busquedas en seguimiento</p>
<p class="text-sm text-neutral-400 mt-1">Crea una para monitorizar precios automaticamente</p>
<UButton label="Crear seguimiento" icon="i-lucide-plus" class="mt-3" @click="showCreateForm = true" />
</div>
<!-- Search cards -->
<TrackingTrackedSearchCard
v-for="search in trackedSearches"
:key="search.id"
:search="search"
@toggle="onToggle"
@remove="onRemove"
@detail="onDetail"
/>
</div>
</UPageSection>
</div>
</template>

201
app/pages/watchlist.vue Normal file
View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
const user = useSupabaseUser()
const { items, loading, checkPrice, checkAll, remove } = useWatchlist()
const { searches } = useRecentSearches()
const { create: createTracking } = useTrackedSearches()
const { buildPayload } = useFlightSearch()
const router = useRouter()
const checkingAll = ref(false)
const checkingId = ref<string | null>(null)
useSeoMeta({ title: 'Vuelato - Watchlist' })
// Redirect to auth if not logged in
watch(user, (u) => {
if (!u) navigateTo('/auth')
}, { immediate: true })
async function onCheckPrice(item: any) {
checkingId.value = item.id
await checkPrice(item)
checkingId.value = null
}
async function onCheckAll() {
checkingAll.value = true
await checkAll()
checkingAll.value = false
}
function statusColor(status: string) {
switch (status) {
case 'price_down': return 'success'
case 'price_up': return 'warning'
case 'unavailable': return 'error'
case 'available': return 'info'
default: return 'neutral'
}
}
function statusLabel(status: string) {
switch (status) {
case 'price_down': return 'Bajo'
case 'price_up': return 'Subio'
case 'unavailable': return 'No disponible'
case 'available': return 'Disponible'
default: return 'Guardado'
}
}
function replaySearch(s: any) {
const p = s.search_params
router.push({
path: '/results',
query: {
mode: p.mode || s.search_mode,
dep: p.departures?.join(','),
dest: Array.isArray(p.destination) ? p.destination.join(',') : p.destination,
from: p.dateFrom,
to: p.dateTo,
smin: p.stayMinDays,
smax: p.stayMaxDays,
adults: p.passengers?.adult,
children: p.passengers?.child,
infants: p.passengers?.infant
}
})
}
async function trackSearch(s: any) {
const p = s.search_params
await createTracking({
name: s.route_summary || 'Busqueda sin nombre',
searchParams: buildPayload({
departures: p.departures || [],
destination: Array.isArray(p.destination) ? p.destination : (p.destination || '').split(',').filter(Boolean),
dateFrom: p.dateFrom || '',
dateTo: p.dateTo || '',
stayMinDays: p.stayMinDays || 2,
stayMaxDays: p.stayMaxDays || 6,
passengers: p.passengers || { adult: 1, child: 0, infant: 0 }
}),
routeSummary: s.route_summary || 'Sin ruta',
intervalHours: 24
})
navigateTo('/tracking')
}
</script>
<template>
<div v-if="user">
<UPageHero title="Watchlist" description="Vuelos guardados y seguimiento de precios" />
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<!-- Actions -->
<div class="flex items-center justify-between">
<p class="text-sm text-muted">{{ items.length }} vuelo{{ items.length !== 1 ? 's' : '' }} guardado{{ items.length !== 1 ? 's' : '' }}</p>
<UButton
v-if="items.length > 0"
label="Verificar todos"
icon="i-lucide-refresh-cw"
:loading="checkingAll"
size="sm"
variant="outline"
@click="onCheckAll"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="space-y-3">
<USkeleton v-for="i in 3" :key="i" class="h-24 w-full" />
</div>
<!-- Empty -->
<div v-else-if="items.length === 0" class="text-center py-12">
<UIcon name="i-lucide-heart" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No tienes vuelos guardados</p>
<UButton to="/search" label="Buscar vuelos" class="mt-3" />
</div>
<!-- Items -->
<UCard v-for="item in items" :key="item.id">
<div class="flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<p class="font-semibold">{{ item.route_summary }}</p>
<UBadge :label="statusLabel(item.price_status)" :color="statusColor(item.price_status)" size="xs" />
</div>
<div class="flex items-center gap-3 text-sm text-muted">
<span v-if="item.departure_date">
{{ new Date(item.departure_date).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' }) }}
</span>
<span>Original: {{ item.original_price.toFixed(0) }}&euro;</span>
<span v-if="item.current_price != null && item.current_price !== item.original_price"
:class="item.current_price < item.original_price ? 'text-green-600' : 'text-red-500'"
>
Actual: {{ item.current_price.toFixed(0) }}&euro;
</span>
<span v-if="item.last_checked_at" class="text-xs">
Verificado {{ new Date(item.last_checked_at).toLocaleDateString('es-ES') }}
</span>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<UButton
icon="i-lucide-refresh-cw"
color="neutral"
variant="ghost"
size="xs"
:loading="checkingId === item.id"
@click="onCheckPrice(item)"
/>
<UButton
:to="`/detail/${encodeURIComponent(item.booking_token)}?price=${item.original_price}&adults=${item.passengers_adult}&children=${item.passengers_child}&infants=${item.passengers_infant}`"
icon="i-lucide-external-link"
color="neutral"
variant="ghost"
size="xs"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
@click="remove(item.id)"
/>
</div>
</div>
</UCard>
</div>
</UPageSection>
<!-- Recent searches -->
<UPageSection v-if="searches.length > 0" title="Busquedas recientes">
<div class="max-w-3xl mx-auto">
<div class="flex gap-2 flex-wrap">
<div v-for="s in searches.slice(0, 8)" :key="s.id" class="flex items-center gap-0.5">
<UButton
:label="s.route_summary"
icon="i-lucide-history"
color="neutral"
variant="soft"
size="sm"
@click="replaySearch(s)"
/>
<UButton
icon="i-lucide-bell-plus"
color="neutral"
variant="ghost"
size="xs"
title="Hacer seguimiento"
@click="trackSearch(s)"
/>
</div>
</div>
</div>
</UPageSection>
</div>
</template>