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

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
defineProps<{
polling: boolean
pollCount: number
}>()
defineEmits<{ stop: [] }>()
</script>
<template>
<div v-if="polling" class="flex items-center justify-center gap-3 py-4">
<UIcon name="i-lucide-loader" class="animate-spin text-primary-500" />
<p class="text-sm text-muted">
Buscando mas resultados (ronda {{ pollCount }})...
</p>
<UButton label="Parar" size="xs" color="neutral" variant="ghost" @click="$emit('stop')" />
</div>
</template>

View File

@@ -0,0 +1,158 @@
<script setup lang="ts">
const props = defineProps<{
maxPrice: number | null
maxStops: number | null
airlines: string[]
departureTimeRange: [number, number]
availableAirlines: { code: string, name: string }[]
priceRange: { min: number, max: number }
}>()
const emit = defineEmits<{
'update:maxPrice': [value: number | null]
'update:maxStops': [value: number | null]
'update:airlines': [value: string[]]
'update:departureTimeRange': [value: [number, number]]
}>()
const priceValue = ref(props.maxPrice ?? props.priceRange.max)
const priceEnabled = ref(props.maxPrice != null)
watch(priceEnabled, (on) => {
emit('update:maxPrice', on ? priceValue.value : null)
})
watch(priceValue, (v) => {
if (priceEnabled.value) emit('update:maxPrice', v)
})
watch(() => props.maxPrice, (v) => {
if (v == null) {
priceEnabled.value = false
} else {
priceValue.value = v
priceEnabled.value = true
}
})
const stopsOptions = [
{ label: 'Todos', value: null },
{ label: 'Directo', value: 0 },
{ label: 'Max 1', value: 1 },
{ label: 'Max 2', value: 2 }
]
function toggleAirline(code: string) {
const current = [...props.airlines]
const idx = current.indexOf(code)
if (idx >= 0) current.splice(idx, 1)
else current.push(code)
emit('update:airlines', current)
}
function formatHour(h: number) {
return `${String(h).padStart(2, '0')}:00`
}
</script>
<template>
<UCard>
<div class="space-y-5">
<!-- Price filter -->
<div>
<div class="flex items-center justify-between mb-2">
<label class="text-sm font-medium flex items-center gap-2">
<USwitch :model-value="priceEnabled" size="xs" @update:model-value="priceEnabled = $event" />
Precio max
</label>
<UInput
v-if="priceEnabled"
:model-value="priceValue"
type="number"
:min="priceRange.min"
:max="priceRange.max"
size="xs"
class="w-24 text-right"
@update:model-value="priceValue = Number($event)"
>
<template #trailing><span class="text-xs text-muted">&euro;</span></template>
</UInput>
</div>
<URange
v-if="priceEnabled"
v-model="priceValue"
:min="priceRange.min"
:max="priceRange.max"
:step="5"
/>
</div>
<!-- Stops filter -->
<div>
<div class="flex items-center justify-between mb-2">
<p class="text-sm font-medium">Escalas</p>
<UInput
:model-value="maxStops ?? ''"
type="number"
:min="0"
:max="10"
placeholder="Sin limite"
size="xs"
class="w-28 text-right"
@update:model-value="$emit('update:maxStops', $event === '' ? null : Number($event))"
/>
</div>
<div class="flex gap-1">
<UButton
v-for="opt in stopsOptions"
:key="String(opt.value)"
:label="opt.label"
:color="maxStops === opt.value ? 'primary' : 'neutral'"
:variant="maxStops === opt.value ? 'soft' : 'ghost'"
size="xs"
@click="$emit('update:maxStops', opt.value)"
/>
</div>
</div>
<!-- Departure time -->
<div>
<p class="text-sm font-medium mb-1">Hora de salida</p>
<p class="text-xs text-muted mb-2">
{{ formatHour(departureTimeRange[0]) }} - {{ formatHour(departureTimeRange[1]) }}
</p>
<div class="flex gap-3">
<URange
:model-value="departureTimeRange[0]"
:min="0"
:max="23"
class="flex-1"
@update:model-value="$emit('update:departureTimeRange', [$event, departureTimeRange[1]])"
/>
<URange
:model-value="departureTimeRange[1]"
:min="1"
:max="24"
class="flex-1"
@update:model-value="$emit('update:departureTimeRange', [departureTimeRange[0], $event])"
/>
</div>
</div>
<!-- Airlines filter -->
<div v-if="availableAirlines.length > 0">
<p class="text-sm font-medium mb-2">Aerolineas</p>
<div class="flex flex-wrap gap-1">
<UButton
v-for="al in availableAirlines"
:key="al.code"
:label="`${al.code}${al.name ? ' · ' + al.name : ''}`"
:color="airlines.includes(al.code) ? 'primary' : 'neutral'"
:variant="airlines.includes(al.code) ? 'soft' : 'ghost'"
size="xs"
@click="toggleAirline(al.code)"
/>
</div>
<p class="text-xs text-muted mt-1">{{ airlines.length === 0 ? 'Sin filtro de aerolinea' : 'Solo vuelos operados exclusivamente por las seleccionadas' }}</p>
</div>
</div>
</UCard>
</template>

