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:
149
app/pages/route/[slug].vue
Normal file
149
app/pages/route/[slug].vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { trips, loading, error, fetchRouteFlights } = useRouteFlights()
|
||||
|
||||
const slug = computed(() => route.params.slug as string)
|
||||
const from = computed(() => slug.value.split('-')[0]?.toUpperCase() || '')
|
||||
const to = computed(() => slug.value.split('-')[1]?.toUpperCase() || '')
|
||||
|
||||
useSeoMeta({ title: () => `Vuelato - ${from.value} → ${to.value}` })
|
||||
|
||||
onMounted(() => {
|
||||
if (from.value && to.value) {
|
||||
fetchRouteFlights(from.value, to.value)
|
||||
}
|
||||
})
|
||||
|
||||
function selectTrip(trip: Trip) {
|
||||
router.push({
|
||||
path: `/detail/${encodeURIComponent(trip.bookingToken)}`,
|
||||
query: { price: String(trip.totalCost), adults: '1', children: '0', infants: '0' }
|
||||
})
|
||||
}
|
||||
|
||||
function formatTime(d: string) {
|
||||
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function legLabel(index: number, total: number) {
|
||||
if (total === 1) return 'Solo ida'
|
||||
return index === 0 ? 'Ida' : 'Vuelta'
|
||||
}
|
||||
|
||||
function legStopsText(leg: Trip['legs'][0]) {
|
||||
const n = leg.segments.length - 1
|
||||
if (n === 0) return 'Directo'
|
||||
return `${n} escala${n > 1 ? 's' : ''}`
|
||||
}
|
||||
|
||||
function legRoute(leg: Trip['legs'][0]) {
|
||||
const codes = leg.segments.map(s => s.departureCode)
|
||||
codes.push(leg.segments.at(-1)!.arrivalCode)
|
||||
return codes.join(' → ')
|
||||
}
|
||||
|
||||
// Group by airline
|
||||
const airlineGroups = computed(() => {
|
||||
const groups = new Map<string, Trip[]>()
|
||||
for (const trip of trips.value) {
|
||||
const code = trip.legs[0]?.segments[0]?.company?.code || 'Otros'
|
||||
const name = trip.legs[0]?.segments[0]?.company?.name || code
|
||||
const key = `${code} - ${name}`
|
||||
if (!groups.has(key)) groups.set(key, [])
|
||||
groups.get(key)!.push(trip)
|
||||
}
|
||||
return [...groups.entries()].sort((a, b) => {
|
||||
const minA = Math.min(...a[1].map(t => t.totalCost))
|
||||
const minB = Math.min(...b[1].map(t => t.totalCost))
|
||||
return minA - minB
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<UPageHero
|
||||
:title="`${from} → ${to}`"
|
||||
description="Vuelos disponibles en esta ruta (ida y vuelta)"
|
||||
/>
|
||||
|
||||
<UPageSection>
|
||||
<div class="max-w-3xl mx-auto space-y-4">
|
||||
<UButton label="Volver" icon="i-lucide-arrow-left" variant="ghost" @click="$router.back()" />
|
||||
|
||||
<div v-if="loading" class="space-y-3">
|
||||
<USkeleton v-for="i in 5" :key="i" class="h-24 w-full" />
|
||||
</div>
|
||||
|
||||
<UAlert v-else-if="error" color="error" icon="i-lucide-alert-circle" :title="error" />
|
||||
|
||||
<div v-else-if="trips.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 para esta ruta</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<p class="text-sm text-muted">{{ trips.length }} opcion{{ trips.length !== 1 ? 'es' : '' }} encontrada{{ trips.length !== 1 ? 's' : '' }}</p>
|
||||
|
||||
<div v-for="[airline, airlineTrips] in airlineGroups" :key="airline" class="space-y-2">
|
||||
<h3 class="font-semibold text-sm flex items-center gap-2">
|
||||
<UIcon name="i-lucide-plane" class="text-muted" />
|
||||
{{ airline }}
|
||||
<UBadge :label="`desde ${Math.min(...airlineTrips.map(t => t.totalCost)).toFixed(0)}€`" color="primary" size="xs" />
|
||||
</h3>
|
||||
|
||||
<UCard
|
||||
v-for="(trip, i) in airlineTrips.slice(0, 10)"
|
||||
:key="i"
|
||||
class="hover:ring-primary-400 cursor-pointer transition-all"
|
||||
@click="selectTrip(trip)"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex-1 space-y-2">
|
||||
<div v-for="(leg, li) in trip.legs" :key="li" class="flex items-center gap-3 text-sm">
|
||||
<UBadge
|
||||
:label="legLabel(li, trip.legs.length)"
|
||||
:color="li === 0 ? 'primary' : 'info'"
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
/>
|
||||
<span class="font-medium">
|
||||
{{ formatTime(leg.segments[0]?.departureDate) }}
|
||||
→
|
||||
{{ formatTime(leg.segments.at(-1)?.arrivalDate || '') }}
|
||||
</span>
|
||||
<span class="text-muted">
|
||||
{{ formatDate(leg.segments[0]?.departureDate) }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{{ legStopsText(leg) }}
|
||||
</span>
|
||||
<span v-if="leg.segments.length > 1" class="text-xs text-muted hidden sm:inline">
|
||||
{{ legRoute(leg) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="shrink-0 text-right">
|
||||
<p class="text-xl font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ trip.totalCost.toFixed(0) }}<span class="text-sm">€</span>
|
||||
</p>
|
||||
<p class="text-xs text-muted">
|
||||
{{ trip.legs.length > 1 ? 'ida+vuelta' : 'solo ida' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</UPageSection>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user