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:
80
app/components/FlightLeg.vue
Normal file
80
app/components/FlightLeg.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import type { Leg } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
leg: Leg
|
||||
index: number
|
||||
originTzOffset?: number
|
||||
showOriginTime?: boolean
|
||||
}>()
|
||||
|
||||
const { resolve } = useAirlineNames()
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function segDuration(dep: number, arr: number) {
|
||||
const mins = Math.round((arr - dep) / 60)
|
||||
const h = Math.floor(mins / 60)
|
||||
const m = mins % 60
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
|
||||
// Convert a UTC timestamp to a time string in the origin airport's timezone
|
||||
function toOriginTime(utcTimestamp: number): string {
|
||||
const localSeconds = utcTimestamp + (props.originTzOffset ?? 0)
|
||||
const d = new Date(localSeconds * 1000)
|
||||
return d.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })
|
||||
}
|
||||
|
||||
// Check if a segment's local timezone differs from the origin
|
||||
function isDifferentTz(localTimestamp: number, utcTimestamp: number): boolean {
|
||||
return (localTimestamp - utcTimestamp) !== (props.originTzOffset ?? 0)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-4 py-2">
|
||||
<UBadge :label="index === 0 ? 'Ida' : 'Vuelta'" :color="index === 0 ? 'primary' : 'info'" variant="subtle" size="sm" />
|
||||
|
||||
<div v-for="(seg, i) in leg.segments" :key="i" class="flex items-center gap-3 flex-1">
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-semibold">{{ formatTime(seg.departureDate) }}</p>
|
||||
<p v-if="showOriginTime && isDifferentTz(seg.departureTimestamp, seg.departureUtcTimestamp)" class="text-[10px] text-primary-500">({{ toOriginTime(seg.departureUtcTimestamp) }})</p>
|
||||
<p class="text-xs text-neutral-500">{{ seg.departureCode }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatDate(seg.departureDate) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center">
|
||||
<p class="text-xs text-neutral-500">
|
||||
<span v-if="resolve(seg.company.code, seg.company.name)" class="font-medium">
|
||||
{{ resolve(seg.company.code, seg.company.name) }} ·
|
||||
</span>
|
||||
<span>{{ seg.company.code }} {{ seg.number }}</span>
|
||||
</p>
|
||||
<div class="w-full relative flex items-center">
|
||||
<div class="flex-1 border-t border-dashed border-neutral-300 dark:border-neutral-600" />
|
||||
<div class="flex items-center gap-1 px-1.5">
|
||||
<UIcon name="i-lucide-plane" class="text-neutral-400 text-xs" />
|
||||
<span class="text-[10px] text-neutral-400 whitespace-nowrap">{{ segDuration(seg.departureUtcTimestamp, seg.arrivalUtcTimestamp) }}</span>
|
||||
</div>
|
||||
<div class="flex-1 border-t border-dashed border-neutral-300 dark:border-neutral-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-semibold">{{ formatTime(seg.arrivalDate) }}</p>
|
||||
<p v-if="showOriginTime && isDifferentTz(seg.arrivalTimestamp, seg.arrivalUtcTimestamp)" class="text-[10px] text-primary-500">({{ toOriginTime(seg.arrivalUtcTimestamp) }})</p>
|
||||
<p class="text-xs text-neutral-500">{{ seg.arrivalCode }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ formatDate(seg.arrivalDate) }}</p>
|
||||
</div>
|
||||
|
||||
<UIcon v-if="i < leg.segments.length - 1" name="i-lucide-arrow-right" class="text-neutral-300" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
62
app/components/InspirationGrid.vue
Normal file
62
app/components/InspirationGrid.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import type { InspirationItem } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{ items: InspirationItem[], from: string }>()
|
||||
|
||||
const { prefetch, getImage } = useDestinationImages()
|
||||
const { airports, loadAirports } = useLocations()
|
||||
|
||||
onMounted(() => loadAirports())
|
||||
|
||||
function cityName(iata: string): string {
|
||||
const a = airports.value.find(ap => ap.iata === iata)
|
||||
return a?.city_name || iata
|
||||
}
|
||||
|
||||
watch(() => props.items, (items) => {
|
||||
const cities = items.slice(0, 12)
|
||||
.map(i => cityName(i.to[0]))
|
||||
.filter(c => c.length > 2)
|
||||
if (cities.length) prefetch(cities)
|
||||
}, { immediate: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
<div
|
||||
v-for="item in items"
|
||||
:key="item.to[0]"
|
||||
class="relative overflow-hidden rounded-lg cursor-pointer group h-36"
|
||||
>
|
||||
<img
|
||||
v-if="getImage(cityName(item.to[0]))?.thumb_url"
|
||||
:src="getImage(cityName(item.to[0]))!.thumb_url"
|
||||
:alt="cityName(item.to[0])"
|
||||
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
>
|
||||
<div v-else class="absolute inset-0 bg-gradient-to-br from-primary-500 to-primary-700" />
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||
<div class="absolute bottom-0 left-0 right-0 p-3 text-white">
|
||||
<p class="font-bold text-sm truncate">{{ cityName(item.to[0]) }}</p>
|
||||
<div class="flex items-center justify-between mt-0.5">
|
||||
<p class="text-xs text-white/80">
|
||||
{{ item.minStops === 0 ? 'Directo' : `${item.minStops} escala(s)` }}
|
||||
</p>
|
||||
<p class="text-lg font-bold">
|
||||
{{ item.minPrice.toFixed(0) }}<span class="text-xs">€</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
v-if="getImage(cityName(item.to[0]))?.photographer"
|
||||
:href="getImage(cityName(item.to[0]))!.photographer_url + '?utm_source=vuelato&utm_medium=referral'"
|
||||
class="absolute top-1 right-1 text-[9px] text-white/50 hover:text-white/80 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
@click.stop
|
||||
>
|
||||
{{ getImage(cityName(item.to[0]))!.photographer }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
33
app/components/PassengerPicker.vue
Normal file
33
app/components/PassengerPicker.vue
Normal file
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: { adult: number; child: number; infant: number }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: { adult: number; child: number; infant: number }]
|
||||
}>()
|
||||
|
||||
function update(key: 'adult' | 'child' | 'infant', delta: number) {
|
||||
const val = { ...props.modelValue }
|
||||
val[key] = Math.max(key === 'adult' ? 1 : 0, val[key] + delta)
|
||||
emit('update:modelValue', val)
|
||||
}
|
||||
|
||||
const total = computed(() => props.modelValue.adult + props.modelValue.child + props.modelValue.infant)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<div v-for="type in (['adult', 'child', 'infant'] as const)" :key="type" class="flex items-center justify-between">
|
||||
<span class="text-sm capitalize text-neutral-600 dark:text-neutral-400">
|
||||
{{ type === 'adult' ? 'Adultos' : type === 'child' ? 'Ninos' : 'Bebes' }}
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<UButton size="xs" icon="i-lucide-minus" color="neutral" variant="outline" :disabled="type === 'adult' ? modelValue[type] <= 1 : modelValue[type] <= 0" @click="update(type, -1)" />
|
||||
<span class="w-6 text-center text-sm font-medium">{{ modelValue[type] }}</span>
|
||||
<UButton size="xs" icon="i-lucide-plus" color="neutral" variant="outline" @click="update(type, 1)" />
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-neutral-500">{{ total }} pasajero{{ total !== 1 ? 's' : '' }}</p>
|
||||
</div>
|
||||
</template>
|
||||
169
app/components/SearchForm.vue
Normal file
169
app/components/SearchForm.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
search: [data: {
|
||||
mode: string
|
||||
departures: string[]
|
||||
destination: string[]
|
||||
dateFrom: string
|
||||
dateTo: string
|
||||
stayMinDays: number
|
||||
stayMaxDays: number
|
||||
passengers: { adult: number; child: number; infant: number }
|
||||
maxStops: number | null
|
||||
budget: number | null
|
||||
}]
|
||||
}>()
|
||||
|
||||
defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const { homeAirports, defaultPassengers } = useUserPreferences()
|
||||
|
||||
const mode = ref('roundtrip')
|
||||
const departures = ref('')
|
||||
const destination = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const stayMinDays = ref(2)
|
||||
const stayMaxDays = ref(6)
|
||||
const passengers = ref({ adult: 2, child: 0, infant: 0 })
|
||||
const maxStops = ref<number | null>(null)
|
||||
const budget = ref(500)
|
||||
const showBudget = ref(false)
|
||||
|
||||
// Apply user preferences when available
|
||||
watch(homeAirports, (airports) => {
|
||||
if (airports.length && !departures.value) {
|
||||
departures.value = airports.join(',')
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
watch(defaultPassengers, (p) => {
|
||||
if (p.adult > 0) passengers.value = { ...p }
|
||||
}, { immediate: true })
|
||||
|
||||
const showDestination = computed(() => mode.value !== 'explore')
|
||||
const showDateTo = computed(() => mode.value !== 'oneway')
|
||||
const showStayDuration = computed(() => ['roundtrip', 'multicity'].includes(mode.value))
|
||||
const isWeekend = computed(() => mode.value === 'weekend')
|
||||
const isExplore = computed(() => mode.value === 'explore')
|
||||
|
||||
function submit() {
|
||||
emit('search', {
|
||||
mode: mode.value,
|
||||
departures: departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
|
||||
destination: destination.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
|
||||
dateFrom: dateFrom.value,
|
||||
dateTo: dateTo.value || dateFrom.value,
|
||||
stayMinDays: stayMinDays.value,
|
||||
stayMaxDays: stayMaxDays.value,
|
||||
passengers: passengers.value,
|
||||
maxStops: maxStops.value,
|
||||
budget: showBudget.value ? budget.value : null
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<SearchModeTabs v-model="mode" />
|
||||
|
||||
<div :class="compact ? 'space-y-3' : 'space-y-4'">
|
||||
<!-- Origen -->
|
||||
<UFormField label="Origen">
|
||||
<SearchAirportInput
|
||||
v-model="departures"
|
||||
placeholder="Buscar aeropuerto..."
|
||||
icon="i-lucide-plane-takeoff"
|
||||
multiple
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Destino (no en modo explorar) -->
|
||||
<UFormField v-if="showDestination" label="Destino">
|
||||
<SearchAirportInput
|
||||
v-model="destination"
|
||||
placeholder="NTE"
|
||||
icon="i-lucide-plane-landing"
|
||||
multiple
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Fechas -->
|
||||
<SearchDateRangePicker
|
||||
v-model:date-from="dateFrom"
|
||||
v-model:date-to="dateTo"
|
||||
:single-date="!showDateTo"
|
||||
/>
|
||||
|
||||
<!-- Estancia (roundtrip, multicity) -->
|
||||
<SearchStayDurationPicker
|
||||
v-if="showStayDuration"
|
||||
v-model:min-days="stayMinDays"
|
||||
v-model:max-days="stayMaxDays"
|
||||
/>
|
||||
|
||||
<!-- Weekend hint -->
|
||||
<UAlert
|
||||
v-if="isWeekend"
|
||||
color="info"
|
||||
icon="i-lucide-info"
|
||||
title="Busca vuelos de viernes a domingo automaticamente"
|
||||
/>
|
||||
|
||||
<!-- Explore hint -->
|
||||
<UAlert
|
||||
v-if="isExplore"
|
||||
color="info"
|
||||
icon="i-lucide-compass"
|
||||
title="Descubre destinos baratos desde tu aeropuerto"
|
||||
/>
|
||||
|
||||
<!-- Pasajeros en popover -->
|
||||
<UFormField label="Pasajeros">
|
||||
<UPopover>
|
||||
<UButton
|
||||
:label="`${passengers.adult + passengers.child + passengers.infant} pasajero(s)`"
|
||||
icon="i-lucide-users"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
class="justify-start"
|
||||
/>
|
||||
<template #content>
|
||||
<div class="p-4 w-64">
|
||||
<PassengerPicker v-model="passengers" />
|
||||
</div>
|
||||
</template>
|
||||
</UPopover>
|
||||
</UFormField>
|
||||
|
||||
<!-- Opciones avanzadas -->
|
||||
<div class="flex flex-wrap gap-4 items-end">
|
||||
<div>
|
||||
<p class="text-xs text-muted mb-1">Escalas</p>
|
||||
<SearchMaxStopsFilter v-model="maxStops" />
|
||||
</div>
|
||||
<UButton
|
||||
:label="showBudget ? 'Ocultar presupuesto' : 'Presupuesto max'"
|
||||
icon="i-lucide-wallet"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@click="showBudget = !showBudget"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<SearchBudgetSlider v-if="showBudget" v-model="budget" />
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
:label="isExplore ? 'Explorar destinos' : 'Buscar vuelos'"
|
||||
icon="i-lucide-search"
|
||||
size="lg"
|
||||
block
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
133
app/components/TripCard.vue
Normal file
133
app/components/TripCard.vue
Normal file
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{ trip: Trip }>()
|
||||
defineEmits<{ select: [trip: Trip] }>()
|
||||
|
||||
const { showOriginTime } = useOriginTime()
|
||||
|
||||
// Timezone offset (in seconds) of the origin airport: local - UTC
|
||||
const originTzOffset = computed(() => {
|
||||
const seg = props.trip.legs[0]?.segments[0]
|
||||
if (!seg) return 0
|
||||
return seg.departureTimestamp - seg.departureUtcTimestamp
|
||||
})
|
||||
|
||||
const departureCode = computed(() => props.trip.legs[0]?.segments[0]?.departureCode ?? '')
|
||||
const arrivalCode = computed(() => props.trip.legs[0]?.segments.at(-1)?.arrivalCode ?? '')
|
||||
const departureDate = computed(() => props.trip.legs[0]?.segments[0]?.departureDate ?? '')
|
||||
const routeSummary = computed(() => {
|
||||
const codes = props.trip.legs.map(l => l.segments[0]?.departureCode).filter(Boolean)
|
||||
const lastArr = props.trip.legs.at(-1)?.segments.at(-1)?.arrivalCode
|
||||
if (lastArr) codes.push(lastArr)
|
||||
return codes.join(' > ')
|
||||
})
|
||||
|
||||
// Total flight time across all legs (sum of each segment's flight duration, using UTC)
|
||||
const totalFlightMs = computed(() => {
|
||||
let ms = 0
|
||||
for (const leg of props.trip.legs) {
|
||||
for (const seg of leg.segments) {
|
||||
ms += (seg.arrivalUtcTimestamp - seg.departureUtcTimestamp) * 1000
|
||||
}
|
||||
}
|
||||
return ms
|
||||
})
|
||||
|
||||
// Time at destination: from arrival of last segment of outbound leg to departure of first segment of return leg (using UTC)
|
||||
const stayInfo = computed(() => {
|
||||
const legs = props.trip.legs
|
||||
if (legs.length < 2) return null
|
||||
|
||||
const arrivalUtc = legs[0].segments.at(-1)?.arrivalUtcTimestamp
|
||||
const departureUtc = legs[1].segments[0]?.departureUtcTimestamp
|
||||
if (!arrivalUtc || !departureUtc) return null
|
||||
|
||||
const stayMs = (departureUtc - arrivalUtc) * 1000
|
||||
if (stayMs <= 0) return null
|
||||
|
||||
const stayHours = stayMs / 3600000
|
||||
const fullDays = Math.floor(stayHours / 24)
|
||||
const remainingHours = Math.round(stayHours - fullDays * 24)
|
||||
const nights = fullDays
|
||||
|
||||
return { nights, fullDays, remainingHours, stayMs }
|
||||
})
|
||||
|
||||
// Total trip days: from first departure to last arrival (local dates for calendar/vacation planning)
|
||||
const totalTripDays = computed(() => {
|
||||
const legs = props.trip.legs
|
||||
if (!legs.length) return null
|
||||
|
||||
const firstDep = legs[0].segments[0]?.departureDate
|
||||
const lastArr = legs.at(-1)?.segments.at(-1)?.arrivalDate
|
||||
if (!firstDep || !lastArr) return null
|
||||
|
||||
const depDate = new Date(firstDep)
|
||||
const arrDate = new Date(lastArr)
|
||||
|
||||
// Calendar days: count from departure day to arrival day inclusive
|
||||
const depDay = new Date(depDate.getFullYear(), depDate.getMonth(), depDate.getDate())
|
||||
const arrDay = new Date(arrDate.getFullYear(), arrDate.getMonth(), arrDate.getDate())
|
||||
const calendarDays = Math.round((arrDay.getTime() - depDay.getTime()) / 86400000) + 1
|
||||
|
||||
return calendarDays
|
||||
})
|
||||
|
||||
function formatDuration(ms: number) {
|
||||
const totalMin = Math.round(ms / 60000)
|
||||
const h = Math.floor(totalMin / 60)
|
||||
const m = totalMin % 60
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard class="hover:ring-primary-500 transition-all cursor-pointer" @click="$emit('select', trip)">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="flex-1 space-y-1">
|
||||
<FlightLeg v-for="(leg, i) in trip.legs" :key="i" :leg="leg" :index="i" :origin-tz-offset="originTzOffset" :show-origin-time="showOriginTime" />
|
||||
</div>
|
||||
|
||||
<div class="text-right shrink-0 pl-4 border-l border-neutral-200 dark:border-neutral-700 space-y-1">
|
||||
<p class="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ trip.totalCost.toFixed(0) }}<span class="text-sm font-normal ml-0.5">€</span>
|
||||
</p>
|
||||
|
||||
<p class="text-xs text-muted flex items-center gap-1 justify-end">
|
||||
<UIcon name="i-lucide-plane" class="text-xs" />
|
||||
{{ formatDuration(totalFlightMs) }}
|
||||
</p>
|
||||
|
||||
<template v-if="stayInfo">
|
||||
<p class="text-xs text-muted flex items-center gap-1 justify-end">
|
||||
<UIcon name="i-lucide-map-pin" class="text-xs" />
|
||||
{{ stayInfo.fullDays }}d {{ stayInfo.remainingHours }}h en destino
|
||||
</p>
|
||||
<p class="text-xs text-muted flex items-center gap-1 justify-end">
|
||||
<UIcon name="i-lucide-moon" class="text-xs" />
|
||||
{{ stayInfo.nights }} noche{{ stayInfo.nights !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<p v-if="totalTripDays" class="text-xs text-muted flex items-center gap-1 justify-end">
|
||||
<UIcon name="i-lucide-calendar-range" class="text-xs" />
|
||||
{{ totalTripDays }} dia{{ totalTripDays !== 1 ? 's' : '' }} total
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-1 justify-end pt-1">
|
||||
<DetailWatchlistToggle
|
||||
:booking-token="trip.bookingToken"
|
||||
:route-summary="routeSummary"
|
||||
:departure-code="departureCode"
|
||||
:arrival-code="arrivalCode"
|
||||
:departure-date="departureDate"
|
||||
:price="trip.totalCost"
|
||||
:passengers="{ adult: 1, child: 0, infant: 0 }"
|
||||
/>
|
||||
<UButton size="xs" label="Ver" trailing-icon="i-lucide-arrow-right" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
71
app/components/auth/LoginForm.vue
Normal file
71
app/components/auth/LoginForm.vue
Normal file
@@ -0,0 +1,71 @@
|
||||
<script setup lang="ts">
|
||||
const { login, register, loginWithGoogle, loading, error } = useAuth()
|
||||
|
||||
const mode = ref<'login' | 'register'>('login')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
|
||||
async function onSubmit() {
|
||||
const success = mode.value === 'login'
|
||||
? await login(email.value, password.value)
|
||||
: await register(email.value, password.value)
|
||||
|
||||
if (success) {
|
||||
await navigateTo('/')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="max-w-sm mx-auto space-y-6">
|
||||
<div class="text-center">
|
||||
<h1 class="text-2xl font-bold">
|
||||
{{ mode === 'login' ? 'Iniciar sesion' : 'Crear cuenta' }}
|
||||
</h1>
|
||||
<p class="text-sm text-muted mt-1">
|
||||
{{ mode === 'login' ? 'Accede a tu cuenta de Vuelato' : 'Registrate para guardar vuelos' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
label="Continuar con Google"
|
||||
icon="i-simple-icons-google"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
block
|
||||
@click="loginWithGoogle"
|
||||
/>
|
||||
|
||||
<USeparator label="o" />
|
||||
|
||||
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||
<UFormField label="Email">
|
||||
<UInput v-model="email" type="email" placeholder="tu@email.com" icon="i-lucide-mail" required />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Contrasena">
|
||||
<UInput v-model="password" type="password" placeholder="••••••••" icon="i-lucide-lock" required :minlength="6" />
|
||||
</UFormField>
|
||||
|
||||
<UAlert v-if="error" color="error" :title="error" icon="i-lucide-alert-circle" />
|
||||
|
||||
<UButton
|
||||
type="submit"
|
||||
:label="mode === 'login' ? 'Iniciar sesion' : 'Crear cuenta'"
|
||||
:loading="loading"
|
||||
block
|
||||
/>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-sm">
|
||||
<template v-if="mode === 'login'">
|
||||
No tienes cuenta?
|
||||
<UButton variant="link" label="Registrate" @click="mode = 'register'" />
|
||||
</template>
|
||||
<template v-else>
|
||||
Ya tienes cuenta?
|
||||
<UButton variant="link" label="Inicia sesion" @click="mode = 'login'" />
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
46
app/components/auth/UserMenu.vue
Normal file
46
app/components/auth/UserMenu.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
const { user, logout } = useAuth()
|
||||
|
||||
const items = computed(() => [
|
||||
[{
|
||||
label: user.value?.email ?? '',
|
||||
disabled: true
|
||||
}],
|
||||
[{
|
||||
label: 'Watchlist',
|
||||
icon: 'i-lucide-heart',
|
||||
to: '/watchlist'
|
||||
},
|
||||
{
|
||||
label: 'Preferencias',
|
||||
icon: 'i-lucide-settings',
|
||||
to: '/settings'
|
||||
}],
|
||||
[{
|
||||
label: 'Cerrar sesion',
|
||||
icon: 'i-lucide-log-out',
|
||||
click: logout
|
||||
}]
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="user">
|
||||
<UDropdownMenu :items="items">
|
||||
<UButton
|
||||
icon="i-lucide-user"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
:label="user.email?.split('@')[0]"
|
||||
/>
|
||||
</UDropdownMenu>
|
||||
</div>
|
||||
<UButton
|
||||
v-else
|
||||
to="/auth"
|
||||
label="Entrar"
|
||||
icon="i-lucide-log-in"
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
/>
|
||||
</template>
|
||||
145
app/components/detail/FlightTracker.vue
Normal file
145
app/components/detail/FlightTracker.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
flightCode: string
|
||||
}>()
|
||||
|
||||
const info = ref<any>(null)
|
||||
const fr24Url = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
async function loadInfo() {
|
||||
if (loaded.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await $fetch<any>('/api/flight-info', {
|
||||
query: { flightno: props.flightCode }
|
||||
})
|
||||
fr24Url.value = data.fr24Url || `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
|
||||
if (data.found) info.value = data.flight
|
||||
} catch {
|
||||
fr24Url.value = `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
|
||||
} finally {
|
||||
loading.value = false
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function formatAltitude(ft: number) {
|
||||
return `${Math.round(ft * 0.3048)}m (FL${Math.round(ft / 100)})`
|
||||
}
|
||||
|
||||
function formatSpeed(knots: number) {
|
||||
return `${Math.round(knots * 1.852)} km/h`
|
||||
}
|
||||
|
||||
function formatDelay(min: number | null) {
|
||||
if (min == null || min === 0) return null
|
||||
if (min > 0) return `+${min} min`
|
||||
return `${min} min`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Toggle button -->
|
||||
<UButton
|
||||
:label="loading ? 'Cargando...' : (info ? 'Info del vuelo' : 'Ver info en vivo')"
|
||||
:icon="info ? 'i-lucide-radar' : 'i-lucide-radio'"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:loading="loading"
|
||||
@click="loadInfo"
|
||||
/>
|
||||
|
||||
<!-- Flight info panel -->
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-200 ease-out"
|
||||
enter-from-class="opacity-0 max-h-0"
|
||||
enter-to-class="opacity-100 max-h-96"
|
||||
leave-active-class="transition-all duration-150 ease-in"
|
||||
leave-from-class="opacity-100 max-h-96"
|
||||
leave-to-class="opacity-0 max-h-0"
|
||||
>
|
||||
<div v-if="info" class="mt-2 overflow-hidden">
|
||||
<div class="rounded-lg bg-neutral-50 dark:bg-neutral-800/50 p-3 space-y-2 text-sm">
|
||||
<!-- Status -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full"
|
||||
:class="info.onGround ? 'bg-amber-500' : info.altitude > 0 ? 'bg-green-500 animate-pulse' : 'bg-neutral-400'"
|
||||
/>
|
||||
<span class="font-medium">{{ info.status }}</span>
|
||||
</div>
|
||||
<a
|
||||
:href="info.fr24Url"
|
||||
target="_blank"
|
||||
class="text-xs text-primary-500 hover:underline"
|
||||
>
|
||||
Flightradar24
|
||||
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Aircraft & airline -->
|
||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||
<div v-if="info.aircraft">
|
||||
<span class="text-muted">Avion</span>
|
||||
<p class="font-medium">{{ info.aircraftAge || info.aircraft }}</p>
|
||||
</div>
|
||||
<div v-if="info.registration">
|
||||
<span class="text-muted">Matricula</span>
|
||||
<p class="font-medium">{{ info.registration }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Live data (if in flight) -->
|
||||
<div v-if="info.altitude > 0 && !info.onGround" class="grid grid-cols-3 gap-2 text-xs">
|
||||
<div>
|
||||
<span class="text-muted">Altitud</span>
|
||||
<p class="font-medium">{{ formatAltitude(info.altitude) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">Velocidad</span>
|
||||
<p class="font-medium">{{ formatSpeed(info.speed) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-muted">Rumbo</span>
|
||||
<p class="font-medium">{{ info.heading }}°</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delays -->
|
||||
<div v-if="formatDelay(info.departureDelay) || formatDelay(info.arrivalDelay)" class="flex gap-4 text-xs">
|
||||
<div v-if="formatDelay(info.departureDelay)">
|
||||
<span class="text-muted">Retraso salida</span>
|
||||
<p class="font-medium" :class="info.departureDelay > 0 ? 'text-red-500' : 'text-green-500'">
|
||||
{{ formatDelay(info.departureDelay) }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="formatDelay(info.arrivalDelay)">
|
||||
<span class="text-muted">Retraso llegada</span>
|
||||
<p class="font-medium" :class="info.arrivalDelay > 0 ? 'text-red-500' : 'text-green-500'">
|
||||
{{ formatDelay(info.arrivalDelay) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- Not found — link to FR24 anyway -->
|
||||
<div v-if="loaded && !info && !loading" class="mt-1">
|
||||
<a
|
||||
:href="fr24Url"
|
||||
target="_blank"
|
||||
class="text-xs text-muted hover:text-primary-500 transition-colors"
|
||||
>
|
||||
No hay datos en vivo · Ver historial en Flightradar24
|
||||
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
49
app/components/detail/ItineraryTimeline.vue
Normal file
49
app/components/detail/ItineraryTimeline.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
defineProps<{ trip: Trip }>()
|
||||
|
||||
function formatFullDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'long' })
|
||||
}
|
||||
|
||||
function legDuration(leg: Trip['legs'][0]) {
|
||||
const segs = leg.segments
|
||||
if (!segs.length) return ''
|
||||
const depMs = segs[0].departureTimestamp * 1000
|
||||
const arrMs = segs[segs.length - 1].arrivalTimestamp * 1000
|
||||
const diffMin = Math.round((arrMs - depMs) / 60000)
|
||||
const h = Math.floor(diffMin / 60)
|
||||
const m = diffMin % 60
|
||||
return `${h}h ${m}m`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-4">
|
||||
<UCard v-for="(leg, i) in trip.legs" :key="i">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<UBadge :label="i === 0 ? 'Ida' : 'Vuelta'" :color="i === 0 ? 'primary' : 'info'" variant="subtle" />
|
||||
<span class="text-sm text-neutral-500">{{ formatFullDate(leg.segments[0].departureDate) }}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-sm text-muted">
|
||||
<UIcon name="i-lucide-timer" class="text-xs" />
|
||||
{{ legDuration(leg) }}
|
||||
<template v-if="leg.segments.length > 1">
|
||||
· {{ leg.segments.length - 1 }} escala{{ leg.segments.length > 2 ? 's' : '' }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<DetailSegmentCard
|
||||
v-for="(seg, j) in leg.segments"
|
||||
:key="j"
|
||||
:segment="seg"
|
||||
:show-divider="j > 0"
|
||||
/>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
44
app/components/detail/PriceVerifier.vue
Normal file
44
app/components/detail/PriceVerifier.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
import type { PassengersCount } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
bookingToken: string
|
||||
originalPrice: number
|
||||
passengers: PassengersCount
|
||||
}>()
|
||||
|
||||
const { checkPrice } = useFlightSearch()
|
||||
const checkedPrice = ref<number | null>(null)
|
||||
const checking = ref(false)
|
||||
|
||||
async function verify() {
|
||||
checking.value = true
|
||||
try {
|
||||
const data = await checkPrice(props.bookingToken, props.passengers)
|
||||
checkedPrice.value = data.trip.totalCost
|
||||
} catch {
|
||||
checkedPrice.value = -1
|
||||
} finally {
|
||||
checking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-semibold">Verificar precio actual</h3>
|
||||
<p class="text-sm text-neutral-500">Comprueba si el precio sigue disponible</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<template v-if="checkedPrice !== null">
|
||||
<UBadge v-if="checkedPrice === -1" label="No disponible" color="error" />
|
||||
<UBadge v-else-if="checkedPrice <= originalPrice" :label="`${checkedPrice.toFixed(0)}€`" color="success" />
|
||||
<UBadge v-else :label="`${checkedPrice.toFixed(0)}€ (subio)`" color="warning" />
|
||||
</template>
|
||||
<UButton label="Verificar" icon="i-lucide-refresh-cw" :loading="checking" @click="verify" />
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
57
app/components/detail/RelatedFlights.vue
Normal file
57
app/components/detail/RelatedFlights.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
from: string
|
||||
to: string
|
||||
}>()
|
||||
|
||||
const { trips, loading, fetchRouteFlights } = useRouteFlights()
|
||||
|
||||
onMounted(() => {
|
||||
if (props.from && props.to) {
|
||||
fetchRouteFlights(props.from, props.to)
|
||||
}
|
||||
})
|
||||
|
||||
function formatTime(d: string) {
|
||||
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function formatDate(d: string) {
|
||||
return new Date(d).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading || trips.length > 0">
|
||||
<h3 class="font-semibold mb-3">Otros vuelos {{ from }} → {{ to }}</h3>
|
||||
<div v-if="loading" class="space-y-2">
|
||||
<USkeleton v-for="i in 3" :key="i" class="h-12" />
|
||||
</div>
|
||||
<div v-else class="space-y-2">
|
||||
<div
|
||||
v-for="(trip, i) in trips.slice(0, 5)"
|
||||
:key="i"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="font-medium">
|
||||
{{ formatTime(trip.legs[0]?.segments[0]?.departureDate) }}
|
||||
→
|
||||
{{ formatTime(trip.legs[0]?.segments.at(-1)?.arrivalDate || '') }}
|
||||
</span>
|
||||
<span class="text-muted">
|
||||
{{ formatDate(trip.legs[0]?.segments[0]?.departureDate) }}
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{{ trip.legs[0]?.segments[0]?.company?.code }}{{ trip.legs[0]?.segments[0]?.number }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ trip.totalCost.toFixed(0) }}€
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
164
app/components/detail/SegmentCard.vue
Normal file
164
app/components/detail/SegmentCard.vue
Normal file
@@ -0,0 +1,164 @@
|
||||
<script setup lang="ts">
|
||||
import type { Segment } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
segment: Segment
|
||||
showDivider?: boolean
|
||||
}>()
|
||||
|
||||
const { resolve } = useAirlineNames()
|
||||
const { getBookingUrl, getAirlineWebsite } = useBookingUrl()
|
||||
|
||||
function formatTime(d: string) {
|
||||
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
const segmentDate = computed(() => props.segment.departureDate.slice(0, 10))
|
||||
|
||||
const segmentLink = computed(() => ({
|
||||
path: '/results',
|
||||
query: {
|
||||
mode: 'oneway',
|
||||
dep: props.segment.departureCode,
|
||||
dest: props.segment.arrivalCode,
|
||||
from: segmentDate.value,
|
||||
to: segmentDate.value,
|
||||
adults: '1'
|
||||
}
|
||||
}))
|
||||
|
||||
const flightCode = computed(() => `${props.segment.company.code}${props.segment.number}`)
|
||||
const trackerUrl = computed(() => `https://www.flightradar24.com/data/flights/${flightCode.value.toLowerCase()}`)
|
||||
const airlineName = computed(() => resolve(props.segment.company.code, props.segment.company.name))
|
||||
|
||||
const bookingUrl = computed(() => getBookingUrl({
|
||||
airlineCode: props.segment.company.code,
|
||||
origin: props.segment.departureCode,
|
||||
destination: props.segment.arrivalCode,
|
||||
date: props.segment.departureDate
|
||||
}))
|
||||
const airlineWebsite = computed(() => getAirlineWebsite(props.segment.company.code))
|
||||
|
||||
// Fetch cheapest price for this specific route
|
||||
const segmentPrice = ref<number | null>(null)
|
||||
const loadingPrice = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
loadingPrice.value = true
|
||||
try {
|
||||
const date = props.segment.departureDate.slice(0, 10)
|
||||
const data = await $fetch<any>('/api/search', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
departures: [props.segment.departureCode],
|
||||
local: 'en',
|
||||
departureDateInterval: {
|
||||
begin: `${date}T00:00:00+00:00`,
|
||||
end: `${date}T00:00:00+00:00`
|
||||
},
|
||||
stops: [{
|
||||
locations: [props.segment.arrivalCode],
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}],
|
||||
endInSameLocation: false,
|
||||
maxStops: 0,
|
||||
fixStopsOrder: false,
|
||||
stopLength: { min: 0, max: 0, isSet: false },
|
||||
maxResults: 1,
|
||||
passengersCount: { adult: 1, child: 0, infant: 0 }
|
||||
}
|
||||
})
|
||||
if (data.trips?.length) {
|
||||
segmentPrice.value = data.trips[0].totalCost
|
||||
}
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
loadingPrice.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center gap-4 py-3 -mx-2 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
|
||||
:class="{ 'border-t border-neutral-100 dark:border-neutral-800': showDivider }"
|
||||
>
|
||||
<!-- Departure -->
|
||||
<div class="text-center w-20">
|
||||
<p class="text-xl font-semibold">{{ formatTime(segment.departureDate) }}</p>
|
||||
<p class="text-sm font-medium">{{ segment.departureCode }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ segment.departureCity }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Flight info -->
|
||||
<div class="flex-1 flex flex-col items-center gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<a
|
||||
:href="trackerUrl"
|
||||
target="_blank"
|
||||
class="text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:text-primary-500 transition-colors"
|
||||
title="Ver en Flightradar24"
|
||||
@click.stop
|
||||
>
|
||||
<span v-if="airlineName" class="font-medium">{{ airlineName }} · </span>
|
||||
<span>{{ segment.company.code }} {{ segment.number }}</span>
|
||||
<UIcon name="i-lucide-radar" class="inline ml-0.5 text-[10px] opacity-50" />
|
||||
</a>
|
||||
</div>
|
||||
<div class="w-full h-px bg-neutral-200 dark:bg-neutral-700 relative">
|
||||
<UIcon name="i-lucide-plane" class="absolute -top-2 left-1/2 -translate-x-1/2 text-primary-500" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrival -->
|
||||
<div class="text-center w-20">
|
||||
<p class="text-xl font-semibold">{{ formatTime(segment.arrivalDate) }}</p>
|
||||
<p class="text-sm font-medium">{{ segment.arrivalCode }}</p>
|
||||
<p class="text-xs text-neutral-400">{{ segment.arrivalCity }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Price (links to one-way search) -->
|
||||
<NuxtLink :to="segmentLink" class="shrink-0 text-right w-16 hover:opacity-80 transition-opacity" @click.stop>
|
||||
<template v-if="loadingPrice">
|
||||
<USkeleton class="h-5 w-12 ml-auto" />
|
||||
</template>
|
||||
<template v-else-if="segmentPrice != null">
|
||||
<p class="text-sm font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ segmentPrice.toFixed(0) }}€
|
||||
</p>
|
||||
<p class="text-xs text-muted">solo ida</p>
|
||||
</template>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Flight tracker (expandable) -->
|
||||
<div class="pl-24 -mt-1 mb-1">
|
||||
<DetailFlightTracker :flight-code="flightCode" />
|
||||
</div>
|
||||
|
||||
<!-- Booking links -->
|
||||
<div class="pl-24 -mt-1 mb-2 flex items-center gap-2">
|
||||
<a
|
||||
:href="bookingUrl"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-ticket" class="text-sm" />
|
||||
Reservar vuelo
|
||||
</a>
|
||||
<a
|
||||
v-if="airlineWebsite"
|
||||
:href="airlineWebsite"
|
||||
target="_blank"
|
||||
class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||
@click.stop
|
||||
>
|
||||
<UIcon name="i-lucide-globe" class="text-sm" />
|
||||
Web de {{ airlineName || segment.company.code }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
31
app/components/detail/ShareButton.vue
Normal file
31
app/components/detail/ShareButton.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
price: number
|
||||
}>()
|
||||
|
||||
const copied = ref(false)
|
||||
|
||||
async function share() {
|
||||
const url = window.location.href
|
||||
const text = `${props.title} - ${props.price.toFixed(0)}€`
|
||||
|
||||
if (navigator.share) {
|
||||
await navigator.share({ title: text, url })
|
||||
} else {
|
||||
await navigator.clipboard.writeText(`${text}\n${url}`)
|
||||
copied.value = true
|
||||
setTimeout(() => { copied.value = false }, 2000)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
:label="copied ? 'Copiado!' : 'Compartir'"
|
||||
:icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'"
|
||||
:color="copied ? 'success' : 'neutral'"
|
||||
variant="outline"
|
||||
@click="share"
|
||||
/>
|
||||
</template>
|
||||
52
app/components/detail/WatchlistToggle.vue
Normal file
52
app/components/detail/WatchlistToggle.vue
Normal file
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
bookingToken: string
|
||||
routeSummary: string
|
||||
departureCode: string
|
||||
arrivalCode: string
|
||||
departureDate: string
|
||||
price: number
|
||||
passengers: { adult: number; child: number; infant: number }
|
||||
}>()
|
||||
|
||||
const user = useSupabaseUser()
|
||||
const { isWatched, getWatchedItem, add, remove } = useWatchlist()
|
||||
|
||||
const toggling = ref(false)
|
||||
const watched = computed(() => isWatched(props.bookingToken))
|
||||
|
||||
async function toggle() {
|
||||
if (!user.value) {
|
||||
await navigateTo('/auth')
|
||||
return
|
||||
}
|
||||
|
||||
toggling.value = true
|
||||
if (watched.value) {
|
||||
const item = getWatchedItem(props.bookingToken)
|
||||
if (item) await remove(item.id)
|
||||
} else {
|
||||
await add({
|
||||
bookingToken: props.bookingToken,
|
||||
routeSummary: props.routeSummary,
|
||||
departureCode: props.departureCode,
|
||||
arrivalCode: props.arrivalCode,
|
||||
departureDate: props.departureDate,
|
||||
price: props.price,
|
||||
passengers: props.passengers
|
||||
})
|
||||
}
|
||||
toggling.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UButton
|
||||
:icon="watched ? 'i-lucide-heart' : 'i-lucide-heart'"
|
||||
:color="watched ? 'error' : 'neutral'"
|
||||
:variant="watched ? 'soft' : 'ghost'"
|
||||
:loading="toggling"
|
||||
size="sm"
|
||||
@click.stop="toggle"
|
||||
/>
|
||||
</template>
|
||||
57
app/components/inspiration/BudgetExplorer.vue
Normal file
57
app/components/inspiration/BudgetExplorer.vue
Normal file
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import type { InspirationItem } from '~/server/utils/flightics'
|
||||
|
||||
defineProps<{
|
||||
items: InspirationItem[]
|
||||
from: string
|
||||
loading: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{ select: [iata: string] }>()
|
||||
|
||||
const budget = ref(100)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">Donde por {{ budget }}€?</h3>
|
||||
<UBadge
|
||||
:label="`${items.filter(i => i.minPrice <= budget).length} destinos`"
|
||||
color="primary"
|
||||
size="xs"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="mb-4">
|
||||
<URange v-model="budget" :min="15" :max="500" :step="5" />
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<USkeleton v-for="i in 6" :key="i" class="h-12" />
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
<button
|
||||
v-for="item in items.filter(i => i.minPrice <= budget).slice(0, 12)"
|
||||
:key="item.to[0]"
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-400 transition-colors text-left text-sm"
|
||||
@click="$emit('select', item.to[0])"
|
||||
>
|
||||
<span>
|
||||
<span class="font-semibold">{{ from }} → {{ item.to[0] }}</span>
|
||||
<span v-if="item.minStops === 0" class="text-xs text-muted ml-1">directo</span>
|
||||
</span>
|
||||
<span class="font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ item.minPrice.toFixed(0) }}€
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="items.filter(i => i.minPrice <= budget).length === 0 && !loading" class="text-center py-4 text-sm text-muted">
|
||||
Sube el presupuesto para ver destinos
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
37
app/components/inspiration/MultiCityCard.vue
Normal file
37
app/components/inspiration/MultiCityCard.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import type { MultiCityInspirationItem } from '~/server/utils/flightics'
|
||||
|
||||
const props = defineProps<{
|
||||
item: MultiCityInspirationItem
|
||||
currency?: string
|
||||
resolveCountry?: (iata: string) => string
|
||||
}>()
|
||||
|
||||
defineEmits<{ select: [item: MultiCityInspirationItem] }>()
|
||||
|
||||
const countrySummary = computed(() => {
|
||||
if (!props.resolveCountry) return ''
|
||||
const unique = [...new Set(props.item.stops.map(s => props.resolveCountry!(s)).filter(Boolean))]
|
||||
return unique.join(', ')
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard class="hover:ring-primary-500 transition-all cursor-pointer" @click="$emit('select', item)">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-semibold text-sm">
|
||||
{{ item.from }} → {{ item.stops.join(' → ') }}
|
||||
</p>
|
||||
<p class="text-xs text-muted mt-1">
|
||||
{{ item.stops.length }} parada{{ item.stops.length !== 1 ? 's' : '' }}
|
||||
<span v-if="countrySummary"> · {{ countrySummary }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ item.minPrice.toFixed(0) }}<span class="text-xs">€</span>
|
||||
</p>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
144
app/components/map/FlightMap.vue
Normal file
144
app/components/map/FlightMap.vue
Normal 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="© OpenStreetMap © 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) }}€
|
||||
</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>
|
||||
38
app/components/map/MapControls.vue
Normal file
38
app/components/map/MapControls.vue
Normal 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>
|
||||
18
app/components/results/LoadingMore.vue
Normal file
18
app/components/results/LoadingMore.vue
Normal file
@@ -0,0 +1,18 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
polling: boolean
|
||||
pollCount: number
|
||||
}>()
|
||||
|
||||
defineEmits<{ stop: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="polling" class="flex items-center justify-center gap-3 py-4">
|
||||
<UIcon name="i-lucide-loader" class="animate-spin text-primary-500" />
|
||||
<p class="text-sm text-muted">
|
||||
Buscando mas resultados (ronda {{ pollCount }})...
|
||||
</p>
|
||||
<UButton label="Parar" size="xs" color="neutral" variant="ghost" @click="$emit('stop')" />
|
||||
</div>
|
||||
</template>
|
||||
158
app/components/results/ResultsFilters.vue
Normal file
158
app/components/results/ResultsFilters.vue
Normal file
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
maxPrice: number | null
|
||||
maxStops: number | null
|
||||
airlines: string[]
|
||||
departureTimeRange: [number, number]
|
||||
availableAirlines: { code: string, name: string }[]
|
||||
priceRange: { min: number, max: number }
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:maxPrice': [value: number | null]
|
||||
'update:maxStops': [value: number | null]
|
||||
'update:airlines': [value: string[]]
|
||||
'update:departureTimeRange': [value: [number, number]]
|
||||
}>()
|
||||
|
||||
const priceValue = ref(props.maxPrice ?? props.priceRange.max)
|
||||
const priceEnabled = ref(props.maxPrice != null)
|
||||
|
||||
watch(priceEnabled, (on) => {
|
||||
emit('update:maxPrice', on ? priceValue.value : null)
|
||||
})
|
||||
watch(priceValue, (v) => {
|
||||
if (priceEnabled.value) emit('update:maxPrice', v)
|
||||
})
|
||||
watch(() => props.maxPrice, (v) => {
|
||||
if (v == null) {
|
||||
priceEnabled.value = false
|
||||
} else {
|
||||
priceValue.value = v
|
||||
priceEnabled.value = true
|
||||
}
|
||||
})
|
||||
|
||||
const stopsOptions = [
|
||||
{ label: 'Todos', value: null },
|
||||
{ label: 'Directo', value: 0 },
|
||||
{ label: 'Max 1', value: 1 },
|
||||
{ label: 'Max 2', value: 2 }
|
||||
]
|
||||
|
||||
function toggleAirline(code: string) {
|
||||
const current = [...props.airlines]
|
||||
const idx = current.indexOf(code)
|
||||
if (idx >= 0) current.splice(idx, 1)
|
||||
else current.push(code)
|
||||
emit('update:airlines', current)
|
||||
}
|
||||
|
||||
function formatHour(h: number) {
|
||||
return `${String(h).padStart(2, '0')}:00`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<div class="space-y-5">
|
||||
<!-- Price filter -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label class="text-sm font-medium flex items-center gap-2">
|
||||
<USwitch :model-value="priceEnabled" size="xs" @update:model-value="priceEnabled = $event" />
|
||||
Precio max
|
||||
</label>
|
||||
<UInput
|
||||
v-if="priceEnabled"
|
||||
:model-value="priceValue"
|
||||
type="number"
|
||||
:min="priceRange.min"
|
||||
:max="priceRange.max"
|
||||
size="xs"
|
||||
class="w-24 text-right"
|
||||
@update:model-value="priceValue = Number($event)"
|
||||
>
|
||||
<template #trailing><span class="text-xs text-muted">€</span></template>
|
||||
</UInput>
|
||||
</div>
|
||||
<URange
|
||||
v-if="priceEnabled"
|
||||
v-model="priceValue"
|
||||
:min="priceRange.min"
|
||||
:max="priceRange.max"
|
||||
:step="5"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Stops filter -->
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<p class="text-sm font-medium">Escalas</p>
|
||||
<UInput
|
||||
:model-value="maxStops ?? ''"
|
||||
type="number"
|
||||
:min="0"
|
||||
:max="10"
|
||||
placeholder="Sin limite"
|
||||
size="xs"
|
||||
class="w-28 text-right"
|
||||
@update:model-value="$emit('update:maxStops', $event === '' ? null : Number($event))"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in stopsOptions"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:color="maxStops === opt.value ? 'primary' : 'neutral'"
|
||||
:variant="maxStops === opt.value ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="$emit('update:maxStops', opt.value)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Departure time -->
|
||||
<div>
|
||||
<p class="text-sm font-medium mb-1">Hora de salida</p>
|
||||
<p class="text-xs text-muted mb-2">
|
||||
{{ formatHour(departureTimeRange[0]) }} - {{ formatHour(departureTimeRange[1]) }}
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<URange
|
||||
:model-value="departureTimeRange[0]"
|
||||
:min="0"
|
||||
:max="23"
|
||||
class="flex-1"
|
||||
@update:model-value="$emit('update:departureTimeRange', [$event, departureTimeRange[1]])"
|
||||
/>
|
||||
<URange
|
||||
:model-value="departureTimeRange[1]"
|
||||
:min="1"
|
||||
:max="24"
|
||||
class="flex-1"
|
||||
@update:model-value="$emit('update:departureTimeRange', [departureTimeRange[0], $event])"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Airlines filter -->
|
||||
<div v-if="availableAirlines.length > 0">
|
||||
<p class="text-sm font-medium mb-2">Aerolineas</p>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<UButton
|
||||
v-for="al in availableAirlines"
|
||||
:key="al.code"
|
||||
:label="`${al.code}${al.name ? ' · ' + al.name : ''}`"
|
||||
:color="airlines.includes(al.code) ? 'primary' : 'neutral'"
|
||||
:variant="airlines.includes(al.code) ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="toggleAirline(al.code)"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-xs text-muted mt-1">{{ airlines.length === 0 ? 'Sin filtro de aerolinea' : 'Solo vuelos operados exclusivamente por las seleccionadas' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</UCard>
|
||||
</template>
|
||||
99
app/components/results/ResultsToolbar.vue
Normal file
99
app/components/results/ResultsToolbar.vue
Normal file
@@ -0,0 +1,99 @@
|
||||
<script setup lang="ts">
|
||||
import type { SortKey } from '~/composables/useResultFilters'
|
||||
|
||||
const sortBy = defineModel<SortKey>('sortBy', { default: 'price' })
|
||||
const viewMode = defineModel<'full' | 'compact'>('viewMode', { default: 'full' })
|
||||
const { showOriginTime } = useOriginTime()
|
||||
|
||||
defineProps<{
|
||||
count: number
|
||||
hasActiveFilters: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
toggleFilters: []
|
||||
resetFilters: []
|
||||
}>()
|
||||
|
||||
const sortOptions: { value: SortKey, label: string, icon: string }[] = [
|
||||
{ value: 'price', label: 'Precio', icon: 'i-lucide-arrow-down-narrow-wide' },
|
||||
{ value: 'departure', label: 'Salida', icon: 'i-lucide-clock' },
|
||||
{ value: 'duration', label: 'Duracion', icon: 'i-lucide-timer' },
|
||||
{ value: 'stops', label: 'Escalas', icon: 'i-lucide-git-branch' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<p class="text-sm text-muted">
|
||||
{{ count }} resultado{{ count !== 1 ? 's' : '' }}
|
||||
</p>
|
||||
<UButton
|
||||
v-if="hasActiveFilters"
|
||||
label="Limpiar filtros"
|
||||
icon="i-lucide-x"
|
||||
color="neutral"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
@click="$emit('resetFilters')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Sort buttons -->
|
||||
<div class="flex gap-0.5">
|
||||
<UButton
|
||||
v-for="opt in sortOptions"
|
||||
:key="opt.value"
|
||||
:label="opt.label"
|
||||
:icon="opt.icon"
|
||||
:color="sortBy === opt.value ? 'primary' : 'neutral'"
|
||||
:variant="sortBy === opt.value ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="sortBy = opt.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<USeparator orientation="vertical" class="h-5" />
|
||||
|
||||
<!-- View mode -->
|
||||
<div class="flex gap-0.5">
|
||||
<UButton
|
||||
icon="i-lucide-rows-3"
|
||||
:color="viewMode === 'full' ? 'primary' : 'neutral'"
|
||||
:variant="viewMode === 'full' ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="viewMode = 'full'"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-list"
|
||||
:color="viewMode === 'compact' ? 'primary' : 'neutral'"
|
||||
:variant="viewMode === 'compact' ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="viewMode = 'compact'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Origin time toggle -->
|
||||
<UButton
|
||||
icon="i-lucide-clock"
|
||||
label="Hora origen"
|
||||
:color="showOriginTime ? 'primary' : 'neutral'"
|
||||
:variant="showOriginTime ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="showOriginTime = !showOriginTime"
|
||||
/>
|
||||
|
||||
<!-- Filter toggle -->
|
||||
<UButton
|
||||
icon="i-lucide-sliders-horizontal"
|
||||
label="Filtros"
|
||||
:color="hasActiveFilters ? 'primary' : 'neutral'"
|
||||
:variant="hasActiveFilters ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="$emit('toggleFilters')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
39
app/components/results/TripCardCompact.vue
Normal file
39
app/components/results/TripCardCompact.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import type { Trip } from '~/server/utils/flightics'
|
||||
|
||||
defineProps<{ trip: Trip }>()
|
||||
defineEmits<{ select: [trip: Trip] }>()
|
||||
|
||||
function formatTime(dateStr: string) {
|
||||
return new Date(dateStr).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function legSummary(leg: Trip['legs'][0]) {
|
||||
const segs = leg.segments
|
||||
if (!segs.length) return ''
|
||||
const dep = segs[0]
|
||||
const arr = segs[segs.length - 1]
|
||||
const stops = segs.length - 1
|
||||
const stopsText = stops === 0 ? 'Directo' : `${stops} escala${stops > 1 ? 's' : ''}`
|
||||
return `${dep.departureCode} ${formatTime(dep.departureDate)} → ${arr.arrivalCode} ${formatTime(arr.arrivalDate)} · ${stopsText}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-400 cursor-pointer transition-colors"
|
||||
@click="$emit('select', trip)"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p v-for="(leg, i) in trip.legs" :key="i" class="text-sm truncate">
|
||||
<span class="text-xs font-medium text-muted mr-1">{{ i === 0 ? 'Ida' : 'Vta' }}</span>
|
||||
{{ legSummary(leg) }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="shrink-0 ml-3 text-right">
|
||||
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">
|
||||
{{ trip.totalCost.toFixed(0) }}<span class="text-xs">€</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
189
app/components/search/AirportInput.vue
Normal file
189
app/components/search/AirportInput.vue
Normal file
@@ -0,0 +1,189 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
placeholder?: string
|
||||
icon?: string
|
||||
multiple?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { loadAirports, searchAirports } = useLocations()
|
||||
const query = ref('')
|
||||
const results = ref<ReturnType<typeof searchAirports>>([])
|
||||
const open = ref(false)
|
||||
const highlightIndex = ref(0)
|
||||
|
||||
// Selected codes as array
|
||||
const selected = ref<string[]>([])
|
||||
|
||||
// Init from modelValue
|
||||
function parseModelValue(v: string) {
|
||||
return v.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||
}
|
||||
|
||||
selected.value = parseModelValue(props.modelValue)
|
||||
|
||||
watch(() => props.modelValue, (v) => {
|
||||
const parsed = parseModelValue(v)
|
||||
if (parsed.join(',') !== selected.value.join(',')) {
|
||||
selected.value = parsed
|
||||
}
|
||||
})
|
||||
|
||||
function emitValue() {
|
||||
emit('update:modelValue', selected.value.join(','))
|
||||
}
|
||||
|
||||
onMounted(() => loadAirports())
|
||||
|
||||
watch(query, (q) => {
|
||||
results.value = searchAirports(q)
|
||||
// Don't show already-selected airports
|
||||
if (props.multiple) {
|
||||
results.value = results.value.filter(a => !selected.value.includes(a.iata))
|
||||
}
|
||||
open.value = results.value.length > 0
|
||||
highlightIndex.value = 0
|
||||
})
|
||||
|
||||
function select(airport: (typeof results.value)[0]) {
|
||||
if (props.multiple) {
|
||||
if (!selected.value.includes(airport.iata)) {
|
||||
selected.value.push(airport.iata)
|
||||
}
|
||||
query.value = ''
|
||||
} else {
|
||||
selected.value = [airport.iata]
|
||||
query.value = airport.iata
|
||||
}
|
||||
emitValue()
|
||||
open.value = false
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeCode(code: string) {
|
||||
selected.value = selected.value.filter(c => c !== code)
|
||||
emitValue()
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Backspace' && !query.value && props.multiple && selected.value.length) {
|
||||
e.preventDefault()
|
||||
selected.value.pop()
|
||||
emitValue()
|
||||
return
|
||||
}
|
||||
|
||||
if (!open.value || results.value.length === 0) return
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
highlightIndex.value = Math.min(highlightIndex.value + 1, results.value.length - 1)
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
highlightIndex.value = Math.max(highlightIndex.value - 1, 0)
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
select(results.value[highlightIndex.value])
|
||||
} else if (e.key === 'Escape') {
|
||||
open.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onBlur() {
|
||||
// If there's text typed and it looks like a code, add it
|
||||
const val = query.value.trim().toUpperCase()
|
||||
if (val && props.multiple) {
|
||||
if (val.length >= 2) {
|
||||
if (!selected.value.includes(val)) {
|
||||
selected.value.push(val)
|
||||
emitValue()
|
||||
}
|
||||
}
|
||||
query.value = ''
|
||||
} else if (val && !props.multiple) {
|
||||
selected.value = [val]
|
||||
query.value = val
|
||||
emitValue()
|
||||
}
|
||||
setTimeout(() => { open.value = false; results.value = [] }, 150)
|
||||
}
|
||||
|
||||
// For single mode, keep query in sync
|
||||
watch(selected, (codes) => {
|
||||
if (!props.multiple && codes.length) {
|
||||
query.value = codes[0]
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Multiple mode: badges + input -->
|
||||
<div
|
||||
v-if="multiple"
|
||||
class="flex flex-wrap items-center gap-1 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1.5 focus-within:ring-2 focus-within:ring-primary-500 transition-shadow"
|
||||
>
|
||||
<UBadge
|
||||
v-for="code in selected"
|
||||
:key="code"
|
||||
:label="code"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
size="sm"
|
||||
class="cursor-pointer"
|
||||
@click="removeCode(code)"
|
||||
>
|
||||
<template #trailing>
|
||||
<UIcon name="i-lucide-x" class="text-xs" />
|
||||
</template>
|
||||
</UBadge>
|
||||
<input
|
||||
v-model="query"
|
||||
:placeholder="selected.length ? '' : placeholder || 'Buscar aeropuerto...'"
|
||||
class="flex-1 min-w-24 bg-transparent outline-none text-sm py-0.5"
|
||||
autocomplete="off"
|
||||
@focus="results = searchAirports(query); open = results.length > 0"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Single mode -->
|
||||
<UInput
|
||||
v-else
|
||||
v-model="query"
|
||||
:placeholder="placeholder || 'Buscar aeropuerto...'"
|
||||
:icon="icon || 'i-lucide-plane'"
|
||||
autocomplete="off"
|
||||
@focus="results = searchAirports(query); open = results.length > 0"
|
||||
@blur="onBlur"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
|
||||
<!-- Dropdown -->
|
||||
<div
|
||||
v-if="open && results.length"
|
||||
class="absolute z-50 mt-1 w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||
>
|
||||
<button
|
||||
v-for="(apt, i) in results"
|
||||
:key="apt.iata"
|
||||
type="button"
|
||||
class="w-full text-left px-3 py-2 flex items-center justify-between text-sm"
|
||||
:class="i === highlightIndex ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'"
|
||||
@mousedown.prevent="select(apt)"
|
||||
@mouseenter="highlightIndex = i"
|
||||
>
|
||||
<span>
|
||||
<span class="font-semibold">{{ apt.iata }}</span>
|
||||
<span class="text-muted ml-2">{{ apt.name }}</span>
|
||||
</span>
|
||||
<span v-if="apt.city_name" class="text-xs text-muted">{{ apt.city_name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
13
app/components/search/BudgetSlider.vue
Normal file
13
app/components/search/BudgetSlider.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<number>({ default: 500 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1">
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-muted">Presupuesto max</span>
|
||||
<span class="font-semibold">{{ model }}€</span>
|
||||
</div>
|
||||
<URange v-model="model" :min="20" :max="2000" :step="10" />
|
||||
</div>
|
||||
</template>
|
||||
27
app/components/search/DateRangePicker.vue
Normal file
27
app/components/search/DateRangePicker.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
const dateFrom = defineModel<string>('dateFrom', { default: '' })
|
||||
const dateTo = defineModel<string>('dateTo', { default: '' })
|
||||
|
||||
defineProps<{
|
||||
singleDate?: boolean
|
||||
}>()
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
watch(dateFrom, (from) => {
|
||||
if (from && dateTo.value && dateTo.value < from) {
|
||||
dateTo.value = from
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField :label="singleDate ? 'Fecha' : 'Desde'">
|
||||
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" required />
|
||||
</UFormField>
|
||||
<UFormField v-if="!singleDate" label="Hasta">
|
||||
<UInput v-model="dateTo" type="date" :min="dateFrom || today" required />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
24
app/components/search/MaxStopsFilter.vue
Normal file
24
app/components/search/MaxStopsFilter.vue
Normal file
@@ -0,0 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<number | null>({ default: null })
|
||||
|
||||
const options = [
|
||||
{ label: 'Cualquiera', value: null },
|
||||
{ label: 'Directo', value: 0 },
|
||||
{ label: 'Max 1', value: 1 },
|
||||
{ label: 'Max 2', value: 2 }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-1">
|
||||
<UButton
|
||||
v-for="opt in options"
|
||||
:key="String(opt.value)"
|
||||
:label="opt.label"
|
||||
:color="model === opt.value ? 'primary' : 'neutral'"
|
||||
:variant="model === opt.value ? 'soft' : 'ghost'"
|
||||
size="xs"
|
||||
@click="model = opt.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
26
app/components/search/SearchModeTabs.vue
Normal file
26
app/components/search/SearchModeTabs.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
const model = defineModel<string>({ default: 'roundtrip' })
|
||||
|
||||
const modes = [
|
||||
{ value: 'roundtrip', label: 'Ida y vuelta', icon: 'i-lucide-repeat' },
|
||||
{ value: 'oneway', label: 'Solo ida', icon: 'i-lucide-arrow-right' },
|
||||
{ value: 'multicity', label: 'Multi-ciudad', icon: 'i-lucide-route' },
|
||||
{ value: 'weekend', label: 'Finde', icon: 'i-lucide-calendar-days' },
|
||||
{ value: 'explore', label: 'Explorar', icon: 'i-lucide-compass' }
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex gap-1 flex-wrap">
|
||||
<UButton
|
||||
v-for="m in modes"
|
||||
:key="m.value"
|
||||
:label="m.label"
|
||||
:icon="m.icon"
|
||||
:color="model === m.value ? 'primary' : 'neutral'"
|
||||
:variant="model === m.value ? 'solid' : 'ghost'"
|
||||
size="sm"
|
||||
@click="model = m.value"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
15
app/components/search/StayDurationPicker.vue
Normal file
15
app/components/search/StayDurationPicker.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
const minDays = defineModel<number>('minDays', { default: 2 })
|
||||
const maxDays = defineModel<number>('maxDays', { default: 6 })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Estancia min (dias)">
|
||||
<UInput v-model.number="minDays" type="number" :min="1" :max="maxDays" />
|
||||
</UFormField>
|
||||
<UFormField label="Estancia max (dias)">
|
||||
<UInput v-model.number="maxDays" type="number" :min="minDays" />
|
||||
</UFormField>
|
||||
</div>
|
||||
</template>
|
||||
205
app/components/tracking/CreateTrackingForm.vue
Normal file
205
app/components/tracking/CreateTrackingForm.vue
Normal file
@@ -0,0 +1,205 @@
|
||||
<script setup lang="ts">
|
||||
const emit = defineEmits<{
|
||||
created: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const { create } = useTrackedSearches()
|
||||
|
||||
const name = ref('')
|
||||
const departures = ref('')
|
||||
const destination = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
const stayMinDays = ref(2)
|
||||
const stayMaxDays = ref(7)
|
||||
const passengers = ref({ adult: 1, child: 0, infant: 0 })
|
||||
const intervalHours = ref(24)
|
||||
const expiresAt = ref('')
|
||||
const submitting = ref(false)
|
||||
const error = ref('')
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
watch(dateFrom, (from) => {
|
||||
if (from && dateTo.value && dateTo.value < from) {
|
||||
dateTo.value = from
|
||||
}
|
||||
})
|
||||
|
||||
const intervalOptions = [
|
||||
{ label: 'Cada 6 horas', value: 6 },
|
||||
{ label: 'Cada 12 horas', value: 12 },
|
||||
{ label: 'Diario (24h)', value: 24 },
|
||||
{ label: 'Cada 2 dias', value: 48 },
|
||||
{ label: 'Semanal', value: 168 }
|
||||
]
|
||||
|
||||
// Cargar preferencias de usuario
|
||||
const { profile } = useUserPreferences()
|
||||
watch(profile, (p) => {
|
||||
if (p?.home_airports?.length) {
|
||||
departures.value = p.home_airports.join(',')
|
||||
}
|
||||
if (p?.default_adults) passengers.value.adult = p.default_adults
|
||||
if (p?.default_children) passengers.value.child = p.default_children
|
||||
if (p?.default_infants) passengers.value.infant = p.default_infants
|
||||
}, { immediate: true })
|
||||
|
||||
function buildRouteSummary() {
|
||||
const dep = departures.value.split(',').filter(Boolean).join(',')
|
||||
const dest = destination.value || '?'
|
||||
return `${dep} > ${dest}`
|
||||
}
|
||||
|
||||
function buildSearchParams() {
|
||||
const depList = departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||
const dest = destination.value.trim().toUpperCase()
|
||||
|
||||
const now = new Date()
|
||||
const defaultFrom = now.toISOString().slice(0, 10)
|
||||
const defaultTo = new Date(now.getTime() + 30 * 86400000).toISOString().slice(0, 10)
|
||||
const from = dateFrom.value || defaultFrom
|
||||
const to = dateTo.value || defaultTo
|
||||
|
||||
const isOneWay = from === to
|
||||
|
||||
const stops = isOneWay
|
||||
? [{
|
||||
locations: [dest],
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}]
|
||||
: [
|
||||
{
|
||||
locations: [dest],
|
||||
stayRange: { min: stayMinDays.value * 24, max: stayMaxDays.value * 24 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: true
|
||||
},
|
||||
{
|
||||
locations: depList,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
departures: depList,
|
||||
local: 'en',
|
||||
departureDateInterval: {
|
||||
begin: `${from}T00:00:00+00:00`,
|
||||
end: `${to}T00:00:00+00:00`
|
||||
},
|
||||
stops,
|
||||
endInSameLocation: !isOneWay,
|
||||
maxStops: null,
|
||||
fixStopsOrder: false,
|
||||
stopLength: { min: 0, max: 0, isSet: false },
|
||||
maxResults: 45,
|
||||
passengersCount: passengers.value
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!departures.value || !destination.value || !name.value) {
|
||||
error.value = 'Rellena nombre, origen y destino'
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await create({
|
||||
name: name.value,
|
||||
searchParams: buildSearchParams(),
|
||||
routeSummary: buildRouteSummary(),
|
||||
intervalHours: intervalHours.value,
|
||||
expiresAt: expiresAt.value || undefined
|
||||
})
|
||||
emit('created')
|
||||
} catch (e: unknown) {
|
||||
const err = e as { data?: { message?: string } }
|
||||
error.value = err?.data?.message || 'Error al crear seguimiento'
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="font-semibold">Nuevo seguimiento de precios</h3>
|
||||
<UButton icon="i-lucide-x" color="neutral" variant="ghost" size="xs" @click="emit('cancel')" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-4">
|
||||
<UFormField label="Nombre" required>
|
||||
<UInput v-model="name" placeholder="Ej: Madrid-Londres julio" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Origen" required>
|
||||
<SearchAirportInput v-model="departures" placeholder="Aeropuerto origen" icon="i-lucide-plane-takeoff" multiple />
|
||||
</UFormField>
|
||||
<UFormField label="Destino" required>
|
||||
<SearchAirportInput v-model="destination" placeholder="Aeropuerto destino" icon="i-lucide-plane-landing" multiple />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Fecha desde">
|
||||
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Fecha hasta">
|
||||
<UInput v-model="dateTo" type="date" :min="dateFrom || today" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Estancia minima (dias)">
|
||||
<UInput v-model.number="stayMinDays" type="number" :min="1" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Estancia maxima (dias)">
|
||||
<UInput v-model.number="stayMaxDays" type="number" :min="1" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UFormField label="Pasajeros">
|
||||
<PassengerPicker v-model="passengers" />
|
||||
</UFormField>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Frecuencia de busqueda">
|
||||
<select
|
||||
v-model.number="intervalHours"
|
||||
class="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm"
|
||||
>
|
||||
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</UFormField>
|
||||
<UFormField label="Expira el (opcional)">
|
||||
<UInput v-model="expiresAt" type="date" :min="today" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<UAlert v-if="error" :title="error" color="error" icon="i-lucide-alert-circle" />
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<UButton label="Cancelar" color="neutral" variant="outline" @click="emit('cancel')" />
|
||||
<UButton label="Crear seguimiento" icon="i-lucide-plus" :loading="submitting" @click="onSubmit" />
|
||||
</div>
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
112
app/components/tracking/PriceChart.vue
Normal file
112
app/components/tracking/PriceChart.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<script setup lang="ts">
|
||||
import { Line } from 'vue-chartjs'
|
||||
import type { ChartOptions } from 'chart.js'
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
PointElement,
|
||||
LineElement,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler
|
||||
} from 'chart.js'
|
||||
|
||||
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
|
||||
|
||||
const props = defineProps<{
|
||||
snapshots: Array<{
|
||||
cheapest_price: number
|
||||
avg_price: number | null
|
||||
median_price: number | null
|
||||
total_results: number
|
||||
recorded_at: string
|
||||
}>
|
||||
}>()
|
||||
|
||||
const chartData = computed(() => {
|
||||
const labels = props.snapshots.map(s =>
|
||||
new Date(s.recorded_at).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||
)
|
||||
|
||||
return {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'Precio mas barato',
|
||||
data: props.snapshots.map(s => s.cheapest_price),
|
||||
borderColor: '#16a34a',
|
||||
backgroundColor: 'rgba(22, 163, 74, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 4,
|
||||
pointHoverRadius: 6
|
||||
},
|
||||
{
|
||||
label: 'Precio medio',
|
||||
data: props.snapshots.map(s => s.avg_price),
|
||||
borderColor: '#9ca3af',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [],
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4
|
||||
},
|
||||
{
|
||||
label: 'Mediana',
|
||||
data: props.snapshots.map(s => s.median_price),
|
||||
borderColor: '#d1d5db',
|
||||
backgroundColor: 'transparent',
|
||||
borderDash: [5, 5],
|
||||
tension: 0.3,
|
||||
pointRadius: 2,
|
||||
pointHoverRadius: 4
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions: ChartOptions<'line'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom' as const,
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
padding: 16
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(0)}\u20AC`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: false,
|
||||
ticks: {
|
||||
callback: (value) => `${value}\u20AC`
|
||||
}
|
||||
}
|
||||
},
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index' as const
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div v-if="snapshots.length < 2" class="text-center py-8">
|
||||
<UIcon name="i-lucide-chart-line" class="text-3xl text-neutral-300 mb-2" />
|
||||
<p class="text-neutral-500 text-sm">Se necesitan al menos 2 puntos de datos para mostrar el grafico</p>
|
||||
</div>
|
||||
<div v-else class="h-72">
|
||||
<Line :data="chartData" :options="chartOptions" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
96
app/components/tracking/RunHistory.vue
Normal file
96
app/components/tracking/RunHistory.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
runs: SearchRun[]
|
||||
}>()
|
||||
|
||||
function statusBadge(status: string) {
|
||||
switch (status) {
|
||||
case 'completed': return { label: 'Completado', color: 'success' as const }
|
||||
case 'running': return { label: 'Ejecutando', color: 'info' as const }
|
||||
case 'failed': return { label: 'Error', color: 'error' as const }
|
||||
default: return { label: 'Pendiente', color: 'neutral' as const }
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(date: string) {
|
||||
return new Date(date).toLocaleString('es-ES', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatShortDate(dateStr: string) {
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||
}
|
||||
|
||||
function duration(start: string | null, end: string | null) {
|
||||
if (!start || !end) return '-'
|
||||
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||
if (ms < 1000) return `${ms}ms`
|
||||
return `${(ms / 1000).toFixed(1)}s`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-semibold text-sm mb-3">Historial de ejecuciones</h3>
|
||||
|
||||
<div v-if="runs.length === 0" class="text-center py-6">
|
||||
<p class="text-sm text-neutral-500">Aun no hay ejecuciones</p>
|
||||
</div>
|
||||
|
||||
<UCard v-for="run in runs" :key="run.id" class="!p-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<UBadge :label="statusBadge(run.status).label" :color="statusBadge(run.status).color" size="xs" />
|
||||
<span class="text-xs text-muted">{{ formatDate(run.created_at) }}</span>
|
||||
<UBadge v-if="run.from_cache" label="Cache" color="neutral" variant="outline" size="xs" />
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 text-sm shrink-0">
|
||||
<span v-if="run.cheapest_price != null" class="font-medium">
|
||||
{{ run.cheapest_price.toFixed(0) }}€
|
||||
</span>
|
||||
<span v-if="run.total_trips_found > 0" class="text-xs text-muted">
|
||||
{{ run.total_trips_found }} vuelos
|
||||
</span>
|
||||
<span class="text-xs text-muted">
|
||||
{{ duration(run.started_at, run.completed_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<p v-if="run.error_message" class="text-xs text-red-500 mt-1">
|
||||
{{ run.error_message }}
|
||||
</p>
|
||||
|
||||
<!-- Top trips preview -->
|
||||
<div v-if="run.top_trips && run.top_trips.length > 0" class="mt-2 space-y-1.5">
|
||||
<NuxtLink
|
||||
v-for="(trip, i) in run.top_trips.slice(0, 3)"
|
||||
:key="i"
|
||||
:to="trip.bookingToken ? `/detail/${encodeURIComponent(trip.bookingToken)}?adults=1` : undefined"
|
||||
class="block text-xs text-muted rounded px-1.5 py-1 -mx-1.5 transition-colors"
|
||||
:class="trip.bookingToken ? 'hover:bg-neutral-100 dark:hover:bg-neutral-800 cursor-pointer' : ''"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="font-medium text-foreground">{{ trip.price?.toFixed(0) }}€</span>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-0.5 flex-1">
|
||||
<span v-for="(leg, j) in trip.legs" :key="j" class="flex items-center gap-1">
|
||||
<UIcon :name="j === 0 ? 'i-lucide-plane-takeoff' : 'i-lucide-plane-landing'" class="text-[10px]" />
|
||||
{{ leg.from }} > {{ leg.to }}
|
||||
<span v-if="leg.departure" class="text-muted">{{ formatShortDate(leg.departure) }}</span>
|
||||
<span v-if="leg.airlines?.length" class="text-muted">({{ leg.airlines.join(', ') }})</span>
|
||||
</span>
|
||||
</div>
|
||||
<UIcon v-if="trip.bookingToken" name="i-lucide-arrow-right" class="text-neutral-400 text-xs shrink-0" />
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</UCard>
|
||||
</div>
|
||||
</template>
|
||||
124
app/components/tracking/TrackedSearchCard.vue
Normal file
124
app/components/tracking/TrackedSearchCard.vue
Normal 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) }}€</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>
|
||||
191
app/components/tracking/TrackingConfig.vue
Normal file
191
app/components/tracking/TrackingConfig.vue
Normal file
@@ -0,0 +1,191 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
search: {
|
||||
id: string
|
||||
name: string
|
||||
interval_hours: number
|
||||
is_active: boolean
|
||||
expires_at: string | null
|
||||
search_params: Record<string, unknown>
|
||||
route_summary: string
|
||||
}
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
updated: []
|
||||
}>()
|
||||
|
||||
const { update } = useTrackedSearches()
|
||||
|
||||
const name = ref(props.search.name)
|
||||
const intervalHours = ref(props.search.interval_hours)
|
||||
const isActive = ref(props.search.is_active)
|
||||
const expiresAt = ref(props.search.expires_at?.slice(0, 10) || '')
|
||||
const saving = ref(false)
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
|
||||
// Parametros de busqueda editables
|
||||
const params = props.search.search_params
|
||||
const departures = ref((params.departures as string[])?.join(',') || '')
|
||||
const destination = ref('')
|
||||
const dateFrom = ref('')
|
||||
const dateTo = ref('')
|
||||
|
||||
watch(dateFrom, (from) => {
|
||||
if (from && dateTo.value && dateTo.value < from) {
|
||||
dateTo.value = from
|
||||
}
|
||||
})
|
||||
const stayMinDays = ref(2)
|
||||
const stayMaxDays = ref(7)
|
||||
|
||||
// Extraer destino y fechas de los search_params guardados
|
||||
const interval = params.departureDateInterval as { begin?: string; end?: string } | undefined
|
||||
if (interval?.begin) dateFrom.value = interval.begin.slice(0, 10)
|
||||
if (interval?.end) dateTo.value = interval.end.slice(0, 10)
|
||||
|
||||
const stops = params.stops as Array<{ locations: string[]; stayRange?: { min: number; max: number } }> | undefined
|
||||
if (stops?.[0]?.locations?.length) destination.value = stops[0].locations.join(',')
|
||||
if (stops?.[0]?.stayRange) {
|
||||
stayMinDays.value = Math.round((stops[0].stayRange.min || 0) / 24) || 2
|
||||
stayMaxDays.value = Math.round((stops[0].stayRange.max || 0) / 24) || 7
|
||||
}
|
||||
|
||||
const intervalOptions = [
|
||||
{ label: 'Cada 6 horas', value: 6 },
|
||||
{ label: 'Cada 12 horas', value: 12 },
|
||||
{ label: 'Diario (24h)', value: 24 },
|
||||
{ label: 'Cada 2 dias', value: 48 },
|
||||
{ label: 'Semanal', value: 168 }
|
||||
]
|
||||
|
||||
function buildSearchParams() {
|
||||
const depList = departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||
const destList = destination.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||
|
||||
const from = dateFrom.value || new Date().toISOString().slice(0, 10)
|
||||
const to = dateTo.value || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10)
|
||||
const isOneWay = from === to
|
||||
|
||||
const newStops = isOneWay
|
||||
? [{
|
||||
locations: destList,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}]
|
||||
: [
|
||||
{
|
||||
locations: destList,
|
||||
stayRange: { min: stayMinDays.value * 24, max: stayMaxDays.value * 24 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: true
|
||||
},
|
||||
{
|
||||
locations: depList,
|
||||
stayRange: { min: 0, max: 0 },
|
||||
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||
continueFromAny: false
|
||||
}
|
||||
]
|
||||
|
||||
return {
|
||||
...params,
|
||||
departures: depList,
|
||||
departureDateInterval: {
|
||||
begin: `${from}T00:00:00+00:00`,
|
||||
end: `${to}T00:00:00+00:00`
|
||||
},
|
||||
stops: newStops,
|
||||
endInSameLocation: !isOneWay
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const depList = departures.value.split(',').filter(Boolean)
|
||||
const destList = destination.value.split(',').filter(Boolean)
|
||||
const routeSummary = `${depList.join(',')} > ${destList.join(',')}`
|
||||
|
||||
await update(props.search.id, {
|
||||
name: name.value,
|
||||
interval_hours: intervalHours.value,
|
||||
is_active: isActive.value,
|
||||
expires_at: expiresAt.value || null,
|
||||
search_params: buildSearchParams(),
|
||||
route_summary: routeSummary
|
||||
})
|
||||
emit('updated')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UCard>
|
||||
<template #header>
|
||||
<h3 class="font-semibold text-sm">Configuracion</h3>
|
||||
</template>
|
||||
|
||||
<div class="space-y-3">
|
||||
<UFormField label="Nombre">
|
||||
<UInput v-model="name" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Origen">
|
||||
<SearchAirportInput v-model="departures" placeholder="MAD" icon="i-lucide-plane-takeoff" multiple />
|
||||
</UFormField>
|
||||
<UFormField label="Destino">
|
||||
<SearchAirportInput v-model="destination" placeholder="BCN" icon="i-lucide-plane-landing" multiple />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Fecha desde">
|
||||
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Fecha hasta">
|
||||
<UInput v-model="dateTo" type="date" :min="dateFrom || today" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<UFormField label="Estancia min (dias)">
|
||||
<UInput v-model.number="stayMinDays" type="number" :min="1" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Estancia max (dias)">
|
||||
<UInput v-model.number="stayMaxDays" type="number" :min="1" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<USeparator />
|
||||
|
||||
<UFormField label="Frecuencia">
|
||||
<select
|
||||
v-model.number="intervalHours"
|
||||
class="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm"
|
||||
>
|
||||
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</option>
|
||||
</select>
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Expira el">
|
||||
<UInput v-model="expiresAt" type="date" :min="today" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">Activa</span>
|
||||
<USwitch v-model="isActive" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<UButton label="Guardar cambios" size="sm" :loading="saving" @click="save" />
|
||||
</template>
|
||||
</UCard>
|
||||
</template>
|
||||
Reference in New Issue
Block a user