View File

@@ -0,0 +1,99 @@
<script setup lang="ts">
import type { SortKey } from '~/composables/useResultFilters'
const sortBy = defineModel<SortKey>('sortBy', { default: 'price' })
const viewMode = defineModel<'full' | 'compact'>('viewMode', { default: 'full' })
const { showOriginTime } = useOriginTime()
defineProps<{
count: number
hasActiveFilters: boolean
}>()
defineEmits<{
toggleFilters: []
resetFilters: []
}>()
const sortOptions: { value: SortKey, label: string, icon: string }[] = [
{ value: 'price', label: 'Precio', icon: 'i-lucide-arrow-down-narrow-wide' },
{ value: 'departure', label: 'Salida', icon: 'i-lucide-clock' },
{ value: 'duration', label: 'Duracion', icon: 'i-lucide-timer' },
{ value: 'stops', label: 'Escalas', icon: 'i-lucide-git-branch' }
]
</script>
<template>
<div class="flex items-center justify-between flex-wrap gap-2">
<div class="flex items-center gap-2">
<p class="text-sm text-muted">
{{ count }} resultado{{ count !== 1 ? 's' : '' }}
</p>
<UButton
v-if="hasActiveFilters"
label="Limpiar filtros"
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="xs"
@click="$emit('resetFilters')"
/>
</div>
<div class="flex items-center gap-2">
<!-- Sort buttons -->
<div class="flex gap-0.5">
<UButton
v-for="opt in sortOptions"
:key="opt.value"
:label="opt.label"
:icon="opt.icon"
:color="sortBy === opt.value ? 'primary' : 'neutral'"
:variant="sortBy === opt.value ? 'soft' : 'ghost'"
size="xs"
@click="sortBy = opt.value"
/>
</div>
<USeparator orientation="vertical" class="h-5" />
<!-- View mode -->
<div class="flex gap-0.5">
<UButton
icon="i-lucide-rows-3"
:color="viewMode === 'full' ? 'primary' : 'neutral'"
:variant="viewMode === 'full' ? 'soft' : 'ghost'"
size="xs"
@click="viewMode = 'full'"
/>
<UButton
icon="i-lucide-list"
:color="viewMode === 'compact' ? 'primary' : 'neutral'"
:variant="viewMode === 'compact' ? 'soft' : 'ghost'"
size="xs"
@click="viewMode = 'compact'"
/>
</div>
<!-- Origin time toggle -->
<UButton
icon="i-lucide-clock"
label="Hora origen"
:color="showOriginTime ? 'primary' : 'neutral'"
:variant="showOriginTime ? 'soft' : 'ghost'"
size="xs"
@click="showOriginTime = !showOriginTime"
/>
<!-- Filter toggle -->
<UButton
icon="i-lucide-sliders-horizontal"
label="Filtros"
:color="hasActiveFilters ? 'primary' : 'neutral'"
:variant="hasActiveFilters ? 'soft' : 'ghost'"
size="xs"
@click="$emit('toggleFilters')"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
defineProps<{ trip: Trip }>()
defineEmits<{ select: [trip: Trip] }>()
function formatTime(dateStr: string) {
return new Date(dateStr).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
}
function legSummary(leg: Trip['legs'][0]) {
const segs = leg.segments
if (!segs.length) return ''
const dep = segs[0]
const arr = segs[segs.length - 1]
const stops = segs.length - 1
const stopsText = stops === 0 ? 'Directo' : `${stops} escala${stops > 1 ? 's' : ''}`
return `${dep.departureCode} ${formatTime(dep.departureDate)}${arr.arrivalCode} ${formatTime(arr.arrivalDate)} · ${stopsText}`
}
</script>
<template>
<div
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-400 cursor-pointer transition-colors"
@click="$emit('select', trip)"
>
<div class="flex-1 min-w-0">
<p v-for="(leg, i) in trip.legs" :key="i" class="text-sm truncate">
<span class="text-xs font-medium text-muted mr-1">{{ i === 0 ? 'Ida' : 'Vta' }}</span>
{{ legSummary(leg) }}
</p>
</div>
<div class="shrink-0 ml-3 text-right">
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">
{{ trip.totalCost.toFixed(0) }}<span class="text-xs">&euro;</span>
</span>
</div>
</div>
</template>