Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled

Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos,
inspiraciones, watchlist, tracking de precios y mapa interactivo.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Martinez
2026-04-10 23:37:06 +02:00
commit b8906efc80
122 changed files with 37809 additions and 0 deletions

13
.editorconfig Executable file
View File

@@ -0,0 +1,13 @@
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false

8
.env.example Normal file
View File

@@ -0,0 +1,8 @@
# Supabase (local Docker)
SUPABASE_URL=http://localhost:8000
SUPABASE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
# Unsplash (imagenes de destinos)
# Registrarse en https://unsplash.com/developers y crear una app
UNSPLASH_ACCESS_KEY=

34
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: ci
on: push
jobs:
ci:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest]
node: [22]
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Install pnpm
uses: pnpm/action-setup@v5
- name: Install node
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Lint
run: pnpm run lint
- name: Typecheck
run: pnpm run typecheck

30
.gitignore vendored Normal file
View File

@@ -0,0 +1,30 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example
# Test results
test-results/
# Claude Code
.claude/

93
CLAUDE.md Normal file
View File

@@ -0,0 +1,93 @@
# CLAUDE.md — Guia para trabajar en Vuelato
## Comandos rapidos
```bash
pnpm dev # Dev server (localhost:3000)
pnpm supabase:up # Levantar Supabase Docker
pnpm supabase:down # Parar Supabase
pnpm supabase:reset # Reset completo (borra datos)
pnpm lint # ESLint
pnpm typecheck # Vue type checking
```
## Estructura del proyecto
```
app/
pages/ # 10 paginas (Nuxt file-based routing)
components/ # 27 componentes organizados por dominio (auth/, search/, results/, detail/, map/, inspiration/)
composables/ # 9 composables (useFlightSearch, useAuth, useWatchlist, etc.)
assets/css/ # Tailwind CSS
server/
api/ # 13 endpoints Nitro (proxy Flightics + sync + flight-info + airlines)
utils/flightics.ts # Cliente API Flightics completo (tipos + 9 funciones)
utils/wikidata.ts # Cliente SPARQL Wikidata (aerolineas)
supabase/
migrations/ # SQL init (tablas cache + usuario + RLS + trigger)
volumes/db/ # roles.sql, jwt.sql (config Supabase internal)
volumes/api/ # kong.yml (API gateway config)
docker-compose.yml # Supabase stack completo (6 servicios)
```
## Convenciones
### Nuxt 4
- El directorio raiz de la app es `app/` (no src/).
- Los server utils (`server/utils/`) se auto-importan en server routes. **No usar** `import ... from '~/server/utils/...'` — causa error porque `~` apunta a `app/`.
- Componentes se auto-importan con prefijo de directorio: `search/AirportInput.vue` -> `<SearchAirportInput />`.
### Componentes
- Usar `@nuxt/ui` v4 components (UCard, UButton, UInput, UBadge, UFormField, UPopover, etc.).
- Iconos: `@iconify-json/lucide` con prefijo `i-lucide-*`.
- No usar emojis en la UI.
- Idioma de la UI: espanol (es).
### Composables
- Cada composable maneja su propio estado reactivo.
- Los composables de usuario (useWatchlist, useRecentSearches, useUserPreferences) hacen watch del user y cargan/limpian datos automaticamente al login/logout.
- `useAirlineNames` es un cache reactivo global — aprende nombres de aerolineas de cualquier respuesta API y los resuelve en la vista.
### Server routes
- Los endpoints de Flightics son proxy directos (sin cache, excepto locations y countries que cachean 24h).
- `defineCachedEventHandler` para cachear respuestas (flight-info: 5 min, locations/countries/airlines: 24h).
- Usar `serverSupabaseServiceRole` para operaciones server-side que necesitan bypass de RLS (ej: sync/locations, sync/airlines).
### Base de datos
- Tablas publicas (airports, countries, airlines, etc.) accesibles por `anon` y `authenticated` (SELECT).
- Tablas de usuario protegidas con RLS: `auth.uid() = user_id`.
- Trigger `on_auth_user_created` crea profile automaticamente.
- Para resetear: `pnpm supabase:reset` (borra volumen Docker y reinicia).
- Para re-sincronizar aeropuertos: `curl -X POST http://localhost:3000/api/sync/locations`.
- Para re-sincronizar aerolineas: `curl -X POST http://localhost:3000/api/sync/airlines` (datos de Wikidata: IATA, ICAO, nombre, logo).
## Gotchas
### Flightics API
- La busqueda requiere polling: primera respuesta tiene `notComplete: true`. El composable hace hasta 3 rondas automaticamente.
- `company.name` viene vacio en rondas de polling posteriores. Se resuelve via `useAirlineNames` (cache reactivo).
- Fechas vacias en el payload causan 400. `useFlightSearch.buildPayload` genera fechas por defecto (hoy + 30 dias).
- `getRouteFlights` devuelve trips ida+vuelta, no vuelos individuales.
- `getInspirations` puede devolver vacio para aeropuertos pequenos — es normal.
### Supabase Docker
- La imagen `supabase/postgres` crea roles internos automaticamente. Los SQL de init solo ajustan passwords.
- Si GoTrue falla al arrancar, verificar que el volumen este limpio: `docker compose down -v && docker compose up -d`.
- Studio corre en puerto 3100 (no 3000, que es Nuxt).
- Meta corre en 8085 (remapeado de 8080 que suele estar ocupado).
### Frontend
- `AirportInput` busca sin acentos (normaliza NFD). "malaga" encuentra "Malaga".
- Modo `multiple` en AirportInput: badges + input. Backspace borra ultimo. Enter/click selecciona.
- Leaflet se renderiza solo client-side (`<ClientOnly>`). Requiere `import 'leaflet/dist/leaflet.css'`.
- El `v-if="seg.company.name"` falla con string vacio — usar `useAirlineNames().resolve()` en su lugar.
## Variables de entorno
```bash
SUPABASE_URL=http://localhost:8000 # Kong gateway
SUPABASE_KEY=eyJ... # anon key (demo JWT)
SUPABASE_SERVICE_ROLE_KEY=eyJ... # service_role key (para server-side)
```
Las keys demo estan en `.env.example`. Para produccion, generar nuevas con un JWT_SECRET propio.

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 Nuxt UI Templates
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

257
README.md Normal file
View File

@@ -0,0 +1,257 @@
# Vuelato
Buscador de vuelos con fechas flexibles, seguimiento de precios y exploracion de destinos en mapa. Construido con Nuxt 4, Supabase y la API de Flightics.
## Stack
| Capa | Tecnologia |
|------|-----------|
| Framework | [Nuxt 4](https://nuxt.com) (Vue 3, file-based routing) |
| UI | [@nuxt/ui v4](https://ui.nuxt.com) + Tailwind CSS v4 |
| Iconos | [Lucide](https://lucide.dev) via `@iconify-json/lucide` (`i-lucide-*`) |
| Auth + DB | [Supabase](https://supabase.com) (PostgreSQL, GoTrue, RLS) en Docker |
| Mapas | [Leaflet](https://leafletjs.com) via `@vue-leaflet/vue-leaflet` |
| Graficos | [Chart.js](https://www.chartjs.org) via `vue-chartjs` |
| API de vuelos | [Flightics](https://flightics.com) (proxy via server routes) |
| Vuelos en vivo | [Flightradar24](https://flightradar24.com) (endpoint no oficial, cache 5 min) |
| Runtime | [Nitro](https://nitro.build) (server routes, cached handlers) |
## Arrancar
```bash
# 1. Instalar dependencias
pnpm install
# 2. Copiar variables de entorno
cp .env.example .env
# 3. Levantar Supabase local
pnpm supabase:up
# 4. Arrancar Nuxt
pnpm dev
# 5. Sincronizar aeropuertos (3300+) y paises (223) desde Flightics
curl -X POST http://localhost:3000/api/sync/locations
# 6. (Opcional) Sincronizar aerolineas desde Wikidata
curl -X POST http://localhost:3000/api/sync/airlines
```
### URLs
| Servicio | URL |
|---|---|
| App | http://localhost:3000 |
| Supabase Studio | http://localhost:3100 |
| Supabase API (Kong) | http://localhost:8000 |
| PostgreSQL | localhost:54322 (user: postgres, pass: postgres) |
### Comandos
| Comando | Descripcion |
|---------|-------------|
| `pnpm dev` | Servidor de desarrollo (localhost:3000) |
| `pnpm build` | Build de produccion |
| `pnpm preview` | Preview del build |
| `pnpm lint` | ESLint |
| `pnpm typecheck` | Comprobacion de tipos Vue |
| `pnpm supabase:up` | Levantar Supabase Docker |
| `pnpm supabase:down` | Parar Supabase |
| `pnpm supabase:reset` | Reset completo (borra datos y volumenes) |
## Funcionalidades
### Busqueda de vuelos
- **5 modos de busqueda**: ida y vuelta, solo ida, multi-ciudad, fin de semana, explorar destinos
- **Origenes y destinos multiples**: selecciona varios aeropuertos con pills (badges)
- **Validacion de fechas**: "hasta" no puede ser anterior a "desde", no se permiten fechas pasadas; correccion automatica si se cambia "desde" a una fecha posterior a "hasta"
- **Filtros avanzados**: precio maximo (slider + input numerico editable), numero de escalas (botones rapidos + input numerico), aerolineas (filtrado exclusivo: solo vuelos operados exclusivamente por las seleccionadas), hora de salida
- **Nombres de aerolineas**: diccionario integrado de 50+ aerolineas comunes (AF = Air France, IB = Iberia, etc.) ya que la API no devuelve nombres
- **Ordenacion**: por precio, hora de salida, duracion de vuelo o numero de escalas
- **Info por tarjeta de vuelo**:
- Duracion de cada tramo (centrada en la linea de vuelo con icono de avion)
- Tiempo total de vuelo (suma de todos los tramos)
- Tiempo efectivo en destino (descontando vuelos)
- Noches en destino
- Dias totales del viaje (de salida a llegada, inclusive — util para vacaciones)
- **Hora en origen**: opcion (guardada en cuenta) para ver entre parentesis la hora equivalente en tu aeropuerto de salida, solo cuando hay diferencia de huso horario
- **Vista completa o compacta** de resultados
- **Creacion de seguimiento** directamente desde resultados
### Exploracion de destinos
- Mapa interactivo con Leaflet mostrando destinos baratos desde tu aeropuerto
- Tarjetas de destino con imagenes (Unsplash) y atribucion de fotografo
- Filtro por presupuesto y vuelos directos
- Click en destino navega a la ruta detallada
### Seguimiento de precios
- Crea seguimientos automaticos de busquedas de vuelos
- Configura frecuencia: cada 6h, 12h, diario, cada 2 dias, semanal
- **Edicion completa**: nombre, origenes, destinos (con pills), fechas (con validacion), estancia, frecuencia, fecha de expiracion, activo/pausado
- Grafico de evolucion de precios historico (7, 14, 30, 60 dias)
- Estadisticas: precio actual, minimo, maximo, media, tendencia
- Historial de ejecuciones con estado y duracion
### Multi-ciudad
- Inspiracion para itinerarios con varias paradas
- Seleccion de aeropuertos de origen multiples (con pills)
### Watchlist
- Guarda vuelos individuales con seguimiento de precio
- Verificacion de precios (badges: bajo/subio/no disponible)
- Replay de busquedas recientes
- Conversion de busquedas guardadas a seguimiento automatico
### Preferencias de usuario (`/settings`)
- **Busquedas**: aeropuertos de origen habituales (se aplican como defecto), pasajeros por defecto (adultos/menores/bebes)
- **Visualizacion**: mostrar hora en origen (sincronizado con la cuenta, fallback a cookie para anonimos)
- **Cuenta**: email del usuario
- Accesible desde el dropdown del menu de usuario > "Preferencias"
### Autenticacion
- Login con email/contrasena via Supabase GoTrue
- Login con Google (OAuth)
- Perfil creado automaticamente al registrarse (trigger PostgreSQL)
## Paginas (13)
| Ruta | Funcion |
|---|---|
| `/` | Home: buscador compacto, inspiraciones, budget explorer, multi-city, busquedas recientes |
| `/search` | Buscador dedicado con 5 modos |
| `/results` | Resultados con sort, filtros, seguimiento, vista dual, hora en origen |
| `/detail/[token]` | Itinerario completo, verificacion de precio, watchlist, compartir, tracking FR24 |
| `/explore` | Mapa Leaflet con destinos baratos, imagenes Unsplash, filtros |
| `/route/[from]-[to]` | Vuelos de una ruta agrupados por aerolinea |
| `/multi-city` | Inspiraciones multi-ciudad con origenes multiples |
| `/tracking` | Lista de seguimientos de precios |
| `/tracking/[id]` | Detalle y edicion de seguimiento, grafico de precios, historial |
| `/watchlist` | Vuelos guardados, verificacion de precios, busquedas recientes |
| `/settings` | Preferencias de usuario (busquedas, visualizacion, cuenta) |
| `/auth` | Login / registro |
| `/auth/confirm` | Callback OAuth |
## Estructura del proyecto
```
app/
pages/ # 13 paginas (file-based routing)
components/ # 33 componentes por dominio
auth/ # LoginForm, UserMenu
search/ # AirportInput, DateRangePicker, ModeTabs, etc.
results/ # ResultsToolbar, ResultsFilters, TripCardCompact
detail/ # SegmentCard, ItineraryTimeline, WatchlistToggle, etc.
map/ # FlightMap, MapControls
inspiration/ # BudgetExplorer, MultiCityCard
tracking/ # TrackingConfig, CreateTrackingForm, TrackedSearchCard, etc.
SearchForm.vue # Formulario principal (5 modos)
TripCard.vue # Tarjeta de vuelo con toda la info de tiempos
FlightLeg.vue # Tramo con duracion por segmento
PassengerPicker.vue # Selector adultos/menores/bebes
InspirationGrid.vue # Grid de inspiraciones
composables/ # 12 composables
useFlightSearch.ts # Busqueda con polling (hasta 3 rondas)
useResultFilters.ts # Filtrado exclusivo y ordenacion
useAirlineNames.ts # Diccionario de 50+ aerolineas + cache API
useOriginTime.ts # Preferencia hora en origen (cuenta + cookie)
useUserPreferences.ts # Perfil via /api/profile (useState singleton)
useTrackedSearches.ts # CRUD seguimientos de precios
useDestinationImages.ts # Imagenes Unsplash de destinos
useWatchlist.ts # Vuelos guardados
useRecentSearches.ts # Busquedas recientes
useLocations.ts # Aeropuertos y paises
useRouteFlights.ts # Vuelos entre dos aeropuertos
useAuth.ts # Login/logout/registro
server/
api/ # 22 endpoints Nitro
search.post.ts # Proxy busqueda Flightics
detail.post.ts # Detalle de vuelo
check.post.ts # Verificar precio
inspirations.get.ts # Inspiraciones por aeropuerto
locations.get.ts # Aeropuertos (cache 24h)
countries.get.ts # Paises (cache 24h)
airlines.get.ts # Aerolineas (cache 24h)
flight-info.get.ts # Info vuelo FR24 (cache 5min)
destination-image.get.ts # Imagenes Unsplash
profile.get.ts # Obtener perfil de usuario
profile.patch.ts # Actualizar perfil de usuario
route-flights.post.ts # Vuelos entre dos aeropuertos
weekend-search.post.ts # Busqueda de fin de semana
multi-city-inspirations.post.ts
sync/locations.post.ts # Sincronizar aeropuertos
sync/airlines.post.ts # Sincronizar aerolineas (Wikidata)
tracking/ # CRUD seguimientos + historial + ejecuciones
utils/
flightics.ts # Cliente API Flightics (tipos + funciones)
wikidata.ts # Cliente SPARQL Wikidata
supabase/
migrations/
00001_init.sql # profiles, watchlist, recent_searches, airports, countries
00002_search_queue.sql # tracked_searches, search_runs, price_snapshots
00003_profile_preferences.sql # show_origin_time en profiles
docker-compose.yml # Supabase stack (6 servicios)
```
## Base de datos
### Tablas publicas (SELECT para anon y authenticated)
- `airports` — 3300+ aeropuertos con IATA, nombre, lat/lon, ciudad, pais
- `countries` — 223 paises con codigo, nombre, idiomas
- `airlines` — aerolineas con IATA, ICAO y nombre
### Tablas de usuario (RLS: `auth.uid() = user_id`)
- `profiles` — aeropuertos habituales, pasajeros por defecto, locale, show_origin_time
- `watchlist` — vuelos guardados con precio original/actual y estado
- `recent_searches` — busquedas recientes con params y route_summary
- `tracked_searches` — seguimientos con frecuencia, estado, parametros de busqueda
- `search_runs` — ejecuciones de seguimientos
- `price_snapshots` — historico de precios por seguimiento
### Resetear y re-sincronizar
```bash
pnpm supabase:reset
pnpm dev
curl -X POST http://localhost:3000/api/sync/locations
curl -X POST http://localhost:3000/api/sync/airlines
```
## Notas de desarrollo
### Auto-import de componentes (Nuxt 4)
Nuxt deduplica el prefijo del directorio cuando el nombre del archivo ya empieza con el nombre del directorio:
- `results/ResultsFilters.vue``<ResultsFilters>` (no `<ResultsResultsFilters>`)
- `tracking/TrackingConfig.vue``<TrackingConfig>` (no `<TrackingTrackingConfig>`)
- `tracking/CreateTrackingForm.vue``<TrackingCreateTrackingForm>` (no empieza con "Tracking", se prefija)
### Nuxt UI v4
- `UToggle` no existe — usar `<USwitch>`
- `URange` no existe — no usar slider de Nuxt UI
### Timestamps de vuelos
Los campos `departureTimestamp`/`arrivalTimestamp` de Flightics estan en **hora local** de cada aeropuerto. Para calcular duraciones entre aeropuertos de distintas zonas horarias, usar siempre `departureUtcTimestamp`/`arrivalUtcTimestamp`.
### Preferencias de usuario
`useUserPreferences` usa `useState` de Nuxt para estado compartido y `$fetch('/api/profile')` para persistencia. La preferencia "hora en origen" (`useOriginTime`) lee de la cuenta del usuario si esta logueado, con fallback a `useCookie` para visitantes anonimos.
### API Flightics
- La busqueda requiere polling: primera respuesta tiene `notComplete: true`. El composable hace hasta 3 rondas automaticamente.
- `company.name` viene vacio (`""`) en la mayoria de respuestas. Se resuelve con diccionario integrado en `useAirlineNames` (50+ aerolineas).
- Las fechas vacias causan 400. Se generan fechas por defecto (hoy + 30 dias).
- `getInspirations` devuelve vacio para aeropuertos pequenos — es normal.
### Supabase Docker
- La imagen trae todos los roles internos pre-creados. Los SQL de init solo ajustan passwords.
- Si GoTrue falla: `docker compose down -v && docker compose up -d`.
- Studio: puerto 3100. Meta: puerto 8085 (remapeado de 8080).
## Variables de entorno
```bash
SUPABASE_URL=http://localhost:8000 # Kong gateway
SUPABASE_KEY=eyJ... # anon key (JWT demo)
SUPABASE_SERVICE_ROLE_KEY=eyJ... # service_role key (server-side)
```
Las keys demo estan en `.env.example`. Para produccion, generar nuevas con un `JWT_SECRET` propio.

8
app/app.config.ts Normal file
View File

@@ -0,0 +1,8 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'blue',
neutral: 'slate'
}
}
})

112
app/app.vue Normal file
View File

@@ -0,0 +1,112 @@
<script setup>
useHead({
meta: [
{ name: 'viewport', content: 'width=device-width, initial-scale=1' }
],
link: [
{ rel: 'icon', href: '/favicon.ico' }
],
htmlAttrs: {
lang: 'es'
}
})
useSeoMeta({
title: 'Vuelato - Busca vuelos baratos',
description: 'Buscador de vuelos con fechas flexibles'
})
const mobileMenu = ref(false)
const navLinks = [
{ to: '/search', label: 'Buscar', icon: 'i-lucide-search' },
{ to: '/explore', label: 'Explorar', icon: 'i-lucide-compass' },
{ to: '/multi-city', label: 'Multi-city', icon: 'i-lucide-route' },
{ to: '/tracking', label: 'Seguimiento', icon: 'i-lucide-bell' },
{ to: '/watchlist', label: 'Watchlist', icon: 'i-lucide-heart' }
]
</script>
<template>
<UApp>
<UHeader>
<template #left>
<NuxtLink to="/" class="font-bold text-lg">
Vuelato
</NuxtLink>
<nav class="hidden md:flex items-center gap-1 ml-4">
<UButton
v-for="link in navLinks"
:key="link.to"
:to="link.to"
:label="link.label"
variant="ghost"
color="neutral"
size="sm"
/>
</nav>
</template>
<template #right>
<AuthUserMenu />
<UColorModeButton />
<!-- Mobile menu button -->
<UButton
class="md:hidden"
:icon="mobileMenu ? 'i-lucide-x' : 'i-lucide-menu'"
color="neutral"
variant="ghost"
@click="mobileMenu = !mobileMenu"
/>
</template>
</UHeader>
<!-- Mobile nav -->
<Transition
enter-active-class="transition-all duration-200 ease-out"
enter-from-class="opacity-0 -translate-y-2"
enter-to-class="opacity-100 translate-y-0"
leave-active-class="transition-all duration-150 ease-in"
leave-from-class="opacity-100 translate-y-0"
leave-to-class="opacity-0 -translate-y-2"
>
<div v-if="mobileMenu" class="md:hidden border-b border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-950 px-4 py-3">
<nav class="flex flex-col gap-1">
<UButton
v-for="link in navLinks"
:key="link.to"
:to="link.to"
:label="link.label"
:icon="link.icon"
variant="ghost"
color="neutral"
block
class="justify-start"
@click="mobileMenu = false"
/>
</nav>
</div>
</Transition>
<UMain>
<NuxtPage />
</UMain>
<USeparator />
<UFooter>
<template #left>
<p class="text-sm text-muted">
Vuelato &copy; {{ new Date().getFullYear() }}
</p>
</template>
<template #right>
<div class="flex gap-3 text-sm text-muted">
<NuxtLink to="/search">Buscar</NuxtLink>
<NuxtLink to="/explore">Explorar</NuxtLink>
<NuxtLink to="/multi-city">Multi-city</NuxtLink>
</div>
</template>
</UFooter>
</UApp>
</template>

18
app/assets/css/main.css Normal file
View File

@@ -0,0 +1,18 @@
@import "tailwindcss";
@import "@nuxt/ui";
@theme static {
--font-sans: 'Public Sans', sans-serif;
--color-green-50: #EFFDF5;
--color-green-100: #D9FBE8;
--color-green-200: #B3F5D1;
--color-green-300: #75EDAE;
--color-green-400: #00DC82;
--color-green-500: #00C16A;
--color-green-600: #00A155;
--color-green-700: #007F45;
--color-green-800: #016538;
--color-green-900: #0A5331;
--color-green-950: #052E16;
}

View 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>

View 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">&euro;</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>

View 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>

View 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
View 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">&euro;</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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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) }}&euro;
</span>
</div>
</div>
</div>
</template>

View 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) }}&euro;
</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>

View 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>

View 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>

View 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 }}&euro;?</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) }}&euro;
</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>

