Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos, inspiraciones, watchlist, tracking de precios y mapa interactivo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
app/pages/auth.vue
Normal file
16
app/pages/auth.vue
Normal 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>
|
||||
17
app/pages/auth/confirm.vue
Normal file
17
app/pages/auth/confirm.vue
Normal 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>
|
||||
117
app/pages/detail/[token].vue
Normal file
117
app/pages/detail/[token].vue
Normal 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) }}€
|
||||
</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
141
app/pages/explore.vue
Normal 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">€</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
197
app/pages/index.vue
Normal 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
211
app/pages/multi-city.vue
Normal 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
202
app/pages/results.vue
Normal 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
149
app/pages/route/[slug].vue
Normal 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">€</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
59
app/pages/search.vue
Normal 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
132
app/pages/settings.vue
Normal 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
159
app/pages/tracking/[id].vue
Normal 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) }}€</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) }}€
|
||||
</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) }}€</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) }}€</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) }}€</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>
|
||||
87
app/pages/tracking/index.vue
Normal file
87
app/pages/tracking/index.vue
Normal 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
201
app/pages/watchlist.vue
Normal 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) }}€</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) }}€
|
||||
</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>
|
||||
Reference in New Issue
Block a user