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

8
app/app.config.ts Normal file
View File

@@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'blue',
neutral: 'slate'
}
}
})

112
app/app.vue Normal file
View File

@@ -0,0 +1,112 @@
<script setup>
useHead({
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [
{ rel: 'icon', href: '/favicon.ico' }
],
htmlAttrs: {
lang: 'es'
}
})
useSeoMeta({
title: 'Vuelato - Busca vuelos baratos',
description: 'Buscador de vuelos con fechas flexibles'
})
const mobileMenu = ref(false)
const navLinks = [
{ to: '/search', label: 'Buscar', icon: 'i-lucide-search' },
{ to: '/explore', label: 'Explorar', icon: 'i-lucide-compass' },
{ to: '/multi-city', label: 'Multi-city', icon: 'i-lucide-route' },
{ to: '/tracking', label: 'Seguimiento', icon: 'i-lucide-bell' },
{ to: '/watchlist', label: 'Watchlist', icon: 'i-lucide-heart' }
]
</script>
<template>
<UApp>
<UHeader>
<template #left>
<NuxtLink to="/" class="font-bold text-lg">
Vuelato
</NuxtLink>
<nav class="hidden md:flex items-center gap-1 ml-4">
<UButton
v-for="link in navLinks"
:key="link.to"
:to="link.to"
:label="link.label"
variant="ghost"
color="neutral"
size="sm"
/>
</nav>
</template>
<template #right>
<AuthUserMenu />
<UColorModeButton />
<!-- Mobile menu button -->
<UButton
class="md:hidden"
:icon="mobileMenu ? 'i-lucide-x' : 'i-lucide-menu'"
color="neutral"
variant="ghost"
@click="mobileMenu = !mobileMenu"
/>
</template>
</UHeader>
<!-- Mobile nav -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="mobileMenu" class="md:hidden border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-950 px-4 py-3">
<nav class="flex flex-col gap-1">
<UButton
v-for="link in navLinks"
:key="link.to"
:to="link.to"
:label="link.label"
:icon="link.icon"
variant="ghost"
color="neutral"
block
class="justify-start"
@click="mobileMenu = false"
/>
</nav>
</div>
</Transition>
<UMain>
<NuxtPage />
</UMain>
<USeparator />
<UFooter>
<template #left>
<p class="text-sm text-muted">
Vuelato &copy; {{ new Date().getFullYear() }}
</p>
</template>
<template #right>
<div class="flex gap-3 text-sm text-muted">
<NuxtLink to="/search">Buscar</NuxtLink>
<NuxtLink to="/explore">Explorar</NuxtLink>
<NuxtLink to="/multi-city">Multi-city</NuxtLink>
</div>
</template>
</UFooter>
</UApp>
</template>

18
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,18 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme static {
--font-sans: 'Public Sans', sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
}

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import type { Leg } from '~/server/utils/flightics'
const props = defineProps<{
leg: Leg
index: number
originTzOffset?: number
showOriginTime?: boolean
}>()
const { resolve } = useAirlineNames()
function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
}
function formatDate(dateStr: string) {
return new Date(dateStr).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
}
function segDuration(dep: number, arr: number) {
const mins = Math.round((arr - dep) / 60)
const h = Math.floor(mins / 60)
const m = mins % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
// Convert a UTC timestamp to a time string in the origin airport's timezone
function toOriginTime(utcTimestamp: number): string {
const localSeconds = utcTimestamp + (props.originTzOffset ?? 0)
const d = new Date(localSeconds * 1000)
return d.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })
}
// Check if a segment's local timezone differs from the origin
function isDifferentTz(localTimestamp: number, utcTimestamp: number): boolean {
return (localTimestamp - utcTimestamp) !== (props.originTzOffset ?? 0)
}
</script>
<template>
<div class="flex items-center gap-4 py-2">
<UBadge :label="index === 0 ? 'Ida' : 'Vuelta'" :color="index === 0 ? 'primary' : 'info'" variant="subtle" size="sm" />
<div v-for="(seg, i) in leg.segments" :key="i" class="flex items-center gap-3 flex-1">
<div class="text-center">
<p class="text-lg font-semibold">{{ formatTime(seg.departureDate) }}</p>
<p v-if="showOriginTime && isDifferentTz(seg.departureTimestamp, seg.departureUtcTimestamp)" class="text-[10px] text-primary-500">({{ toOriginTime(seg.departureUtcTimestamp) }})</p>
<p class="text-xs text-neutral-500">{{ seg.departureCode }}</p>
<p class="text-xs text-neutral-400">{{ formatDate(seg.departureDate) }}</p>
</div>
<div class="flex-1 flex flex-col items-center">
<p class="text-xs text-neutral-500">
<span v-if="resolve(seg.company.code, seg.company.name)" class="font-medium">
{{ resolve(seg.company.code, seg.company.name) }} ·
</span>
<span>{{ seg.company.code }} {{ seg.number }}</span>
</p>
<div class="w-full relative flex items-center">
<div class="flex-1 border-t border-dashed border-neutral-300 dark:border-neutral-600" />
<div class="flex items-center gap-1 px-1.5">
<UIcon name="i-lucide-plane" class="text-neutral-400 text-xs" />
<span class="text-[10px] text-neutral-400 whitespace-nowrap">{{ segDuration(seg.departureUtcTimestamp, seg.arrivalUtcTimestamp) }}</span>
</div>
<div class="flex-1 border-t border-dashed border-neutral-300 dark:border-neutral-600" />
</div>
</div>
<div class="text-center">
<p class="text-lg font-semibold">{{ formatTime(seg.arrivalDate) }}</p>
<p v-if="showOriginTime && isDifferentTz(seg.arrivalTimestamp, seg.arrivalUtcTimestamp)" class="text-[10px] text-primary-500">({{ toOriginTime(seg.arrivalUtcTimestamp) }})</p>
<p class="text-xs text-neutral-500">{{ seg.arrivalCode }}</p>
<p class="text-xs text-neutral-400">{{ formatDate(seg.arrivalDate) }}</p>
</div>
<UIcon v-if="i < leg.segments.length - 1" name="i-lucide-arrow-right" class="text-neutral-300" />
</div>
</div>
</template>

View File

@@ -0,0 +1,62 @@
<script setup lang="ts">
import type { InspirationItem } from '~/server/utils/flightics'
const props = defineProps<{ items: InspirationItem[], from: string }>()
const { prefetch, getImage } = useDestinationImages()
const { airports, loadAirports } = useLocations()
onMounted(() => loadAirports())
function cityName(iata: string): string {
const a = airports.value.find(ap => ap.iata === iata)
return a?.city_name || iata
}
watch(() => props.items, (items) => {
const cities = items.slice(0, 12)
.map(i => cityName(i.to[0]))
.filter(c => c.length > 2)
if (cities.length) prefetch(cities)
}, { immediate: true })
</script>
<template>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
<div
v-for="item in items"
:key="item.to[0]"
class="relative overflow-hidden rounded-lg cursor-pointer group h-36"
>
<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>
<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>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: { adult: number; child: number; infant: number }
}>()
const emit = defineEmits<{
'update:modelValue': [value: { adult: number; child: number; infant: number }]
}>()
function update(key: 'adult' | 'child' | 'infant', delta: number) {
const val = { ...props.modelValue }
val[key] = Math.max(key === 'adult' ? 1 : 0, val[key] + delta)
emit('update:modelValue', val)
}
const total = computed(() => props.modelValue.adult + props.modelValue.child + props.modelValue.infant)
</script>
<template>
<div class="space-y-2">
<div v-for="type in (['adult', 'child', 'infant'] as const)" :key="type" class="flex items-center justify-between">
<span class="text-sm capitalize text-neutral-600 dark:text-neutral-400">
{{ type === 'adult' ? 'Adultos' : type === 'child' ? 'Ninos' : 'Bebes' }}
</span>
<div class="flex items-center gap-2">
<UButton size="xs" icon="i-lucide-minus" color="neutral" variant="outline" :disabled="type === 'adult' ? modelValue[type] <= 1 : modelValue[type] <= 0" @click="update(type, -1)" />
<span class="w-6 text-center text-sm font-medium">{{ modelValue[type] }}</span>
<UButton size="xs" icon="i-lucide-plus" color="neutral" variant="outline" @click="update(type, 1)" />
</div>
</div>
<p class="text-xs text-neutral-500">{{ total }} pasajero{{ total !== 1 ? 's' : '' }}</p>
</div>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
const emit = defineEmits<{
search: [data: {
mode: string
departures: string[]
destination: string[]
dateFrom: string
dateTo: string
stayMinDays: number
stayMaxDays: number
passengers: { adult: number; child: number; infant: number }
maxStops: number | null
budget: number | null
}]
}>()
defineProps<{
compact?: boolean
}>()
const { homeAirports, defaultPassengers } = useUserPreferences()
const mode = ref('roundtrip')
const departures = ref('')
const destination = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const stayMinDays = ref(2)
const stayMaxDays = ref(6)
const passengers = ref({ adult: 2, child: 0, infant: 0 })
const maxStops = ref<number | null>(null)
const budget = ref(500)
const showBudget = ref(false)
// Apply user preferences when available
watch(homeAirports, (airports) => {
if (airports.length && !departures.value) {
departures.value = airports.join(',')
}
}, { immediate: true })
watch(defaultPassengers, (p) => {
if (p.adult > 0) passengers.value = { ...p }
}, { immediate: true })
const showDestination = computed(() => mode.value !== 'explore')
const showDateTo = computed(() => mode.value !== 'oneway')
const showStayDuration = computed(() => ['roundtrip', 'multicity'].includes(mode.value))
const isWeekend = computed(() => mode.value === 'weekend')
const isExplore = computed(() => mode.value === 'explore')
function submit() {
emit('search', {
mode: mode.value,
departures: departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
destination: destination.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
dateFrom: dateFrom.value,
dateTo: dateTo.value || dateFrom.value,
stayMinDays: stayMinDays.value,
stayMaxDays: stayMaxDays.value,
passengers: passengers.value,
maxStops: maxStops.value,
budget: showBudget.value ? budget.value : null
})
}
</script>
<template>
<form class="space-y-4" @submit.prevent="submit">
<SearchModeTabs v-model="mode" />
<div :class="compact ? 'space-y-3' : 'space-y-4'">
<!-- Origen -->
<UFormField label="Origen">
<SearchAirportInput
v-model="departures"
placeholder="Buscar aeropuerto..."
icon="i-lucide-plane-takeoff"
multiple
/>
</UFormField>
<!-- Destino (no en modo explorar) -->
<UFormField v-if="showDestination" label="Destino">
<SearchAirportInput
v-model="destination"
placeholder="NTE"
icon="i-lucide-plane-landing"
multiple
/>
</UFormField>
<!-- Fechas -->
<SearchDateRangePicker
v-model:date-from="dateFrom"
v-model:date-to="dateTo"
:single-date="!showDateTo"
/>
<!-- Estancia (roundtrip, multicity) -->
<SearchStayDurationPicker
v-if="showStayDuration"
v-model:min-days="stayMinDays"
v-model:max-days="stayMaxDays"
/>
<!-- Weekend hint -->
<UAlert
v-if="isWeekend"
color="info"
icon="i-lucide-info"
title="Busca vuelos de viernes a domingo automaticamente"
/>
<!-- Explore hint -->
<UAlert
v-if="isExplore"
color="info"
icon="i-lucide-compass"
title="Descubre destinos baratos desde tu aeropuerto"
/>
<!-- Pasajeros en popover -->
<UFormField label="Pasajeros">
<UPopover>
<UButton
:label="`${passengers.adult + passengers.child + passengers.infant} pasajero(s)`"
icon="i-lucide-users"
color="neutral"
variant="outline"
block
class="justify-start"
/>
<template #content>
<div class="p-4 w-64">
<PassengerPicker v-model="passengers" />
</div>
</template>
</UPopover>
</UFormField>
<!-- Opciones avanzadas -->
<div class="flex flex-wrap gap-4 items-end">
<div>
<p class="text-xs text-muted mb-1">Escalas</p>
<SearchMaxStopsFilter v-model="maxStops" />
</div>
<UButton
:label="showBudget ? 'Ocultar presupuesto' : 'Presupuesto max'"
icon="i-lucide-wallet"
color="neutral"
variant="ghost"
size="xs"
@click="showBudget = !showBudget"
/>
</div>
<SearchBudgetSlider v-if="showBudget" v-model="budget" />
</div>
<UButton
type="submit"
:label="isExplore ? 'Explorar destinos' : 'Buscar vuelos'"
icon="i-lucide-search"
size="lg"
block
/>
</form>
</template>

