Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled

Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos,
inspiraciones, watchlist, tracking de precios y mapa interactivo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Martinez
2026-04-10 23:37:06 +02:00
commit b8906efc80
122 changed files with 37809 additions and 0 deletions

View File

@@ -0,0 +1,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>