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>
212 lines
6.9 KiB
Vue
212 lines
6.9 KiB
Vue
<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>
|