133
app/components/TripCard.vue Normal file
View File

@@ -0,0 +1,133 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
const props = defineProps<{ trip: Trip }>()
defineEmits<{ select: [trip: Trip] }>()
const { showOriginTime } = useOriginTime()
// Timezone offset (in seconds) of the origin airport: local - UTC
const originTzOffset = computed(() => {
const seg = props.trip.legs[0]?.segments[0]
if (!seg) return 0
return seg.departureTimestamp - seg.departureUtcTimestamp
})
const departureCode = computed(() => props.trip.legs[0]?.segments[0]?.departureCode ?? '')
const arrivalCode = computed(() => props.trip.legs[0]?.segments.at(-1)?.arrivalCode ?? '')
const departureDate = computed(() => props.trip.legs[0]?.segments[0]?.departureDate ?? '')
const routeSummary = computed(() => {
const codes = props.trip.legs.map(l => l.segments[0]?.departureCode).filter(Boolean)
const lastArr = props.trip.legs.at(-1)?.segments.at(-1)?.arrivalCode
if (lastArr) codes.push(lastArr)
return codes.join(' > ')
})
// Total flight time across all legs (sum of each segment's flight duration, using UTC)
const totalFlightMs = computed(() => {
let ms = 0
for (const leg of props.trip.legs) {
for (const seg of leg.segments) {
ms += (seg.arrivalUtcTimestamp - seg.departureUtcTimestamp) * 1000
}
}
return ms
})
// Time at destination: from arrival of last segment of outbound leg to departure of first segment of return leg (using UTC)
const stayInfo = computed(() => {
const legs = props.trip.legs
if (legs.length < 2) return null
const arrivalUtc = legs[0].segments.at(-1)?.arrivalUtcTimestamp
const departureUtc = legs[1].segments[0]?.departureUtcTimestamp
if (!arrivalUtc || !departureUtc) return null
const stayMs = (departureUtc - arrivalUtc) * 1000
if (stayMs <= 0) return null
const stayHours = stayMs / 3600000
const fullDays = Math.floor(stayHours / 24)
const remainingHours = Math.round(stayHours - fullDays * 24)
const nights = fullDays
return { nights, fullDays, remainingHours, stayMs }
})
// Total trip days: from first departure to last arrival (local dates for calendar/vacation planning)
const totalTripDays = computed(() => {
const legs = props.trip.legs
if (!legs.length) return null
const firstDep = legs[0].segments[0]?.departureDate
const lastArr = legs.at(-1)?.segments.at(-1)?.arrivalDate
if (!firstDep || !lastArr) return null
const depDate = new Date(firstDep)
const arrDate = new Date(lastArr)
// Calendar days: count from departure day to arrival day inclusive
const depDay = new Date(depDate.getFullYear(), depDate.getMonth(), depDate.getDate())
const arrDay = new Date(arrDate.getFullYear(), arrDate.getMonth(), arrDate.getDate())
const calendarDays = Math.round((arrDay.getTime() - depDay.getTime()) / 86400000) + 1
return calendarDays
})
function formatDuration(ms: number) {
const totalMin = Math.round(ms / 60000)
const h = Math.floor(totalMin / 60)
const m = totalMin % 60
return h > 0 ? `${h}h ${m}m` : `${m}m`
}
</script>
<template>
<UCard class="hover:ring-primary-500 transition-all cursor-pointer" @click="$emit('select', trip)">
<div class="flex items-center justify-between gap-4">
<div class="flex-1 space-y-1">
<FlightLeg v-for="(leg, i) in trip.legs" :key="i" :leg="leg" :index="i" :origin-tz-offset="originTzOffset" :show-origin-time="showOriginTime" />
</div>
<div class="text-right shrink-0 pl-4 border-l border-neutral-200 dark:border-neutral-700 space-y-1">
<p class="text-2xl font-bold text-primary-600 dark:text-primary-400">
{{ trip.totalCost.toFixed(0) }}<span class="text-sm font-normal ml-0.5">&euro;</span>
</p>
<p class="text-xs text-muted flex items-center gap-1 justify-end">
<UIcon name="i-lucide-plane" class="text-xs" />
{{ formatDuration(totalFlightMs) }}
</p>
<template v-if="stayInfo">
<p class="text-xs text-muted flex items-center gap-1 justify-end">
<UIcon name="i-lucide-map-pin" class="text-xs" />
{{ stayInfo.fullDays }}d {{ stayInfo.remainingHours }}h en destino
</p>
<p class="text-xs text-muted flex items-center gap-1 justify-end">
<UIcon name="i-lucide-moon" class="text-xs" />
{{ stayInfo.nights }} noche{{ stayInfo.nights !== 1 ? 's' : '' }}
</p>
</template>
<p v-if="totalTripDays" class="text-xs text-muted flex items-center gap-1 justify-end">
<UIcon name="i-lucide-calendar-range" class="text-xs" />
{{ totalTripDays }} dia{{ totalTripDays !== 1 ? 's' : '' }} total
</p>
<div class="flex items-center gap-1 justify-end pt-1">
<DetailWatchlistToggle
:booking-token="trip.bookingToken"
:route-summary="routeSummary"
:departure-code="departureCode"
:arrival-code="arrivalCode"
:departure-date="departureDate"
:price="trip.totalCost"
:passengers="{ adult: 1, child: 0, infant: 0 }"
/>
<UButton size="xs" label="Ver" trailing-icon="i-lucide-arrow-right" />
</div>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,71 @@
<script setup lang="ts">
const { login, register, loginWithGoogle, loading, error } = useAuth()
const mode = ref<'login' | 'register'>('login')
const email = ref('')
const password = ref('')
async function onSubmit() {
const success = mode.value === 'login'
? await login(email.value, password.value)
: await register(email.value, password.value)
if (success) {
await navigateTo('/')
}
}
</script>
<template>
<div class="max-w-sm mx-auto space-y-6">
<div class="text-center">
<h1 class="text-2xl font-bold">
{{ mode === 'login' ? 'Iniciar sesion' : 'Crear cuenta' }}
</h1>
<p class="text-sm text-muted mt-1">
{{ mode === 'login' ? 'Accede a tu cuenta de Vuelato' : 'Registrate para guardar vuelos' }}
</p>
</div>
<UButton
label="Continuar con Google"
icon="i-simple-icons-google"
color="neutral"
variant="outline"
block
@click="loginWithGoogle"
/>
<USeparator label="o" />
<form class="space-y-4" @submit.prevent="onSubmit">
<UFormField label="Email">
<UInput v-model="email" type="email" placeholder="tu@email.com" icon="i-lucide-mail" required />
</UFormField>
<UFormField label="Contrasena">
<UInput v-model="password" type="password" placeholder="••••••••" icon="i-lucide-lock" required :minlength="6" />
</UFormField>
<UAlert v-if="error" color="error" :title="error" icon="i-lucide-alert-circle" />
<UButton
type="submit"
:label="mode === 'login' ? 'Iniciar sesion' : 'Crear cuenta'"
:loading="loading"
block
/>
</form>
<p class="text-center text-sm">
<template v-if="mode === 'login'">
No tienes cuenta?
<UButton variant="link" label="Registrate" @click="mode = 'register'" />
</template>
<template v-else>
Ya tienes cuenta?
<UButton variant="link" label="Inicia sesion" @click="mode = 'login'" />
</template>
</p>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup lang="ts">
const { user, logout } = useAuth()
const items = computed(() => [
[{
label: user.value?.email ?? '',
disabled: true
}],
[{
label: 'Watchlist',
icon: 'i-lucide-heart',
to: '/watchlist'
},
{
label: 'Preferencias',
icon: 'i-lucide-settings',
to: '/settings'
}],
[{
label: 'Cerrar sesion',
icon: 'i-lucide-log-out',
click: logout
}]
])
</script>
<template>
<div v-if="user">
<UDropdownMenu :items="items">
<UButton
icon="i-lucide-user"
color="neutral"
variant="ghost"
:label="user.email?.split('@')[0]"
/>
</UDropdownMenu>
</div>
<UButton
v-else
to="/auth"
label="Entrar"
icon="i-lucide-log-in"
variant="ghost"
color="neutral"
/>
</template>

