Initial commit: Vuelato - buscador de vuelos
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:
Alejandro Martinez
2026-04-10 23:37:06 +02:00
commit b8906efc80
122 changed files with 37809 additions and 0 deletions

202
app/pages/results.vue Normal file
View 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>