View 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">&euro;</span>
</p>
</div>
</UCard>
</template>

View 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="&copy; OpenStreetMap &copy; 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) }}&euro;
</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>

View 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>

View 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>

View 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">&euro;</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>

View 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>

View 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">&euro;</span>
</span>
</div>
</div>
</template>

View 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>

View 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 }}&euro;</span>
</div>
<URange v-model="model" :min="20" :max="2000" :step="10" />
</div>
</template>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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) }}&euro;
</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) }}&euro;</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>

View File

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

View 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>

View File

@@ -0,0 +1,36 @@
// Well-known airline codes
const KNOWN_AIRLINES: Record<string, string> = {
'2W': 'World2Fly', AA: 'American Airlines', AC: 'Air Canada', AF: 'Air France',
AI: 'Air India', AM: 'Aeromexico', AR: 'Aerolineas Argentinas', AT: 'Royal Air Maroc',
AV: 'Avianca', AY: 'Finnair', AZ: 'ITA Airways', BA: 'British Airways',
CM: 'Copa Airlines', CX: 'Cathay Pacific', DL: 'Delta', DY: 'Norwegian',
EI: 'Aer Lingus', EK: 'Emirates', ET: 'Ethiopian Airlines', EW: 'Eurowings',
EY: 'Etihad', FB: 'Bulgaria Air', FI: 'Icelandair', FR: 'Ryanair',
HA: 'Hawaiian Airlines', HU: 'Hainan Airlines', IB: 'Iberia', JL: 'Japan Airlines',
JU: 'Air Serbia', KE: 'Korean Air', KL: 'KLM', LA: 'LATAM',
LH: 'Lufthansa', LO: 'LOT Polish', LX: 'Swiss', MH: 'Malaysia Airlines',
MS: 'EgyptAir', NH: 'ANA', NK: 'Spirit Airlines', OS: 'Austrian',
OZ: 'Asiana Airlines', PC: 'Pegasus', QF: 'Qantas', QR: 'Qatar Airways',
RO: 'TAROM', SK: 'SAS', SN: 'Brussels Airlines', SQ: 'Singapore Airlines',
SU: 'Aeroflot', TK: 'Turkish Airlines', TP: 'TAP Air Portugal', U2: 'easyJet',
UA: 'United Airlines', UX: 'Air Europa', VB: 'VivaAerobus', VY: 'Vueling',
W6: 'Wizz Air', WS: 'WestJet', X1: 'Hahn Air', ZI: 'Aigle Azur',
}
// Global cache of airline code → name, learned from API responses
const airlineNames = reactive(new Map<string, string>(Object.entries(KNOWN_AIRLINES)))
export function useAirlineNames() {
function learn(code: string, name: string) {
if (name && !airlineNames.has(code)) {
airlineNames.set(code, name)
}
}
function resolve(code: string, name?: string | null): string {
if (name) return name
return airlineNames.get(code) || ''
}
return { airlineNames, learn, resolve }
}

View File

@@ -0,0 +1,43 @@
export function useAuth() {
const supabase = useSupabaseClient()
const user = useSupabaseUser()
const loading = ref(false)
const error = ref<string | null>(null)
async function login(email: string, password: string) {
loading.value = true
error.value = null
const { error: err } = await supabase.auth.signInWithPassword({ email, password })
if (err) error.value = err.message
loading.value = false
return !err
}
async function register(email: string, password: string) {
loading.value = true
error.value = null
const { error: err } = await supabase.auth.signUp({ email, password })
if (err) error.value = err.message
loading.value = false
return !err
}
async function loginWithGoogle() {
loading.value = true
error.value = null
const { error: err } = await supabase.auth.signInWithOAuth({
provider: 'google',
options: { redirectTo: `${window.location.origin}/auth/confirm` }
})
if (err) error.value = err.message
loading.value = false
}
async function logout() {
await supabase.auth.signOut()
await navigateTo('/')
}
return { user, loading, error, login, register, loginWithGoogle, logout }
}

View File

@@ -0,0 +1,107 @@
interface BookingParams {
airlineCode: string
origin: string
destination: string
date: string // ISO date string
passengers?: number
}
// Direct booking URL templates for major airlines
// date format helpers applied per-airline
const BOOKING_TEMPLATES: Record<string, (p: BookingParams & { d: string; ymd: string }) => string> = {
FR: p => `https://www.ryanair.com/es/es/trip/flights/select?adults=${p.passengers}&teens=0&children=0&infants=0&dateOut=${p.ymd}&originIata=${p.origin}&destinationIata=${p.destination}&isReturn=false`,
U2: p => `https://www.easyjet.com/es/search?origin=${p.origin}&destination=${p.destination}&outboundDate=${p.ymd}&adults=${p.passengers}`,
VY: () => `https://www.vueling.com/es/reserva-tu-vuelo/busca-tu-vuelo`,
LH: p => `https://www.lufthansa.com/es/es/offer/search?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&paxAdult=${p.passengers}&cabinClass=ECONOMY&tripType=O`,
IB: p => `https://www.iberia.com/es/?language=es&market=ES&origin=${p.origin}&destination=${p.destination}&outbound=${p.ymd}&adults=${p.passengers}&cabin=ECONOMY`,
W6: p => `https://wizzair.com/es-es#/booking/select-flight/${p.origin}/${p.destination}/${p.ymd}/null/${p.passengers}/0/0/null`,
NK: p => `https://www.spirit.com/book/flights?origStation=${p.origin}&destStation=${p.destination}&date=${p.ymd}&adt=${p.passengers}&chd=0&inf=0&promoCode=&tripType=OW`,
KL: p => `https://www.klm.es/search?pax=${p.passengers}:0:0:0:0:0:0:0&cabinClass=ECONOMY&connections=${p.origin}:C%3E${p.destination}:C&bookingFlow=LEISURE`,
AF: p => `https://www.airfrance.es/search?pax=${p.passengers}:0:0:0:0:0:0:0&cabinClass=ECONOMY&connections=${p.origin}:C%3E${p.destination}:C&bookingFlow=LEISURE`,
TK: p => `https://www.turkishairlines.com/es-es/flights/?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&adult=${p.passengers}&child=0&infant=0&tripType=O`,
AA: p => `https://www.aa.com/booking/find-flights?origin=${p.origin}&destination=${p.destination}&departureDate=${p.ymd}&pax=${p.passengers}&tripType=OneWay&locale=es_ES`,
}
interface AirlineData {
website: string | null
bookingUrl: string | null
bookingUrlTemplate: string | null
}
// Airline data cache (loaded once from API)
const airlineData = ref<Map<string, AirlineData> | null>(null)
let loadingPromise: Promise<void> | null = null
async function loadAirlineData() {
if (airlineData.value) return
if (loadingPromise) return loadingPromise
loadingPromise = $fetch<{ airlines: { iata: string; website: string | null; booking_url: string | null; booking_url_template: string | null }[] }>('/api/airlines')
.then(res => {
const map = new Map<string, AirlineData>()
for (const a of res.airlines) {
map.set(a.iata, {
website: a.website,
bookingUrl: a.booking_url,
bookingUrlTemplate: a.booking_url_template,
})
}
airlineData.value = map
})
.catch(() => {
airlineData.value = new Map()
})
return loadingPromise
}
function applyTemplate(template: string, params: BookingParams, pax: number, ymd: string): string {
return template
.replace(/\{origin\}/gi, params.origin)
.replace(/\{destination\}/gi, params.destination)
.replace(/\{date\}/gi, ymd)
.replace(/\{passengers\}/gi, String(pax))
}
function buildGoogleFlightsUrl(p: BookingParams): string {
const ymd = p.date.slice(0, 10)
return `https://www.google.com/travel/flights?hl=es&curr=EUR&q=flights+from+${p.origin}+to+${p.destination}+on+${ymd}+one+way+${p.passengers}+passenger`
}
export function useBookingUrl() {
// Start loading on first use
loadAirlineData()
function getBookingUrl(params: BookingParams): string {
const pax = params.passengers || 1
const ymd = params.date.slice(0, 10)
const d = ymd.replace(/-/g, '')
// 1. Hardcoded templates (most reliable, manually verified)
const hardcoded = BOOKING_TEMPLATES[params.airlineCode]
if (hardcoded) {
return hardcoded({ ...params, passengers: pax, d, ymd })
}
const data = airlineData.value?.get(params.airlineCode)
// 2. Auto-discovered template with placeholders
if (data?.bookingUrlTemplate) {
return applyTemplate(data.bookingUrlTemplate, params, pax, ymd)
}
// 3. Discovered booking page URL (no params, but lands on the right page)
if (data?.bookingUrl) {
return data.bookingUrl
}
// 4. Google Flights as universal fallback
return buildGoogleFlightsUrl({ ...params, passengers: pax })
}
function getAirlineWebsite(code: string): string | null {
return airlineData.value?.get(code)?.website || null
}
return { getBookingUrl, getAirlineWebsite }
}

View File

@@ -0,0 +1,32 @@
const imageCache = reactive<Record<string, { thumb_url: string; image_url: string; photographer: string; photographer_url: string } | null>>({})
const pending = new Set<string>()
export function useDestinationImages() {
async function fetchImage(cityName: string) {
const key = cityName.trim().toLowerCase()
if (key in imageCache || pending.has(key)) return
pending.add(key)
try {
const data = await $fetch<{ thumb_url: string; image_url: string; photographer: string; photographer_url: string } | null>('/api/destination-image', {
query: { city: cityName },
})
imageCache[key] = data
} catch {
imageCache[key] = null
} finally {
pending.delete(key)
}
}
function getImage(cityName: string) {
const key = cityName.trim().toLowerCase()
return imageCache[key] ?? null
}
function prefetch(cityNames: string[]) {
cityNames.forEach(name => fetchImage(name))
}
return { fetchImage, getImage, prefetch, imageCache }
}

View File