View File

@@ -0,0 +1,145 @@
<script setup lang="ts">
const props = defineProps<{
flightCode: string
}>()
const info = ref<any>(null)
const fr24Url = ref<string | null>(null)
const loading = ref(false)
const loaded = ref(false)
async function loadInfo() {
if (loaded.value) return
loading.value = true
try {
const data = await $fetch<any>('/api/flight-info', {
query: { flightno: props.flightCode }
})
fr24Url.value = data.fr24Url || `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
if (data.found) info.value = data.flight
} catch {
fr24Url.value = `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
} finally {
loading.value = false
loaded.value = true
}
}
function formatAltitude(ft: number) {
return `${Math.round(ft * 0.3048)}m (FL${Math.round(ft / 100)})`
}
function formatSpeed(knots: number) {
return `${Math.round(knots * 1.852)} km/h`
}
function formatDelay(min: number | null) {
if (min == null || min === 0) return null
if (min > 0) return `+${min} min`
return `${min} min`
}
</script>
<template>
<div>
<!-- Toggle button -->
<UButton
:label="loading ? 'Cargando...' : (info ? 'Info del vuelo' : 'Ver info en vivo')"
:icon="info ? 'i-lucide-radar' : 'i-lucide-radio'"
color="neutral"
variant="ghost"
size="xs"
:loading="loading"
@click="loadInfo"
/>
<!-- Flight info panel -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-96"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 max-h-96"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="info" class="mt-2 overflow-hidden">
<div class="rounded-lg bg-neutral-50 dark:bg-neutral-800/50 p-3 space-y-2 text-sm">
<!-- Status -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<span
class="w-2 h-2 rounded-full"
:class="info.onGround ? 'bg-amber-500' : info.altitude > 0 ? 'bg-green-500 animate-pulse' : 'bg-neutral-400'"
/>
<span class="font-medium">{{ info.status }}</span>
</div>
<a
:href="info.fr24Url"
target="_blank"
class="text-xs text-primary-500 hover:underline"
>
Flightradar24
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
</a>
</div>
<!-- Aircraft & airline -->
<div class="grid grid-cols-2 gap-2 text-xs">
<div v-if="info.aircraft">
<span class="text-muted">Avion</span>
<p class="font-medium">{{ info.aircraftAge || info.aircraft }}</p>
</div>
<div v-if="info.registration">
<span class="text-muted">Matricula</span>
<p class="font-medium">{{ info.registration }}</p>
</div>
</div>
<!-- Live data (if in flight) -->
<div v-if="info.altitude > 0 && !info.onGround" class="grid grid-cols-3 gap-2 text-xs">
<div>
<span class="text-muted">Altitud</span>
<p class="font-medium">{{ formatAltitude(info.altitude) }}</p>
</div>
<div>
<span class="text-muted">Velocidad</span>
<p class="font-medium">{{ formatSpeed(info.speed) }}</p>
</div>
<div>
<span class="text-muted">Rumbo</span>
<p class="font-medium">{{ info.heading }}°</p>
</div>
</div>
<!-- Delays -->
<div v-if="formatDelay(info.departureDelay) || formatDelay(info.arrivalDelay)" class="flex gap-4 text-xs">
<div v-if="formatDelay(info.departureDelay)">
<span class="text-muted">Retraso salida</span>
<p class="font-medium" :class="info.departureDelay > 0 ? 'text-red-500' : 'text-green-500'">
{{ formatDelay(info.departureDelay) }}
</p>
</div>
<div v-if="formatDelay(info.arrivalDelay)">
<span class="text-muted">Retraso llegada</span>
<p class="font-medium" :class="info.arrivalDelay > 0 ? 'text-red-500' : 'text-green-500'">
{{ formatDelay(info.arrivalDelay) }}
</p>
</div>
</div>
</div>
</div>
</Transition>
<!-- Not found link to FR24 anyway -->
<div v-if="loaded && !info && !loading" class="mt-1">
<a
:href="fr24Url"
target="_blank"
class="text-xs text-muted hover:text-primary-500 transition-colors"
>
No hay datos en vivo · Ver historial en Flightradar24
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
</a>
</div>
</div>
</template>

View File

