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:
159
app/pages/tracking/[id].vue
Normal file
159
app/pages/tracking/[id].vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
const user = useSupabaseUser()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { trackedSearches, getHistory, getRuns, remove, fetchAll, update } = useTrackedSearches()
|
||||
|
||||
const id = route.params.id as string
|
||||
|
||||
useSeoMeta({ title: 'Vuelato - Detalle seguimiento' })
|
||||
|
||||
watch(user, (u) => {
|
||||
if (!u) navigateTo('/auth')
|
||||
}, { immediate: true })
|
||||
|
||||
const search = computed(() => trackedSearches.value.find(s => s.id === id))
|
||||
const snapshots = ref<PriceSnapshot[]>([])
|
||||
const runs = ref<SearchRun[]>([])
|
||||
const loadingHistory = ref(true)
|
||||
const loadError = ref(false)
|
||||
const days = ref(30)
|
||||
|
||||
async function loadData() {
|
||||
loadingHistory.value = true
|
||||
loadError.value = false
|
||||
try {
|
||||
const [h, r] = await Promise.all([
|
||||
getHistory(id, days.value),
|
||||
getRuns(id, 20)
|
||||
])
|
||||
snapshots.value = h
|
||||
runs.value = r
|
||||
} catch {
|
||||
loadError.value = true
|
||||
} finally {
|
||||
loadingHistory.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(days, () => loadData())
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchAll()
|
||||
loadData()
|
||||
})
|
||||
|
||||
// Stats computadas
|
||||
const stats = computed(() => {
|
||||
if (snapshots.value.length === 0) return null
|
||||
const prices = snapshots.value.map(s => s.cheapest_price)
|
||||
const min = Math.min(...prices)
|
||||
const max = Math.max(...prices)
|
||||
const avg = Math.round(prices.reduce((s, p) => s + p, 0) / prices.length)
|
||||
const current = prices[prices.length - 1] ?? 0
|
||||
const previous = prices.length > 1 ? (prices[prices.length - 2] ?? current) : current
|
||||
const trend = current - previous
|
||||
|
||||
return { current, min, max, avg, trend }
|
||||
})
|
||||
|
||||
async function onRemove() {
|
||||
await remove(id)
|
||||
router.push('/tracking')
|
||||
}
|
||||
|
||||
async function onConfigUpdated() {
|
||||
await fetchAll()
|
||||
loadData()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="user">
|
||||
<!-- Loading si aun no cargo la search -->
|
||||
<div v-if="!search" class="max-w-3xl mx-auto py-12">
|
||||
<USkeleton class="h-8 w-64 mb-4" />
|
||||
<USkeleton class="h-72 w-full" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<UPageHero :title="search.name" :description="search.route_summary">
|
||||
<template #actions>
|
||||
<div class="flex gap-2">
|
||||
<UButton label="Volver" icon="i-lucide-arrow-left" variant="outline" color="neutral" to="/tracking" />
|
||||
<UButton label="Eliminar" icon="i-lucide-trash-2" variant="outline" color="error" @click="onRemove" />
|
||||
</div>
|
||||
</template>
|
||||
</UPageHero>
|
||||
|
||||
<UPageSection>
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<!-- Stats -->
|
||||
<div v-if="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||
<UCard class="text-center">
|
||||
<p class="text-xs text-muted mb-1">Precio actual</p>
|
||||
<p class="text-xl font-bold">{{ stats.current?.toFixed(0) }}€</p>
|
||||
<p v-if="stats.trend !== 0" class="text-xs" :class="stats.trend < 0 ? 'text-green-600' : 'text-red-500'">
|
||||
<UIcon :name="stats.trend < 0 ? 'i-lucide-trending-down' : 'i-lucide-trending-up'" class="text-xs" />
|
||||
{{ stats.trend > 0 ? '+' : '' }}{{ stats.trend.toFixed(0) }}€
|
||||
</p>
|
||||
</UCard>
|
||||
<UCard class="text-center">
|
||||
<p class="text-xs text-muted mb-1">Minimo historico</p>
|
||||
<p class="text-xl font-bold text-green-600">{{ stats.min?.toFixed(0) }}€</p>
|
||||
</UCard>
|
||||
<UCard class="text-center">
|
||||
<p class="text-xs text-muted mb-1">Maximo historico</p>
|
||||
<p class="text-xl font-bold text-red-500">{{ stats.max?.toFixed(0) }}€</p>
|
||||
</UCard>
|
||||
<UCard class="text-center">
|
||||
<p class="text-xs text-muted mb-1">Precio medio</p>
|
||||
<p class="text-xl font-bold">{{ stats.avg?.toFixed(0) }}€</p>
|
||||
</UCard>
|
||||
</div>
|
||||
|
||||
<!-- Chart -->
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">Evolucion de precios</h3>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="d in [7, 14, 30, 60]"
|
||||
:key="d"
|
||||
:label="`${d}d`"
|
||||
size="xs"
|
||||
:variant="days === d ? 'solid' : 'ghost'"
|
||||
:color="days === d ? 'primary' : 'neutral'"
|
||||
@click="days = d"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="loadingHistory" class="flex justify-center py-12">
|
||||
<USkeleton class="h-64 w-full" />
|
||||
</div>
|
||||
<div v-else-if="loadError" class="text-center py-8">
|
||||
<p class="text-sm text-red-500">Error al cargar datos</p>
|
||||
<UButton label="Reintentar" variant="outline" size="sm" class="mt-2" @click="loadData" />
|
||||
</div>
|
||||
<ClientOnly v-else>
|
||||
<TrackingPriceChart :snapshots="snapshots" />
|
||||
</ClientOnly>
|
||||
</UCard>
|
||||
|
||||
<!-- Config + Runs side by side on desktop -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div class="lg:col-span-1">
|
||||
<TrackingConfig :search="search" @updated="onConfigUpdated" />
|
||||
</div>
|
||||
<div class="lg:col-span-2">
|
||||
<TrackingRunHistory :runs="runs" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UPageSection>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
87
app/pages/tracking/index.vue
Normal file
87
app/pages/tracking/index.vue
Normal file
@@ -0,0 +1,87 @@
|
||||
<script setup lang="ts">
|
||||
const user = useSupabaseUser()
|
||||
const { trackedSearches, loading, update, remove, fetchAll } = useTrackedSearches()
|
||||
const router = useRouter()
|
||||
|
||||
const showCreateForm = ref(false)
|
||||
|
||||
useSeoMeta({ title: 'Vuelato - Seguimiento de precios' })
|
||||
|
||||
watch(user, (u) => {
|
||||
if (!u) navigateTo('/auth')
|
||||
}, { immediate: true })
|
||||
|
||||
async function onToggle(id: string, active: boolean) {
|
||||
await update(id, { is_active: active })
|
||||
}
|
||||
|
||||
async function onRemove(id: string) {
|
||||
await remove(id)
|
||||
}
|
||||
|
||||
function onDetail(id: string) {
|
||||
router.push(`/tracking/${id}`)
|
||||
}
|
||||
|
||||
function onCreated() {
|
||||
showCreateForm.value = false
|
||||
fetchAll()
|
||||
}
|
||||
|
||||
const activeCount = computed(() => trackedSearches.value.filter(s => s.is_active).length)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="user">
|
||||
<UPageHero title="Seguimiento de precios" description="Busquedas automaticas que monitorizan fluctuaciones de precio" />
|
||||
|
||||
<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">
|
||||
{{ trackedSearches.length }} seguimiento{{ trackedSearches.length !== 1 ? 's' : '' }}
|
||||
<span v-if="activeCount > 0"> ({{ activeCount }} activo{{ activeCount !== 1 ? 's' : '' }})</span>
|
||||
</p>
|
||||
<UButton
|
||||
v-if="!showCreateForm"
|
||||
label="Nuevo seguimiento"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
@click="showCreateForm = true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Create form -->
|
||||
<TrackingCreateTrackingForm
|
||||
v-if="showCreateForm"
|
||||
@created="onCreated"
|
||||
@cancel="showCreateForm = false"
|
||||
/>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="space-y-3">
|
||||
<USkeleton v-for="i in 3" :key="i" class="h-28 w-full" />
|
||||
</div>
|
||||
|
||||
<!-- Empty -->
|
||||
<div v-else-if="trackedSearches.length === 0 && !showCreateForm" class="text-center py-12">
|
||||
<UIcon name="i-lucide-bell" class="text-4xl text-neutral-300 mb-2" />
|
||||
<p class="text-neutral-500">No tienes busquedas en seguimiento</p>
|
||||
<p class="text-sm text-neutral-400 mt-1">Crea una para monitorizar precios automaticamente</p>
|
||||
<UButton label="Crear seguimiento" icon="i-lucide-plus" class="mt-3" @click="showCreateForm = true" />
|
||||
</div>
|
||||
|
||||
<!-- Search cards -->
|
||||
<TrackingTrackedSearchCard
|
||||
v-for="search in trackedSearches"
|
||||
:key="search.id"
|
||||
:search="search"
|
||||
@toggle="onToggle"
|
||||
@remove="onRemove"
|
||||
@detail="onDetail"
|
||||
/>
|
||||
</div>
|
||||
</UPageSection>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user