@@ -0,0 +1,206 @@
import type { SearchResponse, DetailResponse, PassengersCount, InspirationsResponse, Trip } from '~/server/utils/flightics'
interface SearchFormData {
departures: string[]
destination: string[]
dateFrom: string
dateTo: string
stayMinDays: number
stayMaxDays: number
passengers: PassengersCount
maxResults?: number
maxStops?: number | null
multiCityStops?: string[]
}
export function useFlightSearch() {
const trips = ref<Trip[]>([])
const loading = ref(false)
const polling = ref(false)
const error = ref<string | null>(null)
const searchMeta = ref<{ notComplete: boolean, responseId: string, pollCount: number }>({
notComplete: false,
responseId: '',
pollCount: 0
})
let abortController: AbortController | null = null
function buildPayload(form: SearchFormData) {
// Default dates: from today, to +30 days if not provided
const now = new Date()
const defaultFrom = now.toISOString().slice(0, 10)
const defaultTo = new Date(now.getTime() + 30 * 86400000).toISOString().slice(0, 10)
const dateFrom = form.dateFrom || defaultFrom
const dateTo = form.dateTo || defaultTo
const isOneWay = dateFrom === dateTo
const isMultiCity = form.multiCityStops && form.multiCityStops.length > 0
const defaultStayRange = { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' }
let stops
if (isMultiCity) {
// Each intermediate city gets a stay, then return to origin
stops = [
...form.multiCityStops!.map(code => ({
locations: [code],
stayRange: { min: form.stayMinDays * 24, max: form.stayMaxDays * 24 },
stayDateRange: defaultStayRange,
continueFromAny: true
})),
{
locations: form.departures,
stayRange: { min: 0, max: 0 },
stayDateRange: defaultStayRange,
continueFromAny: false
}
]
} else if (isOneWay) {
stops = [{
locations: form.destination,
stayRange: { min: 0, max: 0 },
stayDateRange: defaultStayRange,
continueFromAny: false
}]
} else {
stops = [
{
locations: form.destination,
stayRange: { min: form.stayMinDays * 24, max: form.stayMaxDays * 24 },
stayDateRange: defaultStayRange,
continueFromAny: true
},
{
locations: form.departures,
stayRange: { min: 0, max: 0 },
stayDateRange: defaultStayRange,
continueFromAny: false
}
]
}
return {
departures: form.departures,
local: 'en',
departureDateInterval: {
begin: `${dateFrom}T00:00:00+00:00`,
end: `${dateTo}T00:00:00+00:00`
},
stops,
endInSameLocation: isMultiCity || !isOneWay,
maxStops: form.maxStops ?? null,
fixStopsOrder: false,
stopLength: { min: 0, max: 0, isSet: false },
maxResults: form.maxResults || 45,
passengersCount: form.passengers
}
}
const { learn: learnAirline } = useAirlineNames()
function learnAirlineNames(tripList: Trip[]) {
for (const trip of tripList) {
for (const leg of trip.legs) {
for (const seg of leg.segments) {
if (seg.company?.name && seg.company.code) {
learnAirline(seg.company.code, seg.company.name)
}
}
}
}
}
// Deduplicate trips by bookingToken
function mergeTrips(existing: Trip[], incoming: Trip[]): Trip[] {
learnAirlineNames(incoming)
const seen = new Set(existing.map(t => t.bookingToken))
const newTrips = incoming.filter(t => !seen.has(t.bookingToken))
return [...existing, ...newTrips]
}
async function search(form: SearchFormData, maxPolls = 3) {
// Abort any ongoing search
abortController?.abort()
abortController = new AbortController()
loading.value = true
error.value = null
trips.value = []
searchMeta.value = { notComplete: false, responseId: '', pollCount: 0 }
const payload = buildPayload(form)
try {
// Initial search
const data = await $fetch<SearchResponse>('/api/search', {
method: 'POST',
body: payload,
signal: abortController.signal
})
const initialTrips = data.trips || []
learnAirlineNames(initialTrips)
trips.value = initialTrips
searchMeta.value = { notComplete: data.notComplete, responseId: data.responseId, pollCount: 1 }
loading.value = false
// Progressive polling if more results available
if (data.notComplete && maxPolls > 1) {
polling.value = true
for (let i = 1; i < maxPolls; i++) {
if (abortController.signal.aborted) break
await new Promise(r => setTimeout(r, 800))
const more = await $fetch<SearchResponse>('/api/search', {
method: 'POST',
body: payload,
signal: abortController.signal
})
trips.value = mergeTrips(trips.value, more.trips || [])
searchMeta.value = {
notComplete: more.notComplete,
responseId: more.responseId,
pollCount: i + 1
}
if (!more.notComplete) break
}
polling.value = false
}
} catch (e: any) {
if (e.name === 'AbortError') return
error.value = e?.data?.message || e?.message || 'Error searching flights'
loading.value = false
polling.value = false
}
}
function stopPolling() {
abortController?.abort()
polling.value = false
}
async function getDetail(bookingToken: string, passengers: PassengersCount) {
return $fetch<DetailResponse>('/api/detail', {
method: 'POST',
body: { bookingToken, local: 'en', passengersCount: passengers }
})
}
async function checkPrice(bookingToken: string, passengers: PassengersCount) {
return $fetch<DetailResponse>('/api/check', {
method: 'POST',
body: { bookingToken, local: 'en', passengersCount: passengers }
})
}
async function fetchInspirations(from: string, take: number = 100) {
return $fetch<InspirationsResponse>('/api/inspirations', {
query: { from, take, locale: 'en' }
})
}
return { trips, loading, polling, error, searchMeta, search, stopPolling, getDetail, checkPrice, fetchInspirations, buildPayload }
}

View File

@@ -0,0 +1,67 @@
interface AirportResult {
iata: string
name: string
city_name: string
country_code: string
country_name: string
lat: number | null
lon: number | null
}
export function useLocations() {
const supabase = useSupabaseClient()
const airports = ref<AirportResult[]>([])
const loaded = ref(false)
async function loadAirports() {
if (loaded.value) return
const { data } = await supabase
.from('airports')
.select('iata, name, city_name, country_code, country_name, lat, lon')
.order('iata')
.limit(10000)
if (data && data.length > 0) {
airports.value = data as AirportResult[]
loaded.value = true
} else {
// Fallback: fetch from Flightics API and cache in Supabase
await syncLocations()
}
}
async function syncLocations() {
try {
const data = await $fetch('/api/sync/locations', { method: 'POST' })
if (data?.count) {
// Reload from Supabase after sync
const { data: fresh } = await supabase
.from('airports')
.select('iata, name, city_name, country_code, country_name, lat, lon')
.order('iata')
airports.value = (fresh as AirportResult[]) || []
loaded.value = true
}
} catch {
// Silently fail, user can retry
}
}
function normalize(s: string): string {
return s.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
}
function searchAirports(query: string): AirportResult[] {
if (!query || query.length < 2) return []
const q = normalize(query)
return airports.value
.filter(a =>
normalize(a.iata).includes(q) ||
normalize(a.name).includes(q) ||
normalize(a.city_name || '').includes(q)
)
.slice(0, 20)
}
return { airports, loaded, loadAirports, searchAirports, syncLocations }
}

View File

@@ -0,0 +1,18 @@
export function useOriginTime() {
const cookie = useCookie('showOriginTime', { default: () => false, watch: true })
const { profile, updateProfile } = useUserPreferences()
const user = useSupabaseUser()
const showOriginTime = computed({
get: () => user.value && profile.value ? profile.value.show_origin_time : cookie.value,
set: (v: boolean) => {
cookie.value = v
if (user.value && profile.value) {
profile.value.show_origin_time = v
updateProfile({ show_origin_time: v })
}
}
})
return { showOriginTime }
}

View File

@@ -0,0 +1,44 @@
interface RecentSearch {
id: string
search_params: Record<string, any>
route_summary: string
search_mode: string
created_at: string
}
export function useRecentSearches() {
const supabase = useSupabaseClient()
const user = useSupabaseUser()
const searches = ref<RecentSearch[]>([])
const loading = ref(false)
async function fetchRecent(limit = 10) {
if (!user.value) return
loading.value = true
const { data } = await supabase
.from('recent_searches')
.select('*')
.order('created_at', { ascending: false })
.limit(limit)
searches.value = (data as RecentSearch[]) || []
loading.value = false
}
async function saveSearch(params: Record<string, any>, routeSummary: string, mode: string) {
if (!user.value) return
await supabase.from('recent_searches').insert({
user_id: user.value.id,
search_params: params,
route_summary: routeSummary,
search_mode: mode
})
}
watch(user, (u) => {
if (u) fetchRecent()
else searches.value = []
}, { immediate: true })
return { searches, loading, fetchRecent, saveSearch }
}

View File

@@ -0,0 +1,144 @@
import type { Trip } from '~/server/utils/flightics'
export type SortKey = 'price' | 'duration' | 'departure' | 'stops'
interface Filters {
maxPrice: number | null
maxStops: number | null
airlines: string[]
departureTimeRange: [number, number] // hours 0-24
}
function getTripDuration(trip: Trip): number {
let total = 0
for (const leg of trip.legs) {
for (const seg of leg.segments) {
total += (seg.arrivalUtcTimestamp ?? 0) - (seg.departureUtcTimestamp ?? 0)
}
}
return total
}
function getTripStops(trip: Trip): number {
return trip.legs.reduce((sum, leg) => sum + Math.max(0, leg.segments.length - 1), 0)
}
function getTripAirlines(trip: Trip): string[] {
const codes = new Set<string>()
for (const leg of trip.legs) {
for (const seg of leg.segments) {
if (seg.company?.code) codes.add(seg.company.code)
}
}
return [...codes]
}
function getDepartureHour(trip: Trip): number {
const dep = trip.legs[0]?.segments[0]?.departureDate
return dep ? new Date(dep).getHours() : 0
}
export function useResultFilters(trips: Ref<Trip[]>) {
const sortBy = ref<SortKey>('price')
const filters = reactive<Filters>({
maxPrice: null,
maxStops: null,
airlines: [],
departureTimeRange: [0, 24]
})
const viewMode = ref<'full' | 'compact'>('full')
// Extract available airlines from results
const { resolve: resolveAirline } = useAirlineNames()
const availableAirlines = computed(() => {
const codes = new Set<string>()
for (const trip of trips.value) {
for (const leg of trip.legs) {
for (const seg of leg.segments) {
if (seg.company?.code) codes.add(seg.company.code)
}
}
}
return [...codes].sort().map(code => ({ code, name: resolveAirline(code) }))
})
// Price range in results
const priceRange = computed(() => {
if (!trips.value.length) return { min: 0, max: 1000 }
const prices = trips.value.map(t => t.totalCost)
return { min: Math.floor(Math.min(...prices)), max: Math.ceil(Math.max(...prices)) }
})
const filtered = computed(() => {
let result = [...trips.value]
// Filter by price
if (filters.maxPrice != null) {
result = result.filter(t => t.totalCost <= filters.maxPrice!)
}
// Filter by stops
if (filters.maxStops != null) {
result = result.filter(t => getTripStops(t) <= filters.maxStops!)
}
// Filter by airlines (exclusive: all airlines in the trip must be in the selected set)
if (filters.airlines.length > 0) {
result = result.filter(t => {
const tripAirlines = getTripAirlines(t)
return tripAirlines.every(a => filters.airlines.includes(a))
})
}
// Filter by departure time
if (filters.departureTimeRange[0] > 0 || filters.departureTimeRange[1] < 24) {
result = result.filter(t => {
const hour = getDepartureHour(t)
return hour >= filters.departureTimeRange[0] && hour <= filters.departureTimeRange[1]
})
}
// Sort
if (sortBy.value === 'price') {
result.sort((a, b) => a.totalCost - b.totalCost)
} else if (sortBy.value === 'departure') {
result.sort((a, b) => {
const aTime = a.legs[0]?.segments[0]?.departureTimestamp ?? 0
const bTime = b.legs[0]?.segments[0]?.departureTimestamp ?? 0
return aTime - bTime
})
} else if (sortBy.value === 'duration') {
result.sort((a, b) => getTripDuration(a) - getTripDuration(b))
} else if (sortBy.value === 'stops') {
result.sort((a, b) => getTripStops(a) - getTripStops(b))
}
return result
})
function resetFilters() {
filters.maxPrice = null
filters.maxStops = null
filters.airlines = []
filters.departureTimeRange = [0, 24]
}
const hasActiveFilters = computed(() =>
filters.maxPrice != null ||
filters.maxStops != null ||
filters.airlines.length > 0 ||
filters.departureTimeRange[0] > 0 ||
filters.departureTimeRange[1] < 24
)
return {
sortBy,
filters,
viewMode,
filtered,
availableAirlines,
priceRange,
hasActiveFilters,
resetFilters
}
}

View File

@@ -0,0 +1,26 @@
import type { RouteFlightsResponse, PassengersCount, Trip } from '~/server/utils/flightics'
export function useRouteFlights() {
const trips = ref<Trip[]>([])
const loading = ref(false)
const error = ref<string | null>(null)
async function fetchRouteFlights(from: string, to: string, passengers?: PassengersCount) {
loading.value = true
error.value = null
trips.value = []
try {
const data = await $fetch<RouteFlightsResponse>('/api/route-flights', {
method: 'POST',
body: { from, to, locale: 'en', passengersCount: passengers }
})
trips.value = data.returnResults?.flatMap(r => r.trips) || []
} catch (e: any) {
error.value = e?.data?.message || e?.message || 'Error loading route flights'
} finally {
loading.value = false
}
}
return { trips, loading, error, fetchRouteFlights }
}

View File

@@ -0,0 +1,115 @@
export interface TrackedSearch {
id: string
name: string
search_params: Record<string, unknown>
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
created_at: string
latest_snapshot: PriceSnapshot | null
}
export interface PriceSnapshot {
cheapest_price: number
avg_price: number | null
median_price: number | null
total_results: number
recorded_at: string
}
export interface TopTrip {
price: number
currency: string
bookingToken: string
legs: Array<{ from: string; to: string; departure: string; airlines: string[] }>
}
export interface SearchRun {
id: string
tracked_search_id: string
status: string
cheapest_price: number | null
total_trips_found: number
top_trips: TopTrip[] | null
from_cache: boolean
error_message: string | null
started_at: string | null
completed_at: string | null
created_at: string
}
export function useTrackedSearches() {
const user = useSupabaseUser()
const trackedSearches = ref<TrackedSearch[]>([])
const loading = ref(false)
async function fetchAll() {
if (!user.value) return
loading.value = true
try {
const data = await $fetch<TrackedSearch[]>('/api/tracking')
trackedSearches.value = data || []
} catch {
trackedSearches.value = []
} finally {
loading.value = false
}
}
async function create(params: {
name: string
searchParams: Record<string, unknown>
routeSummary: string
intervalHours?: number
expiresAt?: string
}) {
const data = await $fetch<TrackedSearch>('/api/tracking', {
method: 'POST',
body: params
})
await fetchAll()
return data
}
async function update(id: string, patch: Partial<Pick<TrackedSearch, 'name' | 'interval_hours' | 'is_active' | 'expires_at' | 'search_params' | 'route_summary'>>) {
const data = await $fetch<TrackedSearch>(`/api/tracking/${id}`, {
method: 'PATCH',
body: patch
})
const idx = trackedSearches.value.findIndex(s => s.id === id)
if (idx >= 0) {
trackedSearches.value[idx] = { ...trackedSearches.value[idx], ...data }
}
return data
}
async function remove(id: string) {
await $fetch(`/api/tracking/${id}`, { method: 'DELETE' })
trackedSearches.value = trackedSearches.value.filter(s => s.id !== id)
}
async function getHistory(id: string, days = 30): Promise<PriceSnapshot[]> {
return $fetch<PriceSnapshot[]>(`/api/tracking/${id}/history`, {
query: { days }
})
}
async function getRuns(id: string, limit = 20): Promise<SearchRun[]> {
return $fetch<SearchRun[]>(`/api/tracking/${id}/runs`, {
query: { limit }
})
}
watch(user, (u) => {
if (u) fetchAll()
else trackedSearches.value = []
}, { immediate: true })
return { trackedSearches, loading, fetchAll, create, update, remove, getHistory, getRuns }
}

View File

@@ -0,0 +1,62 @@
interface UserProfile {
home_airports: string[]
default_adults: number
default_children: number
default_infants: number
locale: string
show_origin_time: boolean
}
export function useUserPreferences() {
const user = useSupabaseUser()
const profile = useState<UserProfile | null>('user-profile', () => null)
const loading = useState<boolean>('user-profile-loading', () => false)
let fetchPromise: Promise<void> | null = null
async function fetchProfile() {
if (!user.value) return
if (fetchPromise) return fetchPromise
loading.value = true
fetchPromise = (async () => {
try {
const data = await $fetch<UserProfile>('/api/profile')
profile.value = data
} catch {
profile.value = null
} finally {
loading.value = false
fetchPromise = null
}
})()
return fetchPromise
}
async function updateProfile(updates: Partial<UserProfile>) {
if (!user.value) return
try {
const data = await $fetch<UserProfile>('/api/profile', {
method: 'PATCH',
body: updates
})
profile.value = data
} catch {
// silent
}
}
const homeAirports = computed(() => profile.value?.home_airports ?? [])
const defaultPassengers = computed(() => ({
adult: profile.value?.default_adults ?? 1,
child: profile.value?.default_children ?? 0,
infant: profile.value?.default_infants ?? 0
}))
watch(user, (u) => {
if (u) fetchProfile()
else profile.value = null
}, { immediate: true })
return { profile, loading, homeAirports, defaultPassengers, fetchProfile, updateProfile }
}

View File

@@ -0,0 +1,135 @@
interface WatchlistItem {
id: string
booking_token: string
route_summary: string
departure_code: string
arrival_code: string
departure_date: string
original_price: number
current_price: number | null
price_status: string
passengers_adult: number
passengers_child: number
passengers_infant: number
last_checked_at: string | null
created_at: string
}
export function useWatchlist() {
const supabase = useSupabaseClient()
const user = useSupabaseUser()
const items = ref<WatchlistItem[]>([])
const loading = ref(false)
async function fetchAll() {
if (!user.value) return
loading.value = true
const { data } = await supabase
.from('watchlist')
.select('*')
.order('created_at', { ascending: false })
items.value = (data as WatchlistItem[]) || []
loading.value = false
}
async function add(params: {
bookingToken: string
routeSummary: string
departureCode: string
arrivalCode: string
departureDate: string
price: number
passengers: { adult: number; child: number; infant: number }
}) {
if (!user.value) return false
const { error } = await supabase.from('watchlist').insert({
user_id: user.value.id,
booking_token: params.bookingToken,
route_summary: params.routeSummary,
departure_code: params.departureCode,
arrival_code: params.arrivalCode,
departure_date: params.departureDate,
original_price: params.price,
current_price: params.price,
passengers_adult: params.passengers.adult,
passengers_child: params.passengers.child,
passengers_infant: params.passengers.infant
})
if (!error) await fetchAll()
return !error
}
async function remove(id: string) {
const { error } = await supabase.from('watchlist').delete().eq('id', id)
if (!error) items.value = items.value.filter(i => i.id !== id)
return !error
}
async function checkPrice(item: WatchlistItem) {
try {
const data = await $fetch<any>('/api/check', {
method: 'POST',
body: {
bookingToken: item.booking_token,
local: 'en',
passengersCount: {
adult: item.passengers_adult,
child: item.passengers_child,
infant: item.passengers_infant
}
}
})
const newPrice = data.trip.totalCost
let status = 'available'
if (newPrice < item.original_price) status = 'price_down'
else if (newPrice > item.original_price) status = 'price_up'
await supabase.from('watchlist').update({
current_price: newPrice,
price_status: status,
last_checked_at: new Date().toISOString()
}).eq('id', item.id)
// Update local state
const idx = items.value.findIndex(i => i.id === item.id)
if (idx >= 0) {
items.value[idx] = { ...items.value[idx], current_price: newPrice, price_status: status, last_checked_at: new Date().toISOString() }
}
return { price: newPrice, status }
} catch {
await supabase.from('watchlist').update({
price_status: 'unavailable',
last_checked_at: new Date().toISOString()
}).eq('id', item.id)
const idx = items.value.findIndex(i => i.id === item.id)
if (idx >= 0) {
items.value[idx] = { ...items.value[idx], price_status: 'unavailable', last_checked_at: new Date().toISOString() }
}
return { price: null, status: 'unavailable' }
}
}
async function checkAll() {
for (const item of items.value) {
await checkPrice(item)
}
}
function isWatched(bookingToken: string) {
return items.value.some(i => i.booking_token === bookingToken)
}
function getWatchedItem(bookingToken: string) {
return items.value.find(i => i.booking_token === bookingToken)
}
watch(user, (u) => {
if (u) fetchAll()
else items.value = []
}, { immediate: true })
return { items, loading, fetchAll, add, remove, checkPrice, checkAll, isWatched, getWatchedItem }
}

16
app/pages/auth.vue Normal file
View File

@@ -0,0 +1,16 @@
<script setup lang="ts">
const user = useSupabaseUser()
// Redirect if already logged in
watch(user, (u) => {
if (u) navigateTo('/')
}, { immediate: true })
useSeoMeta({ title: 'Vuelato - Iniciar sesion' })
</script>
<template>
<UPageSection>
<AuthLoginForm />
</UPageSection>
</template>

View File

@@ -0,0 +1,17 @@
<script setup lang="ts">
// OAuth callback page - Supabase handles the token exchange automatically
const user = useSupabaseUser()
watch(user, (u) => {
if (u) navigateTo('/')
}, { immediate: true })
</script>
<template>
<UPageSection>
<div class="text-center">
<UIcon name="i-lucide-loader" class="animate-spin text-2xl" />
<p class="mt-2 text-muted">Verificando...</p>
</div>
</UPageSection>
</template>

View File

@@ -0,0 +1,117 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
const route = useRoute()
const { getDetail } = useFlightSearch()
const token = computed(() => route.params.token as string)
const originalPrice = computed(() => Number(route.query.price) || 0)
const passengers = computed(() => ({
adult: Number(route.query.adults) || 1,
child: Number(route.query.children) || 0,
infant: Number(route.query.infants) || 0
}))
const trip = ref<Trip | null>(null)
const loading = ref(true)
const error = ref<string | null>(null)
const routeSummary = computed(() => {
if (!trip.value) return ''
const legs = trip.value.legs
const codes = legs.map(l => l.segments[0]?.departureCode).filter(Boolean)
const lastArr = legs[legs.length - 1]?.segments.at(-1)?.arrivalCode
if (lastArr) codes.push(lastArr)
return codes.join(' > ')
})
const departureCode = computed(() => trip.value?.legs[0]?.segments[0]?.departureCode ?? '')
const arrivalCode = computed(() => trip.value?.legs[0]?.segments.at(-1)?.arrivalCode ?? '')
const departureDate = computed(() => trip.value?.legs[0]?.segments[0]?.departureDate ?? '')
useSeoMeta({ title: () => `Vuelato - ${routeSummary.value || 'Detalle'}` })
onMounted(async () => {
try {
const data = await getDetail(token.value, passengers.value)
trip.value = data.trip
} catch (e: any) {
error.value = e?.data?.message || 'Error loading detail'
} finally {
loading.value = false
}
})
</script>
<template>
<UPageSection>
<div class="max-w-3xl mx-auto">
<UButton label="Volver" icon="i-lucide-arrow-left" variant="ghost" class="mb-4" @click="$router.back()" />
<div v-if="loading" class="space-y-4">
<USkeleton class="h-48 w-full" />
<USkeleton class="h-48 w-full" />
</div>
<UAlert v-else-if="error" color="error" icon="i-lucide-alert-circle" :title="error" />
<template v-else-if="trip">
<!-- Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-bold">{{ routeSummary }}</h1>
<p class="text-sm text-muted">
{{ passengers.adult + passengers.child + passengers.infant }} pasajero(s)
</p>
</div>
<div class="flex items-center gap-3">
<div class="text-right">
<p class="text-3xl font-bold text-primary-600 dark:text-primary-400">
{{ originalPrice.toFixed(0) }}&euro;
</p>
</div>
<DetailWatchlistToggle
:booking-token="token"
:route-summary="routeSummary"
:departure-code="departureCode"
:arrival-code="arrivalCode"
:departure-date="departureDate"
:price="originalPrice"
:passengers="passengers"
/>
<DetailShareButton :title="routeSummary" :price="originalPrice" />
</div>
</div>
<!-- Itinerary -->
<DetailItineraryTimeline :trip="trip" />
<!-- Price verifier -->
<div class="mt-6">
<DetailPriceVerifier
:booking-token="token"
:original-price="originalPrice"
:passengers="passengers"
/>
</div>
<!-- Booking CTA -->
<div v-if="trip.deepLink" class="mt-4">
<UButton
:to="trip.deepLink"
target="_blank"
label="Reservar en aerolinea"
icon="i-lucide-external-link"
size="lg"
block
/>
</div>
<!-- Related flights -->
<div v-if="departureCode && arrivalCode" class="mt-6">
<DetailRelatedFlights :from="departureCode" :to="arrivalCode" />
</div>
</template>
</div>
</UPageSection>
</template>

141
app/pages/explore.vue Normal file
View File

@@ -0,0 +1,141 @@
<script setup lang="ts">
import type { InspirationItem } from '~/server/utils/flightics'
const route = useRoute()
const router = useRouter()
const { fetchInspirations } = useFlightSearch()
const { airports, loadAirports } = useLocations()
const origin = ref((route.query.dep as string) || 'MAD')
const budget = ref<number | null>(route.query.budget ? Number(route.query.budget) : null)
const directOnly = ref(false)
const inspirations = ref<InspirationItem[]>([])
const loadingInsp = ref(false)
const { prefetch, getImage } = useDestinationImages()
useSeoMeta({ title: 'Vuelato - Explorar destinos' })
// Load airports for map
onMounted(() => loadAirports())
async function loadInspirations() {
loadingInsp.value = true
try {
const data = await fetchInspirations(origin.value)
inspirations.value = data.items || []
} catch {
inspirations.value = []
} finally {
loadingInsp.value = false
}
}
watch(origin, () => loadInspirations(), { immediate: true })
const filteredInspirations = computed(() => {
let items = inspirations.value
if (directOnly.value) items = items.filter(i => i.minStops === 0)
if (budget.value) items = items.filter(i => i.minPrice <= budget.value!)
return items
})
function cityName(iata: string): string {
const a = airports.value.find(ap => ap.iata === iata)
return a?.city_name || iata
}
// Prefetch images when inspirations change
watch(filteredInspirations, (items) => {
const cities = items.slice(0, 20)
.map(i => cityName(i.to[0]))
.filter(c => c.length > 2)
if (cities.length) prefetch(cities)
})
// Map airports (only those with lat/lon)
const mapAirports = computed(() =>
airports.value
.filter(a => a.lat && a.lon)
.map(a => ({ iata: a.iata, name: a.name, lat: a.lat, lon: a.lon, city_name: a.city_name }))
)
function onSelectOrigin(iata: string) {
origin.value = iata
}
function onSelectDestination(iata: string) {
router.push(`/route/${origin.value}-${iata}`)
}
</script>
<template>
<div>
<UPageHero title="Explorar destinos" description="Descubre vuelos baratos en el mapa" />
<UPageSection>
<div class="space-y-4">
<MapControls
v-model:origin="origin"
v-model:budget="budget"
v-model:direct-only="directOnly"
:inspiration-count="filteredInspirations.length"
/>
<ClientOnly>
<MapFlightMap
:airports="mapAirports"
:origin="origin"
:inspirations="filteredInspirations"
:budget="budget"
@select-origin="onSelectOrigin"
@select-destination="onSelectDestination"
/>
<template #fallback>
<USkeleton class="h-[500px] w-full rounded-lg" />
</template>
</ClientOnly>
<!-- Destination list below map -->
<div v-if="filteredInspirations.length > 0" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<div
v-for="item in filteredInspirations.slice(0, 20)"
:key="item.to[0]"
class="relative overflow-hidden rounded-lg cursor-pointer group h-40"
@click="onSelectDestination(item.to[0])"
>
<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">&euro;</span>
</p>
</div>
</div>
<!-- Unsplash attribution -->
<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>
</div>
</UPageSection>
</div>
</template>

197
app/pages/index.vue Normal file
View File

@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { InspirationItem, MultiCityInspirationItem } from '~/server/utils/flightics'
const router = useRouter()
const user = useSupabaseUser()
const { fetchInspirations } = useFlightSearch()
const { searches, saveSearch } = useRecentSearches()
const { homeAirports } = useUserPreferences()
const inspirations = ref<InspirationItem[]>([])
const inspirationFrom = ref('MAD')
const loadingInspirations = ref(false)
// Multi-city inspirations
const multiCityItems = ref<MultiCityInspirationItem[]>([])
const loadingMultiCity = ref(false)
async function loadInspirations() {
loadingInspirations.value = true
try {
const data = await fetchInspirations(inspirationFrom.value)
inspirations.value = data.items || []
} catch {
inspirations.value = []
} finally {
loadingInspirations.value = false
}
}
async function loadMultiCity() {
const codes = quickAirports.value.slice(0, 3)
if (codes.length === 0) return
loadingMultiCity.value = true
try {
const data = await $fetch<any>('/api/multi-city-inspirations', {
method: 'POST',
body: { startLocationsCodes: codes, locale: 'en', take: 8 }
})
multiCityItems.value = data.items || []
} catch {
multiCityItems.value = []
} finally {
loadingMultiCity.value = false
}
}
// Use home airports from profile, fallback to defaults
const quickAirports = computed(() => {
if (homeAirports.value.length) return homeAirports.value
return ['MAD', 'BCN', 'AGP', 'SVQ', 'VLC', 'PMI']
})
// Set initial origin from user preferences
watch(homeAirports, (airports) => {
if (airports.length) inspirationFrom.value = airports[0]
}, { immediate: true })
onMounted(() => {
loadInspirations()
loadMultiCity()
})
function onSearch(data: any) {
const dep = data.departures.join(',')
const dest = data.destination.join(',')
const summary = dest ? `${dep} > ${dest}` : `${dep} > Explorar`
if (user.value) saveSearch(data, summary, data.mode)
if (data.mode === 'explore') {
router.push({ path: '/explore', query: { dep, budget: data.budget } })
return
}
router.push({
path: '/results',
query: {
mode: data.mode,
dep,
dest,
from: data.dateFrom,
to: data.dateTo,
smin: data.stayMinDays,
smax: data.stayMaxDays,
adults: data.passengers.adult,
children: data.passengers.child,
infants: data.passengers.infant,
maxStops: data.maxStops ?? undefined,
budget: data.budget ?? undefined
}
})
}
function replaySearch(s: any) {
const p = s.search_params
router.push({
path: '/results',
query: {
mode: p.mode || s.search_mode,
dep: p.departures?.join(','),
dest: Array.isArray(p.destination) ? p.destination.join(',') : p.destination,
from: p.dateFrom,
to: p.dateTo,
smin: p.stayMinDays,
smax: p.stayMaxDays,
adults: p.passengers?.adult,
children: p.passengers?.child,
infants: p.passengers?.infant
}
})
}
function goToRoute(iata: string) {
router.push(`/route/${inspirationFrom.value}-${iata}`)
}
</script>
<template>
<div>
<UPageHero
title="Vuelato"
description="Busca vuelos baratos con fechas flexibles"
/>
<!-- Search -->
<UPageSection>
<UCard class="max-w-2xl mx-auto">
<SearchForm compact @search="onSearch" />
</UCard>
</UPageSection>
<!-- Recent searches -->
<UPageSection v-if="user && searches.length" title="Busquedas recientes">
<div class="flex gap-2 flex-wrap">
<UButton
v-for="s in searches.slice(0, 5)"
:key="s.id"
:label="s.route_summary"
icon="i-lucide-history"
color="neutral"
variant="soft"
size="sm"
@click="replaySearch(s)"
/>
</div>
</UPageSection>
<!-- Inspirations -->
<UPageSection title="Vuelos baratos" description="Los mejores precios desde tu aeropuerto">
<div class="flex gap-2 mb-4 flex-wrap">
<UButton
v-for="apt in quickAirports"
:key="apt"
:label="apt"
:color="inspirationFrom === apt ? 'primary' : 'neutral'"
:variant="inspirationFrom === apt ? 'solid' : 'outline'"
size="sm"
@click="inspirationFrom = apt; loadInspirations()"
/>
</div>
<div v-if="loadingInspirations" class="grid grid-cols-2 md:grid-cols-4 gap-3">
<USkeleton v-for="i in 12" :key="i" class="h-16" />
</div>
<InspirationGrid v-else :items="inspirations" :from="inspirationFrom" />
</UPageSection>
<!-- Budget explorer -->
<UPageSection>
<InspirationBudgetExplorer
:items="inspirations"
:from="inspirationFrom"
:loading="loadingInspirations"
@select="goToRoute"
/>
</UPageSection>
<!-- Multi-city carousel -->
<UPageSection title="Inspiracion multi-ciudad" description="Itinerarios con varias paradas">
<div v-if="loadingMultiCity" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<USkeleton v-for="i in 4" :key="i" class="h-20" />
</div>
<div v-else-if="multiCityItems.length" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-3">
<InspirationMultiCityCard
v-for="(item, i) in multiCityItems"
:key="i"
:item="item"
@select="router.push('/multi-city')"
/>
</div>
<div v-else class="text-center py-6">
<UButton to="/multi-city" label="Explorar multi-ciudad" variant="outline" icon="i-lucide-route" />
</div>
</UPageSection>
</div>
</template>

211
app/pages/multi-city.vue Normal file
View File

@@ -0,0 +1,211 @@
<script setup lang="ts">
import type { MultiCityInspirationItem, MultiCityInspirationsResponse } from '~/server/utils/flightics'
const router = useRouter()
const { airports, loadAirports } = useLocations()
const { homeAirports } = useUserPreferences()
const origins = ref('')
const loading = ref(false)
const items = ref<MultiCityInspirationItem[]>([])
const currency = ref('')
const includeCountries = ref<string[]>([])
const excludeCountries = ref<string[]>([])
const excludeRest = ref(false)
const sortBy = ref<'price' | 'stops'>('price')
useSeoMeta({ title: 'Vuelato - Multi-ciudad' })
// Map IATA code -> country name
const airportCountryMap = computed(() => {
const map = new Map<string, string>()
for (const a of airports.value) {
if (a.country_name) map.set(a.iata, a.country_name)
}
return map
})
// All unique countries present in current results (stops only, not origin)
const availableCountries = computed(() => {
const names = new Set<string>()
for (const item of items.value) {
for (const stop of item.stops) {
const name = airportCountryMap.value.get(stop)
if (name) names.add(name)
}
}
return Array.from(names).sort((a, b) => a.localeCompare(b))
})
// Filtered and sorted items
const filteredItems = computed(() => {
const filtered = items.value.filter((item) => {
const stopCountries = item.stops
.map(s => airportCountryMap.value.get(s))
.filter(Boolean) as string[]
if (includeCountries.value.length > 0) {
if (!includeCountries.value.some(c => stopCountries.includes(c))) return false
if (excludeRest.value) {
if (stopCountries.some(c => !includeCountries.value.includes(c))) return false
}
}
if (excludeCountries.value.length > 0) {
if (excludeCountries.value.some(c => stopCountries.includes(c))) return false
}
return true
})
return filtered.sort((a, b) => {
if (sortBy.value === 'price') return a.minPrice - b.minPrice
return a.stops.length - b.stops.length
})
})
function resolveCountryName(iata: string): string {
return airportCountryMap.value.get(iata) || ''
}
async function search() {
const codes = origins.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
if (codes.length === 0) return
loading.value = true
try {
const data = await $fetch<MultiCityInspirationsResponse>('/api/multi-city-inspirations', {
method: 'POST',
body: { startLocationsCodes: codes, locale: 'en', take: 30 }
})
items.value = data.items || []
currency.value = data.currency?.symbol || '€'
// Reset filters on new search
includeCountries.value = []
excludeCountries.value = []
excludeRest.value = false
} catch {
items.value = []
} finally {
loading.value = false
}
}
// Initialize origins from user's home airports and trigger search
watch(homeAirports, (airports) => {
if (airports.length && !origins.value) {
origins.value = airports.join(',')
}
}, { immediate: true })
onMounted(async () => {
await loadAirports()
if (!origins.value) {
origins.value = 'MAD,BCN'
}
search()
})
function onSelect(item: MultiCityInspirationItem) {
router.push({
path: '/results',
query: {
mode: 'multicity',
dep: item.from,
stops: item.stops.join(','),
from: '',
to: '',
adults: '1'
}
})
}
const hasFilters = computed(() => includeCountries.value.length > 0 || excludeCountries.value.length > 0 || excludeRest.value)
</script>
<template>
<div>
<UPageHero title="Multi-ciudad" description="Inspiracion para itinerarios con varias paradas" />
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<UCard>
<form class="flex items-end gap-3" @submit.prevent="search">
<UFormField label="Aeropuertos origen" class="flex-1">
<SearchAirportInput v-model="origins" placeholder="MAD, BCN..." icon="i-lucide-plane-takeoff" multiple />
<template #hint>
<span class="text-xs text-muted">Codigos IATA separados por coma</span>
</template>
</UFormField>
<UButton type="submit" label="Buscar" icon="i-lucide-search" :loading="loading" />
</form>
</UCard>
<!-- Filters -->
<div v-if="!loading && items.length > 0 && availableCountries.length > 0" class="flex flex-wrap items-end gap-3">
<UFormField label="Incluir paises" class="flex-1 min-w-40">
<USelectMenu
v-model="includeCountries"
:items="availableCountries"
multiple
placeholder="Todos"
class="w-full"
/>
</UFormField>
<UFormField label="Excluir paises" class="flex-1 min-w-40">
<USelectMenu
v-model="excludeCountries"
:items="availableCountries"
multiple
placeholder="Ninguno"
class="w-full"
/>
</UFormField>
<UFormField label="Ordenar por" class="w-36">
<USelectMenu
v-model="sortBy"
:items="[
{ label: 'Precio', value: 'price' },
{ label: 'Paradas', value: 'stops' }
]"
value-key="value"
class="w-full"
/>
</UFormField>
<div v-if="includeCountries.length > 0" class="flex items-center gap-2 pb-1">
<UCheckbox v-model="excludeRest" />
<span class="text-xs text-muted whitespace-nowrap">Excluir el resto</span>
</div>
<UButton
v-if="hasFilters"
icon="i-lucide-x"
variant="ghost"
size="sm"
@click="includeCountries = []; excludeCountries = []; excludeRest = false"
/>
</div>
<p v-if="hasFilters && !loading" class="text-xs text-muted">
{{ filteredItems.length }} de {{ items.length }} itinerarios
</p>
<div v-if="loading" class="grid grid-cols-1 md:grid-cols-2 gap-3">
<USkeleton v-for="i in 6" :key="i" class="h-20" />
</div>
<div v-else-if="filteredItems.length === 0 && !loading" class="text-center py-12">
<UIcon name="i-lucide-route" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">{{ hasFilters ? 'Ningun itinerario coincide con los filtros' : 'No se encontraron itinerarios' }}</p>
<UButton v-if="hasFilters" label="Limpiar filtros" class="mt-3" variant="outline" @click="includeCountries = []; excludeCountries = []; excludeRest = false" />
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-3">
<InspirationMultiCityCard
v-for="(item, i) in filteredItems"
:key="i"
:item="item"
:resolve-country="resolveCountryName"
@select="onSelect"
/>
</div>
</div>
</UPageSection>
</div>
</template>

