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:
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>
|
||||
Reference in New Issue
Block a user