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