@@ -0,0 +1,49 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
defineProps<{ trip: Trip }>()
function formatFullDate(d: string) {
return new Date(d).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'long' })
}
function legDuration(leg: Trip['legs'][0]) {
const segs = leg.segments
if (!segs.length) return ''
const depMs = segs[0].departureTimestamp * 1000
const arrMs = segs[segs.length - 1].arrivalTimestamp * 1000
const diffMin = Math.round((arrMs - depMs) / 60000)
const h = Math.floor(diffMin / 60)
const m = diffMin % 60
return `${h}h ${m}m`
}
</script>
<template>
<div class="space-y-4">
<UCard v-for="(leg, i) in trip.legs" :key="i">
<template #header>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<UBadge :label="i === 0 ? 'Ida' : 'Vuelta'" :color="i === 0 ? 'primary' : 'info'" variant="subtle" />
<span class="text-sm text-neutral-500">{{ formatFullDate(leg.segments[0].departureDate) }}</span>
</div>
<div class="flex items-center gap-2 text-sm text-muted">
<UIcon name="i-lucide-timer" class="text-xs" />
{{ legDuration(leg) }}
<template v-if="leg.segments.length > 1">
· {{ leg.segments.length - 1 }} escala{{ leg.segments.length > 2 ? 's' : '' }}
</template>
</div>
</div>
</template>
<DetailSegmentCard
v-for="(seg, j) in leg.segments"
:key="j"
:segment="seg"
:show-divider="j > 0"
/>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup lang="ts">
import type { PassengersCount } from '~/server/utils/flightics'
const props = defineProps<{
bookingToken: string
originalPrice: number
passengers: PassengersCount
}>()
const { checkPrice } = useFlightSearch()
const checkedPrice = ref<number | null>(null)
const checking = ref(false)
async function verify() {
checking.value = true
try {
const data = await checkPrice(props.bookingToken, props.passengers)
checkedPrice.value = data.trip.totalCost
} catch {
checkedPrice.value = -1
} finally {
checking.value = false
}
}
</script>
<template>
<UCard>
<div class="flex items-center justify-between">
<div>
<h3 class="font-semibold">Verificar precio actual</h3>
<p class="text-sm text-neutral-500">Comprueba si el precio sigue disponible</p>
</div>
<div class="flex items-center gap-3">
<template v-if="checkedPrice !== null">
<UBadge v-if="checkedPrice === -1" label="No disponible" color="error" />
<UBadge v-else-if="checkedPrice <= originalPrice" :label="`${checkedPrice.toFixed(0)}€`" color="success" />
<UBadge v-else :label="`${checkedPrice.toFixed(0)}€ (subio)`" color="warning" />
</template>
<UButton label="Verificar" icon="i-lucide-refresh-cw" :loading="checking" @click="verify" />
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
const props = defineProps<{
from: string
to: string
}>()
const { trips, loading, fetchRouteFlights } = useRouteFlights()
onMounted(() => {
if (props.from && props.to) {
fetchRouteFlights(props.from, props.to)
}
})
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', { day: 'numeric', month: 'short' })
}
</script>
<template>
<div v-if="loading || trips.length > 0">
<h3 class="font-semibold mb-3">Otros vuelos {{ from }} {{ to }}</h3>
<div v-if="loading" class="space-y-2">
<USkeleton v-for="i in 3" :key="i" class="h-12" />
</div>
<div v-else class="space-y-2">
<div
v-for="(trip, i) in trips.slice(0, 5)"
:key="i"
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm"
>
<div class="flex items-center gap-4">
<span class="font-medium">
{{ formatTime(trip.legs[0]?.segments[0]?.departureDate) }}
{{ formatTime(trip.legs[0]?.segments.at(-1)?.arrivalDate || '') }}
</span>
<span class="text-muted">
{{ formatDate(trip.legs[0]?.segments[0]?.departureDate) }}
</span>
<span class="text-xs text-muted">
{{ trip.legs[0]?.segments[0]?.company?.code }}{{ trip.legs[0]?.segments[0]?.number }}
</span>
</div>
<span class="font-bold text-primary-600 dark:text-primary-400">
{{ trip.totalCost.toFixed(0) }}&euro;
</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,164 @@
<script setup lang="ts">
import type { Segment } from '~/server/utils/flightics'
const props = defineProps<{
segment: Segment
showDivider?: boolean
}>()
const { resolve } = useAirlineNames()
const { getBookingUrl, getAirlineWebsite } = useBookingUrl()
function formatTime(d: string) {
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
}
const segmentDate = computed(() => props.segment.departureDate.slice(0, 10))
const segmentLink = computed(() => ({
path: '/results',
query: {
mode: 'oneway',
dep: props.segment.departureCode,
dest: props.segment.arrivalCode,
from: segmentDate.value,
to: segmentDate.value,
adults: '1'
}
}))
const flightCode = computed(() => `${props.segment.company.code}${props.segment.number}`)
const trackerUrl = computed(() => `https://www.flightradar24.com/data/flights/${flightCode.value.toLowerCase()}`)
const airlineName = computed(() => resolve(props.segment.company.code, props.segment.company.name))
const bookingUrl = computed(() => getBookingUrl({
airlineCode: props.segment.company.code,
origin: props.segment.departureCode,
destination: props.segment.arrivalCode,
date: props.segment.departureDate
}))
const airlineWebsite = computed(() => getAirlineWebsite(props.segment.company.code))
// Fetch cheapest price for this specific route
const segmentPrice = ref<number | null>(null)
const loadingPrice = ref(false)
onMounted(async () => {
loadingPrice.value = true
try {
const date = props.segment.departureDate.slice(0, 10)
const data = await $fetch<any>('/api/search', {
method: 'POST',
body: {
departures: [props.segment.departureCode],
local: 'en',
departureDateInterval: {
begin: `${date}T00:00:00+00:00`,
end: `${date}T00:00:00+00:00`
},
stops: [{
locations: [props.segment.arrivalCode],
stayRange: { min: 0, max: 0 },
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
continueFromAny: false
}],
endInSameLocation: false,
maxStops: 0,
fixStopsOrder: false,
stopLength: { min: 0, max: 0, isSet: false },
maxResults: 1,
passengersCount: { adult: 1, child: 0, infant: 0 }
}
})
if (data.trips?.length) {
segmentPrice.value = data.trips[0].totalCost
}
} catch {
// silently fail
} finally {
loadingPrice.value = false
}
})
</script>
<template>
<div
class="flex items-center gap-4 py-3 -mx-2 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
:class="{ 'border-t border-neutral-100 dark:border-neutral-800': showDivider }"
>
<!-- Departure -->
<div class="text-center w-20">
<p class="text-xl font-semibold">{{ formatTime(segment.departureDate) }}</p>
<p class="text-sm font-medium">{{ segment.departureCode }}</p>
<p class="text-xs text-neutral-400">{{ segment.departureCity }}</p>
</div>
<!-- Flight info -->
<div class="flex-1 flex flex-col items-center gap-1">
<div class="flex items-center gap-2">
<a
:href="trackerUrl"
target="_blank"
class="text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:text-primary-500 transition-colors"
title="Ver en Flightradar24"
@click.stop
>
<span v-if="airlineName" class="font-medium">{{ airlineName }} · </span>
<span>{{ segment.company.code }} {{ segment.number }}</span>
<UIcon name="i-lucide-radar" class="inline ml-0.5 text-[10px] opacity-50" />
</a>
</div>
<div class="w-full h-px bg-neutral-200 dark:bg-neutral-700 relative">
<UIcon name="i-lucide-plane" class="absolute -top-2 left-1/2 -translate-x-1/2 text-primary-500" />
</div>
</div>
<!-- Arrival -->
<div class="text-center w-20">
<p class="text-xl font-semibold">{{ formatTime(segment.arrivalDate) }}</p>
<p class="text-sm font-medium">{{ segment.arrivalCode }}</p>
<p class="text-xs text-neutral-400">{{ segment.arrivalCity }}</p>
</div>
<!-- Price (links to one-way search) -->
<NuxtLink :to="segmentLink" class="shrink-0 text-right w-16 hover:opacity-80 transition-opacity" @click.stop>
<template v-if="loadingPrice">
<USkeleton class="h-5 w-12 ml-auto" />
</template>
<template v-else-if="segmentPrice != null">
<p class="text-sm font-bold text-primary-600 dark:text-primary-400">
{{ segmentPrice.toFixed(0) }}&euro;
</p>
<p class="text-xs text-muted">solo ida</p>
</template>
</NuxtLink>
</div>
<!-- Flight tracker (expandable) -->
<div class="pl-24 -mt-1 mb-1">
<DetailFlightTracker :flight-code="flightCode" />
</div>
<!-- Booking links -->
<div class="pl-24 -mt-1 mb-2 flex items-center gap-2">
<a
:href="bookingUrl"
target="_blank"
class="inline-flex items-center gap-1 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
@click.stop
>
<UIcon name="i-lucide-ticket" class="text-sm" />
Reservar vuelo
</a>
<a
v-if="airlineWebsite"
:href="airlineWebsite"
target="_blank"
class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
@click.stop
>
<UIcon name="i-lucide-globe" class="text-sm" />
Web de {{ airlineName || segment.company.code }}
</a>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
const props = defineProps<{
title: string
price: number
}>()
const copied = ref(false)
async function share() {
const url = window.location.href
const text = `${props.title} - ${props.price.toFixed(0)}`
if (navigator.share) {
await navigator.share({ title: text, url })
} else {
await navigator.clipboard.writeText(`${text}\n${url}`)
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
}
</script>
<template>
<UButton
:label="copied ? 'Copiado!' : 'Compartir'"
:icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'"
:color="copied ? 'success' : 'neutral'"
variant="outline"
@click="share"
/>
</template>

View File

@@ -0,0 +1,52 @@
<script setup lang="ts">
const props = defineProps<{
bookingToken: string
routeSummary: string
departureCode: string
arrivalCode: string
departureDate: string
price: number
passengers: { adult: number; child: number; infant: number }
}>()
const user = useSupabaseUser()
const { isWatched, getWatchedItem, add, remove } = useWatchlist()
const toggling = ref(false)
const watched = computed(() => isWatched(props.bookingToken))
async function toggle() {
if (!user.value) {
await navigateTo('/auth')
return
}
toggling.value = true
if (watched.value) {
const item = getWatchedItem(props.bookingToken)
if (item) await remove(item.id)
} else {
await add({
bookingToken: props.bookingToken,
routeSummary: props.routeSummary,
departureCode: props.departureCode,
arrivalCode: props.arrivalCode,
departureDate: props.departureDate,
price: props.price,
passengers: props.passengers
})
}
toggling.value = false
}
</script>
<template>
<UButton
:icon="watched ? 'i-lucide-heart' : 'i-lucide-heart'"
:color="watched ? 'error' : 'neutral'"
:variant="watched ? 'soft' : 'ghost'"
:loading="toggling"
size="sm"
@click.stop="toggle"
/>
</template>

View File

@@ -0,0 +1,57 @@
<script setup lang="ts">
import type { InspirationItem } from '~/server/utils/flightics'
defineProps<{
items: InspirationItem[]
from: string
loading: boolean
}>()
defineEmits<{ select: [iata: string] }>()
const budget = ref(100)
</script>
<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">Donde por {{ budget }}&euro;?</h3>
<UBadge
:label="`${items.filter(i => i.minPrice <= budget).length} destinos`"
color="primary"
size="xs"
/>
</div>
</template>
<div class="mb-4">
<URange v-model="budget" :min="15" :max="500" :step="5" />
</div>
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 gap-2">
<USkeleton v-for="i in 6" :key="i" class="h-12" />
</div>
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-2">
<button
v-for="item in items.filter(i => i.minPrice <= budget).slice(0, 12)"
:key="item.to[0]"
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-400 transition-colors text-left text-sm"
@click="$emit('select', item.to[0])"
>
<span>
<span class="font-semibold">{{ from }} {{ item.to[0] }}</span>
<span v-if="item.minStops === 0" class="text-xs text-muted ml-1">directo</span>
</span>
<span class="font-bold text-primary-600 dark:text-primary-400">
{{ item.minPrice.toFixed(0) }}&euro;
</span>
</button>
</div>
<div v-if="items.filter(i => i.minPrice <= budget).length === 0 && !loading" class="text-center py-4 text-sm text-muted">
Sube el presupuesto para ver destinos
</div>
</UCard>
</template>

View File

@@ -0,0 +1,37 @@
<script setup lang="ts">
import type { MultiCityInspirationItem } from '~/server/utils/flightics'
const props = defineProps<{
item: MultiCityInspirationItem
currency?: string
resolveCountry?: (iata: string) => string
}>()
defineEmits<{ select: [item: MultiCityInspirationItem] }>()
const countrySummary = computed(() => {
if (!props.resolveCountry) return ''
const unique = [...new Set(props.item.stops.map(s => props.resolveCountry!(s)).filter(Boolean))]
return unique.join(', ')
})
</script>
<template>
<UCard class="hover:ring-primary-500 transition-all cursor-pointer" @click="$emit('select', item)">
<div class="flex items-center justify-between">
<div>
<p class="font-semibold text-sm">
{{ item.from }} {{ item.stops.join(' → ') }}
</p>
<p class="text-xs text-muted mt-1">
{{ item.stops.length }} parada{{ item.stops.length !== 1 ? 's' : '' }}
<span v-if="countrySummary"> · {{ countrySummary }}</span>
</p>
</div>
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">
{{ item.minPrice.toFixed(0) }}<span class="text-xs">&euro;</span>
</p>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,144 @@
<script setup lang="ts">
import { LMap, LTileLayer, LCircleMarker, LPolyline, LPopup } from '@vue-leaflet/vue-leaflet'
import 'leaflet/dist/leaflet.css'
import type { InspirationItem } from '~/server/utils/flightics'
const props = defineProps<{
airports: { iata: string; name: string; lat: number; lon: number; city_name: string }[]
origin: string | null
inspirations: InspirationItem[]
budget: number | null
}>()
const emit = defineEmits<{
selectOrigin: [iata: string]
selectDestination: [iata: string]
}>()
const zoom = ref(5)
const center = ref<[number, number]>([40.4, -3.7]) // Madrid default
// Airport lookup
const airportMap = computed(() => {
const map = new Map<string, (typeof props.airports)[0]>()
for (const a of props.airports) map.set(a.iata, a)
return map
})
// Filter inspirations by budget
const filteredInspirations = computed(() => {
if (!props.budget) return props.inspirations
return props.inspirations.filter(i => i.minPrice <= props.budget!)
})
// Lines from origin to destinations
const routes = computed(() => {
const origin = props.origin ? airportMap.value.get(props.origin) : null
if (!origin) return []
return filteredInspirations.value
.map(insp => {
const dest = airportMap.value.get(insp.to[0])
if (!dest) return null
return {
from: [origin.lat, origin.lon] as [number, number],
to: [dest.lat, dest.lon] as [number, number],
iata: insp.to[0],
price: insp.minPrice,
stops: insp.minStops,
destName: dest.name
}
})
.filter(Boolean) as { from: [number, number]; to: [number, number]; iata: string; price: number; stops: number; destName: string }[]
})
// Center on origin when selected
watch(() => props.origin, (iata) => {
if (iata) {
const a = airportMap.value.get(iata)
if (a) {
center.value = [a.lat, a.lon]
zoom.value = 5
}
}
})
function priceColor(price: number): string {
if (price < 30) return '#22c55e'
if (price < 60) return '#84cc16'
if (price < 100) return '#eab308'
if (price < 200) return '#f97316'
return '#ef4444'
}
</script>
<template>
<div class="h-[500px] rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
<LMap :zoom="zoom" :center="center" :use-global-leaflet="false">
<LTileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution="&copy; OpenStreetMap &copy; CARTO"
/>
<!-- All airports as small dots -->
<LCircleMarker
v-for="a in airports.filter(a => a.lat && a.lon)"
:key="a.iata"
:lat-lng="[a.lat, a.lon]"
:radius="origin === a.iata ? 8 : 3"
:color="origin === a.iata ? '#3b82f6' : '#94a3b8'"
:fill-opacity="origin === a.iata ? 0.8 : 0.4"
:weight="origin === a.iata ? 2 : 1"
@click="$emit('selectOrigin', a.iata)"
>
<LPopup>
<div class="text-sm">
<p class="font-semibold">{{ a.iata }} - {{ a.name }}</p>
<p v-if="a.city_name" class="text-neutral-500">{{ a.city_name }}</p>
<UButton
v-if="origin !== a.iata"
label="Buscar desde aqui"
size="xs"
class="mt-1"
@click="$emit('selectOrigin', a.iata)"
/>
</div>
</LPopup>
</LCircleMarker>
<!-- Route lines -->
<template v-for="r in routes" :key="r.iata">
<LPolyline
:lat-lngs="[r.from, r.to]"
:color="priceColor(r.price)"
:weight="2"
:opacity="0.6"
/>
<LCircleMarker
:lat-lng="r.to"
:radius="6"
:color="priceColor(r.price)"
:fill-opacity="0.8"
:weight="2"
>
<LPopup>
<div class="text-sm min-w-32">
<p class="font-semibold">{{ r.iata }} - {{ r.destName }}</p>
<p class="text-lg font-bold" :style="{ color: priceColor(r.price) }">
{{ r.price.toFixed(0) }}&euro;
</p>
<p class="text-xs text-neutral-500">
{{ r.stops === 0 ? 'Directo' : `${r.stops} escala(s)` }}
</p>
<UButton
label="Ver vuelos"
size="xs"
class="mt-1"
@click="$emit('selectDestination', r.iata)"
/>
</div>
</LPopup>
</LCircleMarker>
</template>
</LMap>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
const origin = defineModel<string>('origin', { default: '' })
const budget = defineModel<number | null>('budget', { default: null })
const directOnly = defineModel<boolean>('directOnly', { default: false })
defineProps<{
inspirationCount: number
}>()
</script>
<template>
<UCard>
<div class="flex flex-wrap items-end gap-4">
<UFormField label="Origen" class="w-48">
<SearchAirportInput v-model="origin" placeholder="MAD" icon="i-lucide-plane-takeoff" />
</UFormField>
<div class="flex-1 min-w-48">
<div class="flex justify-between text-sm mb-1">
<span class="text-muted">Presupuesto</span>
<span class="font-semibold">{{ budget ? `${budget}` : 'Sin limite' }}</span>
</div>
<URange :model-value="budget || 500" :min="20" :max="1000" :step="10" @update:model-value="budget = $event" />
</div>
<UButton
:label="directOnly ? 'Solo directos' : 'Todos'"
:icon="directOnly ? 'i-lucide-arrow-right' : 'i-lucide-git-branch'"
:color="directOnly ? 'primary' : 'neutral'"
:variant="directOnly ? 'soft' : 'ghost'"
size="sm"
@click="directOnly = !directOnly"
/>
<p class="text-sm text-muted">{{ inspirationCount }} destinos</p>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
polling: boolean
pollCount: number
}>()
defineEmits<{ stop: [] }>()
</script>
<template>
<div v-if="polling" class="flex items-center justify-center gap-3 py-4">
<UIcon name="i-lucide-loader" class="animate-spin text-primary-500" />
<p class="text-sm text-muted">
Buscando mas resultados (ronda {{ pollCount }})...
</p>
<UButton label="Parar" size="xs" color="neutral" variant="ghost" @click="$emit('stop')" />
</div>
</template>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
const props = defineProps<{
maxPrice: number | null
maxStops: number | null
airlines: string[]
departureTimeRange: [number, number]
availableAirlines: { code: string, name: string }[]
priceRange: { min: number, max: number }
}>()
const emit = defineEmits<{
'update:maxPrice': [value: number | null]
'update:maxStops': [value: number | null]
'update:airlines': [value: string[]]
'update:departureTimeRange': [value: [number, number]]
}>()
const priceValue = ref(props.maxPrice ?? props.priceRange.max)
const priceEnabled = ref(props.maxPrice != null)
watch(priceEnabled, (on) => {
emit('update:maxPrice', on ? priceValue.value : null)
})
watch(priceValue, (v) => {
if (priceEnabled.value) emit('update:maxPrice', v)
})
watch(() => props.maxPrice, (v) => {
if (v == null) {
priceEnabled.value = false
} else {
priceValue.value = v
priceEnabled.value = true
}
})
const stopsOptions = [
{ label: 'Todos', value: null },
{ label: 'Directo', value: 0 },
{ label: 'Max 1', value: 1 },
{ label: 'Max 2', value: 2 }
]
function toggleAirline(code: string) {
const current = [...props.airlines]
const idx = current.indexOf(code)
if (idx >= 0) current.splice(idx, 1)
else current.push(code)
emit('update:airlines', current)
}
function formatHour(h: number) {
return `${String(h).padStart(2, '0')}:00`
}
</script>
<template>
<UCard>
<div class="space-y-5">
<!-- Price filter -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium flex items-center gap-2">
<USwitch :model-value="priceEnabled" size="xs" @update:model-value="priceEnabled = $event" />
Precio max
</label>
<UInput
v-if="priceEnabled"
:model-value="priceValue"
type="number"
:min="priceRange.min"
:max="priceRange.max"
size="xs"
class="w-24 text-right"
@update:model-value="priceValue = Number($event)"
>
<template #trailing><span class="text-xs text-muted">&euro;</span></template>
</UInput>
</div>
<URange
v-if="priceEnabled"
v-model="priceValue"
:min="priceRange.min"
:max="priceRange.max"
:step="5"
/>
</div>
<!-- Stops filter -->
<div>
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium">Escalas</p>
<UInput
:model-value="maxStops ?? ''"
type="number"
:min="0"
:max="10"
placeholder="Sin limite"
size="xs"
class="w-28 text-right"
@update:model-value="$emit('update:maxStops', $event === '' ? null : Number($event))"
/>
</div>
<div class="flex gap-1">
<UButton
v-for="opt in stopsOptions"
:key="String(opt.value)"
:label="opt.label"
:color="maxStops === opt.value ? 'primary' : 'neutral'"
:variant="maxStops === opt.value ? 'soft' : 'ghost'"
size="xs"
@click="$emit('update:maxStops', opt.value)"
/>
</div>
</div>
<!-- Departure time -->
<div>
<p class="text-sm font-medium mb-1">Hora de salida</p>
<p class="text-xs text-muted mb-2">
{{ formatHour(departureTimeRange[0]) }} - {{ formatHour(departureTimeRange[1]) }}
</p>
<div class="flex gap-3">
<URange
:model-value="departureTimeRange[0]"
:min="0"
:max="23"
class="flex-1"
@update:model-value="$emit('update:departureTimeRange', [$event, departureTimeRange[1]])"
/>
<URange
:model-value="departureTimeRange[1]"
:min="1"
:max="24"
class="flex-1"
@update:model-value="$emit('update:departureTimeRange', [departureTimeRange[0], $event])"
/>
</div>
</div>
<!-- Airlines filter -->
<div v-if="availableAirlines.length > 0">
<p class="text-sm font-medium mb-2">Aerolineas</p>
<div class="flex flex-wrap gap-1">
<UButton
v-for="al in availableAirlines"
:key="al.code"
:label="`${al.code}${al.name ? ' · ' + al.name : ''}`"
:color="airlines.includes(al.code) ? 'primary' : 'neutral'"
:variant="airlines.includes(al.code) ? 'soft' : 'ghost'"
size="xs"
@click="toggleAirline(al.code)"
/>
</div>
<p class="text-xs text-muted mt-1">{{ airlines.length === 0 ? 'Sin filtro de aerolinea' : 'Solo vuelos operados exclusivamente por las seleccionadas' }}</p>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { SortKey } from '~/composables/useResultFilters'
const sortBy = defineModel<SortKey>('sortBy', { default: 'price' })
const viewMode = defineModel<'full' | 'compact'>('viewMode', { default: 'full' })
const { showOriginTime } = useOriginTime()
defineProps<{
count: number
hasActiveFilters: boolean
}>()
defineEmits<{
toggleFilters: []
resetFilters: []
}>()
const sortOptions: { value: SortKey, label: string, icon: string }[] = [
{ value: 'price', label: 'Precio', icon: 'i-lucide-arrow-down-narrow-wide' },
{ value: 'departure', label: 'Salida', icon: 'i-lucide-clock' },
{ value: 'duration', label: 'Duracion', icon: 'i-lucide-timer' },
{ value: 'stops', label: 'Escalas', icon: 'i-lucide-git-branch' }
]
</script>
<template>
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-2">
<p class="text-sm text-muted">
{{ count }} resultado{{ count !== 1 ? 's' : '' }}
</p>
<UButton
v-if="hasActiveFilters"
label="Limpiar filtros"
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
@click="$emit('resetFilters')"
/>
</div>
<div class="flex items-center gap-2">
<!-- Sort buttons -->
<div class="flex gap-0.5">
<UButton
v-for="opt in sortOptions"
:key="opt.value"
:label="opt.label"
:icon="opt.icon"
:color="sortBy === opt.value ? 'primary' : 'neutral'"
:variant="sortBy === opt.value ? 'soft' : 'ghost'"
size="xs"
@click="sortBy = opt.value"
/>
</div>
<USeparator orientation="vertical" class="h-5" />
<!-- View mode -->
<div class="flex gap-0.5">
<UButton
icon="i-lucide-rows-3"
:color="viewMode === 'full' ? 'primary' : 'neutral'"
:variant="viewMode === 'full' ? 'soft' : 'ghost'"
size="xs"
@click="viewMode = 'full'"
/>
<UButton
icon="i-lucide-list"
:color="viewMode === 'compact' ? 'primary' : 'neutral'"
:variant="viewMode === 'compact' ? 'soft' : 'ghost'"
size="xs"
@click="viewMode = 'compact'"
/>
</div>
<!-- Origin time toggle -->
<UButton
icon="i-lucide-clock"
label="Hora origen"
:color="showOriginTime ? 'primary' : 'neutral'"
:variant="showOriginTime ? 'soft' : 'ghost'"
size="xs"
@click="showOriginTime = !showOriginTime"
/>
<!-- Filter toggle -->
<UButton
icon="i-lucide-sliders-horizontal"
label="Filtros"
:color="hasActiveFilters ? 'primary' : 'neutral'"
:variant="hasActiveFilters ? 'soft' : 'ghost'"
size="xs"
@click="$emit('toggleFilters')"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
defineProps<{ trip: Trip }>()
defineEmits<{ select: [trip: Trip] }>()
function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
}
function legSummary(leg: Trip['legs'][0]) {
const segs = leg.segments
if (!segs.length) return ''
const dep = segs[0]
const arr = segs[segs.length - 1]
const stops = segs.length - 1
const stopsText = stops === 0 ? 'Directo' : `${stops} escala${stops > 1 ? 's' : ''}`
return `${dep.departureCode} ${formatTime(dep.departureDate)}${arr.arrivalCode} ${formatTime(arr.arrivalDate)} · ${stopsText}`
}
</script>
<template>
<div
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-400 cursor-pointer transition-colors"
@click="$emit('select', trip)"
>
<div class="flex-1 min-w-0">
<p v-for="(leg, i) in trip.legs" :key="i" class="text-sm truncate">
<span class="text-xs font-medium text-muted mr-1">{{ i === 0 ? 'Ida' : 'Vta' }}</span>
{{ legSummary(leg) }}
</p>
</div>
<div class="shrink-0 ml-3 text-right">
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">
{{ trip.totalCost.toFixed(0) }}<span class="text-xs">&euro;</span>
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,189 @@
<script setup lang="ts">
const props = defineProps<{
modelValue: string
placeholder?: string
icon?: string
multiple?: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: string]
}>()
const { loadAirports, searchAirports } = useLocations()
const query = ref('')
const results = ref<ReturnType<typeof searchAirports>>([])
const open = ref(false)
const highlightIndex = ref(0)
// Selected codes as array
const selected = ref<string[]>([])
// Init from modelValue
function parseModelValue(v: string) {
return v.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
}
selected.value = parseModelValue(props.modelValue)
watch(() => props.modelValue, (v) => {
const parsed = parseModelValue(v)
if (parsed.join(',') !== selected.value.join(',')) {
selected.value = parsed
}
})
function emitValue() {
emit('update:modelValue', selected.value.join(','))
}
onMounted(() => loadAirports())
watch(query, (q) => {
results.value = searchAirports(q)
// Don't show already-selected airports
if (props.multiple) {
results.value = results.value.filter(a => !selected.value.includes(a.iata))
}
open.value = results.value.length > 0
highlightIndex.value = 0
})
function select(airport: (typeof results.value)[0]) {
if (props.multiple) {
if (!selected.value.includes(airport.iata)) {
selected.value.push(airport.iata)
}
query.value = ''
} else {
selected.value = [airport.iata]
query.value = airport.iata
}
emitValue()
open.value = false
results.value = []
}
function removeCode(code: string) {
selected.value = selected.value.filter(c => c !== code)
emitValue()
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Backspace' && !query.value && props.multiple && selected.value.length) {
e.preventDefault()
selected.value.pop()
emitValue()
return
}
if (!open.value || results.value.length === 0) return
if (e.key === 'ArrowDown') {
e.preventDefault()
highlightIndex.value = Math.min(highlightIndex.value + 1, results.value.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
highlightIndex.value = Math.max(highlightIndex.value - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
select(results.value[highlightIndex.value])
} else if (e.key === 'Escape') {
open.value = false
}
}
function onBlur() {
// If there's text typed and it looks like a code, add it
const val = query.value.trim().toUpperCase()
if (val && props.multiple) {
if (val.length >= 2) {
if (!selected.value.includes(val)) {
selected.value.push(val)
emitValue()
}
}
query.value = ''
} else if (val && !props.multiple) {
selected.value = [val]
query.value = val
emitValue()
}
setTimeout(() => { open.value = false; results.value = [] }, 150)
}
// For single mode, keep query in sync
watch(selected, (codes) => {
if (!props.multiple && codes.length) {
query.value = codes[0]
}
}, { deep: true })
</script>
<template>
<div class="relative">
<!-- Multiple mode: badges + input -->
<div
v-if="multiple"
class="flex flex-wrap items-center gap-1 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1.5 focus-within:ring-2 focus-within:ring-primary-500 transition-shadow"
>
<UBadge
v-for="code in selected"
:key="code"
:label="code"
color="primary"
variant="subtle"
size="sm"
class="cursor-pointer"
@click="removeCode(code)"
>
<template #trailing>
<UIcon name="i-lucide-x" class="text-xs" />
</template>
</UBadge>
<input
v-model="query"
:placeholder="selected.length ? '' : placeholder || 'Buscar aeropuerto...'"
class="flex-1 min-w-24 bg-transparent outline-none text-sm py-0.5"
autocomplete="off"
@focus="results = searchAirports(query); open = results.length > 0"
@blur="onBlur"
@keydown="onKeydown"
/>
</div>
<!-- Single mode -->
<UInput
v-else
v-model="query"
:placeholder="placeholder || 'Buscar aeropuerto...'"
:icon="icon || 'i-lucide-plane'"
autocomplete="off"
@focus="results = searchAirports(query); open = results.length > 0"
@blur="onBlur"
@keydown="onKeydown"
/>
<!-- Dropdown -->
<div
v-if="open && results.length"
class="absolute z-50 mt-1 w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
>
<button
v-for="(apt, i) in results"
:key="apt.iata"
type="button"
class="w-full text-left px-3 py-2 flex items-center justify-between text-sm"
:class="i === highlightIndex ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'"
@mousedown.prevent="select(apt)"
@mouseenter="highlightIndex = i"
>
<span>
<span class="font-semibold">{{ apt.iata }}</span>
<span class="text-muted ml-2">{{ apt.name }}</span>
</span>
<span v-if="apt.city_name" class="text-xs text-muted">{{ apt.city_name }}</span>
</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,13 @@
<script setup lang="ts">
const model = defineModel<number>({ default: 500 })
</script>
<template>
<div class="space-y-1">
<div class="flex justify-between text-sm">
<span class="text-muted">Presupuesto max</span>
<span class="font-semibold">{{ model }}&euro;</span>
</div>
<URange v-model="model" :min="20" :max="2000" :step="10" />
</div>
</template>

View File

@@ -0,0 +1,27 @@
<script setup lang="ts">
const dateFrom = defineModel<string>('dateFrom', { default: '' })
const dateTo = defineModel<string>('dateTo', { default: '' })
defineProps<{
singleDate?: boolean
}>()
const today = new Date().toISOString().slice(0, 10)
watch(dateFrom, (from) => {
if (from && dateTo.value && dateTo.value < from) {
dateTo.value = from
}
})
</script>
<template>
<div class="grid grid-cols-2 gap-3">
<UFormField :label="singleDate ? 'Fecha' : 'Desde'">
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" required />
</UFormField>
<UFormField v-if="!singleDate" label="Hasta">
<UInput v-model="dateTo" type="date" :min="dateFrom || today" required />
</UFormField>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup lang="ts">
const model = defineModel<number | null>({ default: null })
const options = [
{ label: 'Cualquiera', value: null },
{ label: 'Directo', value: 0 },
{ label: 'Max 1', value: 1 },
{ label: 'Max 2', value: 2 }
]
</script>
<template>
<div class="flex gap-1">
<UButton
v-for="opt in options"
:key="String(opt.value)"
:label="opt.label"
:color="model === opt.value ? 'primary' : 'neutral'"
:variant="model === opt.value ? 'soft' : 'ghost'"
size="xs"
@click="model = opt.value"
/>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup lang="ts">
const model = defineModel<string>({ default: 'roundtrip' })
const modes = [
{ value: 'roundtrip', label: 'Ida y vuelta', icon: 'i-lucide-repeat' },
{ value: 'oneway', label: 'Solo ida', icon: 'i-lucide-arrow-right' },
{ value: 'multicity', label: 'Multi-ciudad', icon: 'i-lucide-route' },
{ value: 'weekend', label: 'Finde', icon: 'i-lucide-calendar-days' },
{ value: 'explore', label: 'Explorar', icon: 'i-lucide-compass' }
]
</script>
<template>
<div class="flex gap-1 flex-wrap">
<UButton
v-for="m in modes"
:key="m.value"
:label="m.label"
:icon="m.icon"
:color="model === m.value ? 'primary' : 'neutral'"
:variant="model === m.value ? 'solid' : 'ghost'"
size="sm"
@click="model = m.value"
/>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup lang="ts">
const minDays = defineModel<number>('minDays', { default: 2 })
const maxDays = defineModel<number>('maxDays', { default: 6 })
</script>
<template>
<div class="grid grid-cols-2 gap-3">
<UFormField label="Estancia min (dias)">
<UInput v-model.number="minDays" type="number" :min="1" :max="maxDays" />
</UFormField>
<UFormField label="Estancia max (dias)">
<UInput v-model.number="maxDays" type="number" :min="minDays" />
</UFormField>
</div>
</template>

View File

@@ -0,0 +1,205 @@
<script setup lang="ts">
const emit = defineEmits<{
created: []
cancel: []
}>()
const { create } = useTrackedSearches()
const name = ref('')
const departures = ref('')
const destination = ref('')
const dateFrom = ref('')
const dateTo = ref('')
const stayMinDays = ref(2)
const stayMaxDays = ref(7)
const passengers = ref({ adult: 1, child: 0, infant: 0 })
const intervalHours = ref(24)
const expiresAt = ref('')
const submitting = ref(false)
const error = ref('')
const today = new Date().toISOString().slice(0, 10)
watch(dateFrom, (from) => {
if (from && dateTo.value && dateTo.value < from) {
dateTo.value = from
}
})
const intervalOptions = [
{ label: 'Cada 6 horas', value: 6 },
{ label: 'Cada 12 horas', value: 12 },
{ label: 'Diario (24h)', value: 24 },
{ label: 'Cada 2 dias', value: 48 },
{ label: 'Semanal', value: 168 }
]
// Cargar preferencias de usuario
const { profile } = useUserPreferences()
watch(profile, (p) => {
if (p?.home_airports?.length) {
departures.value = p.home_airports.join(',')
}
if (p?.default_adults) passengers.value.adult = p.default_adults
if (p?.default_children) passengers.value.child = p.default_children
if (p?.default_infants) passengers.value.infant = p.default_infants
}, { immediate: true })
function buildRouteSummary() {
const dep = departures.value.split(',').filter(Boolean).join(',')
const dest = destination.value || '?'
return `${dep} > ${dest}`
}
function buildSearchParams() {
const depList = departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
const dest = destination.value.trim().toUpperCase()
const now = new Date()
const defaultFrom = now.toISOString().slice(0, 10)
const defaultTo = new Date(now.getTime() + 30 * 86400000).toISOString().slice(0, 10)
const from = dateFrom.value || defaultFrom
const to = dateTo.value || defaultTo
const isOneWay = from === to
const stops = isOneWay
? [{
locations: [dest],
stayRange: { min: 0, max: 0 },
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
continueFromAny: false
}]
: [
{
locations: [dest],
stayRange: { min: stayMinDays.value * 24, max: stayMaxDays.value * 24 },
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
continueFromAny: true
},
{
locations: depList,
stayRange: { min: 0, max: 0 },
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
continueFromAny: false
}
]
return {
departures: depList,
local: 'en',
departureDateInterval: {
begin: `${from}T00:00:00+00:00`,
end: `${to}T00:00:00+00:00`
},
stops,
endInSameLocation: !isOneWay,
maxStops: null,
fixStopsOrder: false,
stopLength: { min: 0, max: 0, isSet: false },
maxResults: 45,
passengersCount: passengers.value
}
}
async function onSubmit() {
if (!departures.value || !destination.value || !name.value) {
error.value = 'Rellena nombre, origen y destino'
return
}
submitting.value = true
error.value = ''
try {
await create({
name: name.value,
searchParams: buildSearchParams(),
routeSummary: buildRouteSummary(),
intervalHours: intervalHours.value,
expiresAt: expiresAt.value || undefined
})
emit('created')
} catch (e: unknown) {
const err = e as { data?: { message?: string } }
error.value = err?.data?.message || 'Error al crear seguimiento'
} finally {
submitting.value = false
}
}
</script>
<template>
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">Nuevo seguimiento de precios</h3>
<UButton icon="i-lucide-x" color="neutral" variant="ghost" size="xs" @click="emit('cancel')" />
</div>
</template>
<div class="space-y-4">
<UFormField label="Nombre" required>
<UInput v-model="name" placeholder="Ej: Madrid-Londres julio" class="w-full" />
</UFormField>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UFormField label="Origen" required>
<SearchAirportInput v-model="departures" placeholder="Aeropuerto origen" icon="i-lucide-plane-takeoff" multiple />
</UFormField>
<UFormField label="Destino" required>
<SearchAirportInput v-model="destination" placeholder="Aeropuerto destino" icon="i-lucide-plane-landing" multiple />
</UFormField>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UFormField label="Fecha desde">
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" class="w-full" />
</UFormField>
<UFormField label="Fecha hasta">
<UInput v-model="dateTo" type="date" :min="dateFrom || today" class="w-full" />
</UFormField>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UFormField label="Estancia minima (dias)">
<UInput v-model.number="stayMinDays" type="number" :min="1" class="w-full" />
</UFormField>
<UFormField label="Estancia maxima (dias)">
<UInput v-model.number="stayMaxDays" type="number" :min="1" class="w-full" />
</UFormField>
</div>
<UFormField label="Pasajeros">
<PassengerPicker v-model="passengers" />
</UFormField>
<USeparator />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UFormField label="Frecuencia de busqueda">
<select
v-model.number="intervalHours"
class="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm"
>
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</UFormField>
<UFormField label="Expira el (opcional)">
<UInput v-model="expiresAt" type="date" :min="today" class="w-full" />
</UFormField>
</div>
<UAlert v-if="error" :title="error" color="error" icon="i-lucide-alert-circle" />
</div>
<template #footer>
<div class="flex justify-end gap-2">
<UButton label="Cancelar" color="neutral" variant="outline" @click="emit('cancel')" />
<UButton label="Crear seguimiento" icon="i-lucide-plus" :loading="submitting" @click="onSubmit" />
</div>
</template>
</UCard>
</template>

View File

@@ -0,0 +1,112 @@
<script setup lang="ts">
import { Line } from 'vue-chartjs'
import type { ChartOptions } from 'chart.js'
import {
Chart as ChartJS,
CategoryScale,
LinearScale,
PointElement,
LineElement,
Title,
Tooltip,
Legend,
Filler
} from 'chart.js'
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
const props = defineProps<{
snapshots: Array<{
cheapest_price: number
avg_price: number | null
median_price: number | null
total_results: number
recorded_at: string
}>
}>()
const chartData = computed(() => {
const labels = props.snapshots.map(s =>
new Date(s.recorded_at).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
)
return {
labels,
datasets: [
{
label: 'Precio mas barato',
data: props.snapshots.map(s => s.cheapest_price),
borderColor: '#16a34a',
backgroundColor: 'rgba(22, 163, 74, 0.1)',
fill: true,
tension: 0.3,
pointRadius: 4,
pointHoverRadius: 6
},
{
label: 'Precio medio',
data: props.snapshots.map(s => s.avg_price),
borderColor: '#9ca3af',
backgroundColor: 'transparent',
borderDash: [],
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 4
},
{
label: 'Mediana',
data: props.snapshots.map(s => s.median_price),
borderColor: '#d1d5db',
backgroundColor: 'transparent',
borderDash: [5, 5],
tension: 0.3,
pointRadius: 2,
pointHoverRadius: 4
}
]
}
})
const chartOptions: ChartOptions<'line'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom' as const,
labels: {
usePointStyle: true,
padding: 16
}
},
tooltip: {
callbacks: {
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(0)}\u20AC`
}
}
},
scales: {
y: {
beginAtZero: false,
ticks: {
callback: (value) => `${value}\u20AC`
}
}
},
interaction: {
intersect: false,
mode: 'index' as const
}
}
</script>
<template>
<div>
<div v-if="snapshots.length < 2" class="text-center py-8">
<UIcon name="i-lucide-chart-line" class="text-3xl text-neutral-300 mb-2" />
<p class="text-neutral-500 text-sm">Se necesitan al menos 2 puntos de datos para mostrar el grafico</p>
</div>
<div v-else class="h-72">
<Line :data="chartData" :options="chartOptions" />
</div>
</div>
</template>

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
defineProps<{
runs: SearchRun[]
}>()
function statusBadge(status: string) {
switch (status) {
case 'completed': return { label: 'Completado', color: 'success' as const }
case 'running': return { label: 'Ejecutando', color: 'info' as const }
case 'failed': return { label: 'Error', color: 'error' as const }
default: return { label: 'Pendiente', color: 'neutral' as const }
}
}
function formatDate(date: string) {
return new Date(date).toLocaleString('es-ES', {
day: 'numeric',
month: 'short',
hour: '2-digit',
minute: '2-digit'
})
}
function formatShortDate(dateStr: string) {
const d = new Date(dateStr)
return d.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
}
function duration(start: string | null, end: string | null) {
if (!start || !end) return '-'
const ms = new Date(end).getTime() - new Date(start).getTime()
if (ms < 1000) return `${ms}ms`
return `${(ms / 1000).toFixed(1)}s`
}
</script>
<template>
<div class="space-y-2">
<h3 class="font-semibold text-sm mb-3">Historial de ejecuciones</h3>
<div v-if="runs.length === 0" class="text-center py-6">
<p class="text-sm text-neutral-500">Aun no hay ejecuciones</p>
</div>
<UCard v-for="run in runs" :key="run.id" class="!p-3">
<div class="flex items-center justify-between gap-3">
<div class="flex items-center gap-2 flex-1 min-w-0">
<UBadge :label="statusBadge(run.status).label" :color="statusBadge(run.status).color" size="xs" />
<span class="text-xs text-muted">{{ formatDate(run.created_at) }}</span>
<UBadge v-if="run.from_cache" label="Cache" color="neutral" variant="outline" size="xs" />
</div>
<div class="flex items-center gap-3 text-sm shrink-0">
<span v-if="run.cheapest_price != null" class="font-medium">
{{ run.cheapest_price.toFixed(0) }}&euro;
</span>
<span v-if="run.total_trips_found > 0" class="text-xs text-muted">
{{ run.total_trips_found }} vuelos
</span>
<span class="text-xs text-muted">
{{ duration(run.started_at, run.completed_at) }}
</span>
</div>
</div>
<!-- Error message -->
<p v-if="run.error_message" class="text-xs text-red-500 mt-1">
{{ run.error_message }}
</p>
<!-- Top trips preview -->
<div v-if="run.top_trips && run.top_trips.length > 0" class="mt-2 space-y-1.5">
<NuxtLink
v-for="(trip, i) in run.top_trips.slice(0, 3)"
:key="i"
:to="trip.bookingToken ? `/detail/${encodeURIComponent(trip.bookingToken)}?adults=1` : undefined"
class="block text-xs text-muted rounded px-1.5 py-1 -mx-1.5 transition-colors"
:class="trip.bookingToken ? 'hover:bg-neutral-100 dark:hover:bg-neutral-800 cursor-pointer' : ''"
>
<div class="flex items-center gap-2">
<span class="font-medium text-foreground">{{ trip.price?.toFixed(0) }}&euro;</span>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 flex-1">
<span v-for="(leg, j) in trip.legs" :key="j" class="flex items-center gap-1">
<UIcon :name="j === 0 ? 'i-lucide-plane-takeoff' : 'i-lucide-plane-landing'" class="text-[10px]" />
{{ leg.from }} > {{ leg.to }}
<span v-if="leg.departure" class="text-muted">{{ formatShortDate(leg.departure) }}</span>
<span v-if="leg.airlines?.length" class="text-muted">({{ leg.airlines.join(', ') }})</span>
</span>
</div>
<UIcon v-if="trip.bookingToken" name="i-lucide-arrow-right" class="text-neutral-400 text-xs shrink-0" />
</div>
</NuxtLink>
</div>
</UCard>
</div>
</template>

View File

@@ -0,0 +1,124 @@
<script setup lang="ts">
const props = defineProps<{
search: {
id: string
name: string
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
latest_snapshot: {
cheapest_price: number
avg_price: number | null
total_results: number
recorded_at: string
} | null
}
}>()
const emit = defineEmits<{
toggle: [id: string, active: boolean]
remove: [id: string]
}>()
function statusBadge() {
if (!props.search.is_active) return { label: 'Pausada', color: 'neutral' as const }
if (props.search.last_error) return { label: 'Error', color: 'error' as const }
if (props.search.expires_at && new Date(props.search.expires_at) < new Date()) return { label: 'Expirada', color: 'warning' as const }
return { label: 'Activa', color: 'success' as const }
}
function formatInterval(hours: number) {
if (hours < 24) return `Cada ${hours}h`
if (hours === 24) return 'Diario'
if (hours === 48) return 'Cada 2 dias'
if (hours === 168) return 'Semanal'
return `Cada ${Math.round(hours / 24)} dias`
}
function timeAgo(date: string) {
const diff = Date.now() - new Date(date).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 60) return `hace ${mins}min`
const hours = Math.floor(mins / 60)
if (hours < 24) return `hace ${hours}h`
const days = Math.floor(hours / 24)
return `hace ${days}d`
}
function timeUntil(date: string) {
const diff = new Date(date).getTime() - Date.now()
if (diff < 0) return 'pendiente'
const mins = Math.floor(diff / 60000)
if (mins < 60) return `en ${mins}min`
const hours = Math.floor(mins / 60)
if (hours < 24) return `en ${hours}h`
const days = Math.floor(hours / 24)
return `en ${days}d`
}
</script>
<template>
<div class="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4 hover:ring-1 hover:ring-primary-500 transition-all">
<div class="flex items-center justify-between gap-4">
<NuxtLink :to="`/tracking/${search.id}`" class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<p class="font-semibold truncate">{{ search.name }}</p>
<UBadge :label="statusBadge().label" :color="statusBadge().color" size="xs" />
</div>
<p class="text-sm text-muted mb-1">{{ search.route_summary }}</p>
<div class="flex items-center gap-3 text-xs text-muted flex-wrap">
<span class="flex items-center gap-1">
<UIcon name="i-lucide-timer" class="text-xs" />
{{ formatInterval(search.interval_hours) }}
</span>
<span v-if="search.run_count > 0" class="flex items-center gap-1">
<UIcon name="i-lucide-activity" class="text-xs" />
{{ search.run_count }} ejecucion{{ search.run_count !== 1 ? 'es' : '' }}
</span>
<span v-if="search.last_run_at" class="flex items-center gap-1">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ timeAgo(search.last_run_at) }}
</span>
<span v-if="search.is_active && search.next_run_at" class="flex items-center gap-1">
<UIcon name="i-lucide-calendar-clock" class="text-xs" />
Proxima: {{ timeUntil(search.next_run_at) }}
</span>
</div>
</NuxtLink>
<!-- Precio actual -->
<NuxtLink :to="`/tracking/${search.id}`" class="text-right shrink-0">
<div v-if="search.latest_snapshot" class="mb-1">
<p class="text-lg font-bold">{{ search.latest_snapshot.cheapest_price.toFixed(0) }}&euro;</p>
<p class="text-xs text-muted">{{ search.latest_snapshot.total_results }} resultados</p>
</div>
<p v-else class="text-sm text-muted">Sin datos</p>
</NuxtLink>
<!-- Acciones -->
<div class="flex flex-col gap-1 shrink-0">
<UButton
:icon="search.is_active ? 'i-lucide-pause' : 'i-lucide-play'"
color="neutral"
variant="ghost"
size="xs"
:title="search.is_active ? 'Pausar' : 'Reanudar'"
@click="emit('toggle', search.id, !search.is_active)"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
title="Eliminar"
@click="emit('remove', search.id)"
/>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
const props = defineProps<{
search: {
id: string
name: string
interval_hours: number
is_active: boolean
expires_at: string | null
search_params: Record<string, unknown>
route_summary: string
}
}>()
const emit = defineEmits<{
updated: []
}>()
const { update } = useTrackedSearches()
const name = ref(props.search.name)
const intervalHours = ref(props.search.interval_hours)
const isActive = ref(props.search.is_active)
const expiresAt = ref(props.search.expires_at?.slice(0, 10) || '')
const saving = ref(false)
const today = new Date().toISOString().slice(0, 10)
// Parametros de busqueda editables
const params = props.search.search_params
const departures = ref((params.departures as string[])?.join(',') || '')
const destination = ref('')
const dateFrom = ref('')
const dateTo = ref('')
watch(dateFrom, (from) => {
if (from && dateTo.value && dateTo.value < from) {
dateTo.value = from
}
})
const stayMinDays = ref(2)
const stayMaxDays = ref(7)
// Extraer destino y fechas de los search_params guardados
const interval = params.departureDateInterval as { begin?: string; end?: string } | undefined
if (interval?.begin) dateFrom.value = interval.begin.slice(0, 10)
if (interval?.end) dateTo.value = interval.end.slice(0, 10)
const stops = params.stops as Array<{ locations: string[]; stayRange?: { min: number; max: number } }> | undefined
if (stops?.[0]?.locations?.length) destination.value = stops[0].locations.join(',')
if (stops?.[0]?.stayRange) {
stayMinDays.value = Math.round((stops[0].stayRange.min || 0) / 24) || 2
stayMaxDays.value = Math.round((stops[0].stayRange.max || 0) / 24) || 7
}
const intervalOptions = [
{ label: 'Cada 6 horas', value: 6 },
{ label: 'Cada 12 horas', value: 12 },
{ label: 'Diario (24h)', value: 24 },
{ label: 'Cada 2 dias', value: 48 },
{ label: 'Semanal', value: 168 }
]
function buildSearchParams() {
const depList = departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
const destList = destination.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
const from = dateFrom.value || new Date().toISOString().slice(0, 10)
const to = dateTo.value || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10)
const isOneWay = from === to
const newStops = isOneWay
? [{
locations: destList,
stayRange: { min: 0, max: 0 },
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
continueFromAny: false
}]
: [
{
locations: destList,
stayRange: { min: stayMinDays.value * 24, max: stayMaxDays.value * 24 },
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
continueFromAny: true
},
{
locations: depList,
stayRange: { min: 0, max: 0 },
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
continueFromAny: false
}
]
return {
...params,
departures: depList,
departureDateInterval: {
begin: `${from}T00:00:00+00:00`,
end: `${to}T00:00:00+00:00`
},
stops: newStops,
endInSameLocation: !isOneWay
}
}
async function save() {
saving.value = true
try {
const depList = departures.value.split(',').filter(Boolean)
const destList = destination.value.split(',').filter(Boolean)
const routeSummary = `${depList.join(',')} > ${destList.join(',')}`
await update(props.search.id, {
name: name.value,
interval_hours: intervalHours.value,
is_active: isActive.value,
expires_at: expiresAt.value || null,
search_params: buildSearchParams(),
route_summary: routeSummary
})
emit('updated')
} finally {
saving.value = false
}
}
</script>
<template>
<UCard>
<template #header>
<h3 class="font-semibold text-sm">Configuracion</h3>
</template>
<div class="space-y-3">
<UFormField label="Nombre">
<UInput v-model="name" class="w-full" />
</UFormField>
<div class="grid grid-cols-2 gap-3">
<UFormField label="Origen">
<SearchAirportInput v-model="departures" placeholder="MAD" icon="i-lucide-plane-takeoff" multiple />
</UFormField>
<UFormField label="Destino">
<SearchAirportInput v-model="destination" placeholder="BCN" icon="i-lucide-plane-landing" multiple />
</UFormField>
</div>
<div class="grid grid-cols-2 gap-3">
<UFormField label="Fecha desde">
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" class="w-full" />
</UFormField>
<UFormField label="Fecha hasta">
<UInput v-model="dateTo" type="date" :min="dateFrom || today" class="w-full" />
</UFormField>
</div>
<div class="grid grid-cols-2 gap-3">
<UFormField label="Estancia min (dias)">
<UInput v-model.number="stayMinDays" type="number" :min="1" class="w-full" />
</UFormField>
<UFormField label="Estancia max (dias)">
<UInput v-model.number="stayMaxDays" type="number" :min="1" class="w-full" />
</UFormField>
</div>
<USeparator />
<UFormField label="Frecuencia">
<select
v-model.number="intervalHours"
class="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm"
>
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</UFormField>
<UFormField label="Expira el">
<UInput v-model="expiresAt" type="date" :min="today" class="w-full" />
</UFormField>
<div class="flex items-center justify-between">
<span class="text-sm">Activa</span>
<USwitch v-model="isActive" />
</div>
</div>
<template #footer>
<UButton label="Guardar cambios" size="sm" :loading="saving" @click="save" />
</template>
</UCard>
</template>

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

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>