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,124 @@
<script setup lang="ts">
const props = defineProps<{
search: {
id: string
name: string
route_summary: string
interval_hours: number
is_active: boolean
next_run_at: string | null
last_run_at: string | null
run_count: number
last_error: string | null
expires_at: string | null
latest_snapshot: {
cheapest_price: number
avg_price: number | null
total_results: number
recorded_at: string
} | null
}
}>()
const emit = defineEmits<{
toggle: [id: string, active: boolean]
remove: [id: string]
}>()
function statusBadge() {
if (!props.search.is_active) return { label: 'Pausada', color: 'neutral' as const }
if (props.search.last_error) return { label: 'Error', color: 'error' as const }
if (props.search.expires_at && new Date(props.search.expires_at) < new Date()) return { label: 'Expirada', color: 'warning' as const }
return { label: 'Activa', color: 'success' as const }
}
function formatInterval(hours: number) {
if (hours < 24) return `Cada ${hours}h`
if (hours === 24) return 'Diario'
if (hours === 48) return 'Cada 2 dias'
if (hours === 168) return 'Semanal'
return `Cada ${Math.round(hours / 24)} dias`
}
function timeAgo(date: string) {
const diff = Date.now() - new Date(date).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 60) return `hace ${mins}min`
const hours = Math.floor(mins / 60)
if (hours < 24) return `hace ${hours}h`
const days = Math.floor(hours / 24)
return `hace ${days}d`
}
function timeUntil(date: string) {
const diff = new Date(date).getTime() - Date.now()
if (diff < 0) return 'pendiente'
const mins = Math.floor(diff / 60000)
if (mins < 60) return `en ${mins}min`
const hours = Math.floor(mins / 60)
if (hours < 24) return `en ${hours}h`
const days = Math.floor(hours / 24)
return `en ${days}d`
}
</script>
<template>
<div class="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4 hover:ring-1 hover:ring-primary-500 transition-all">
<div class="flex items-center justify-between gap-4">
<NuxtLink :to="`/tracking/${search.id}`" class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<p class="font-semibold truncate">{{ search.name }}</p>
<UBadge :label="statusBadge().label" :color="statusBadge().color" size="xs" />
</div>
<p class="text-sm text-muted mb-1">{{ search.route_summary }}</p>
<div class="flex items-center gap-3 text-xs text-muted flex-wrap">
<span class="flex items-center gap-1">
<UIcon name="i-lucide-timer" class="text-xs" />
{{ formatInterval(search.interval_hours) }}
</span>
<span v-if="search.run_count > 0" class="flex items-center gap-1">
<UIcon name="i-lucide-activity" class="text-xs" />
{{ search.run_count }} ejecucion{{ search.run_count !== 1 ? 'es' : '' }}
</span>
<span v-if="search.last_run_at" class="flex items-center gap-1">
<UIcon name="i-lucide-clock" class="text-xs" />
{{ timeAgo(search.last_run_at) }}
</span>
<span v-if="search.is_active && search.next_run_at" class="flex items-center gap-1">
<UIcon name="i-lucide-calendar-clock" class="text-xs" />
Proxima: {{ timeUntil(search.next_run_at) }}
</span>
</div>
</NuxtLink>
<!-- Precio actual -->
<NuxtLink :to="`/tracking/${search.id}`" class="text-right shrink-0">
<div v-if="search.latest_snapshot" class="mb-1">
<p class="text-lg font-bold">{{ search.latest_snapshot.cheapest_price.toFixed(0) }}&euro;</p>
<p class="text-xs text-muted">{{ search.latest_snapshot.total_results }} resultados</p>
</div>
<p v-else class="text-sm text-muted">Sin datos</p>
</NuxtLink>
<!-- Acciones -->
<div class="flex flex-col gap-1 shrink-0">
<UButton
:icon="search.is_active ? 'i-lucide-pause' : 'i-lucide-play'"
color="neutral"
variant="ghost"
size="xs"
:title="search.is_active ? 'Pausar' : 'Reanudar'"
@click="emit('toggle', search.id, !search.is_active)"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
title="Eliminar"
@click="emit('remove', search.id)"
/>
</div>
</div>
</div>
</template>