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,144 @@
<script setup lang="ts">
import { LMap, LTileLayer, LCircleMarker, LPolyline, LPopup } from '@vue-leaflet/vue-leaflet'
import 'leaflet/dist/leaflet.css'
import type { InspirationItem } from '~/server/utils/flightics'
const props = defineProps<{
airports: { iata: string; name: string; lat: number; lon: number; city_name: string }[]
origin: string | null
inspirations: InspirationItem[]
budget: number | null
}>()
const emit = defineEmits<{
selectOrigin: [iata: string]
selectDestination: [iata: string]
}>()
const zoom = ref(5)
const center = ref<[number, number]>([40.4, -3.7]) // Madrid default
// Airport lookup
const airportMap = computed(() => {
const map = new Map<string, (typeof props.airports)[0]>()
for (const a of props.airports) map.set(a.iata, a)
return map
})
// Filter inspirations by budget
const filteredInspirations = computed(() => {
if (!props.budget) return props.inspirations
return props.inspirations.filter(i => i.minPrice <= props.budget!)
})
// Lines from origin to destinations
const routes = computed(() => {
const origin = props.origin ? airportMap.value.get(props.origin) : null
if (!origin) return []
return filteredInspirations.value
.map(insp => {
const dest = airportMap.value.get(insp.to[0])
if (!dest) return null
return {
from: [origin.lat, origin.lon] as [number, number],
to: [dest.lat, dest.lon] as [number, number],
iata: insp.to[0],
price: insp.minPrice,
stops: insp.minStops,
destName: dest.name
}
})
.filter(Boolean) as { from: [number, number]; to: [number, number]; iata: string; price: number; stops: number; destName: string }[]
})
// Center on origin when selected
watch(() => props.origin, (iata) => {
if (iata) {
const a = airportMap.value.get(iata)
if (a) {
center.value = [a.lat, a.lon]
zoom.value = 5
}
}
})
function priceColor(price: number): string {
if (price < 30) return '#22c55e'
if (price < 60) return '#84cc16'
if (price < 100) return '#eab308'
if (price < 200) return '#f97316'
return '#ef4444'
}
</script>
<template>
<div class="h-[500px] rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
<LMap :zoom="zoom" :center="center" :use-global-leaflet="false">
<LTileLayer
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
attribution="&copy; OpenStreetMap &copy; CARTO"
/>
<!-- All airports as small dots -->
<LCircleMarker
v-for="a in airports.filter(a => a.lat && a.lon)"
:key="a.iata"
:lat-lng="[a.lat, a.lon]"
:radius="origin === a.iata ? 8 : 3"
:color="origin === a.iata ? '#3b82f6' : '#94a3b8'"
:fill-opacity="origin === a.iata ? 0.8 : 0.4"
:weight="origin === a.iata ? 2 : 1"
@click="$emit('selectOrigin', a.iata)"
>
<LPopup>
<div class="text-sm">
<p class="font-semibold">{{ a.iata }} - {{ a.name }}</p>
<p v-if="a.city_name" class="text-neutral-500">{{ a.city_name }}</p>
<UButton
v-if="origin !== a.iata"
label="Buscar desde aqui"
size="xs"
class="mt-1"
@click="$emit('selectOrigin', a.iata)"
/>
</div>
</LPopup>
</LCircleMarker>
<!-- Route lines -->
<template v-for="r in routes" :key="r.iata">
<LPolyline
:lat-lngs="[r.from, r.to]"
:color="priceColor(r.price)"
:weight="2"
:opacity="0.6"
/>
<LCircleMarker
:lat-lng="r.to"
:radius="6"
:color="priceColor(r.price)"
:fill-opacity="0.8"
:weight="2"
>
<LPopup>
<div class="text-sm min-w-32">
<p class="font-semibold">{{ r.iata }} - {{ r.destName }}</p>
<p class="text-lg font-bold" :style="{ color: priceColor(r.price) }">
{{ r.price.toFixed(0) }}&euro;
</p>
<p class="text-xs text-neutral-500">
{{ r.stops === 0 ? 'Directo' : `${r.stops} escala(s)` }}
</p>
<UButton
label="Ver vuelos"
size="xs"
class="mt-1"
@click="$emit('selectDestination', r.iata)"
/>
</div>
</LPopup>
</LCircleMarker>
</template>
</LMap>
</div>
</template>

View File

@@ -0,0 +1,38 @@
<script setup lang="ts">
const origin = defineModel<string>('origin', { default: '' })
const budget = defineModel<number | null>('budget', { default: null })
const directOnly = defineModel<boolean>('directOnly', { default: false })
defineProps<{
inspirationCount: number
}>()
</script>
<template>
<UCard>
<div class="flex flex-wrap items-end gap-4">
<UFormField label="Origen" class="w-48">
<SearchAirportInput v-model="origin" placeholder="MAD" icon="i-lucide-plane-takeoff" />
</UFormField>
<div class="flex-1 min-w-48">
<div class="flex justify-between text-sm mb-1">
<span class="text-muted">Presupuesto</span>
<span class="font-semibold">{{ budget ? `${budget}` : 'Sin limite' }}</span>
</div>
<URange :model-value="budget || 500" :min="20" :max="1000" :step="10" @update:model-value="budget = $event" />
</div>
<UButton
:label="directOnly ? 'Solo directos' : 'Todos'"
:icon="directOnly ? 'i-lucide-arrow-right' : 'i-lucide-git-branch'"
:color="directOnly ? 'primary' : 'neutral'"
:variant="directOnly ? 'soft' : 'ghost'"
size="sm"
@click="directOnly = !directOnly"
/>
<p class="text-sm text-muted">{{ inspirationCount }} destinos</p>
</div>
</UCard>
</template>