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:
18
app/components/results/LoadingMore.vue
Normal file
18
app/components/results/LoadingMore.vue
Normal 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>
|
||||
158
app/components/results/ResultsFilters.vue
Normal file
158
app/components/results/ResultsFilters.vue
Normal 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">€</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>
|
||||
99
app/components/results/ResultsToolbar.vue
Normal file
99
app/components/results/ResultsToolbar.vue
Normal 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>
|
||||
39
app/components/results/TripCardCompact.vue
Normal file
39
app/components/results/TripCardCompact.vue
Normal 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">€</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user