202
app/pages/results.vue Normal file
View File

@@ -0,0 +1,202 @@
<script setup lang="ts">
const route = useRoute()
const router = useRouter()
const { trips, loading, polling, error, searchMeta, search, stopPolling } = useFlightSearch()
const { sortBy, filters, viewMode, filtered, availableAirlines, priceRange, hasActiveFilters, resetFilters } = useResultFilters(trips)
const { create: createTracking } = useTrackedSearches()
const user = useSupabaseUser()
const trackingName = ref('')
const showTrackingForm = ref(false)
const creatingTracking = ref(false)
const searchMode = computed(() => (route.query.mode as string) || 'roundtrip')
const showFilters = ref(false)
const passengers = computed(() => ({
adult: Number(route.query.adults) || 1,
child: Number(route.query.children) || 0,
infant: Number(route.query.infants) || 0
}))
const multiCityStops = computed(() =>
(route.query.stops as string)?.split(',').filter(Boolean) || []
)
const searchParams = computed(() => ({
departures: (route.query.dep as string)?.split(',') || [],
destination: (route.query.dest as string)?.split(',').filter(Boolean) || [],
dateFrom: (route.query.from as string) || '',
dateTo: (route.query.to as string) || '',
stayMinDays: Number(route.query.smin) || 2,
stayMaxDays: Number(route.query.smax) || 6,
passengers: passengers.value,
maxStops: route.query.maxStops != null ? Number(route.query.maxStops) : null,
multiCityStops: multiCityStops.value.length > 0 ? multiCityStops.value : undefined
}))
// Apply budget from query as initial filter
onMounted(() => {
if (route.query.budget) {
filters.maxPrice = Number(route.query.budget)
}
const hasDestination = searchParams.value.destination.length > 0 || (searchParams.value.multiCityStops && searchParams.value.multiCityStops.length > 0)
if (searchParams.value.departures.length && hasDestination) {
search(searchParams.value)
}
})
function selectTrip(trip: any) {
router.push({
path: `/detail/${encodeURIComponent(trip.bookingToken)}`,
query: {
adults: String(passengers.value.adult),
children: String(passengers.value.child),
infants: String(passengers.value.infant),
price: String(trip.totalCost)
}
})
}
const modeLabels: Record<string, string> = {
roundtrip: 'Ida y vuelta',
oneway: 'Solo ida',
multicity: 'Multi-ciudad',
weekend: 'Finde'
}
const routeSummary = computed(() => {
if (multiCityStops.value.length > 0) {
return `${searchParams.value.departures.join(',')} > ${multiCityStops.value.join(' > ')}`
}
return `${searchParams.value.departures.join(',')} > ${searchParams.value.destination.join(',')}`
})
async function onCreateTracking() {
if (!trackingName.value) return
creatingTracking.value = true
try {
const { buildPayload } = useFlightSearch()
await createTracking({
name: trackingName.value,
searchParams: buildPayload(searchParams.value),
routeSummary: routeSummary.value,
intervalHours: 24
})
showTrackingForm.value = false
trackingName.value = ''
} finally {
creatingTracking.value = false
}
}
</script>
<template>
<div>
<UPageHero
:title="multiCityStops.length > 0
? `${searchParams.departures.join(', ')} → ${multiCityStops.join(' → ')}`
: `${searchParams.departures.join(', ')} → ${searchParams.destination.join(', ')}`"
:description="`${modeLabels[searchMode] || searchMode} · ${searchParams.dateFrom || 'Flexible'} al ${searchParams.dateTo || 'Flexible'} · ${passengers.adult + passengers.child + passengers.infant} pasajero(s)`"
/>
<UPageSection>
<div class="max-w-4xl mx-auto space-y-4">
<ResultsToolbar
v-model:sort-by="sortBy"
v-model:view-mode="viewMode"
:count="filtered.length"
:has-active-filters="hasActiveFilters"
@toggle-filters="showFilters = !showFilters"
@reset-filters="resetFilters"
/>
<!-- Tracking button -->
<div v-if="user && !loading && filtered.length > 0 && !showTrackingForm" class="flex justify-end">
<UButton
label="Hacer seguimiento"
icon="i-lucide-bell-plus"
variant="outline"
size="sm"
@click="showTrackingForm = true"
/>
</div>
<UCard v-if="showTrackingForm" class="border-primary-200">
<div class="flex items-end gap-3">
<UFormField label="Nombre del seguimiento" class="flex-1">
<UInput v-model="trackingName" :placeholder="`Ej: ${routeSummary}`" class="w-full" />
</UFormField>
<UButton
label="Crear"
icon="i-lucide-plus"
size="sm"
:loading="creatingTracking"
:disabled="!trackingName"
@click="onCreateTracking"
/>
<UButton
icon="i-lucide-x"
color="neutral"
variant="ghost"
size="sm"
@click="showTrackingForm = false"
/>
</div>
</UCard>
<!-- Filters panel -->
<ResultsFilters
v-if="showFilters"
v-model:max-price="filters.maxPrice"
v-model:max-stops="filters.maxStops"
v-model:airlines="filters.airlines"
v-model:departure-time-range="filters.departureTimeRange"
:available-airlines="availableAirlines"
:price-range="priceRange"
/>
<!-- Loading -->
<div v-if="loading" class="space-y-4">
<USkeleton v-for="i in 5" :key="i" class="h-32 w-full" />
</div>
<UAlert v-else-if="error" color="error" icon="i-lucide-alert-circle" :title="error" />
<template v-else>
<div v-if="filtered.length === 0" class="text-center py-12">
<UIcon name="i-lucide-plane" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No se encontraron vuelos</p>
<UButton v-if="hasActiveFilters" label="Limpiar filtros" class="mt-3" variant="outline" @click="resetFilters" />
</div>
<!-- Full view -->
<template v-if="viewMode === 'full'">
<TripCard
v-for="(trip, i) in filtered"
:key="trip.bookingToken || i"
:trip="trip"
@select="selectTrip"
/>
</template>
<!-- Compact view -->
<template v-else>
<ResultsTripCardCompact
v-for="(trip, i) in filtered"
:key="trip.bookingToken || i"
:trip="trip"
@select="selectTrip"
/>
</template>
</template>
<!-- Polling indicator -->
<ResultsLoadingMore
:polling="polling"
:poll-count="searchMeta.pollCount"
@stop="stopPolling"
/>
</div>
</UPageSection>
</div>
</template>

149
app/pages/route/[slug].vue Normal file
View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import type { Trip } from '~/server/utils/flightics'
const route = useRoute()
const router = useRouter()
const { trips, loading, error, fetchRouteFlights } = useRouteFlights()
const slug = computed(() => route.params.slug as string)
const from = computed(() => slug.value.split('-')[0]?.toUpperCase() || '')
const to = computed(() => slug.value.split('-')[1]?.toUpperCase() || '')
useSeoMeta({ title: () => `Vuelato - ${from.value}${to.value}` })
onMounted(() => {
if (from.value && to.value) {
fetchRouteFlights(from.value, to.value)
}
})
function selectTrip(trip: Trip) {
router.push({
path: `/detail/${encodeURIComponent(trip.bookingToken)}`,
query: { price: String(trip.totalCost), adults: '1', children: '0', infants: '0' }
})
}
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', { weekday: 'short', day: 'numeric', month: 'short' })
}
function legLabel(index: number, total: number) {
if (total === 1) return 'Solo ida'
return index === 0 ? 'Ida' : 'Vuelta'
}
function legStopsText(leg: Trip['legs'][0]) {
const n = leg.segments.length - 1
if (n === 0) return 'Directo'
return `${n} escala${n > 1 ? 's' : ''}`
}
function legRoute(leg: Trip['legs'][0]) {
const codes = leg.segments.map(s => s.departureCode)
codes.push(leg.segments.at(-1)!.arrivalCode)
return codes.join(' → ')
}
// Group by airline
const airlineGroups = computed(() => {
const groups = new Map<string, Trip[]>()
for (const trip of trips.value) {
const code = trip.legs[0]?.segments[0]?.company?.code || 'Otros'
const name = trip.legs[0]?.segments[0]?.company?.name || code
const key = `${code} - ${name}`
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(trip)
}
return [...groups.entries()].sort((a, b) => {
const minA = Math.min(...a[1].map(t => t.totalCost))
const minB = Math.min(...b[1].map(t => t.totalCost))
return minA - minB
})
})
</script>
<template>
<div>
<UPageHero
:title="`${from} → ${to}`"
description="Vuelos disponibles en esta ruta (ida y vuelta)"
/>
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<UButton label="Volver" icon="i-lucide-arrow-left" variant="ghost" @click="$router.back()" />
<div v-if="loading" class="space-y-3">
<USkeleton v-for="i in 5" :key="i" class="h-24 w-full" />
</div>
<UAlert v-else-if="error" color="error" icon="i-lucide-alert-circle" :title="error" />
<div v-else-if="trips.length === 0" class="text-center py-12">
<UIcon name="i-lucide-plane" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No se encontraron vuelos para esta ruta</p>
</div>
<template v-else>
<p class="text-sm text-muted">{{ trips.length }} opcion{{ trips.length !== 1 ? 'es' : '' }} encontrada{{ trips.length !== 1 ? 's' : '' }}</p>
<div v-for="[airline, airlineTrips] in airlineGroups" :key="airline" class="space-y-2">
<h3 class="font-semibold text-sm flex items-center gap-2">
<UIcon name="i-lucide-plane" class="text-muted" />
{{ airline }}
<UBadge :label="`desde ${Math.min(...airlineTrips.map(t => t.totalCost)).toFixed(0)}€`" color="primary" size="xs" />
</h3>
<UCard
v-for="(trip, i) in airlineTrips.slice(0, 10)"
:key="i"
class="hover:ring-primary-400 cursor-pointer transition-all"
@click="selectTrip(trip)"
>
<div class="flex items-center justify-between gap-4">
<div class="flex-1 space-y-2">
<div v-for="(leg, li) in trip.legs" :key="li" class="flex items-center gap-3 text-sm">
<UBadge
:label="legLabel(li, trip.legs.length)"
:color="li === 0 ? 'primary' : 'info'"
variant="subtle"
size="xs"
/>
<span class="font-medium">
{{ formatTime(leg.segments[0]?.departureDate) }}
{{ formatTime(leg.segments.at(-1)?.arrivalDate || '') }}
</span>
<span class="text-muted">
{{ formatDate(leg.segments[0]?.departureDate) }}
</span>
<span class="text-xs text-muted">
{{ legStopsText(leg) }}
</span>
<span v-if="leg.segments.length > 1" class="text-xs text-muted hidden sm:inline">
{{ legRoute(leg) }}
</span>
</div>
</div>
<div class="shrink-0 text-right">
<p class="text-xl font-bold text-primary-600 dark:text-primary-400">
{{ trip.totalCost.toFixed(0) }}<span class="text-sm">&euro;</span>
</p>
<p class="text-xs text-muted">
{{ trip.legs.length > 1 ? 'ida+vuelta' : 'solo ida' }}
</p>
</div>
</div>
</UCard>
</div>
</template>
</div>
</UPageSection>
</div>
</template>

59
app/pages/search.vue Normal file
View File

@@ -0,0 +1,59 @@
<script setup lang="ts">
const router = useRouter()
const user = useSupabaseUser()
const { saveSearch } = useRecentSearches()
useSeoMeta({ title: 'Vuelato - Buscar vuelos' })
function onSearch(data: any) {
// Build route summary
const dep = data.departures.join(',')
const dest = data.destination.join(',')
const summary = dest
? `${dep} > ${dest}`
: `${dep} > Explorar`
// Save to recent searches if logged in
if (user.value) {
saveSearch(data, summary, data.mode)
}
if (data.mode === 'explore') {
router.push({
path: '/explore',
query: { dep, budget: data.budget }
})
return
}
router.push({
path: '/results',
query: {
mode: data.mode,
dep,
dest,
from: data.dateFrom,
to: data.dateTo,
smin: data.stayMinDays,
smax: data.stayMaxDays,
adults: data.passengers.adult,
children: data.passengers.child,
infants: data.passengers.infant,
maxStops: data.maxStops ?? undefined,
budget: data.budget ?? undefined
}
})
}
</script>
<template>
<div>
<UPageHero title="Buscar vuelos" description="5 modos de busqueda para encontrar el vuelo perfecto" />
<UPageSection>
<UCard class="max-w-2xl mx-auto">
<SearchForm @search="onSearch" />
</UCard>
</UPageSection>
</div>
</template>

