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

201
app/pages/watchlist.vue Normal file
View File

@@ -0,0 +1,201 @@
<script setup lang="ts">
const user = useSupabaseUser()
const { items, loading, checkPrice, checkAll, remove } = useWatchlist()
const { searches } = useRecentSearches()
const { create: createTracking } = useTrackedSearches()
const { buildPayload } = useFlightSearch()
const router = useRouter()
const checkingAll = ref(false)
const checkingId = ref<string | null>(null)
useSeoMeta({ title: 'Vuelato - Watchlist' })
// Redirect to auth if not logged in
watch(user, (u) => {
if (!u) navigateTo('/auth')
}, { immediate: true })
async function onCheckPrice(item: any) {
checkingId.value = item.id
await checkPrice(item)
checkingId.value = null
}
async function onCheckAll() {
checkingAll.value = true
await checkAll()
checkingAll.value = false
}
function statusColor(status: string) {
switch (status) {
case 'price_down': return 'success'
case 'price_up': return 'warning'
case 'unavailable': return 'error'
case 'available': return 'info'
default: return 'neutral'
}
}
function statusLabel(status: string) {
switch (status) {
case 'price_down': return 'Bajo'
case 'price_up': return 'Subio'
case 'unavailable': return 'No disponible'
case 'available': return 'Disponible'
default: return 'Guardado'
}
}
function replaySearch(s: any) {
const p = s.search_params
router.push({
path: '/results',
query: {
mode: p.mode || s.search_mode,
dep: p.departures?.join(','),
dest: Array.isArray(p.destination) ? p.destination.join(',') : p.destination,
from: p.dateFrom,
to: p.dateTo,
smin: p.stayMinDays,
smax: p.stayMaxDays,
adults: p.passengers?.adult,
children: p.passengers?.child,
infants: p.passengers?.infant
}
})
}
async function trackSearch(s: any) {
const p = s.search_params
await createTracking({
name: s.route_summary || 'Busqueda sin nombre',
searchParams: buildPayload({
departures: p.departures || [],
destination: Array.isArray(p.destination) ? p.destination : (p.destination || '').split(',').filter(Boolean),
dateFrom: p.dateFrom || '',
dateTo: p.dateTo || '',
stayMinDays: p.stayMinDays || 2,
stayMaxDays: p.stayMaxDays || 6,
passengers: p.passengers || { adult: 1, child: 0, infant: 0 }
}),
routeSummary: s.route_summary || 'Sin ruta',
intervalHours: 24
})
navigateTo('/tracking')
}
</script>
<template>
<div v-if="user">
<UPageHero title="Watchlist" description="Vuelos guardados y seguimiento de precios" />
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<!-- Actions -->
<div class="flex items-center justify-between">
<p class="text-sm text-muted">{{ items.length }} vuelo{{ items.length !== 1 ? 's' : '' }} guardado{{ items.length !== 1 ? 's' : '' }}</p>
<UButton
v-if="items.length > 0"
label="Verificar todos"
icon="i-lucide-refresh-cw"
:loading="checkingAll"
size="sm"
variant="outline"
@click="onCheckAll"
/>
</div>
<!-- Loading -->
<div v-if="loading" class="space-y-3">
<USkeleton v-for="i in 3" :key="i" class="h-24 w-full" />
</div>
<!-- Empty -->
<div v-else-if="items.length === 0" class="text-center py-12">
<UIcon name="i-lucide-heart" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No tienes vuelos guardados</p>
<UButton to="/search" label="Buscar vuelos" class="mt-3" />
</div>
<!-- Items -->
<UCard v-for="item in items" :key="item.id">
<div class="flex items-center justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<p class="font-semibold">{{ item.route_summary }}</p>
<UBadge :label="statusLabel(item.price_status)" :color="statusColor(item.price_status)" size="xs" />
</div>
<div class="flex items-center gap-3 text-sm text-muted">
<span v-if="item.departure_date">
{{ new Date(item.departure_date).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' }) }}
</span>
<span>Original: {{ item.original_price.toFixed(0) }}&euro;</span>
<span v-if="item.current_price != null && item.current_price !== item.original_price"
:class="item.current_price < item.original_price ? 'text-green-600' : 'text-red-500'"
>
Actual: {{ item.current_price.toFixed(0) }}&euro;
</span>
<span v-if="item.last_checked_at" class="text-xs">
Verificado {{ new Date(item.last_checked_at).toLocaleDateString('es-ES') }}
</span>
</div>
</div>
<div class="flex items-center gap-1 shrink-0">
<UButton
icon="i-lucide-refresh-cw"
color="neutral"
variant="ghost"
size="xs"
:loading="checkingId === item.id"
@click="onCheckPrice(item)"
/>
<UButton
:to="`/detail/${encodeURIComponent(item.booking_token)}?price=${item.original_price}&adults=${item.passengers_adult}&children=${item.passengers_child}&infants=${item.passengers_infant}`"
icon="i-lucide-external-link"
color="neutral"
variant="ghost"
size="xs"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
@click="remove(item.id)"
/>
</div>
</div>
</UCard>
</div>
</UPageSection>
<!-- Recent searches -->
<UPageSection v-if="searches.length > 0" title="Busquedas recientes">
<div class="max-w-3xl mx-auto">
<div class="flex gap-2 flex-wrap">
<div v-for="s in searches.slice(0, 8)" :key="s.id" class="flex items-center gap-0.5">
<UButton
:label="s.route_summary"
icon="i-lucide-history"
color="neutral"
variant="soft"
size="sm"
@click="replaySearch(s)"
/>
<UButton
icon="i-lucide-bell-plus"
color="neutral"
variant="ghost"
size="xs"
title="Hacer seguimiento"
@click="trackSearch(s)"
/>
</div>
</div>
</div>
</UPageSection>
</div>
</template>