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:
8
app/app.config.ts
Normal file
8
app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export default defineAppConfig({
|
||||
ui: {
|
||||
colors: {
|
||||
primary: 'blue',
|
||||
neutral: 'slate'
|
||||
}
|
||||
}
|
||||
})
|
||||
112
app/app.vue
Normal file
112
app/app.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup>
|
||||
useHead({
|
||||
meta: [
|
||||
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
|
||||
],
|
||||
link: [
|
||||
{ rel: 'icon', href: '/favicon.ico' }
|
||||
],
|
||||
htmlAttrs: {
|
||||
lang: 'es'
|
||||
}
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: 'Vuelato - Busca vuelos baratos',
|
||||
description: 'Buscador de vuelos con fechas flexibles'
|
||||
})
|
||||
|
||||
const mobileMenu = ref(false)
|
||||
|
||||
const navLinks = [
|
||||
{ to: '/search', label: 'Buscar', icon: 'i-lucide-search' },
|
||||
{ to: '/explore', label: 'Explorar', icon: 'i-lucide-compass' },
|
||||
{ to: '/multi-city', label: 'Multi-city', icon: 'i-lucide-route' },
|
||||
{ to: '/tracking', label: 'Seguimiento', icon: 'i-lucide-bell' },
|
||||
{ to: '/watchlist', label: 'Watchlist', icon: 'i-lucide-heart' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UApp>
|
||||
<UHeader>
|
||||
<template #left>
|
||||
<NuxtLink to="/" class="font-bold text-lg">
|
||||
Vuelato
|
||||
</NuxtLink>
|
||||
<nav class="hidden md:flex items-center gap-1 ml-4">
|
||||
<UButton
|
||||
v-for="link in navLinks"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
:label="link.label"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
/>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<template #right>
|
||||
<AuthUserMenu />
|
||||
<UColorModeButton />
|
||||
<!-- Mobile menu button -->
|
||||
<UButton
|
||||
class="md:hidden"
|
||||
:icon="mobileMenu ? 'i-lucide-x' : 'i-lucide-menu'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
@click="mobileMenu = !mobileMenu"
|
||||
/>
|
||||
</template>
|
||||
</UHeader>
|
||||
|
||||
<!-- Mobile nav -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 -translate-y-2"
|
||||
enter-to-class="opacity-100 translate-y-0"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 translate-y-0"
|
||||
leave-to-class="opacity-0 -translate-y-2"
|
||||
>
|
||||
<div v-if="mobileMenu" class="md:hidden border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-950 px-4 py-3">
|
||||
<nav class="flex flex-col gap-1">
|
||||
<UButton
|
||||
v-for="link in navLinks"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
:label="link.label"
|
||||
:icon="link.icon"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
block
|
||||
class="justify-start"
|
||||
@click="mobileMenu = false"
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<UMain>
|
||||
<NuxtPage />
|
||||
</UMain>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<UFooter>
|
||||
<template #left>
|
||||
<p class="text-sm text-muted">
|
||||
Vuelato © {{ new Date().getFullYear() }}
|
||||
</p>
|
||||
</template>
|
||||
<template #right>
|
||||
<div class="flex gap-3 text-sm text-muted">
|
||||
<NuxtLink to="/search">Buscar</NuxtLink>
|
||||
<NuxtLink to="/explore">Explorar</NuxtLink>
|
||||
<NuxtLink to="/multi-city">Multi-city</NuxtLink>
|
||||
</div>
|
||||
</template>
|
||||
</UFooter>
|
||||
</UApp>
|
||||
</template>
|
||||
18
app/assets/css/main.css
Normal file
18
app/assets/css/main.css
Normal file
@@ -0,0 +1,18 @@
|
||||
@import "tailwindcss";
|
||||
@import "@nuxt/ui";
|
||||
|
||||
@theme static {
|
||||
--font-sans: 'Public Sans', sans-serif;
|
||||
|
||||
--color-green-50: #EFFDF5;
|
||||
--color-green-100: #D9FBE8;
|
||||
--color-green-200: #B3F5D1;
|
||||
--color-green-300: #75EDAE;
|
||||
--color-green-400: #00DC82;
|
||||
--color-green-500: #00C16A;
|
||||
--color-green-600: #00A155;
|
||||
--color-green-700: #007F45;
|
||||
--color-green-800: #016538;
|
||||
--color-green-900: #0A5331;
|
||||
--color-green-950: #052E16;
|
||||
}
|
||||
80
app/components/FlightLeg.vue
Normal file
80
app/components/FlightLeg.vue
Normal 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>
|
||||
62
app/components/InspirationGrid.vue
Normal file
62
app/components/InspirationGrid.vue
Normal 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">€</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>
|
||||
33
app/components/PassengerPicker.vue
Normal file
33
app/components/PassengerPicker.vue
Normal 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>
|
||||
169
app/components/SearchForm.vue
Normal file
169
app/components/SearchForm.vue
Normal 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
133
app/components/TripCard.vue
Normal 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">€</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>
|
||||
71
app/components/auth/LoginForm.vue
Normal file
71
app/components/auth/LoginForm.vue
Normal 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>
|
||||
46
app/components/auth/UserMenu.vue
Normal file
46
app/components/auth/UserMenu.vue
Normal 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>
|
||||
145
app/components/detail/FlightTracker.vue
Normal file
145
app/components/detail/FlightTracker.vue
Normal 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>
|
||||
49
app/components/detail/ItineraryTimeline.vue
Normal file
49
app/components/detail/ItineraryTimeline.vue
Normal 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>
|
||||
44
app/components/detail/PriceVerifier.vue
Normal file
44
app/components/detail/PriceVerifier.vue
Normal 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>
|
||||
57
app/components/detail/RelatedFlights.vue
Normal file
57
app/components/detail/RelatedFlights.vue
Normal 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) }}€
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
164
app/components/detail/SegmentCard.vue
Normal file
164
app/components/detail/SegmentCard.vue
Normal 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) }}€
|
||||
</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>
|
||||
31
app/components/detail/ShareButton.vue
Normal file
31
app/components/detail/ShareButton.vue
Normal 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>
|
||||
52
app/components/detail/WatchlistToggle.vue
Normal file
52
app/components/detail/WatchlistToggle.vue
Normal 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>
|
||||
57
app/components/inspiration/BudgetExplorer.vue
Normal file
57
app/components/inspiration/BudgetExplorer.vue
Normal 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 }}€?</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) }}€
|
||||
</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>
|
||||
37
app/components/inspiration/MultiCityCard.vue
Normal file
37
app/components/inspiration/MultiCityCard.vue
Normal 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">€</span>
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
144
app/components/map/FlightMap.vue
Normal file
144
app/components/map/FlightMap.vue
Normal 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="© OpenStreetMap © 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) }}€
|
||||
</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>
|
||||
38
app/components/map/MapControls.vue
Normal file
38
app/components/map/MapControls.vue
Normal 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>
|
||||
18
app/components/results/LoadingMore.vue
Normal file
18
app/components/results/LoadingMore.vue
Normal 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>
|
||||
158
app/components/results/ResultsFilters.vue
Normal file
158
app/components/results/ResultsFilters.vue
Normal 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">€</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>
|
||||
99
app/components/results/ResultsToolbar.vue
Normal file
99
app/components/results/ResultsToolbar.vue
Normal 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>
|
||||
39
app/components/results/TripCardCompact.vue
Normal file
39
app/components/results/TripCardCompact.vue
Normal 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">€</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
189
app/components/search/AirportInput.vue
Normal file
189
app/components/search/AirportInput.vue
Normal 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>
|
||||
13
app/components/search/BudgetSlider.vue
Normal file
13
app/components/search/BudgetSlider.vue
Normal 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 }}€</span>
|
||||
</div>
|
||||
<URange v-model="model" :min="20" :max="2000" :step="10" />
|
||||
</div>
|
||||
</template>
|
||||
27
app/components/search/DateRangePicker.vue
Normal file
27
app/components/search/DateRangePicker.vue
Normal 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>
|
||||
24
app/components/search/MaxStopsFilter.vue
Normal file
24
app/components/search/MaxStopsFilter.vue
Normal 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>
|
||||
26
app/components/search/SearchModeTabs.vue
Normal file
26
app/components/search/SearchModeTabs.vue
Normal 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>
|
||||
15
app/components/search/StayDurationPicker.vue
Normal file
15
app/components/search/StayDurationPicker.vue
Normal 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>
|
||||
205
app/components/tracking/CreateTrackingForm.vue
Normal file
205
app/components/tracking/CreateTrackingForm.vue
Normal 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>
|
||||
112
app/components/tracking/PriceChart.vue
Normal file
112
app/components/tracking/PriceChart.vue
Normal 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>
|
||||
96
app/components/tracking/RunHistory.vue
Normal file
96
app/components/tracking/RunHistory.vue
Normal 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) }}€
|
||||
</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) }}€</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>
|
||||
124
app/components/tracking/TrackedSearchCard.vue
Normal file
124
app/components/tracking/TrackedSearchCard.vue
Normal 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) }}€</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>
|
||||
191
app/components/tracking/TrackingConfig.vue
Normal file
191
app/components/tracking/TrackingConfig.vue
Normal 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>
|
||||
36
app/composables/useAirlineNames.ts
Normal file
36
app/composables/useAirlineNames.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Well-known airline codes
|
||||
const KNOWN_AIRLINES: Record<string, string> = {
|
||||
'2W': 'World2Fly', AA: 'American Airlines', AC: 'Air Canada', AF: 'Air France',
|
||||
AI: 'Air India', AM: 'Aeromexico', AR: 'Aerolineas Argentinas', AT: 'Royal Air Maroc',
|
||||
AV: 'Avianca', AY: 'Finnair', AZ: 'ITA Airways', BA: 'British Airways',
|
||||
CM: 'Copa Airlines', CX: 'Cathay Pacific', DL: 'Delta', DY: 'Norwegian',
|
||||
EI: 'Aer Lingus', EK: 'Emirates', ET: 'Ethiopian Airlines', EW: 'Eurowings',
|
||||
EY: 'Etihad', FB: 'Bulgaria Air', FI: 'Icelandair', FR: 'Ryanair',
|
||||
HA: 'Hawaiian Airlines', HU: 'Hainan Airlines', IB: 'Iberia', JL: 'Japan Airlines',
|
||||
JU: 'Air Serbia', KE: 'Korean Air', KL: 'KLM', LA: 'LATAM',
|
||||
LH: 'Lufthansa', LO: 'LOT Polish', LX: 'Swiss', MH: 'Malaysia Airlines',
|
||||
MS: 'EgyptAir', NH: 'ANA', NK: 'Spirit Airlines', OS: 'Austrian',
|
||||
OZ: 'Asiana Airlines', PC: 'Pegasus', QF: 'Qantas', QR: 'Qatar Airways',
|
||||
RO: 'TAROM', SK: 'SAS', SN: 'Brussels Airlines', SQ: 'Singapore Airlines',
|
||||
SU: 'Aeroflot', TK: 'Turkish Airlines', TP: 'TAP Air Portugal', U2: 'easyJet',
|
||||
UA: 'United Airlines', UX: 'Air Europa', VB: 'VivaAerobus', VY: 'Vueling',
|
||||
W6: 'Wizz Air', WS: 'WestJet', X1: 'Hahn Air', ZI: 'Aigle Azur',
|
||||
}
|
||||
|
||||
// Global cache of airline code → name, learned from API responses
|
||||
const airlineNames = reactive(new Map<string, string>(Object.entries(KNOWN_AIRLINES)))
|
||||
|
||||
export function useAirlineNames() {
|
||||
function learn(code: string, name: string) {
|
||||
if (name && !airlineNames.has(code)) {
|
||||
airlineNames.set(code, name)
|
||||
}
|
||||
}
|
||||
|
||||
function resolve(code: string, name?: string | null): string {
|
||||
if (name) return name
|
||||
return airlineNames.get(code) || ''
|
||||
}
|
||||
|
||||
return { airlineNames, learn, resolve }
|
||||
}
|
||||
43
app/composables/useAuth.ts
Normal file
43
app/composables/useAuth.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export function useAuth() {
|
||||
const supabase = useSupabaseClient()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function login(email: string, password: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const { error: err } = await supabase.auth.signInWithPassword({ email, password })
|
||||
if (err) error.value = err.message
|
||||
loading.value = false
|
||||
return !err
|
||||
}
|
||||
|
||||
async function register(email: string, password: string) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const { error: err } = await supabase.auth.signUp({ email, password })
|
||||
if (err) error.value = err.message
|
||||
loading.value = false
|
||||
return !err
|
||||
}
|
||||
|
||||
async function loginWithGoogle() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
const { error: err } = await supabase.auth.signInWithOAuth({
|
||||
provider: 'google',
|
||||
options: { redirectTo: `${window.location.origin}/auth/confirm` }
|
||||
})
|
||||
if (err) error.value = err.message
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await supabase.auth.signOut()
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
return { user, loading, error, login, register, loginWithGoogle, logout }
|
||||
}
|
||||
107
app/composables/useBookingUrl.ts
Normal file
107
app/composables/useBookingUrl.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
interface BookingParams {
|
||||
airlineCode: string
|
||||
origin: string
|
||||
destination: string
|
||||
date: string // ISO date string
|
||||
passengers?: number
|
||||
}
|
||||
|
||||
// Direct booking URL templates for major airlines
|
||||
// date format helpers applied per-airline
|
||||
const BOOKING_TEMPLATES: Record<string, (p: BookingParams & { d: string; ymd: string }) => string> = {
|
||||
FR: p => `https://www.ryanair.com/es/es/trip/flights/select?adults=${p.passengers}&teens=0&children=0&infants=0&dateOut=${p.ymd}&originIata=${p.origin}&destinationIata=${p.destination}&isReturn=false`,
|
||||
U2: p => `https://www.easyjet.com/es/search?origin=${p.origin}&destination=${p.destination}&outboundDate=${p.ymd}&adults=${p.passengers}`,
|
||||
VY: () => `https://www.vueling.com/es/reserva-tu-vuelo/busca-tu-vuelo`,
|
||||
LH: p => `https://www.lufthansa.com/es/es/offer/search?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&paxAdult=${p.passengers}&cabinClass=ECONOMY&tripType=O`,
|
||||
IB: p => `https://www.iberia.com/es/?language=es&market=ES&origin=${p.origin}&destination=${p.destination}&outbound=${p.ymd}&adults=${p.passengers}&cabin=ECONOMY`,
|
||||
W6: p => `https://wizzair.com/es-es#/booking/select-flight/${p.origin}/${p.destination}/${p.ymd}/null/${p.passengers}/0/0/null`,
|
||||
NK: p => `https://www.spirit.com/book/flights?origStation=${p.origin}&destStation=${p.destination}&date=${p.ymd}&adt=${p.passengers}&chd=0&inf=0&promoCode=&tripType=OW`,
|
||||
KL: p => `https://www.klm.es/search?pax=${p.passengers}:0:0:0:0:0:0:0&cabinClass=ECONOMY&connections=${p.origin}:C%3E${p.destination}:C&bookingFlow=LEISURE`,
|
||||
AF: p => `https://www.airfrance.es/search?pax=${p.passengers}:0:0:0:0:0:0:0&cabinClass=ECONOMY&connections=${p.origin}:C%3E${p.destination}:C&bookingFlow=LEISURE`,
|
||||
TK: p => `https://www.turkishairlines.com/es-es/flights/?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&adult=${p.passengers}&child=0&infant=0&tripType=O`,
|
||||
AA: p => `https://www.aa.com/booking/find-flights?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&pax=${p.passengers}&tripType=OneWay&locale=es_ES`,
|
||||
}
|
||||
|
||||
interface AirlineData {
|
||||
website: string | null
|
||||
bookingUrl: string | null
|
||||
bookingUrlTemplate: string | null
|
||||
}
|
||||
|
||||
// Airline data cache (loaded once from API)
|
||||
const airlineData = ref<Map<string, AirlineData> | null>(null)
|
||||
let loadingPromise: Promise<void> | null = null
|
||||
|
||||
async function loadAirlineData() {
|
||||
if (airlineData.value) return
|
||||
if (loadingPromise) return loadingPromise
|
||||
|
||||
loadingPromise = $fetch<{ airlines: { iata: string; website: string | null; booking_url: string | null; booking_url_template: string | null }[] }>('/api/airlines')
|
||||
.then(res => {
|
||||
const map = new Map<string, AirlineData>()
|
||||
for (const a of res.airlines) {
|
||||
map.set(a.iata, {
|
||||
website: a.website,
|
||||
bookingUrl: a.booking_url,
|
||||
bookingUrlTemplate: a.booking_url_template,
|
||||
})
|
||||
}
|
||||
airlineData.value = map
|
||||
})
|
||||
.catch(() => {
|
||||
airlineData.value = new Map()
|
||||
})
|
||||
|
||||
return loadingPromise
|
||||
}
|
||||
|
||||
function applyTemplate(template: string, params: BookingParams, pax: number, ymd: string): string {
|
||||
return template
|
||||
.replace(/\{origin\}/gi, params.origin)
|
||||
.replace(/\{destination\}/gi, params.destination)
|
||||
.replace(/\{date\}/gi, ymd)
|
||||
.replace(/\{passengers\}/gi, String(pax))
|
||||
}
|
||||
|
||||
function buildGoogleFlightsUrl(p: BookingParams): string {
|
||||
const ymd = p.date.slice(0, 10)
|
||||
return `https://www.google.com/travel/flights?hl=es&curr=EUR&q=flights+from+${p.origin}+to+${p.destination}+on+${ymd}+one+way+${p.passengers}+passenger`
|
||||
}
|
||||
|
||||
export function useBookingUrl() {
|
||||
// Start loading on first use
|
||||
loadAirlineData()
|
||||
|
||||
function getBookingUrl(params: BookingParams): string {
|
||||
const pax = params.passengers || 1
|
||||
const ymd = params.date.slice(0, 10)
|
||||
const d = ymd.replace(/-/g, '')
|
||||
|
||||
// 1. Hardcoded templates (most reliable, manually verified)
|
||||
const hardcoded = BOOKING_TEMPLATES[params.airlineCode]
|
||||
if (hardcoded) {
|
||||
return hardcoded({ ...params, passengers: pax, d, ymd })
|
||||
}
|
||||
|
||||
const data = airlineData.value?.get(params.airlineCode)
|
||||
|
||||
// 2. Auto-discovered template with placeholders
|
||||
if (data?.bookingUrlTemplate) {
|
||||
return applyTemplate(data.bookingUrlTemplate, params, pax, ymd)
|
||||
}
|
||||
|
||||
// 3. Discovered booking page URL (no params, but lands on the right page)
|
||||
if (data?.bookingUrl) {
|
||||
return data.bookingUrl
|
||||
}
|
||||
|
||||
// 4. Google Flights as universal fallback
|
||||
return buildGoogleFlightsUrl({ ...params, passengers: pax })
|
||||
}
|
||||
|
||||
function getAirlineWebsite(code: string): string | null {
|
||||
return airlineData.value?.get(code)?.website || null
|
||||
}
|
||||
|
||||
return { getBookingUrl, getAirlineWebsite }
|
||||
}
|
||||
32
app/composables/useDestinationImages.ts
Normal file
32
app/composables/useDestinationImages.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
const imageCache = reactive<Record<string, { thumb_url: string; image_url: string; photographer: string; photographer_url: string } | null>>({})
|
||||
const pending = new Set<string>()
|
||||
|
||||
export function useDestinationImages() {
|
||||
async function fetchImage(cityName: string) {
|
||||
const key = cityName.trim().toLowerCase()
|
||||
if (key in imageCache || pending.has(key)) return
|
||||
pending.add(key)
|
||||
|
||||
try {
|
||||
const data = await $fetch<{ thumb_url: string; image_url: string; photographer: string; photographer_url: string } | null>('/api/destination-image', {
|
||||
query: { city: cityName },
|
||||
})
|
||||
imageCache[key] = data
|
||||
} catch {
|
||||
imageCache[key] = null
|
||||
} finally {
|
||||
pending.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
function getImage(cityName: string) {
|
||||
const key = cityName.trim().toLowerCase()
|
||||
return imageCache[key] ?? null
|
||||
}
|
||||
|
||||
function prefetch(cityNames: string[]) {
|
||||
cityNames.forEach(name => fetchImage(name))
|
||||
}
|
||||
|
||||
return { fetchImage, getImage, prefetch, imageCache }
|
||||
}
|
||||
206
app/composables/useFlightSearch.ts
Normal file
206
app/composables/useFlightSearch.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import type { SearchResponse, DetailResponse, PassengersCount, InspirationsResponse, Trip } from '~/server/utils/flightics'
|
||||
|
||||
interface SearchFormData {
|
||||
departures: string[]
|
||||
destination: string[]
|
||||
dateFrom: string
|
||||
dateTo: string
|
||||
stayMinDays: number
|
||||
stayMaxDays: number
|
||||
passengers: PassengersCount
|
||||
maxResults?: number
|
||||
maxStops?: number | null
|
||||
multiCityStops?: string[]
|
||||
}
|
||||
|
||||
export function useFlightSearch() {
|
||||
const trips = ref<Trip[]>([])
|
||||
const loading = ref(false)
|
||||
const polling = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const searchMeta = ref<{ notComplete: boolean, responseId: string, pollCount: number }>({
|
||||
notComplete: false,
|
||||
responseId: '',
|
||||
pollCount: 0
|
||||
})
|
||||
|
||||
let abortController: AbortController | null = null
|
||||
|
||||
function buildPayload(form: SearchFormData) {
|
||||
// Default dates: from today, to +30 days if not provided
|
||||
const now = new Date()
|
||||
const defaultFrom = now.toISOString().slice(0, 10)
|
||||
const defaultTo = new Date(now.getTime() + 30 * 86400000).toISOString().slice(0, 10)
|
||||
const dateFrom = form.dateFrom || defaultFrom
|
||||
const dateTo = form.dateTo || defaultTo
|
||||
|
||||
const isOneWay = dateFrom === dateTo
|
||||
const isMultiCity = form.multiCityStops && form.multiCityStops.length > 0
|
||||
|
||||
const defaultStayRange = { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' }
|
||||
|
||||
let stops
|
||||
if (isMultiCity) {
|
||||
// Each intermediate city gets a stay, then return to origin
|
||||
stops = [
|
||||
...form.multiCityStops!.map(code => ({
|
||||
locations: [code],
|
||||
stayRange: { min: form.stayMinDays * 24, max: form.stayMaxDays * 24 },
|
||||
stayDateRange: defaultStayRange,
|
||||
continueFromAny: true
|
||||
})),
|
||||
{
|
||||
locations: form.departures,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: defaultStayRange,
|
||||
continueFromAny: false
|
||||
}
|
||||
]
|
||||
} else if (isOneWay) {
|
||||
stops = [{
|
||||
locations: form.destination,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: defaultStayRange,
|
||||
continueFromAny: false
|
||||
}]
|
||||
} else {
|
||||
stops = [
|
||||
{
|
||||
locations: form.destination,
|
||||
stayRange: { min: form.stayMinDays * 24, max: form.stayMaxDays * 24 },
|
||||
stayDateRange: defaultStayRange,
|
||||
continueFromAny: true
|
||||
},
|
||||
{
|
||||
locations: form.departures,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: defaultStayRange,
|
||||
continueFromAny: false
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
departures: form.departures,
|
||||
local: 'en',
|
||||
departureDateInterval: {
|
||||
begin: `${dateFrom}T00:00:00+00:00`,
|
||||
end: `${dateTo}T00:00:00+00:00`
|
||||
},
|
||||
stops,
|
||||
endInSameLocation: isMultiCity || !isOneWay,
|
||||
maxStops: form.maxStops ?? null,
|
||||
fixStopsOrder: false,
|
||||
stopLength: { min: 0, max: 0, isSet: false },
|
||||
maxResults: form.maxResults || 45,
|
||||
passengersCount: form.passengers
|
||||
}
|
||||
}
|
||||
|
||||
const { learn: learnAirline } = useAirlineNames()
|
||||
|
||||
function learnAirlineNames(tripList: Trip[]) {
|
||||
for (const trip of tripList) {
|
||||
for (const leg of trip.legs) {
|
||||
for (const seg of leg.segments) {
|
||||
if (seg.company?.name && seg.company.code) {
|
||||
learnAirline(seg.company.code, seg.company.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate trips by bookingToken
|
||||
function mergeTrips(existing: Trip[], incoming: Trip[]): Trip[] {
|
||||
learnAirlineNames(incoming)
|
||||
const seen = new Set(existing.map(t => t.bookingToken))
|
||||
const newTrips = incoming.filter(t => !seen.has(t.bookingToken))
|
||||
return [...existing, ...newTrips]
|
||||
}
|
||||
|
||||
async function search(form: SearchFormData, maxPolls = 3) {
|
||||
// Abort any ongoing search
|
||||
abortController?.abort()
|
||||
abortController = new AbortController()
|
||||
|
||||
loading.value = true
|
||||
error.value = null
|
||||
trips.value = []
|
||||
searchMeta.value = { notComplete: false, responseId: '', pollCount: 0 }
|
||||
|
||||
const payload = buildPayload(form)
|
||||
|
||||
try {
|
||||
// Initial search
|
||||
const data = await $fetch<SearchResponse>('/api/search', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
signal: abortController.signal
|
||||
})
|
||||
const initialTrips = data.trips || []
|
||||
learnAirlineNames(initialTrips)
|
||||
trips.value = initialTrips
|
||||
searchMeta.value = { notComplete: data.notComplete, responseId: data.responseId, pollCount: 1 }
|
||||
loading.value = false
|
||||
|
||||
// Progressive polling if more results available
|
||||
if (data.notComplete && maxPolls > 1) {
|
||||
polling.value = true
|
||||
for (let i = 1; i < maxPolls; i++) {
|
||||
if (abortController.signal.aborted) break
|
||||
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
|
||||
const more = await $fetch<SearchResponse>('/api/search', {
|
||||
method: 'POST',
|
||||
body: payload,
|
||||
signal: abortController.signal
|
||||
})
|
||||
|
||||
trips.value = mergeTrips(trips.value, more.trips || [])
|
||||
searchMeta.value = {
|
||||
notComplete: more.notComplete,
|
||||
responseId: more.responseId,
|
||||
pollCount: i + 1
|
||||
}
|
||||
|
||||
if (!more.notComplete) break
|
||||
}
|
||||
polling.value = false
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') return
|
||||
error.value = e?.data?.message || e?.message || 'Error searching flights'
|
||||
loading.value = false
|
||||
polling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
abortController?.abort()
|
||||
polling.value = false
|
||||
}
|
||||
|
||||
async function getDetail(bookingToken: string, passengers: PassengersCount) {
|
||||
return $fetch<DetailResponse>('/api/detail', {
|
||||
method: 'POST',
|
||||
body: { bookingToken, local: 'en', passengersCount: passengers }
|
||||
})
|
||||
}
|
||||
|
||||
async function checkPrice(bookingToken: string, passengers: PassengersCount) {
|
||||
return $fetch<DetailResponse>('/api/check', {
|
||||
method: 'POST',
|
||||
body: { bookingToken, local: 'en', passengersCount: passengers }
|
||||
})
|
||||
}
|
||||
|
||||
async function fetchInspirations(from: string, take: number = 100) {
|
||||
return $fetch<InspirationsResponse>('/api/inspirations', {
|
||||
query: { from, take, locale: 'en' }
|
||||
})
|
||||
}
|
||||
|
||||
return { trips, loading, polling, error, searchMeta, search, stopPolling, getDetail, checkPrice, fetchInspirations, buildPayload }
|
||||
}
|
||||
67
app/composables/useLocations.ts
Normal file
67
app/composables/useLocations.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
interface AirportResult {
|
||||
iata: string
|
||||
name: string
|
||||
city_name: string
|
||||
country_code: string
|
||||
country_name: string
|
||||
lat: number | null
|
||||
lon: number | null
|
||||
}
|
||||
|
||||
export function useLocations() {
|
||||
const supabase = useSupabaseClient()
|
||||
|
||||
const airports = ref<AirportResult[]>([])
|
||||
const loaded = ref(false)
|
||||
|
||||
async function loadAirports() {
|
||||
if (loaded.value) return
|
||||
const { data } = await supabase
|
||||
.from('airports')
|
||||
.select('iata, name, city_name, country_code, country_name, lat, lon')
|
||||
.order('iata')
|
||||
.limit(10000)
|
||||
if (data && data.length > 0) {
|
||||
airports.value = data as AirportResult[]
|
||||
loaded.value = true
|
||||
} else {
|
||||
// Fallback: fetch from Flightics API and cache in Supabase
|
||||
await syncLocations()
|
||||
}
|
||||
}
|
||||
|
||||
async function syncLocations() {
|
||||
try {
|
||||
const data = await $fetch('/api/sync/locations', { method: 'POST' })
|
||||
if (data?.count) {
|
||||
// Reload from Supabase after sync
|
||||
const { data: fresh } = await supabase
|
||||
.from('airports')
|
||||
.select('iata, name, city_name, country_code, country_name, lat, lon')
|
||||
.order('iata')
|
||||
airports.value = (fresh as AirportResult[]) || []
|
||||
loaded.value = true
|
||||
}
|
||||
} catch {
|
||||
// Silently fail, user can retry
|
||||
}
|
||||
}
|
||||
|
||||
function normalize(s: string): string {
|
||||
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
||||
}
|
||||
|
||||
function searchAirports(query: string): AirportResult[] {
|
||||
if (!query || query.length < 2) return []
|
||||
const q = normalize(query)
|
||||
return airports.value
|
||||
.filter(a =>
|
||||
normalize(a.iata).includes(q) ||
|
||||
normalize(a.name).includes(q) ||
|
||||
normalize(a.city_name || '').includes(q)
|
||||
)
|
||||
.slice(0, 20)
|
||||
}
|
||||
|
||||
return { airports, loaded, loadAirports, searchAirports, syncLocations }
|
||||
}
|
||||
18
app/composables/useOriginTime.ts
Normal file
18
app/composables/useOriginTime.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
export function useOriginTime() {
|
||||
const cookie = useCookie('showOriginTime', { default: () => false, watch: true })
|
||||
const { profile, updateProfile } = useUserPreferences()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const showOriginTime = computed({
|
||||
get: () => user.value && profile.value ? profile.value.show_origin_time : cookie.value,
|
||||
set: (v: boolean) => {
|
||||
cookie.value = v
|
||||
if (user.value && profile.value) {
|
||||
profile.value.show_origin_time = v
|
||||
updateProfile({ show_origin_time: v })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return { showOriginTime }
|
||||
}
|
||||
44
app/composables/useRecentSearches.ts
Normal file
44
app/composables/useRecentSearches.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
interface RecentSearch {
|
||||
id: string
|
||||
search_params: Record<string, any>
|
||||
route_summary: string
|
||||
search_mode: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useRecentSearches() {
|
||||
const supabase = useSupabaseClient()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const searches = ref<RecentSearch[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchRecent(limit = 10) {
|
||||
if (!user.value) return
|
||||
loading.value = true
|
||||
const { data } = await supabase
|
||||
.from('recent_searches')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(limit)
|
||||
searches.value = (data as RecentSearch[]) || []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function saveSearch(params: Record<string, any>, routeSummary: string, mode: string) {
|
||||
if (!user.value) return
|
||||
await supabase.from('recent_searches').insert({
|
||||
user_id: user.value.id,
|
||||
search_params: params,
|
||||
route_summary: routeSummary,
|
||||
search_mode: mode
|
||||
})
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u) fetchRecent()
|
||||
else searches.value = []
|
||||
}, { immediate: true })
|
||||
|
||||
return { searches, loading, fetchRecent, saveSearch }
|
||||
}
|
||||
144
app/composables/useResultFilters.ts
Normal file
144
app/composables/useResultFilters.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
export type SortKey = 'price' | 'duration' | 'departure' | 'stops'
|
||||
|
||||
interface Filters {
|
||||
maxPrice: number | null
|
||||
maxStops: number | null
|
||||
airlines: string[]
|
||||
departureTimeRange: [number, number] // hours 0-24
|
||||
}
|
||||
|
||||
function getTripDuration(trip: Trip): number {
|
||||
let total = 0
|
||||
for (const leg of trip.legs) {
|
||||
for (const seg of leg.segments) {
|
||||
total += (seg.arrivalUtcTimestamp ?? 0) - (seg.departureUtcTimestamp ?? 0)
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
function getTripStops(trip: Trip): number {
|
||||
return trip.legs.reduce((sum, leg) => sum + Math.max(0, leg.segments.length - 1), 0)
|
||||
}
|
||||
|
||||
function getTripAirlines(trip: Trip): string[] {
|
||||
const codes = new Set<string>()
|
||||
for (const leg of trip.legs) {
|
||||
for (const seg of leg.segments) {
|
||||
if (seg.company?.code) codes.add(seg.company.code)
|
||||
}
|
||||
}
|
||||
return [...codes]
|
||||
}
|
||||
|
||||
function getDepartureHour(trip: Trip): number {
|
||||
const dep = trip.legs[0]?.segments[0]?.departureDate
|
||||
return dep ? new Date(dep).getHours() : 0
|
||||
}
|
||||
|
||||
export function useResultFilters(trips: Ref<Trip[]>) {
|
||||
const sortBy = ref<SortKey>('price')
|
||||
const filters = reactive<Filters>({
|
||||
maxPrice: null,
|
||||
maxStops: null,
|
||||
airlines: [],
|
||||
departureTimeRange: [0, 24]
|
||||
})
|
||||
const viewMode = ref<'full' | 'compact'>('full')
|
||||
|
||||
// Extract available airlines from results
|
||||
const { resolve: resolveAirline } = useAirlineNames()
|
||||
const availableAirlines = computed(() => {
|
||||
const codes = new Set<string>()
|
||||
for (const trip of trips.value) {
|
||||
for (const leg of trip.legs) {
|
||||
for (const seg of leg.segments) {
|
||||
if (seg.company?.code) codes.add(seg.company.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...codes].sort().map(code => ({ code, name: resolveAirline(code) }))
|
||||
})
|
||||
|
||||
// Price range in results
|
||||
const priceRange = computed(() => {
|
||||
if (!trips.value.length) return { min: 0, max: 1000 }
|
||||
const prices = trips.value.map(t => t.totalCost)
|
||||
return { min: Math.floor(Math.min(...prices)), max: Math.ceil(Math.max(...prices)) }
|
||||
})
|
||||
|
||||
const filtered = computed(() => {
|
||||
let result = [...trips.value]
|
||||
|
||||
// Filter by price
|
||||
if (filters.maxPrice != null) {
|
||||
result = result.filter(t => t.totalCost <= filters.maxPrice!)
|
||||
}
|
||||
|
||||
// Filter by stops
|
||||
if (filters.maxStops != null) {
|
||||
result = result.filter(t => getTripStops(t) <= filters.maxStops!)
|
||||
}
|
||||
|
||||
// Filter by airlines (exclusive: all airlines in the trip must be in the selected set)
|
||||
if (filters.airlines.length > 0) {
|
||||
result = result.filter(t => {
|
||||
const tripAirlines = getTripAirlines(t)
|
||||
return tripAirlines.every(a => filters.airlines.includes(a))
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by departure time
|
||||
if (filters.departureTimeRange[0] > 0 || filters.departureTimeRange[1] < 24) {
|
||||
result = result.filter(t => {
|
||||
const hour = getDepartureHour(t)
|
||||
return hour >= filters.departureTimeRange[0] && hour <= filters.departureTimeRange[1]
|
||||
})
|
||||
}
|
||||
|
||||
// Sort
|
||||
if (sortBy.value === 'price') {
|
||||
result.sort((a, b) => a.totalCost - b.totalCost)
|
||||
} else if (sortBy.value === 'departure') {
|
||||
result.sort((a, b) => {
|
||||
const aTime = a.legs[0]?.segments[0]?.departureTimestamp ?? 0
|
||||
const bTime = b.legs[0]?.segments[0]?.departureTimestamp ?? 0
|
||||
return aTime - bTime
|
||||
})
|
||||
} else if (sortBy.value === 'duration') {
|
||||
result.sort((a, b) => getTripDuration(a) - getTripDuration(b))
|
||||
} else if (sortBy.value === 'stops') {
|
||||
result.sort((a, b) => getTripStops(a) - getTripStops(b))
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
filters.maxPrice = null
|
||||
filters.maxStops = null
|
||||
filters.airlines = []
|
||||
filters.departureTimeRange = [0, 24]
|
||||
}
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
filters.maxPrice != null ||
|
||||
filters.maxStops != null ||
|
||||
filters.airlines.length > 0 ||
|
||||
filters.departureTimeRange[0] > 0 ||
|
||||
filters.departureTimeRange[1] < 24
|
||||
)
|
||||
|
||||
return {
|
||||
sortBy,
|
||||
filters,
|
||||
viewMode,
|
||||
filtered,
|
||||
availableAirlines,
|
||||
priceRange,
|
||||
hasActiveFilters,
|
||||
resetFilters
|
||||
}
|
||||
}
|
||||
26
app/composables/useRouteFlights.ts
Normal file
26
app/composables/useRouteFlights.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { RouteFlightsResponse, PassengersCount, Trip } from '~/server/utils/flightics'
|
||||
|
||||
export function useRouteFlights() {
|
||||
const trips = ref<Trip[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function fetchRouteFlights(from: string, to: string, passengers?: PassengersCount) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
trips.value = []
|
||||
try {
|
||||
const data = await $fetch<RouteFlightsResponse>('/api/route-flights', {
|
||||
method: 'POST',
|
||||
body: { from, to, locale: 'en', passengersCount: passengers }
|
||||
})
|
||||
trips.value = data.returnResults?.flatMap(r => r.trips) || []
|
||||
} catch (e: any) {
|
||||
error.value = e?.data?.message || e?.message || 'Error loading route flights'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return { trips, loading, error, fetchRouteFlights }
|
||||
}
|
||||
115
app/composables/useTrackedSearches.ts
Normal file
115
app/composables/useTrackedSearches.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
export interface TrackedSearch {
|
||||
id: string
|
||||
name: string
|
||||
search_params: Record<string, unknown>
|
||||
route_summary: string
|
||||
interval_hours: number
|
||||
is_active: boolean
|
||||
next_run_at: string | null
|
||||
last_run_at: string | null
|
||||
run_count: number
|
||||
last_error: string | null
|
||||
expires_at: string | null
|
||||
created_at: string
|
||||
latest_snapshot: PriceSnapshot | null
|
||||
}
|
||||
|
||||
export interface PriceSnapshot {
|
||||
cheapest_price: number
|
||||
avg_price: number | null
|
||||
median_price: number | null
|
||||
total_results: number
|
||||
recorded_at: string
|
||||
}
|
||||
|
||||
export interface TopTrip {
|
||||
price: number
|
||||
currency: string
|
||||
bookingToken: string
|
||||
legs: Array<{ from: string; to: string; departure: string; airlines: string[] }>
|
||||
}
|
||||
|
||||
export interface SearchRun {
|
||||
id: string
|
||||
tracked_search_id: string
|
||||
status: string
|
||||
cheapest_price: number | null
|
||||
total_trips_found: number
|
||||
top_trips: TopTrip[] | null
|
||||
from_cache: boolean
|
||||
error_message: string | null
|
||||
started_at: string | null
|
||||
completed_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useTrackedSearches() {
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const trackedSearches = ref<TrackedSearch[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchAll() {
|
||||
if (!user.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await $fetch<TrackedSearch[]>('/api/tracking')
|
||||
trackedSearches.value = data || []
|
||||
} catch {
|
||||
trackedSearches.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function create(params: {
|
||||
name: string
|
||||
searchParams: Record<string, unknown>
|
||||
routeSummary: string
|
||||
intervalHours?: number
|
||||
expiresAt?: string
|
||||
}) {
|
||||
const data = await $fetch<TrackedSearch>('/api/tracking', {
|
||||
method: 'POST',
|
||||
body: params
|
||||
})
|
||||
await fetchAll()
|
||||
return data
|
||||
}
|
||||
|
||||
async function update(id: string, patch: Partial<Pick<TrackedSearch, 'name' | 'interval_hours' | 'is_active' | 'expires_at' | 'search_params' | 'route_summary'>>) {
|
||||
const data = await $fetch<TrackedSearch>(`/api/tracking/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: patch
|
||||
})
|
||||
const idx = trackedSearches.value.findIndex(s => s.id === id)
|
||||
if (idx >= 0) {
|
||||
trackedSearches.value[idx] = { ...trackedSearches.value[idx], ...data }
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
await $fetch(`/api/tracking/${id}`, { method: 'DELETE' })
|
||||
trackedSearches.value = trackedSearches.value.filter(s => s.id !== id)
|
||||
}
|
||||
|
||||
async function getHistory(id: string, days = 30): Promise<PriceSnapshot[]> {
|
||||
return $fetch<PriceSnapshot[]>(`/api/tracking/${id}/history`, {
|
||||
query: { days }
|
||||
})
|
||||
}
|
||||
|
||||
async function getRuns(id: string, limit = 20): Promise<SearchRun[]> {
|
||||
return $fetch<SearchRun[]>(`/api/tracking/${id}/runs`, {
|
||||
query: { limit }
|
||||
})
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u) fetchAll()
|
||||
else trackedSearches.value = []
|
||||
}, { immediate: true })
|
||||
|
||||
return { trackedSearches, loading, fetchAll, create, update, remove, getHistory, getRuns }
|
||||
}
|
||||
62
app/composables/useUserPreferences.ts
Normal file
62
app/composables/useUserPreferences.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
interface UserProfile {
|
||||
home_airports: string[]
|
||||
default_adults: number
|
||||
default_children: number
|
||||
default_infants: number
|
||||
locale: string
|
||||
show_origin_time: boolean
|
||||
}
|
||||
|
||||
export function useUserPreferences() {
|
||||
const user = useSupabaseUser()
|
||||
const profile = useState<UserProfile | null>('user-profile', () => null)
|
||||
const loading = useState<boolean>('user-profile-loading', () => false)
|
||||
|
||||
let fetchPromise: Promise<void> | null = null
|
||||
|
||||
async function fetchProfile() {
|
||||
if (!user.value) return
|
||||
if (fetchPromise) return fetchPromise
|
||||
loading.value = true
|
||||
fetchPromise = (async () => {
|
||||
try {
|
||||
const data = await $fetch<UserProfile>('/api/profile')
|
||||
profile.value = data
|
||||
} catch {
|
||||
profile.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
fetchPromise = null
|
||||
}
|
||||
})()
|
||||
return fetchPromise
|
||||
}
|
||||
|
||||
async function updateProfile(updates: Partial<UserProfile>) {
|
||||
if (!user.value) return
|
||||
try {
|
||||
const data = await $fetch<UserProfile>('/api/profile', {
|
||||
method: 'PATCH',
|
||||
body: updates
|
||||
})
|
||||
profile.value = data
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
const homeAirports = computed(() => profile.value?.home_airports ?? [])
|
||||
|
||||
const defaultPassengers = computed(() => ({
|
||||
adult: profile.value?.default_adults ?? 1,
|
||||
child: profile.value?.default_children ?? 0,
|
||||
infant: profile.value?.default_infants ?? 0
|
||||
}))
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u) fetchProfile()
|
||||
else profile.value = null
|
||||
}, { immediate: true })
|
||||
|
||||
return { profile, loading, homeAirports, defaultPassengers, fetchProfile, updateProfile }
|
||||
}
|
||||
135
app/composables/useWatchlist.ts
Normal file
135
app/composables/useWatchlist.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
interface WatchlistItem {
|
||||
id: string
|
||||
booking_token: string
|
||||
route_summary: string
|
||||
departure_code: string
|
||||
arrival_code: string
|
||||
departure_date: string
|
||||
original_price: number
|
||||
current_price: number | null
|
||||
price_status: string
|
||||
passengers_adult: number
|
||||
passengers_child: number
|
||||
passengers_infant: number
|
||||
last_checked_at: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function useWatchlist() {
|
||||
const supabase = useSupabaseClient()
|
||||
const user = useSupabaseUser()
|
||||
|
||||
const items = ref<WatchlistItem[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchAll() {
|
||||
if (!user.value) return
|
||||
loading.value = true
|
||||
const { data } = await supabase
|
||||
.from('watchlist')
|
||||
.select('*')
|
||||
.order('created_at', { ascending: false })
|
||||
items.value = (data as WatchlistItem[]) || []
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
async function add(params: {
|
||||
bookingToken: string
|
||||
routeSummary: string
|
||||
departureCode: string
|
||||
arrivalCode: string
|
||||
departureDate: string
|
||||
price: number
|
||||
passengers: { adult: number; child: number; infant: number }
|
||||
}) {
|
||||
if (!user.value) return false
|
||||
const { error } = await supabase.from('watchlist').insert({
|
||||
user_id: user.value.id,
|
||||
booking_token: params.bookingToken,
|
||||
route_summary: params.routeSummary,
|
||||
departure_code: params.departureCode,
|
||||
arrival_code: params.arrivalCode,
|
||||
departure_date: params.departureDate,
|
||||
original_price: params.price,
|
||||
current_price: params.price,
|
||||
passengers_adult: params.passengers.adult,
|
||||
passengers_child: params.passengers.child,
|
||||
passengers_infant: params.passengers.infant
|
||||
})
|
||||
if (!error) await fetchAll()
|
||||
return !error
|
||||
}
|
||||
|
||||
async function remove(id: string) {
|
||||
const { error } = await supabase.from('watchlist').delete().eq('id', id)
|
||||
if (!error) items.value = items.value.filter(i => i.id !== id)
|
||||
return !error
|
||||
}
|
||||
|
||||
async function checkPrice(item: WatchlistItem) {
|
||||
try {
|
||||
const data = await $fetch<any>('/api/check', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
bookingToken: item.booking_token,
|
||||
local: 'en',
|
||||
passengersCount: {
|
||||
adult: item.passengers_adult,
|
||||
child: item.passengers_child,
|
||||
infant: item.passengers_infant
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const newPrice = data.trip.totalCost
|
||||
let status = 'available'
|
||||
if (newPrice < item.original_price) status = 'price_down'
|
||||
else if (newPrice > item.original_price) status = 'price_up'
|
||||
|
||||
await supabase.from('watchlist').update({
|
||||
current_price: newPrice,
|
||||
price_status: status,
|
||||
last_checked_at: new Date().toISOString()
|
||||
}).eq('id', item.id)
|
||||
|
||||
// Update local state
|
||||
const idx = items.value.findIndex(i => i.id === item.id)
|
||||
if (idx >= 0) {
|
||||
items.value[idx] = { ...items.value[idx], current_price: newPrice, price_status: status, last_checked_at: new Date().toISOString() }
|
||||
}
|
||||
return { price: newPrice, status }
|
||||
} catch {
|
||||
await supabase.from('watchlist').update({
|
||||
price_status: 'unavailable',
|
||||
last_checked_at: new Date().toISOString()
|
||||
}).eq('id', item.id)
|
||||
|
||||
const idx = items.value.findIndex(i => i.id === item.id)
|
||||
if (idx >= 0) {
|
||||
items.value[idx] = { ...items.value[idx], price_status: 'unavailable', last_checked_at: new Date().toISOString() }
|
||||
}
|
||||
return { price: null, status: 'unavailable' }
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAll() {
|
||||
for (const item of items.value) {
|
||||
await checkPrice(item)
|
||||
}
|
||||
}
|
||||
|
||||
function isWatched(bookingToken: string) {
|
||||
return items.value.some(i => i.booking_token === bookingToken)
|
||||
}
|
||||
|
||||
function getWatchedItem(bookingToken: string) {
|
||||
return items.value.find(i => i.booking_token === bookingToken)
|
||||
}
|
||||
|
||||
watch(user, (u) => {
|
||||
if (u) fetchAll()
|
||||
else items.value = []
|
||||
}, { immediate: true })
|
||||
|
||||
return { items, loading, fetchAll, add, remove, checkPrice, checkAll, isWatched, getWatchedItem }
|
||||
}
|
||||
16
app/pages/auth.vue
Normal file
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