132
app/pages/settings.vue Normal file
View File

@@ -0,0 +1,132 @@
<script setup lang="ts">
const user = useSupabaseUser()
const { profile, loading, updateProfile, fetchProfile } = useUserPreferences()
const { showOriginTime } = useOriginTime()
useSeoMeta({ title: 'Vuelato - Preferencias' })
watch(user, (u) => {
if (!u) navigateTo('/auth')
}, { immediate: true })
const homeAirports = ref('')
const defaultAdults = ref(1)
const defaultChildren = ref(0)
const defaultInfants = ref(0)
const saving = ref(false)
const saved = ref(false)
watch(profile, (p) => {
if (!p) return
homeAirports.value = p.home_airports?.join(',') || ''
defaultAdults.value = p.default_adults ?? 1
defaultChildren.value = p.default_children ?? 0
defaultInfants.value = p.default_infants ?? 0
}, { immediate: true })
async function save() {
saving.value = true
saved.value = false
await updateProfile({
home_airports: homeAirports.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
default_adults: defaultAdults.value,
default_children: defaultChildren.value,
default_infants: defaultInfants.value,
})
saving.value = false
saved.value = true
setTimeout(() => { saved.value = false }, 2000)
}
</script>
<template>
<div v-if="user">
<UPageHero title="Preferencias" description="Configura tu experiencia en Vuelato" />
<UPageSection>
<div class="max-w-2xl mx-auto space-y-6">
<!-- Loading -->
<div v-if="loading" class="space-y-4">
<USkeleton class="h-12 w-full" />
<USkeleton class="h-12 w-full" />
<USkeleton class="h-12 w-full" />
</div>
<template v-else>
<!-- Busquedas -->
<UCard>
<template #header>
<h3 class="font-semibold">Busquedas</h3>
</template>
<div class="space-y-4">
<UFormField label="Aeropuertos de origen habituales">
<SearchAirportInput v-model="homeAirports" placeholder="MAD, BCN..." icon="i-lucide-plane-takeoff" multiple />
<template #hint>
<span class="text-xs text-muted">Se usaran como origen por defecto en las busquedas</span>
</template>
</UFormField>
<USeparator />
<p class="text-sm font-medium">Pasajeros por defecto</p>
<div class="grid grid-cols-3 gap-3">
<UFormField label="Adultos">
<UInput v-model.number="defaultAdults" type="number" :min="1" :max="9" class="w-full" />
</UFormField>
<UFormField label="Menores">
<UInput v-model.number="defaultChildren" type="number" :min="0" :max="9" class="w-full" />
</UFormField>
<UFormField label="Bebes">
<UInput v-model.number="defaultInfants" type="number" :min="0" :max="9" class="w-full" />
</UFormField>
</div>
</div>
<template #footer>
<div class="flex items-center gap-2">
<UButton label="Guardar" icon="i-lucide-save" size="sm" :loading="saving" @click="save" />
<Transition enter-active-class="transition-opacity" enter-from-class="opacity-0" leave-active-class="transition-opacity" leave-to-class="opacity-0">
<span v-if="saved" class="text-sm text-green-600">Guardado</span>
</Transition>
</div>
</template>
</UCard>
<!-- Visualizacion -->
<UCard>
<template #header>
<h3 class="font-semibold">Visualizacion</h3>
</template>
<div class="space-y-4">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Mostrar hora en origen</p>
<p class="text-xs text-muted">Muestra entre parentesis la hora equivalente en tu aeropuerto de salida</p>
</div>
<USwitch v-model="showOriginTime" />
</div>
</div>
</UCard>
<!-- Cuenta -->
<UCard>
<template #header>
<h3 class="font-semibold">Cuenta</h3>
</template>
<div class="space-y-2">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-medium">Email</p>
<p class="text-sm text-muted">{{ user.email }}</p>
</div>
</div>
</div>
</UCard>
</template>
</div>
</UPageSection>
</div>
</template>

159
app/pages/tracking/[id].vue Normal file
View File

@@ -0,0 +1,159 @@
<script setup lang="ts">
const user = useSupabaseUser()
const route = useRoute()
const router = useRouter()
const { trackedSearches, getHistory, getRuns, remove, fetchAll, update } = useTrackedSearches()
const id = route.params.id as string
useSeoMeta({ title: 'Vuelato - Detalle seguimiento' })
watch(user, (u) => {
if (!u) navigateTo('/auth')
}, { immediate: true })
const search = computed(() => trackedSearches.value.find(s => s.id === id))
const snapshots = ref<PriceSnapshot[]>([])
const runs = ref<SearchRun[]>([])
const loadingHistory = ref(true)
const loadError = ref(false)
const days = ref(30)
async function loadData() {
loadingHistory.value = true
loadError.value = false
try {
const [h, r] = await Promise.all([
getHistory(id, days.value),
getRuns(id, 20)
])
snapshots.value = h
runs.value = r
} catch {
loadError.value = true
} finally {
loadingHistory.value = false
}
}
watch(days, () => loadData())
onMounted(async () => {
await fetchAll()
loadData()
})
// Stats computadas
const stats = computed(() => {
if (snapshots.value.length === 0) return null
const prices = snapshots.value.map(s => s.cheapest_price)
const min = Math.min(...prices)
const max = Math.max(...prices)
const avg = Math.round(prices.reduce((s, p) => s + p, 0) / prices.length)
const current = prices[prices.length - 1] ?? 0
const previous = prices.length > 1 ? (prices[prices.length - 2] ?? current) : current
const trend = current - previous
return { current, min, max, avg, trend }
})
async function onRemove() {
await remove(id)
router.push('/tracking')
}
async function onConfigUpdated() {
await fetchAll()
loadData()
}
</script>
<template>
<div v-if="user">
<!-- Loading si aun no cargo la search -->
<div v-if="!search" class="max-w-3xl mx-auto py-12">
<USkeleton class="h-8 w-64 mb-4" />
<USkeleton class="h-72 w-full" />
</div>
<template v-else>
<UPageHero :title="search.name" :description="search.route_summary">
<template #actions>
<div class="flex gap-2">
<UButton label="Volver" icon="i-lucide-arrow-left" variant="outline" color="neutral" to="/tracking" />
<UButton label="Eliminar" icon="i-lucide-trash-2" variant="outline" color="error" @click="onRemove" />
</div>
</template>
</UPageHero>
<UPageSection>
<div class="max-w-4xl mx-auto space-y-6">
<!-- Stats -->
<div v-if="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
<UCard class="text-center">
<p class="text-xs text-muted mb-1">Precio actual</p>
<p class="text-xl font-bold">{{ stats.current?.toFixed(0) }}&euro;</p>
<p v-if="stats.trend !== 0" class="text-xs" :class="stats.trend < 0 ? 'text-green-600' : 'text-red-500'">
<UIcon :name="stats.trend < 0 ? 'i-lucide-trending-down' : 'i-lucide-trending-up'" class="text-xs" />
{{ stats.trend > 0 ? '+' : '' }}{{ stats.trend.toFixed(0) }}&euro;
</p>
</UCard>
<UCard class="text-center">
<p class="text-xs text-muted mb-1">Minimo historico</p>
<p class="text-xl font-bold text-green-600">{{ stats.min?.toFixed(0) }}&euro;</p>
</UCard>
<UCard class="text-center">
<p class="text-xs text-muted mb-1">Maximo historico</p>
<p class="text-xl font-bold text-red-500">{{ stats.max?.toFixed(0) }}&euro;</p>
</UCard>
<UCard class="text-center">
<p class="text-xs text-muted mb-1">Precio medio</p>
<p class="text-xl font-bold">{{ stats.avg?.toFixed(0) }}&euro;</p>
</UCard>
</div>
<!-- Chart -->
<UCard>
<template #header>
<div class="flex items-center justify-between">
<h3 class="font-semibold">Evolucion de precios</h3>
<div class="flex gap-1">
<UButton
v-for="d in [7, 14, 30, 60]"
:key="d"
:label="`${d}d`"
size="xs"
:variant="days === d ? 'solid' : 'ghost'"
:color="days === d ? 'primary' : 'neutral'"
@click="days = d"
/>
</div>
</div>
</template>
<div v-if="loadingHistory" class="flex justify-center py-12">
<USkeleton class="h-64 w-full" />
</div>
<div v-else-if="loadError" class="text-center py-8">
<p class="text-sm text-red-500">Error al cargar datos</p>
<UButton label="Reintentar" variant="outline" size="sm" class="mt-2" @click="loadData" />
</div>
<ClientOnly v-else>
<TrackingPriceChart :snapshots="snapshots" />
</ClientOnly>
</UCard>
<!-- Config + Runs side by side on desktop -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
<div class="lg:col-span-1">
<TrackingConfig :search="search" @updated="onConfigUpdated" />
</div>
<div class="lg:col-span-2">
<TrackingRunHistory :runs="runs" />
</div>
</div>
</div>
</UPageSection>
</template>
</div>
</template>

View File

@@ -0,0 +1,87 @@
<script setup lang="ts">
const user = useSupabaseUser()
const { trackedSearches, loading, update, remove, fetchAll } = useTrackedSearches()
const router = useRouter()
const showCreateForm = ref(false)
useSeoMeta({ title: 'Vuelato - Seguimiento de precios' })
watch(user, (u) => {
if (!u) navigateTo('/auth')
}, { immediate: true })
async function onToggle(id: string, active: boolean) {
await update(id, { is_active: active })
}
async function onRemove(id: string) {
await remove(id)
}
function onDetail(id: string) {
router.push(`/tracking/${id}`)
}
function onCreated() {
showCreateForm.value = false
fetchAll()
}
const activeCount = computed(() => trackedSearches.value.filter(s => s.is_active).length)
</script>
<template>
<div v-if="user">
<UPageHero title="Seguimiento de precios" description="Busquedas automaticas que monitorizan fluctuaciones de precio" />
<UPageSection>
<div class="max-w-3xl mx-auto space-y-4">
<!-- Actions -->
<div class="flex items-center justify-between">
<p class="text-sm text-muted">
{{ trackedSearches.length }} seguimiento{{ trackedSearches.length !== 1 ? 's' : '' }}
<span v-if="activeCount > 0"> ({{ activeCount }} activo{{ activeCount !== 1 ? 's' : '' }})</span>
</p>
<UButton
v-if="!showCreateForm"
label="Nuevo seguimiento"
icon="i-lucide-plus"
size="sm"
@click="showCreateForm = true"
/>
</div>
<!-- Create form -->
<TrackingCreateTrackingForm
v-if="showCreateForm"
@created="onCreated"
@cancel="showCreateForm = false"
/>
<!-- Loading -->
<div v-if="loading" class="space-y-3">
<USkeleton v-for="i in 3" :key="i" class="h-28 w-full" />
</div>
<!-- Empty -->
<div v-else-if="trackedSearches.length === 0 && !showCreateForm" class="text-center py-12">
<UIcon name="i-lucide-bell" class="text-4xl text-neutral-300 mb-2" />
<p class="text-neutral-500">No tienes busquedas en seguimiento</p>
<p class="text-sm text-neutral-400 mt-1">Crea una para monitorizar precios automaticamente</p>
<UButton label="Crear seguimiento" icon="i-lucide-plus" class="mt-3" @click="showCreateForm = true" />
</div>
<!-- Search cards -->
<TrackingTrackedSearchCard
v-for="search in trackedSearches"
:key="search.id"
:search="search"
@toggle="onToggle"
@remove="onRemove"
@detail="onDetail"
/>
</div>
</UPageSection>
</div>
</template>

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

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

129
docker-compose.yml Normal file
View File

@@ -0,0 +1,129 @@
services:
db:
image: supabase/postgres:15.8.1.085
ports:
- "54322:5432"
environment:
POSTGRES_HOST: /var/run/postgresql
PGPORT: 5432
POSTGRES_PORT: 5432
PGPASSWORD: postgres
POSTGRES_PASSWORD: postgres
PGDATABASE: postgres
POSTGRES_DB: postgres
JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long
JWT_EXP: 3600
volumes:
- supabase-db:/var/lib/postgresql/data
- ./supabase/volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro
- ./supabase/volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:ro
- ./supabase/migrations/00001_init.sql:/docker-entrypoint-initdb.d/migrations/99-vuelato-init.sql:ro
- ./supabase/migrations/00002_search_queue.sql:/docker-entrypoint-initdb.d/migrations/99-vuelato-search-queue.sql:ro
healthcheck:
test: pg_isready -U postgres -h localhost
interval: 5s
timeout: 5s
retries: 10
auth:
image: supabase/gotrue:v2.186.0
ports:
- "9999:9999"
environment:
GOTRUE_API_HOST: 0.0.0.0
GOTRUE_API_PORT: 9999
API_EXTERNAL_URL: http://localhost:8000
GOTRUE_DB_DRIVER: postgres
GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:postgres@db:5432/postgres
GOTRUE_SITE_URL: http://localhost:3000
GOTRUE_URI_ALLOW_LIST: ""
GOTRUE_DISABLE_SIGNUP: "false"
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
GOTRUE_JWT_EXP: 3600
GOTRUE_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long
GOTRUE_EXTERNAL_EMAIL_ENABLED: "true"
GOTRUE_MAILER_AUTOCONFIRM: "true"
GOTRUE_SMS_AUTOCONFIRM: "true"
GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false"
GOTRUE_EXTERNAL_PHONE_ENABLED: "false"
depends_on:
db:
condition: service_healthy
restart: unless-stopped
rest:
image: postgrest/postgrest:v14.8
ports:
- "3001:3000"
environment:
PGRST_DB_URI: postgres://authenticator:postgres@db:5432/postgres
PGRST_DB_SCHEMAS: public,storage,graphql_public
PGRST_DB_ANON_ROLE: anon
PGRST_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long
PGRST_DB_USE_LEGACY_GUCS: "false"
PGRST_APP_SETTINGS_JWT_SECRET: super-secret-jwt-token-with-at-least-32-characters-long
PGRST_APP_SETTINGS_JWT_EXP: 3600
PGRST_DB_EXTRA_SEARCH_PATH: public
depends_on:
db:
condition: service_healthy
restart: unless-stopped
kong:
image: kong/kong:3.9.1
ports:
- "8000:8000"
environment:
KONG_DATABASE: "off"
KONG_DECLARATIVE_CONFIG: /var/lib/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth
KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k
volumes:
- ./supabase/volumes/api/kong.yml:/var/lib/kong/kong.yml:ro
depends_on:
auth:
condition: service_started
rest:
condition: service_started
restart: unless-stopped
meta:
image: supabase/postgres-meta:v0.96.3
ports:
- "8085:8080"
environment:
PG_META_PORT: 8080
PG_META_DB_HOST: db
PG_META_DB_PORT: 5432
PG_META_DB_NAME: postgres
PG_META_DB_USER: supabase_admin
PG_META_DB_PASSWORD: postgres
depends_on:
db:
condition: service_healthy
studio:
image: supabase/studio:latest
ports:
- "3100:3000"
environment:
STUDIO_PG_META_URL: http://meta:8080
POSTGRES_PASSWORD: postgres
DEFAULT_ORGANIZATION_NAME: Vuelato
DEFAULT_PROJECT_NAME: Vuelato
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: http://localhost:8000
SUPABASE_ANON_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0
SUPABASE_SERVICE_KEY: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU
NEXT_PUBLIC_ENABLE_LOGS: "false"
NEXT_ANALYTICS_BACKEND_PROVIDER: postgres
depends_on:
meta:
condition: service_started
volumes:
supabase-db:

6
eslint.config.mjs Normal file
View File

@@ -0,0 +1,6 @@
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt(
// Your custom configs here
)

318
explore.mjs Normal file
View File

