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