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:
201
app/pages/watchlist.vue
Normal file
201
app/pages/watchlist.vue
Normal 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) }}€</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) }}€
|
||||
</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>
|
||||
Reference in New Issue
Block a user