@@ -0,0 +1,318 @@
import { chromium } from "playwright";
import { writeFileSync, appendFileSync } from "fs";
const LOG_FILE = "flightics_explore.log";
writeFileSync(LOG_FILE, `=== Flightics Full Exploration - ${new Date().toISOString()} ===\n\n`);
function log(text) {
console.log(text);
appendFileSync(LOG_FILE, text + "\n");
}
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext({
userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
});
const page = await context.newPage();
const allRequests = new Map();
page.on("request", (req) => {
const url = req.url();
const method = req.method();
const rt = req.resourceType();
if ((rt === "xhr" || rt === "fetch") && url.includes("flightics.com")) {
const key = `${method} ${url.split("?")[0]}`;
if (!allRequests.has(key)) {
allRequests.set(key, { method, url, headers: req.headers(), postData: req.postData() });
log(`\n>>> ${method} ${url}`);
if (req.postData()) log(` BODY: ${req.postData()}`);
}
}
});
page.on("response", async (res) => {
const req = res.request();
const url = res.url();
if ((req.resourceType() === "xhr" || req.resourceType() === "fetch") && url.includes("flightics.com")) {
try {
const ct = res.headers()["content-type"] || "";
if (ct.includes("json")) {
const body = await res.json();
log(`<<< ${res.status()} ${url}`);
log(` RESPONSE: ${JSON.stringify(body, null, 2).substring(0, 3000)}`);
} else if (ct.includes("grpc")) {
log(`<<< ${res.status()} ${url} [grpc-web binary]`);
const buf = await res.body();
log(` RESPONSE SIZE: ${buf.length} bytes`);
}
} catch {}
}
});
log("=== PHASE 1: Homepage ===");
await page.goto("https://www.flightics.com/en");
await page.waitForTimeout(3000);
log("\n=== PHASE 2: Inspiration pages ===");
// Try different inspiration endpoints
const airports = ["MAD", "BCN", "AGP", "GRX", "SVQ", "VLC", "PMI", "TFS", "LPA"];
for (const apt of airports.slice(0, 3)) {
try {
await page.goto(`https://www.flightics.com/en/from/${apt.toLowerCase()}`);
await page.waitForTimeout(2000);
} catch {}
}
log("\n=== PHASE 3: Search with different params ===");
// One-way search
await page.goto("https://www.flightics.com/en/trip/1-0-0/20-05-2026/mad/nte/0-0");
await page.waitForTimeout(5000);
// Multi-city / complex
await page.goto("https://www.flightics.com/en/trip/2-0-0/01-06-2026_15-06-2026/mad/nte/3-7");
await page.waitForTimeout(5000);
// Explore URL patterns
await page.goto("https://www.flightics.com/en/trip/1-0-0/20-05-2026/bcn/cdg/0-0?maxStops=0");
await page.waitForTimeout(5000);
log("\n=== PHASE 4: Probing API endpoints ===");
// Try common API patterns
const probeEndpoints = [
{ method: "GET", path: "/api/v1" },
{ method: "GET", path: "/api/v1/airports" },
{ method: "GET", path: "/api/v1/airlines" },
{ method: "GET", path: "/api/v1/routes" },
{ method: "GET", path: "/api/v1/countries" },
{ method: "GET", path: "/api/v1/cities" },
{ method: "GET", path: "/api/v1/cheapest" },
{ method: "GET", path: "/api/v1/calendar" },
{ method: "GET", path: "/api/v1/prices" },
{ method: "GET", path: "/api/v1/inspirations" },
{ method: "GET", path: "/api/v1/inspirations/search?from=MAD&take=100&locale=en" },
{ method: "GET", path: "/api/v1/inspirations/search?from=BCN&take=100&locale=en" },
{ method: "GET", path: "/api/v1/inspirations/search?from=MAD,BCN,AGP,GRX,SVQ,VLC&take=100&locale=en" },
{ method: "GET", path: "/api/v2" },
{ method: "GET", path: "/api/v2/trips/search" },
{ method: "GET", path: "/api" },
{ method: "GET", path: "/_configuration" },
{ method: "GET", path: "/_blazor" },
{ method: "POST", path: "/api/v1/trips/search/calendar" },
{ method: "POST", path: "/api/v1/trips/cheapest" },
{ method: "POST", path: "/api/v1/airports/search" },
{ method: "POST", path: "/api/v1/routes/search" },
{ method: "GET", path: "/api/v1/config" },
{ method: "GET", path: "/api/v1/settings" },
{ method: "GET", path: "/api/v1/meta" },
{ method: "GET", path: "/api/v1/health" },
{ method: "GET", path: "/api/v1/version" },
{ method: "GET", path: "/api/v1/status" },
{ method: "GET", path: "/api/v1/sitemap" },
];
// Also probe gRPC services
const grpcServices = [
"trips.v1.TripService/SearchTrips",
"trips.v1.TripService/GetTripDetail",
"trips.v1.TripService/CheckTrip",
"locations.v1.LocationsService/GetLocationsV1",
"locations.v1.LocationsService/SearchLocations",
"inspirations.v1.InspirationService/GetInspirationsV1",
"inspirations.v1.InspirationService/SearchInspirations",
"airports.v1.AirportService/GetAirports",
"airports.v1.AirportService/SearchAirports",
"airlines.v1.AirlineService/GetAirlines",
"routes.v1.RouteService/GetRoutes",
"prices.v1.PriceService/GetPrices",
"calendar.v1.CalendarService/GetCalendar",
];
for (const ep of probeEndpoints) {
const url = `https://www.flightics.com${ep.path}`;
try {
const opts = { method: ep.method, headers: { "Content-Type": "application/json" } };
if (ep.method === "POST") opts.body = JSON.stringify({});
const res = await page.evaluate(async ({ url, opts }) => {
try {
const r = await fetch(url, opts);
const text = await r.text();
return { status: r.status, body: text.substring(0, 2000), contentType: r.headers.get("content-type") };
} catch (e) {
return { status: -1, body: e.message, contentType: null };
}
}, { url, opts });
if (res.status !== 404 && res.status !== -1 && res.status !== 405) {
log(`\n[PROBE] ${ep.method} ${ep.path} => ${res.status} (${res.contentType})`);
log(` ${res.body.substring(0, 1500)}`);
} else {
log(`[PROBE] ${ep.method} ${ep.path} => ${res.status}`);
}
} catch (e) {
log(`[PROBE] ${ep.method} ${ep.path} => ERROR: ${e.message}`);
}
}
for (const svc of grpcServices) {
const url = `https://www.flightics.com/${svc}`;
try {
const res = await page.evaluate(async ({ url }) => {
try {
const r = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/grpc-web",
"x-user-agent": "grpc-dotnet/2.76.0",
"grpc-accept-encoding": "identity,gzip,deflate",
},
body: new Uint8Array([0, 0, 0, 0, 0]),
});
return { status: r.status, headers: Object.fromEntries(r.headers.entries()) };
} catch (e) {
return { status: -1, error: e.message };
}
}, { url });
const grpcStatus = res.headers?.["grpc-status"];
if (res.status === 200 || grpcStatus !== undefined) {
log(`\n[gRPC] ${svc} => HTTP ${res.status}, grpc-status: ${grpcStatus || "N/A"}`);
log(` Headers: ${JSON.stringify(res.headers)}`);
} else {
log(`[gRPC] ${svc} => ${res.status}`);
}
} catch (e) {
log(`[gRPC] ${svc} => ERROR: ${e.message}`);
}
}
log("\n=== PHASE 5: Try search with POST body variations ===");
const searchVariations = [
// One-way
{
departures: ["MAD"],
local: "en",
departureDateInterval: { begin: "2026-05-20T00:00:00+00:00", end: "2026-05-20T00:00:00+00:00" },
stops: [{ locations: ["NTE"], stayRange: { min: 0, max: 0 }, stayDateRange: { begin: "0001-01-01T00:00:00", end: "0001-01-01T00:00:00" }, continueFromAny: false }],
endInSameLocation: false, maxStops: null, fixStopsOrder: false,
stopLength: { min: 0, max: 0, isSet: false }, maxResults: 10,
passengersCount: { adult: 1, child: 0, infant: 0 }
},
// Anywhere destination (empty locations)
{
departures: ["MAD"],
local: "en",
departureDateInterval: { begin: "2026-06-01T00:00:00+00:00", end: "2026-06-30T00:00:00+00:00" },
stops: [{ locations: [], stayRange: { min: 48, max: 168 }, stayDateRange: { begin: "0001-01-01T00:00:00", end: "0001-01-01T00:00:00" }, continueFromAny: true }, { locations: ["MAD"], stayRange: { min: 0, max: 0 }, stayDateRange: { begin: "0001-01-01T00:00:00", end: "0001-01-01T00:00:00" }, continueFromAny: false }],
endInSameLocation: false, maxStops: null, fixStopsOrder: false,
stopLength: { min: 0, max: 0, isSet: false }, maxResults: 10,
passengersCount: { adult: 1, child: 0, infant: 0 }
},
// Multi-stop (3 cities)
{
departures: ["MAD"],
local: "en",
departureDateInterval: { begin: "2026-06-01T00:00:00+00:00", end: "2026-06-30T00:00:00+00:00" },
stops: [
{ locations: ["PAR"], stayRange: { min: 48, max: 72 }, stayDateRange: { begin: "0001-01-01T00:00:00", end: "0001-01-01T00:00:00" }, continueFromAny: true },
{ locations: ["ROM"], stayRange: { min: 48, max: 72 }, stayDateRange: { begin: "0001-01-01T00:00:00", end: "0001-01-01T00:00:00" }, continueFromAny: true },
{ locations: ["MAD"], stayRange: { min: 0, max: 0 }, stayDateRange: { begin: "0001-01-01T00:00:00", end: "0001-01-01T00:00:00" }, continueFromAny: false },
],
endInSameLocation: false, maxStops: null, fixStopsOrder: false,
stopLength: { min: 0, max: 0, isSet: false }, maxResults: 10,
passengersCount: { adult: 1, child: 0, infant: 0 }
},
// Direct flights only
{
departures: ["BCN"],
local: "en",
departureDateInterval: { begin: "2026-06-01T00:00:00+00:00", end: "2026-06-15T00:00:00+00:00" },
stops: [{ locations: ["LHR"], stayRange: { min: 48, max: 120 }, stayDateRange: { begin: "0001-01-01T00:00:00", end: "0001-01-01T00:00:00" }, continueFromAny: true }, { locations: ["BCN"], 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: 10,
passengersCount: { adult: 1, child: 0, infant: 0 }
},
];
for (let i = 0; i < searchVariations.length; i++) {
const variant = searchVariations[i];
log(`\n--- Search variation ${i + 1} ---`);
try {
const res = await page.evaluate(async ({ body }) => {
const r = await fetch("https://www.flightics.com/api/v1/trips/search", {
method: "POST",
headers: { "Content-Type": "application/json; charset=utf-8" },
body: JSON.stringify(body),
});
const data = await r.json();
return { status: r.status, tripCount: data.trips?.length || 0, sample: JSON.stringify(data).substring(0, 2000) };
}, { body: variant });
log(`[SEARCH] Status: ${res.status}, Trips: ${res.tripCount}`);
log(` Body sent: ${JSON.stringify(variant).substring(0, 500)}`);
if (res.tripCount > 0) {
log(` Sample: ${res.sample.substring(0, 1000)}`);
}
} catch (e) {
log(`[SEARCH] ERROR: ${e.message}`);
}
}
log("\n=== PHASE 6: Explore sitemap and other pages ===");
const pagesToVisit = [
"/en/about",
"/en/privacy",
"/en/terms",
"/en/faq",
"/en/contact",
"/en/blog",
"/en/destinations",
"/en/airlines",
"/en/airports",
"/en/cheap-flights",
"/en/map",
"/en/calendar",
"/en/explore",
"/en/anywhere",
"/sitemap.xml",
"/robots.txt",
];
for (const p of pagesToVisit) {
try {
const res = await page.evaluate(async ({ url }) => {
const r = await fetch(url);
return { status: r.status, contentType: r.headers.get("content-type"), size: (await r.text()).length };
}, { url: `https://www.flightics.com${p}` });
if (res.status !== 404) {
log(`[PAGE] ${p} => ${res.status} (${res.contentType}, ${res.size} bytes)`);
}
} catch {}
}
// Try to get the blazor boot manifest to discover API surface
log("\n=== PHASE 7: Blazor manifest ===");
try {
const res = await page.evaluate(async () => {
const r = await fetch("https://www.flightics.com/_framework/blazor.boot.json");
if (r.ok) return await r.text();
return `Status: ${r.status}`;
});
if (res.length < 5000) {
log(`[BLAZOR] boot.json: ${res}`);
} else {
log(`[BLAZOR] boot.json: ${res.length} bytes (too large, checking for API hints...)`);
// Extract interesting parts
const matches = res.match(/[A-Za-z]+\.(Api|Service|Grpc|Client)[A-Za-z.]*/g);
if (matches) {
log(` API-related assemblies: ${[...new Set(matches)].join(", ")}`);
}
}
} catch (e) {
log(`[BLAZOR] ERROR: ${e.message}`);
}
log("\n\n=== SUMMARY OF UNIQUE ENDPOINTS ===");
for (const [key, val] of allRequests) {
log(` ${key}`);
}
log("\n=== DONE ===");
await browser.close();

29
nuxt.config.ts Normal file
View File

@@ -0,0 +1,29 @@
export default defineNuxtConfig({
modules: [
'@nuxt/eslint',
'@nuxt/ui',
'@nuxtjs/supabase'
],
supabase: {
redirectOptions: {
login: '/auth',
callback: '/auth/confirm',
exclude: ['/', '/search', '/results', '/explore', '/route/*', '/multi-city', '/detail/*', '/tracking', '/tracking/*'],
}
},
devtools: {
enabled: false
},
css: ['~/assets/css/main.css'],
routeRules: {},
compatibilityDate: '2025-01-15',
future: {
compatibilityVersion: 4
}
})

16789
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "vuelato",
"private": true,
"type": "module",
"scripts": {
"build": "nuxt build",
"dev": "nuxt dev",
"start": "docker compose up -d && nuxt dev",
"preview": "nuxt preview",
"postinstall": "nuxt prepare",
"lint": "eslint .",
"typecheck": "nuxt typecheck",
"supabase:up": "docker compose up -d",
"supabase:down": "docker compose down",
"supabase:reset": "docker compose down -v && docker compose up -d"
},
"dependencies": {
"@iconify-json/lucide": "^1.2.102",
"@iconify-json/simple-icons": "^1.2.77",
"@nuxt/ui": "^4.6.1",
"@nuxtjs/supabase": "^2.0.5",
"@nuxtjs/tailwindcss": "^6.14.0",
"@supabase/supabase-js": "^2.103.0",
"@vue-leaflet/vue-leaflet": "^0.10.1",
"chart.js": "^4.5.1",
"leaflet": "^1.9.4",
"nuxt": "^4.4.2",
"playwright": "^1.59.1",
"protobufjs": "^8.0.1",
"tailwindcss": "^4.2.2",
"vue-chartjs": "^5.3.3"
},
"devDependencies": {
"@nuxt/eslint": "^1.15.2",
"@types/node": "^25.6.0",
"eslint": "^10.2.0",
"typescript": "^6.0.2",
"vue-tsc": "^3.2.6"
},
"packageManager": "pnpm@10.33.0"
}

View File

@@ -0,0 +1,130 @@
# Plan: Sistema de cola de busquedas y seguimiento de precios
## Contexto
Vuelato tiene watchlist manual (check precio uno a uno) y busquedas recientes, pero no hay:
- Busquedas automaticas en segundo plano
- Historico de precios con graficos
- Cache de resultados entre usuarios
El usuario quiere un sistema completo: definir busquedas recurrentes desde la UI, ver fluctuaciones de precio en tablas/graficos, y ademas una capa de cache que evite llamadas duplicadas a Flightics (TTL 1h) reutilizable tanto por el worker como por busquedas manuales.
---
## 1. Esquema de base de datos
**Archivo: `supabase/migrations/00002_search_queue.sql`**
### Tabla `search_cache` — Cache de resultados (publica, sin RLS)
- `id` SERIAL PRIMARY KEY
- `params_hash` TEXT UNIQUE — SHA-256 del JSON de SearchParams normalizado
- `search_params` JSONB — el payload completo
- `trips` JSONB — array completo de trips devueltos
- `cheapest_price` NUMERIC(10,2)
- `total_results` INTEGER
- `fetched_at` TIMESTAMPTZ DEFAULT now()
### Tabla `tracked_searches` — Busquedas recurrentes (RLS por user_id)
- `id` UUID PK DEFAULT gen_random_uuid()
- `user_id` UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE
- `name` TEXT NOT NULL
- `search_params` JSONB NOT NULL
- `route_summary` TEXT NOT NULL
- `interval_hours` INTEGER NOT NULL DEFAULT 24
- `is_active` BOOLEAN DEFAULT true
- `next_run_at` TIMESTAMPTZ DEFAULT now()
- `last_run_at` TIMESTAMPTZ
- `run_count` INTEGER DEFAULT 0
- `last_error` TEXT
- `expires_at` TIMESTAMPTZ
- `created_at` TIMESTAMPTZ DEFAULT now()
### Tabla `search_runs` — Log de ejecuciones (RLS via tracked_search_id)
- `id` UUID PK
- `tracked_search_id` UUID NOT NULL REFERENCES tracked_searches(id) ON DELETE CASCADE
- `status` TEXT DEFAULT 'pending'
- `cheapest_price` NUMERIC(10,2)
- `total_trips_found` INTEGER DEFAULT 0
- `top_trips` JSONB
- `from_cache` BOOLEAN DEFAULT false
- `error_message` TEXT
- `started_at` TIMESTAMPTZ
- `completed_at` TIMESTAMPTZ
- `created_at` TIMESTAMPTZ DEFAULT now()
### Tabla `price_snapshots` — Datos para graficos (RLS via tracked_search_id)
- `id` UUID PK
- `tracked_search_id` UUID NOT NULL REFERENCES tracked_searches(id) ON DELETE CASCADE
- `search_run_id` UUID NOT NULL REFERENCES search_runs(id) ON DELETE CASCADE
- `cheapest_price` NUMERIC(10,2) NOT NULL
- `avg_price` NUMERIC(10,2)
- `median_price` NUMERIC(10,2)
- `total_results` INTEGER DEFAULT 0
- `recorded_at` TIMESTAMPTZ DEFAULT now()
---
## 2. Cache de busquedas — Flujo integrado
### Hash de parametros (`server/utils/search-hash.ts`)
- Normaliza SearchParams (ordena keys, elimina `_poll`), genera SHA-256
- Compartido entre `/api/search` y el worker
### Endpoint `/api/search` modificado (cache-aware)
1. Calcular params_hash
2. Buscar en search_cache WHERE params_hash = hash AND fetched_at > now() - 1 hour
3. Si hay cache fresco → devolver trips del cache
4. Si no → llamar a Flightics, guardar en search_cache, devolver
### Worker usa el mismo cache
- Lee cache antes de llamar a Flightics
- Escribe al cache tras cada busqueda nueva
---
## 3. Worker (`server/plugins/search-worker.ts`)
- Nitro plugin con `setInterval(processQueue, 60_000)`
- Mutex para evitar solapamiento
- Procesa hasta 5 jobs por ciclo con 10s entre cada uno
- Usa `server/utils/supabase-admin.ts` (cliente Supabase con service_role sin H3 event)
---
## 4. API Endpoints (`server/api/tracking/`)
| Archivo | Metodo | Funcion |
|---------|--------|---------|
| `index.get.ts` | GET | Listar tracked_searches con ultimo snapshot |
| `index.post.ts` | POST | Crear tracked_search |
| `[id].patch.ts` | PATCH | Editar configuracion |
| `[id].delete.ts` | DELETE | Eliminar + cascade |
| `[id]/history.get.ts` | GET | Price snapshots para graficos |
| `[id]/runs.get.ts` | GET | Log de ejecuciones |
---
## 5. Composable (`app/composables/useTrackedSearches.ts`)
Patron identico a useWatchlist.ts con watch(user) para auto-load/cleanup.
---
## 6. UI: vue-chartjs + chart.js
## 7. Componentes: tracking/PriceChart, TrackedSearchCard, CreateTrackingForm, RunHistory, TrackingConfig
## 8. Paginas: /tracking (dashboard) + /tracking/[id] (detalle con grafico)
## 9. Integracion: boton en /results, icono en recientes, link en nav
---
## Orden de implementacion
### Fase 1: DB + Cache
### Fase 2: Worker
### Fase 3: API tracking
### Fase 4: Composable + UI basica
### Fase 5: Graficos y detalle
### Fase 6: Integracion

393
plans/vuelato-plan.md Normal file
View File

@@ -0,0 +1,393 @@
# Vuelato - Frontend + Backend Supabase
## Contexto
Tenemos un buscador de vuelos con un cliente API completo de Flightics (9 endpoints REST funcionando). El frontend actual tiene 3 paginas basicas. Queremos:
1. Un backend Supabase/PostgreSQL que wrapee Flightics y persista datos
2. Un frontend ambicioso que explote toda la API
## Por que Supabase
- **Persistencia**: watchlists, preferencias, busquedas recientes -> entre dispositivos
- **Cache inteligente**: airports, countries, inspirations en Postgres (no llamar a Flightics cada vez)
- **Auth**: usuarios con sus datos
- **Cron/Edge Functions**: verificar precios de watchlist automaticamente
- **Realtime**: notificar bajadas de precio
- **Row Level Security**: cada usuario ve solo sus datos
---
## Stack
- **Nuxt 4** + **@nuxt/ui v4** + **Tailwind CSS v4** (ya instalado)
- **@nuxtjs/supabase v2** - modulo oficial, composables auto-importados
- **Supabase PostgreSQL** - tablas, RLS, funciones
- **Leaflet** - mapa explorador
- Server routes Nitro wrapeando Flightics -> Supabase cache
---
## Esquema de base de datos (Supabase/PostgreSQL)
### Tablas de cache (datos de Flightics, publicas, sin RLS)
```sql
-- Aeropuertos, ciudades, paises, regiones (de getLocations + getCountries)
-- Se sincroniza via cron o al primer request, TTL 24h
CREATE TABLE airports (
iata TEXT PRIMARY KEY,
icao TEXT,
name TEXT NOT NULL,
lat DOUBLE PRECISION,
lon DOUBLE PRECISION,
city_id TEXT,
city_name TEXT,
country_code TEXT,
country_name TEXT,
region_slug TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE countries (
iso_code2 TEXT PRIMARY KEY,
iso_code3 TEXT,
name_eng TEXT,
name_native TEXT,
phone_prefix TEXT,
updated_at TIMESTAMPTZ DEFAULT now()
);
CREATE TABLE regions (
slug TEXT PRIMARY KEY,
name TEXT,
name_localized TEXT,
airport_codes TEXT[], -- array de IATA codes
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Cache de inspiraciones (por aeropuerto origen, TTL 1h)
CREATE TABLE inspirations_cache (
id SERIAL PRIMARY KEY,
from_airport TEXT NOT NULL,
to_airports TEXT[] NOT NULL,
min_price NUMERIC(10,2),
min_stops INTEGER,
fetched_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(from_airport, to_airports)
);
-- Cache de multi-city inspirations (TTL 1h)
CREATE TABLE multi_city_cache (
id SERIAL PRIMARY KEY,
origin_codes TEXT[] NOT NULL,
from_airport TEXT,
stops TEXT[] NOT NULL,
min_price NUMERIC(10,2),
fetched_at TIMESTAMPTZ DEFAULT now()
);
```
### Tablas de usuario (con RLS)
```sql
-- Perfiles de usuario (extends auth.users)
CREATE TABLE profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
home_airports TEXT[] DEFAULT '{}',
default_adults INTEGER DEFAULT 1,
default_children INTEGER DEFAULT 0,
default_infants INTEGER DEFAULT 0,
locale TEXT DEFAULT 'es',
created_at TIMESTAMPTZ DEFAULT now()
);
-- Watchlist: vuelos guardados para monitorizar precio
CREATE TABLE watchlist (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
booking_token TEXT NOT NULL,
route_summary TEXT NOT NULL, -- "MAD > NTE > MAD"
departure_code TEXT,
arrival_code TEXT,
departure_date TEXT,
original_price NUMERIC(10,2) NOT NULL,
current_price NUMERIC(10,2),
price_status TEXT DEFAULT 'saved', -- saved, available, price_up, price_down, unavailable
passengers_adult INTEGER DEFAULT 1,
passengers_child INTEGER DEFAULT 0,
passengers_infant INTEGER DEFAULT 0,
last_checked_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- Busquedas recientes
CREATE TABLE recent_searches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
search_params JSONB NOT NULL, -- el payload completo de busqueda
route_summary TEXT, -- "AGP,GRX > NTE"
search_mode TEXT DEFAULT 'roundtrip',
created_at TIMESTAMPTZ DEFAULT now()
);
-- Alertas de precio (futuro: cron verifica y notifica)
CREATE TABLE price_alerts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
watchlist_id UUID REFERENCES watchlist(id) ON DELETE CASCADE,
target_price NUMERIC(10,2), -- notificar si baja de este precio
is_active BOOLEAN DEFAULT true,
last_notified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
-- RLS policies
ALTER TABLE profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE watchlist ENABLE ROW LEVEL SECURITY;
ALTER TABLE recent_searches ENABLE ROW LEVEL SECURITY;
ALTER TABLE price_alerts ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users see own profile" ON profiles FOR ALL USING (auth.uid() = id);
CREATE POLICY "Users see own watchlist" ON watchlist FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users see own searches" ON recent_searches FOR ALL USING (auth.uid() = user_id);
CREATE POLICY "Users see own alerts" ON price_alerts FOR ALL USING (auth.uid() = user_id);
```
---
## Arquitectura server routes (Nitro)
Los server routes actuan como **proxy inteligente**: Flightics API -> cache en Supabase -> respuesta al frontend.
### Rutas existentes (refactored con cache)
| Ruta | Logica |
|---|---|
| `GET /api/locations` | Lee de tabla `airports`. Si vacia o >24h, llama a Flightics `getLocations()`, parsea y guarda. |
| `GET /api/countries` | Lee de tabla `countries`. Si vacia o >24h, sincroniza. |
| `GET /api/inspirations` | Lee de `inspirations_cache` si <1h. Si no, llama a Flightics, guarda en cache, retorna. |
| `POST /api/search` | Proxy directo a Flightics `searchTrips`. Sin cache (resultados son efimeros). |
| `POST /api/detail` | Proxy directo a Flightics. |
| `POST /api/check` | Proxy directo a Flightics. |
| `POST /api/weekend-search` | Proxy directo. |
| `POST /api/route-flights` | Proxy directo (datos cambian frecuentemente). |
| `POST /api/multi-city-inspirations` | Cache 1h en `multi_city_cache`, sino Flightics. |
### Rutas nuevas (usuario/Supabase)
| Ruta | Logica |
|---|---|
| `GET /api/user/profile` | Lee profile del usuario autenticado |
| `PUT /api/user/profile` | Actualiza home_airports, passengers, locale |
| `GET /api/user/watchlist` | Lista watchlist del usuario |
| `POST /api/user/watchlist` | Anadir vuelo a watchlist |
| `DELETE /api/user/watchlist/[id]` | Eliminar de watchlist |
| `POST /api/user/watchlist/[id]/check` | Verificar precio: llama `checkTrip`, actualiza en DB |
| `POST /api/user/watchlist/check-all` | Verificar todos los precios del usuario |
| `GET /api/user/recent-searches` | Ultimas 10 busquedas |
| `POST /api/user/recent-searches` | Guardar busqueda reciente |
| `POST /api/sync/locations` | Fuerza sync de airports/countries/regions desde Flightics a Supabase |
---
## Paginas (9)
### `/` - Home (discovery hub)
- Hero con SearchBar compacto inline
- Tabs rapidos: Ida y vuelta | Solo ida | Multi-ciudad | Finde | Explorar
- Inspiraciones desde home airports del usuario (profile o localStorage si no logueado)
- Carrusel multi-city inspirations
- Widget "Donde por X€?" con slider presupuesto
- Busquedas recientes del usuario (si logueado)
### `/auth` - Login/Register
- Login con email/password (Supabase Auth)
- OAuth con Google
- Registro
- Redirect post-login
### `/search` - Busqueda completa
- 5 modos: standard, solo ida, multi-city, finde, explorar
- AirportInput con autocomplete (datos de Supabase `airports` table)
- Al buscar, guarda en `recent_searches` si logueado
### `/results` - Resultados
- Sort: precio, duracion, salida, escalas
- Filtros: precio max, escalas, hora, aerolineas
- Polling progresivo
- Vista lista + compacta
- Estrella watchlist (requiere login, o prompt to login)
### `/detail/[token]` - Detalle
- Timeline itinerario
- Verificar precio
- Vuelos relacionados (getRouteFlights)
- Compartir
- Watchlist toggle
- CTA booking
### `/explore` - Mapa explorador
- Mapa Leaflet con aeropuertos de tabla `airports` (lat/lon)
- Seleccionar origen -> inspiraciones como arcos
- Slider presupuesto
- Click destino -> popup
- Toggle directos
### `/route/[from]-[to]` - Explorador de ruta
- getRouteFlights sin fechas
- Todas las opciones de vuelo
- Comparativa aerolineas
### `/multi-city` - Inspiracion multi-ciudad
- Input origenes -> getMultiCityInspirations
- Tarjetas de itinerarios
- Click -> busqueda multi-city
### `/watchlist` - Lista seguimiento (requiere auth)
- Tabla desde Supabase `watchlist`
- Verificar precios individual / bulk
- Badges de estado: precio bajo (verde), subio (rojo), no disponible (gris)
- Alertas de precio (target price)
- Busquedas recientes
---
## Componentes (mismos del plan anterior + nuevos auth)
### Auth
| Componente | Proposito |
|---|---|
| `auth/LoginForm.vue` | Email/password + Google OAuth |
| `auth/UserMenu.vue` | Avatar + dropdown: perfil, watchlist, logout. O boton login si no auth. |
| `auth/AuthGuard.vue` | Wrapper que redirige a /auth si no logueado |
### Busqueda (sin cambios del plan anterior)
`search/SearchBar`, `search/AirportInput`, `search/SearchModeTabs`, `search/StopsConfigurator`, `search/MaxStopsFilter`, `search/BudgetSlider`, `search/DateRangePicker`, `search/StayDurationPicker`, `search/PassengerPicker` (en popover)
### Resultados
`results/TripCard`, `results/TripCardCompact`, `results/ResultsToolbar`, `results/ResultsFilters`, `results/LoadingMore`
### Detalle
`detail/ItineraryTimeline`, `detail/SegmentCard`, `detail/PriceVerifier`, `detail/RelatedFlights`, `detail/ShareButton`
### Mapa
`map/FlightMap`, `map/MapControls`, `map/MapDestinationPopup`
### Inspiracion
`inspiration/InspirationGrid`, `inspiration/MultiCityCard`, `inspiration/BudgetExplorer`
### Watchlist
`watchlist/WatchlistItem`, `watchlist/PriceAlertSetter`
---
## Composables
### Existentes (refactored)
- **`useFlightSearch`** - search con polling, sort, filter, weekend
- **`useLocations`** - ahora lee de Supabase (tabla airports), con fallback a API
- **`useInspirations`** - lee de cache Supabase, refresh si stale
### Nuevos
- **`useAuth`** - wrapper de `useSupabaseUser()` + `useSupabaseClient()`. Login, logout, register, profile CRUD.
- **`useWatchlist`** - CRUD contra Supabase `watchlist` table. `add`, `remove`, `checkPrice`, `checkAll`, `isWatched`.
- **`useRecentSearches`** - ultimas busquedas del usuario desde Supabase
- **`useUserPreferences`** - lee/escribe `profiles` table. Home airports, default passengers.
- **`useRouteFlights`** - fetch route flights
- **`useResultFilters`** - sort/filter computeds client-side
---
## Dependencias a instalar
```
pnpm add @nuxtjs/supabase leaflet @vue-leaflet/vue-leaflet
```
---
## Orden de implementacion
### Fase 0: Setup Supabase
1. Crear proyecto Supabase (o usar existente)
2. `pnpm add @nuxtjs/supabase`
3. Configurar modulo en nuxt.config.ts + env vars
4. Ejecutar SQL para crear tablas + RLS policies
5. Crear server route `POST /api/sync/locations` para poblar airports/countries/regions
### Fase 1: Auth + Infraestructura
6. `auth/LoginForm.vue` + `/auth` page
7. `auth/UserMenu.vue` en app.vue header
8. `useAuth` composable
9. `useUserPreferences` composable (lee/escribe profiles)
10. `useLocations` composable (lee de Supabase airports table)
11. `search/AirportInput.vue` con autocomplete
### Fase 2: Busqueda completa
12. `SearchModeTabs` + refactor SearchForm para 5 modos
13. `StopsConfigurator`, `MaxStopsFilter`, `BudgetSlider`, `DateRangePicker`, `StayDurationPicker`
14. `PassengerPicker` en popover
15. `/search` page
16. `useRecentSearches` + guardar busquedas en Supabase
### Fase 3: Resultados mejorados
17. `useResultFilters` composable
18. Refactor `useFlightSearch` con polling + sort/filter
19. `ResultsToolbar` + `ResultsFilters`
20. `TripCard` mejorado + `TripCardCompact`
21. `LoadingMore` polling indicator
### Fase 4: Detalle + Watchlist
22. `/detail/[token]` ruta dinamica
23. `ItineraryTimeline` + `SegmentCard`
24. `PriceVerifier`
25. `useWatchlist` composable (Supabase CRUD)
26. Watchlist toggle en TripCard y detail
27. `/watchlist` page con verificacion de precios
28. Server routes: `/api/user/watchlist/*`
### Fase 5: Discovery
29. `pnpm add leaflet @vue-leaflet/vue-leaflet`
30. `/explore` + `FlightMap` + controles
31. `/route/[from]-[to]` + `useRouteFlights`
32. `/multi-city` + `MultiCityCard`
33. `RelatedFlights` en detail page
### Fase 6: Home + Polish
34. Home refactored: SearchBar compacto, inspiraciones, multi-city carousel, BudgetExplorer, busquedas recientes
35. Navegacion actualizada en app.vue (Buscar, Explorar, Multi-city, Watchlist)
36. Cache de inspiraciones en Supabase
37. Responsive polish
---
## Configuracion Nuxt
```ts
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/eslint', '@nuxt/ui', '@nuxtjs/supabase'],
supabase: {
redirectOptions: {
login: '/auth',
callback: '/auth/confirm',
exclude: ['/', '/search', '/results', '/explore', '/route/*', '/multi-city'],
}
},
// ...
})
```
Las paginas publicas (home, search, results, explore, route, multi-city) funcionan sin auth. Watchlist y profile requieren login.
---
## Verificacion
- `pnpm dev` compila sin errores
- Supabase: tablas creadas, sync de locations funciona
- Auth: registro, login, logout, profile update
- Home: inspiraciones, multi-city carousel, budget explorer, busquedas recientes
- `/search`: 5 modos, autocomplete de aeropuerto lee de Supabase
- `/results`: polling, sort/filter, watchlist star (pide login si no auth)
- `/detail/[token]`: timeline, verificar precio, related flights, compartir
- `/explore`: mapa con aeropuertos, arcos de precio, slider presupuesto
- `/route/MAD-NTE`: todos los vuelos sin fechas
- `/multi-city`: itinerarios sugeridos
- `/watchlist`: CRUD, verificar precios, badges estado
- Mobile responsive en todas las paginas

11571
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
pnpm-workspace.yaml Normal file
View File

@@ -0,0 +1,6 @@
ignoredBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- esbuild
- unrs-resolver
- vue-demi

23
proto/inspirations.proto Normal file
View File

@@ -0,0 +1,23 @@
syntax = "proto3";
package inspirations.v1;
service InspirationService {
rpc GetInspirationsV1 (InspirationsRequest) returns (InspirationsResponse);
}
message InspirationsRequest {
string locale = 1; // e.g. "en"
repeated string start_locations_codes = 2; // e.g. ["LEI", "GRX", "MLN"]
}
message InspirationsResponse {
bytes unknown1 = 1; // empty in observed responses
repeated InspirationItem items = 2;
}
message InspirationItem {
string from = 1; // departure airport IATA e.g. "LEI"
repeated string stops = 2; // destination airports e.g. ["LGW", "DBV", "FCO"]
double min_price = 3; // e.g. 119.25
}

61
proto/locations.proto Normal file
View File

@@ -0,0 +1,61 @@
syntax = "proto3";
package locations.v1;
service LocationsService {
rpc GetLocationsV1 (LocationsRequest) returns (LocationsResponse);
}
message LocationsRequest {
string locale = 1; // e.g. "en"
}
message LocationsResponse {
// field 1-4: unknown/unused
repeated Airport airports = 5;
repeated City cities = 6;
repeated Country countries = 7;
repeated Region regions = 8;
}
message Uuid {
bytes value = 1; // 16-byte UUID
}
message Airport {
Uuid id = 1;
string iata = 2; // e.g. "MCO"
string icao = 3; // e.g. "KMCO"
string name = 4; // e.g. "Orlando International"
double lat = 5;
double lon = 6;
Uuid city_id = 7; // references City.id
}
message City {
Uuid id = 1;
string name = 2; // e.g. "New York"
// field 3: unknown
Uuid country_id = 4; // references Country.id
double lat = 5;
double lon = 6;
}
message Country {
Uuid id = 1;
string iso_code2 = 2; // e.g. "US"
string iso_code3 = 3; // e.g. "USA"
string name_eng = 4; // e.g. "United States"
string name_native = 5; // e.g. "United States"
string phone_prefix = 6; // e.g. "1"
}
message Region {
Uuid id = 1;
string slug = 2; // e.g. "azores"
string name = 3; // e.g. "Azores"
string name_localized = 4; // e.g. "Azores"
string slug_localized = 5; // e.g. "azores"
// field 6: unknown
repeated string airport_iata_codes = 7; // e.g. ["TER", "PDL", "SMA"]
}

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

13
renovate.json Normal file
View File

@@ -0,0 +1,13 @@
{
"extends": [
"github>nuxt/renovate-config-nuxt"
],
"lockFileMaintenance": {
"enabled": true
},
"packageRules": [{
"matchDepTypes": ["resolutions"],
"enabled": false
}],
"postUpdateOptions": ["pnpmDedupe"]
}

View File

@@ -0,0 +1,520 @@
/**
* Descubre URLs de reserva de aerolineas usando Playwright.
*
* Estrategia por orden de prioridad:
* 1. Buscar links en el HTML con codigos IATA → extraer template de URL
* 2. Interceptar pushState/replaceState al interactuar con formulario
* 3. Capturar requests de red con parametros de busqueda
* 4. Fallback: guardar la booking page URL sin template
*
* Ejecutar: npx tsx scripts/discover-booking-urls.ts --offset 0 --limit 100
* Test: npx tsx scripts/discover-booking-urls.ts --iata KL,BA,FR
* 2nd pass: npx tsx scripts/discover-booking-urls.ts --retry-failed --offset 0 --limit 1020
*/
import { chromium, type Browser, type Page } from 'playwright'
import { createClient } from '@supabase/supabase-js'
import { parseArgs } from 'node:util'
// --- Config ---
const SUPABASE_URL = process.env.SUPABASE_URL || 'http://localhost:8000'
const SUPABASE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY || ''
const SITE_TIMEOUT = 20_000
const NAV_TIMEOUT = 15_000
// Well-known IATA airport codes used to detect search URL patterns in links
// If any of these appear in an href, the link likely reveals the search URL format
const KNOWN_IATA_CODES = new Set([
'AMS', 'LHR', 'CDG', 'FRA', 'MAD', 'BCN', 'FCO', 'MXP', 'IST', 'ATH',
'JFK', 'LAX', 'MIA', 'SFO', 'ORD', 'BOS', 'ATL', 'DFW', 'SEA', 'DEN',
'NRT', 'HND', 'ICN', 'PEK', 'PVG', 'HKG', 'SIN', 'BKK', 'DEL', 'BOM',
'DXB', 'DOH', 'CAI', 'JNB', 'NBO', 'ADD', 'CMN', 'ALG', 'LOS', 'ACC',
'GRU', 'EZE', 'BOG', 'LIM', 'SCL', 'MEX', 'CUN', 'PTY', 'SJO', 'HAV',
'SYD', 'MEL', 'AKL', 'NYC', 'LON', 'PAR', 'TYO', 'ROM', 'MIL',
])
// Booking-related href keywords
const BOOKING_HREF_PATTERNS = [
'book', 'booking', 'flight', 'search', 'reserv', 'vuelo',
'fly', 'ticket', 'trip', 'travel', 'buy', 'fare', 'offer'
]
const BOOKING_HREF_SELECTOR = BOOKING_HREF_PATTERNS
.map(p => `a[href*="${p}" i]`)
.join(', ')
// Selectors for search form inputs
const ORIGIN_SELECTORS = [
'input[name*="origin" i]', 'input[name*="from" i]', 'input[name*="departure" i]',
'input[name*="depart" i]', 'input[name*="salida" i]', 'input[name*="origen" i]',
'input[placeholder*="from" i]', 'input[placeholder*="origin" i]',
'input[placeholder*="departure" i]', 'input[placeholder*="desde" i]',
'input[placeholder*="origen" i]', 'input[placeholder*="salida" i]',
'input[aria-label*="from" i]', 'input[aria-label*="origin" i]',
'input[aria-label*="departure" i]', 'input[aria-label*="desde" i]',
'input[id*="origin" i]', 'input[id*="from" i]', 'input[id*="depart" i]',
]
const DEST_SELECTORS = [
'input[name*="destination" i]', 'input[name*="to" i]', 'input[name*="arrival" i]',
'input[name*="arriv" i]', 'input[name*="destino" i]', 'input[name*="llegada" i]',
'input[placeholder*="to" i]', 'input[placeholder*="destination" i]',
'input[placeholder*="arrival" i]', 'input[placeholder*="hacia" i]',
'input[placeholder*="destino" i]', 'input[placeholder*="llegada" i]',
'input[aria-label*="to" i]', 'input[aria-label*="destination" i]',
'input[aria-label*="arrival" i]', 'input[aria-label*="destino" i]',
'input[id*="destination" i]', 'input[id*="to" i]', 'input[id*="arriv" i]',
]
const SEARCH_BUTTON_SELECTORS = [
'button[type="submit"]',
'button:has-text("Search")', 'button:has-text("Buscar")',
'button:has-text("Book")', 'button:has-text("Find")',
'button:has-text("Reservar")', 'button:has-text("Buscar vuelos")',
'button:has-text("Search flights")', 'button:has-text("Find flights")',
'a:has-text("Search")', 'a:has-text("Buscar")',
'input[type="submit"]',
]
// --- Supabase ---
const supabase = createClient(SUPABASE_URL, SUPABASE_KEY)
interface Airline {
iata: string
name: string
website: string
}
interface DiscoveryResult {
iata: string
bookingUrl: string | null
bookingUrlTemplate: string | null
method?: string // how the template was discovered
error?: string
}
// --- Template extraction from links ---
/**
* Scan all links on the page for URLs containing IATA airport codes.
* These destination links reveal the search URL pattern.
* Returns a template with {origin} and {destination} placeholders.
*/
async function extractTemplateFromLinks(page: Page, baseUrl: string): Promise<{ template: string; bookingUrl: string } | null> {
try {
const links = await page.$$eval('a[href]', (els) => {
return els.map(el => ({
href: el.getAttribute('href') || '',
text: (el.textContent || '').trim().slice(0, 100),
}))
})
for (const link of links) {
const href = link.href
if (!href || href.length < 10 || href.length > 500) continue
// Must look like a FLIGHT search/booking URL
if (!href.match(/search|book|flight|reserv|offer|fare|vuelo|select/i)) continue
// Exclude non-flight links (cars, hotels, guides, insurance, etc.)
if (href.match(/car[s.]|hotel|guide|insurance|lounge|cargo|club|baggage|checkin|check-in|status|manage/i)) continue
// Find known IATA airport codes in the URL
const decoded = decodeURIComponent(href)
const threeLetterWords = [...decoded.matchAll(/\b([A-Z]{3})\b/g)].map(m => m[1])
const foundCodes = threeLetterWords.filter(c => KNOWN_IATA_CODES.has(c))
// Need at least one real airport code
if (foundCodes.length < 1) continue
let template = href
const resolvedUrl = resolveUrl(baseUrl, href)
const uniqueCodes = [...new Set(foundCodes)]
if (uniqueCodes.length >= 2) {
// Two codes: first = origin, second = destination
template = template.replace(new RegExp(`\\b${uniqueCodes[0]}\\b`), '{origin}')
template = template.replace(new RegExp(`\\b${uniqueCodes[1]}\\b`), '{destination}')
// Handle round-trip (origin repeated at end)
template = template.replace(new RegExp(`\\b${uniqueCodes[0]}\\b`), '{origin}')
} else {
// Single code — likely a destination-only link from the homepage
template = template.replace(new RegExp(`\\b${uniqueCodes[0]}\\b`, 'g'), '{destination}')
}
// Replace passenger counts in query params
template = template.replace(/(?<=[=:])1(?=[&:,\s]|$)/g, '{passengers}')
if (template.includes('{destination}') || template.includes('{origin}')) {
const resolvedTemplate = resolveUrl(baseUrl, template)
return {
template: resolvedTemplate,
bookingUrl: resolvedUrl,
}
}
}
} catch {
// DOM query failed
}
return null
}
// --- Helper functions ---
function resolveUrl(base: string, href: string): string {
try {
return new URL(href, base).toString()
} catch {
return href
}
}
async function findBookingLink(page: Page): Promise<string | null> {
try {
const links = await page.$$(BOOKING_HREF_SELECTOR)
for (const link of links) {
const href = await link.getAttribute('href')
const text = (await link.textContent())?.toLowerCase() || ''
const isVisible = await link.isVisible().catch(() => false)
if (!href || !isVisible) continue
if (href.includes('career') || href.includes('about') || href.includes('press') || href.includes('blog')) continue
if (text.match(/book|reserv|search|buscar|vuelo|flight|fly|ticket|buy/i) || href.match(/book|reserv|search|flight/i)) {
return href
}
}
if (links.length > 0) {
return await links[0].getAttribute('href')
}
} catch {}
return null
}
async function findAndFillInput(page: Page, selectors: string[], value: string): Promise<boolean> {
for (const sel of selectors) {
try {
const el = await page.$(sel)
if (el && await el.isVisible().catch(() => false)) {
await el.click()
await el.fill(value)
await page.waitForTimeout(500)
await el.press('Enter').catch(() => {})
return true
}
} catch { continue }
}
return false
}
async function clickSearchButton(page: Page): Promise<boolean> {
for (const sel of SEARCH_BUTTON_SELECTORS) {
try {
const btn = await page.$(sel)
if (btn && await btn.isVisible().catch(() => false)) {
await btn.click()
return true
}
} catch { continue }
}
return false
}
function buildTemplateFromUrl(url: string, origin: string, destination: string, dateIso: string): string {
let template = url
const dateCompact = dateIso.replace(/-/g, '')
const dateDMY = dateIso.split('-').reverse().join('/')
template = template.replaceAll(dateIso, '{date}')
template = template.replaceAll(dateDMY, '{date}')
template = template.replaceAll(dateCompact, '{date}')
template = template.replaceAll(encodeURIComponent(dateIso), '{date}')
template = template.replaceAll(encodeURIComponent(dateDMY), '{date}')
template = template.replace(new RegExp(origin, 'gi'), '{origin}')
template = template.replace(new RegExp(destination, 'gi'), '{destination}')
template = template.replace(/madrid/gi, '{origin}')
template = template.replace(/london/gi, '{destination}')
template = template.replace(/londres/gi, '{destination}')
template = template.replace(/heathrow/gi, '{destination}')
const urlParts = template.split('?')
if (urlParts[1]) {
urlParts[1] = urlParts[1].replace(/(?<=[=:])1(?=[&:,\s]|$)/g, '{passengers}')
template = urlParts.join('?')
}
return template
}
// --- Main discovery function ---
async function discoverAirline(browser: Browser, airline: Airline): Promise<DiscoveryResult> {
const result: DiscoveryResult = {
iata: airline.iata,
bookingUrl: null,
bookingUrlTemplate: null
}
let page: Page | null = null
try {
page = await browser.newPage({
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36',
viewport: { width: 1280, height: 720 },
locale: 'es-ES',
})
page.setDefaultTimeout(SITE_TIMEOUT)
// Mask webdriver + capture pushState/replaceState
await page.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => false })
;(window as any).__capturedUrls = []
const origPush = history.pushState.bind(history)
const origReplace = history.replaceState.bind(history)
history.pushState = (...args: any[]) => {
;(window as any).__capturedUrls.push(args[2])
return origPush(...args)
}
history.replaceState = (...args: any[]) => {
;(window as any).__capturedUrls.push(args[2])
return origReplace(...args)
}
})
// Capture search-related network requests
const searchRequests: string[] = []
page.on('request', req => {
const url = req.url()
if (req.resourceType() === 'xhr' || req.resourceType() === 'fetch') {
if (url.match(/search|book|flight|offer|avail|fare/i)) {
searchRequests.push(url)
}
}
})
// Step 1: Navigate to airline website
await page.goto(airline.website, { waitUntil: 'domcontentloaded', timeout: SITE_TIMEOUT })
await page.waitForTimeout(3000)
const startUrl = page.url()
// ========================================
// Strategy 1: Extract template from links
// ========================================
const linkTemplate = await extractTemplateFromLinks(page, startUrl)
if (linkTemplate) {
result.bookingUrlTemplate = linkTemplate.template
result.bookingUrl = linkTemplate.bookingUrl
result.method = 'link-template'
return result
}
// ========================================
// Strategy 2: Find booking page + try form interaction
// ========================================
const bookingHref = await findBookingLink(page)
if (bookingHref) {
const bookingUrl = resolveUrl(startUrl, bookingHref)
result.bookingUrl = bookingUrl
try {
await page.goto(bookingUrl, { waitUntil: 'domcontentloaded', timeout: NAV_TIMEOUT })
await page.waitForTimeout(2000)
} catch {
// Navigation failed, but we have the URL
}
// Check links on booking page too
const bookingPageTemplate = await extractTemplateFromLinks(page, page.url())
if (bookingPageTemplate) {
result.bookingUrlTemplate = bookingPageTemplate.template
result.bookingUrl = bookingPageTemplate.bookingUrl
result.method = 'booking-page-link-template'
return result
}
} else {
result.bookingUrl = startUrl
}
// ========================================
// Strategy 3: Fill form + capture URL change / pushState
// ========================================
const testOrigin = 'MAD'
const testDest = 'LHR'
const testDate = (() => {
const d = new Date()
d.setDate(d.getDate() + 30)
return d.toISOString().slice(0, 10)
})()
const filledOrigin = await findAndFillInput(page, ORIGIN_SELECTORS, testOrigin)
const filledDest = await findAndFillInput(page, DEST_SELECTORS, testDest)
if (filledOrigin || filledDest) {
const urlBefore = page.url()
const clicked = await clickSearchButton(page)
if (clicked) {
// Wait for navigation or pushState
await page.waitForTimeout(5000)
// Check 3a: URL changed (traditional navigation)
const urlAfter = page.url()
if (urlAfter !== urlBefore) {
result.bookingUrlTemplate = buildTemplateFromUrl(urlAfter, testOrigin, testDest, testDate)
result.bookingUrl = urlAfter
result.method = 'form-url-change'
return result
}
// Check 3b: pushState/replaceState captured
const captured: string[] = await page.evaluate(() => (window as any).__capturedUrls || [])
const relevantCapture = captured.find(u =>
typeof u === 'string' && (u.includes(testOrigin) || u.includes(testDest) || u.match(/search|book|flight/i))
)
if (relevantCapture) {
const fullUrl = resolveUrl(page.url(), relevantCapture)
result.bookingUrlTemplate = buildTemplateFromUrl(fullUrl, testOrigin, testDest, testDate)
result.bookingUrl = fullUrl
result.method = 'pushstate'
return result
}
// Check 3c: Network requests with search params
const relevantRequest = searchRequests.find(u =>
u.includes(testOrigin) || u.includes(testDest)
)
if (relevantRequest) {
result.bookingUrlTemplate = buildTemplateFromUrl(relevantRequest, testOrigin, testDest, testDate)
result.bookingUrl = relevantRequest
result.method = 'network-request'
return result
}
}
}
} catch (err: any) {
result.error = err.message?.slice(0, 200)
} finally {
await page?.close().catch(() => {})
}
return result
}
// --- Main ---
async function main() {
const { values } = parseArgs({
options: {
offset: { type: 'string', default: '0' },
limit: { type: 'string', default: '100' },
iata: { type: 'string' },
'retry-failed': { type: 'boolean', default: false },
}
})
const offset = parseInt(values.offset!)
const limit = parseInt(values.limit!)
const iataCodes = values.iata?.split(',').map(s => s.trim().toUpperCase())
const retryFailed = values['retry-failed']
if (iataCodes) {
console.log(`[discover] Starting with specific airlines: ${iataCodes.join(', ')}`)
} else if (retryFailed) {
console.log(`[discover] Retrying airlines without template, offset=${offset} limit=${limit}`)
} else {
console.log(`[discover] Starting offset=${offset} limit=${limit}`)
}
// Fetch airlines from Supabase
let query = supabase
.from('airlines')
.select('iata, name, website')
.not('website', 'is', null)
.order('iata')
if (iataCodes) {
query = query.in('iata', iataCodes)
} else {
if (retryFailed) {
// Only process airlines that have no template yet
query = query.is('booking_url_template', null)
}
query = query.range(offset, offset + limit - 1)
}
const { data: airlines, error } = await query
if (error) {
console.error('[discover] Failed to fetch airlines:', error.message)
process.exit(1)
}
if (!airlines?.length) {
console.log('[discover] No airlines to process')
process.exit(0)
}
console.log(`[discover] Processing ${airlines.length} airlines`)
const browser = await chromium.launch({
headless: true,
args: [
'--disable-blink-features=AutomationControlled',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-http2',
]
})
let discovered = 0
let withTemplate = 0
const failed: string[] = []
for (let i = 0; i < airlines.length; i++) {
const airline = airlines[i] as Airline
const progress = `[${i + 1}/${airlines.length}]`
try {
const result = await discoverAirline(browser, airline)
if (result.bookingUrl || result.bookingUrlTemplate) {
const update: Record<string, any> = {
booking_url_discovered_at: new Date().toISOString()
}
if (result.bookingUrl) update.booking_url = result.bookingUrl
if (result.bookingUrlTemplate) update.booking_url_template = result.bookingUrlTemplate
await supabase
.from('airlines')
.update(update)
.eq('iata', airline.iata)
discovered++
if (result.bookingUrlTemplate) withTemplate++
const methodTag = result.method ? ` [${result.method}]` : ''
console.log(`${progress} ${airline.iata} (${airline.name}): OK${result.bookingUrlTemplate ? ' +template' : ''}${methodTag} -> ${result.bookingUrl?.slice(0, 120)}`)
} else {
failed.push(airline.iata)
console.log(`${progress} ${airline.iata} (${airline.name}): SKIP${result.error ? ` (${result.error.slice(0, 80)})` : ''}`)
}
} catch (err: any) {
failed.push(airline.iata)
console.log(`${progress} ${airline.iata} (${airline.name}): ERROR ${err.message?.slice(0, 80)}`)
}
}
await browser.close()
console.log(`\n[discover] Done: ${discovered} discovered, ${withTemplate} with template, ${failed.length} failed`)
if (failed.length > 0) console.log(`[discover] Failed: ${failed.join(', ')}`)
console.log(JSON.stringify({ discovered, withTemplate, failed: failed.length, failedCodes: failed }))
}
main().catch(err => {
console.error('[discover] Fatal:', err)
process.exit(1)
})

View File

@@ -0,0 +1,14 @@
import { serverSupabaseServiceRole } from '#supabase/server'
export default defineCachedEventHandler(async (event) => {
const supabase = serverSupabaseServiceRole(event)
const { data, error } = await supabase
.from('airlines')
.select('iata, icao, name, logo_url, website, booking_url, booking_url_template')
.order('name')
if (error) throw createError({ statusCode: 500, message: error.message })
return { airlines: data }
}, { maxAge: 60 * 60 * 24 }) // cache 24h

4
server/api/check.post.ts Normal file
View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { bookingToken, local, passengersCount } = await readBody(event)
return checkTrip(bookingToken, local, passengersCount)
})

View File

@@ -0,0 +1,3 @@
export default defineCachedEventHandler(async () => {
return getCountries()
}, { maxAge: 60 * 60 * 24 }) // cache 24h

View File

@@ -0,0 +1,64 @@
import { serverSupabaseServiceRole } from '#supabase/server'
export default defineEventHandler(async (event) => {
const { city } = getQuery(event)
if (!city || typeof city !== 'string') {
throw createError({ statusCode: 400, message: 'city is required' })
}
const cityKey = city.trim().toLowerCase()
const client = serverSupabaseServiceRole(event)
// Check cache first
const { data: cached } = await client
.from('destination_images')
.select('*')
.eq('city_name', cityKey)
.single()
if (cached) {
// Refresh if older than 30 days
const age = Date.now() - new Date(cached.cached_at).getTime()
if (age < 30 * 24 * 60 * 60 * 1000) {
return cached
}
}
// Fetch from Unsplash
const accessKey = process.env.UNSPLASH_ACCESS_KEY
if (!accessKey) {
throw createError({ statusCode: 500, message: 'UNSPLASH_ACCESS_KEY not configured' })
}
const result = await $fetch<{ results: { urls: { regular: string; small: string }; user: { name: string; links: { html: string } } }[] }>('https://api.unsplash.com/search/photos', {
query: {
query: `${city} city travel`,
per_page: 1,
orientation: 'landscape',
},
headers: {
Authorization: `Client-ID ${accessKey}`,
},
}).catch(() => null)
if (!result?.results?.length) {
return null
}
const photo = result.results[0]
const row = {
city_name: cityKey,
image_url: photo.urls.regular,
thumb_url: photo.urls.small,
photographer: photo.user.name,
photographer_url: photo.user.links.html,
cached_at: new Date().toISOString(),
}
// Upsert cache
await client
.from('destination_images')
.upsert(row, { onConflict: 'city_name' })
return row
})

View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { bookingToken, local, passengersCount } = await readBody(event)
return getTripDetail(bookingToken, local, passengersCount)
})

View File

@@ -0,0 +1,100 @@
export default defineCachedEventHandler(async (event) => {
const { flightno } = getQuery(event)
if (!flightno || typeof flightno !== 'string') {
throw createError({ statusCode: 400, message: 'flightno is required' })
}
const code = flightno.replace(/\s/g, '').toUpperCase()
const fr24Url = `https://www.flightradar24.com/data/flights/${code.toLowerCase()}`
// FR24 live feed
const data = await $fetch<any>(`https://data-live.flightradar24.com/zones/fcgi/feed.js`, {
query: { flightno: code, faa: 1, satellite: 1, mlat: 1, flarm: 1, adsb: 1, gnd: 1, air: 1, vehicles: 0, estimated: 1, gliders: 0 },
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36',
'Accept': 'application/json'
}
}).catch(() => null)
if (!data) return { found: false, fr24Url }
// Parse flight entries (skip metadata keys)
const flights = Object.entries(data)
.filter(([key]) => !['full_count', 'version', 'stats'].includes(key))
.map(([id, val]: [string, any]) => {
if (!Array.isArray(val) || val.length < 18) return null
return {
fr24Id: id,
icao24: val[0],
lat: val[1],
lon: val[2],
heading: val[3],
altitude: val[4],
speed: val[5],
squawk: val[6],
aircraft: val[8],
registration: val[9],
timestamp: val[10],
origin: val[11],
destination: val[12],
flightNumber: val[13],
onGround: val[14] === 1,
verticalSpeed: val[15],
callsign: val[16],
airline: val[18]
}
})
.filter((f): f is NonNullable<typeof f> => f != null && (f.lat !== 0 || f.lon !== 0 || f.aircraft != null))
if (flights.length === 0) return { found: false, fr24Url }
const flight = flights[0]
// Get detail for richer data
let detail: any = null
if (flight.fr24Id) {
detail = await $fetch<any>(`https://data-live.flightradar24.com/clickhandler/`, {
query: { version: '1.5', flight: flight.fr24Id },
headers: {
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
}
}).catch(() => null)
}
// Determine status
let status = 'Programado'
if (detail?.status?.text) {
status = detail.status.text
} else if (flight.altitude > 0 && !flight.onGround) {
status = 'En vuelo'
} else if (flight.onGround && flight.speed > 5) {
status = 'En tierra (taxiing)'
} else if (flight.onGround) {
status = 'En tierra'
}
return {
found: true,
fr24Url,
flight: {
flightNumber: flight.flightNumber || code,
callsign: flight.callsign,
aircraft: detail?.aircraft?.model?.text || flight.aircraft || null,
aircraftCode: flight.aircraft,
registration: flight.registration,
airline: detail?.airline?.name || flight.airline || null,
origin: flight.origin,
destination: flight.destination,
lat: flight.lat,
lon: flight.lon,
altitude: flight.altitude,
speed: flight.speed,
heading: flight.heading,
onGround: flight.onGround,
verticalSpeed: flight.verticalSpeed,
status,
departureDelay: detail?.time?.historical?.delay?.departure ?? null,
arrivalDelay: detail?.time?.historical?.delay?.arrival ?? null
}
}
}, { maxAge: 60 * 5 }) // Cache 5 min

View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { from, take, locale } = getQuery(event)
return getInspirations(from as string, Number(take) || 36, (locale as string) || 'en')
})

View File

@@ -0,0 +1,3 @@
export default defineCachedEventHandler(async () => {
return getLocations()
}, { maxAge: 60 * 60 * 24 }) // cache 24h

View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { startLocationsCodes, locale, take } = await readBody(event)
return getMultiCityInspirations(startLocationsCodes, locale, take)
})

16
server/api/profile.get.ts Normal file
View File

@@ -0,0 +1,16 @@
import { serverSupabaseClient } from '#supabase/server'
export default defineEventHandler(async (event) => {
const client = await serverSupabaseClient(event)
const { data: { user } } = await client.auth.getUser()
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
const { data, error } = await client
.from('profiles')
.select('*')
.eq('id', user.id)
.single()
if (error) throw createError({ statusCode: 500, message: error.message })
return data
})

View File

@@ -0,0 +1,28 @@
import { serverSupabaseClient } from '#supabase/server'
export default defineEventHandler(async (event) => {
const client = await serverSupabaseClient(event)
const { data: { user } } = await client.auth.getUser()
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
const body = await readBody(event)
const allowed = ['home_airports', 'default_adults', 'default_children', 'default_infants', 'locale', 'show_origin_time']
const patch: Record<string, unknown> = {}
for (const key of allowed) {
if (body[key] !== undefined) patch[key] = body[key]
}
if (Object.keys(patch).length === 0) {
throw createError({ statusCode: 400, message: 'Nada que actualizar' })
}
const { data, error } = await client
.from('profiles')
.update(patch)
.eq('id', user.id)
.select()
.single()
if (error) throw createError({ statusCode: 500, message: error.message })
return data
})

View File

@@ -0,0 +1,4 @@
export default defineEventHandler(async (event) => {
const { from, to, locale, passengersCount } = await readBody(event)
return getRouteFlights(from, to, locale, passengersCount)
})

52
server/api/search.post.ts Normal file
View File

@@ -0,0 +1,52 @@
import { serverSupabaseServiceRole } from '#supabase/server'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const poll = body._poll !== false
delete body._poll
const supabase = serverSupabaseServiceRole(event)
const paramsHash = computeSearchHash(body)
// Buscar en cache (TTL 1 hora)
const { data: cached } = await supabase
.from('search_cache')
.select('trips, cheapest_price, total_results, fetched_at')
.eq('params_hash', paramsHash)
.gte('fetched_at', new Date(Date.now() - 60 * 60 * 1000).toISOString())
.single()
if (cached) {
const trips = (cached.trips as any[]) || []
return {
trips,
notComplete: false,
contractVersion: 0,
responseId: `cache-${paramsHash.slice(0, 8)}`
}
}
// Sin cache — llamar a Flightics
const result = poll
? await searchTripsComplete(body, 3)
: await searchTrips(body)
// Guardar en cache (upsert por params_hash)
if (result.trips && result.trips.length > 0) {
const prices = result.trips.map(t => t.totalCost).filter(p => p > 0)
const cheapest = prices.length > 0 ? Math.min(...prices) : null
await supabase
.from('search_cache')
.upsert({
params_hash: paramsHash,
search_params: body,
trips: result.trips,
cheapest_price: cheapest,
total_results: result.trips.length,
fetched_at: new Date().toISOString()
}, { onConflict: 'params_hash' })
}
return result
})

View File

@@ -0,0 +1,25 @@
import { serverSupabaseServiceRole } from '#supabase/server'
export default defineEventHandler(async (event) => {
const supabase = serverSupabaseServiceRole(event)
const airlines = await getAirlinesFromWikidata()
const rows = airlines.map(a => ({
iata: a.iata,
icao: a.icao,
name: a.name,
logo_url: a.logoUrl,
website: a.website,
updated_at: new Date().toISOString()
}))
if (rows.length > 0) {
const { error } = await supabase
.from('airlines')
.upsert(rows as never, { onConflict: 'iata' })
if (error) throw createError({ statusCode: 500, message: error.message })
}
return { count: rows.length }
})

View File

@@ -0,0 +1,59 @@
import { serverSupabaseServiceRole } from '#supabase/server'
export default defineEventHandler(async (event) => {
const supabase = serverSupabaseServiceRole(event)
// Fetch from Flightics
const [locData, countryData] = await Promise.all([
getLocations(),
getCountries()
])
// Build lookup maps for city names and countries
const cityMap = new Map(locData.cities.map(c => [c.id, c]))
const locCountryMap = new Map(locData.countries.map(c => [c.id, c]))
// Upsert airports enriched with city and country names
const airports = locData.airports.map(a => {
const city = cityMap.get(a.cityId)
const country = city ? locCountryMap.get(city.countryId) : undefined
return {
iata: a.iata,
icao: a.icao,
name: a.nameEng,
lat: a.lat,
lon: a.lon,
city_id: a.cityId,
city_name: city?.nameEng || '',
country_code: country?.isoCode2 || '',
country_name: country?.nameEng || '',
updated_at: new Date().toISOString()
}
})
if (airports.length > 0) {
const { error: airportErr } = await supabase
.from('airports')
.upsert(airports as never, { onConflict: 'iata' })
if (airportErr) throw createError({ statusCode: 500, message: airportErr.message })
}
// Upsert countries from dedicated endpoint
const countries = countryData.countries.map(c => ({
iso_code2: c.isoCode2,
iso_code3: c.isoCode3,
name_eng: c.nameEng,
name_native: c.nameNative,
phone_prefix: c.phonePreselection,
updated_at: new Date().toISOString()
}))
if (countries.length > 0) {
const { error: countryErr } = await supabase
.from('countries')
.upsert(countries as never, { onConflict: 'iso_code2' })
if (countryErr) throw createError({ statusCode: 500, message: countryErr.message })
}
return { count: airports.length, countries: countries.length }
})

View File

@@ -0,0 +1,22 @@
import { serverSupabaseServiceRole, serverSupabaseClient } from '#supabase/server'
export default defineEventHandler(async (event) => {
const client = await serverSupabaseClient(event)
const { data: { user } } = await client.auth.getUser()
if (!user) throw createError({ statusCode: 401, message: 'No autenticado' })
const id = getRouterParam(event, 'id')
if (!id) throw createError({ statusCode: 400, message: 'ID requerido' })
const supabase = serverSupabaseServiceRole(event)
const { error } = await supabase
.from('tracked_searches')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (error) throw createError({ statusCode: 500, message: error.message })
return { ok: true }
})

Some files were not shown because too many files have changed in this diff Show More