Initial commit: Vuelato - buscador de vuelos
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Some checks failed
ci / ci (22, ubuntu-latest) (push) Has been cancelled
Nuxt 4 + Supabase + Flightics API. Incluye búsqueda de vuelos, inspiraciones, watchlist, tracking de precios y mapa interactivo. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
13
.editorconfig
Executable file
13
.editorconfig
Executable 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
8
.env.example
Normal 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
34
.github/workflows/ci.yml
vendored
Normal 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
30
.gitignore
vendored
Normal 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
93
CLAUDE.md
Normal 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
21
LICENSE
Normal 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
257
README.md
Normal 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
8
app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'blue',
|
||||||
|
neutral: 'slate'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
112
app/app.vue
Normal file
112
app/app.vue
Normal 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 © {{ 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
18
app/assets/css/main.css
Normal 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;
|
||||||
|
}
|
||||||
80
app/components/FlightLeg.vue
Normal file
80
app/components/FlightLeg.vue
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Leg } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
leg: Leg
|
||||||
|
index: number
|
||||||
|
originTzOffset?: number
|
||||||
|
showOriginTime?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { resolve } = useAirlineNames()
|
||||||
|
|
||||||
|
function formatTime(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function segDuration(dep: number, arr: number) {
|
||||||
|
const mins = Math.round((arr - dep) / 60)
|
||||||
|
const h = Math.floor(mins / 60)
|
||||||
|
const m = mins % 60
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert a UTC timestamp to a time string in the origin airport's timezone
|
||||||
|
function toOriginTime(utcTimestamp: number): string {
|
||||||
|
const localSeconds = utcTimestamp + (props.originTzOffset ?? 0)
|
||||||
|
const d = new Date(localSeconds * 1000)
|
||||||
|
return d.toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit', timeZone: 'UTC' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a segment's local timezone differs from the origin
|
||||||
|
function isDifferentTz(localTimestamp: number, utcTimestamp: number): boolean {
|
||||||
|
return (localTimestamp - utcTimestamp) !== (props.originTzOffset ?? 0)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center gap-4 py-2">
|
||||||
|
<UBadge :label="index === 0 ? 'Ida' : 'Vuelta'" :color="index === 0 ? 'primary' : 'info'" variant="subtle" size="sm" />
|
||||||
|
|
||||||
|
<div v-for="(seg, i) in leg.segments" :key="i" class="flex items-center gap-3 flex-1">
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-lg font-semibold">{{ formatTime(seg.departureDate) }}</p>
|
||||||
|
<p v-if="showOriginTime && isDifferentTz(seg.departureTimestamp, seg.departureUtcTimestamp)" class="text-[10px] text-primary-500">({{ toOriginTime(seg.departureUtcTimestamp) }})</p>
|
||||||
|
<p class="text-xs text-neutral-500">{{ seg.departureCode }}</p>
|
||||||
|
<p class="text-xs text-neutral-400">{{ formatDate(seg.departureDate) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 flex flex-col items-center">
|
||||||
|
<p class="text-xs text-neutral-500">
|
||||||
|
<span v-if="resolve(seg.company.code, seg.company.name)" class="font-medium">
|
||||||
|
{{ resolve(seg.company.code, seg.company.name) }} ·
|
||||||
|
</span>
|
||||||
|
<span>{{ seg.company.code }} {{ seg.number }}</span>
|
||||||
|
</p>
|
||||||
|
<div class="w-full relative flex items-center">
|
||||||
|
<div class="flex-1 border-t border-dashed border-neutral-300 dark:border-neutral-600" />
|
||||||
|
<div class="flex items-center gap-1 px-1.5">
|
||||||
|
<UIcon name="i-lucide-plane" class="text-neutral-400 text-xs" />
|
||||||
|
<span class="text-[10px] text-neutral-400 whitespace-nowrap">{{ segDuration(seg.departureUtcTimestamp, seg.arrivalUtcTimestamp) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 border-t border-dashed border-neutral-300 dark:border-neutral-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-lg font-semibold">{{ formatTime(seg.arrivalDate) }}</p>
|
||||||
|
<p v-if="showOriginTime && isDifferentTz(seg.arrivalTimestamp, seg.arrivalUtcTimestamp)" class="text-[10px] text-primary-500">({{ toOriginTime(seg.arrivalUtcTimestamp) }})</p>
|
||||||
|
<p class="text-xs text-neutral-500">{{ seg.arrivalCode }}</p>
|
||||||
|
<p class="text-xs text-neutral-400">{{ formatDate(seg.arrivalDate) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UIcon v-if="i < leg.segments.length - 1" name="i-lucide-arrow-right" class="text-neutral-300" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
62
app/components/InspirationGrid.vue
Normal file
62
app/components/InspirationGrid.vue
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { InspirationItem } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
const props = defineProps<{ items: InspirationItem[], from: string }>()
|
||||||
|
|
||||||
|
const { prefetch, getImage } = useDestinationImages()
|
||||||
|
const { airports, loadAirports } = useLocations()
|
||||||
|
|
||||||
|
onMounted(() => loadAirports())
|
||||||
|
|
||||||
|
function cityName(iata: string): string {
|
||||||
|
const a = airports.value.find(ap => ap.iata === iata)
|
||||||
|
return a?.city_name || iata
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => props.items, (items) => {
|
||||||
|
const cities = items.slice(0, 12)
|
||||||
|
.map(i => cityName(i.to[0]))
|
||||||
|
.filter(c => c.length > 2)
|
||||||
|
if (cities.length) prefetch(cities)
|
||||||
|
}, { immediate: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||||
|
<div
|
||||||
|
v-for="item in items"
|
||||||
|
:key="item.to[0]"
|
||||||
|
class="relative overflow-hidden rounded-lg cursor-pointer group h-36"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="getImage(cityName(item.to[0]))?.thumb_url"
|
||||||
|
:src="getImage(cityName(item.to[0]))!.thumb_url"
|
||||||
|
:alt="cityName(item.to[0])"
|
||||||
|
class="absolute inset-0 w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
>
|
||||||
|
<div v-else class="absolute inset-0 bg-gradient-to-br from-primary-500 to-primary-700" />
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent" />
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 p-3 text-white">
|
||||||
|
<p class="font-bold text-sm truncate">{{ cityName(item.to[0]) }}</p>
|
||||||
|
<div class="flex items-center justify-between mt-0.5">
|
||||||
|
<p class="text-xs text-white/80">
|
||||||
|
{{ item.minStops === 0 ? 'Directo' : `${item.minStops} escala(s)` }}
|
||||||
|
</p>
|
||||||
|
<p class="text-lg font-bold">
|
||||||
|
{{ item.minPrice.toFixed(0) }}<span class="text-xs">€</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
v-if="getImage(cityName(item.to[0]))?.photographer"
|
||||||
|
:href="getImage(cityName(item.to[0]))!.photographer_url + '?utm_source=vuelato&utm_medium=referral'"
|
||||||
|
class="absolute top-1 right-1 text-[9px] text-white/50 hover:text-white/80 transition-colors"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
{{ getImage(cityName(item.to[0]))!.photographer }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
33
app/components/PassengerPicker.vue
Normal file
33
app/components/PassengerPicker.vue
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: { adult: number; child: number; infant: number }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: { adult: number; child: number; infant: number }]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function update(key: 'adult' | 'child' | 'infant', delta: number) {
|
||||||
|
const val = { ...props.modelValue }
|
||||||
|
val[key] = Math.max(key === 'adult' ? 1 : 0, val[key] + delta)
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = computed(() => props.modelValue.adult + props.modelValue.child + props.modelValue.infant)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div v-for="type in (['adult', 'child', 'infant'] as const)" :key="type" class="flex items-center justify-between">
|
||||||
|
<span class="text-sm capitalize text-neutral-600 dark:text-neutral-400">
|
||||||
|
{{ type === 'adult' ? 'Adultos' : type === 'child' ? 'Ninos' : 'Bebes' }}
|
||||||
|
</span>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton size="xs" icon="i-lucide-minus" color="neutral" variant="outline" :disabled="type === 'adult' ? modelValue[type] <= 1 : modelValue[type] <= 0" @click="update(type, -1)" />
|
||||||
|
<span class="w-6 text-center text-sm font-medium">{{ modelValue[type] }}</span>
|
||||||
|
<UButton size="xs" icon="i-lucide-plus" color="neutral" variant="outline" @click="update(type, 1)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-neutral-500">{{ total }} pasajero{{ total !== 1 ? 's' : '' }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
169
app/components/SearchForm.vue
Normal file
169
app/components/SearchForm.vue
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits<{
|
||||||
|
search: [data: {
|
||||||
|
mode: string
|
||||||
|
departures: string[]
|
||||||
|
destination: string[]
|
||||||
|
dateFrom: string
|
||||||
|
dateTo: string
|
||||||
|
stayMinDays: number
|
||||||
|
stayMaxDays: number
|
||||||
|
passengers: { adult: number; child: number; infant: number }
|
||||||
|
maxStops: number | null
|
||||||
|
budget: number | null
|
||||||
|
}]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
compact?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { homeAirports, defaultPassengers } = useUserPreferences()
|
||||||
|
|
||||||
|
const mode = ref('roundtrip')
|
||||||
|
const departures = ref('')
|
||||||
|
const destination = ref('')
|
||||||
|
const dateFrom = ref('')
|
||||||
|
const dateTo = ref('')
|
||||||
|
const stayMinDays = ref(2)
|
||||||
|
const stayMaxDays = ref(6)
|
||||||
|
const passengers = ref({ adult: 2, child: 0, infant: 0 })
|
||||||
|
const maxStops = ref<number | null>(null)
|
||||||
|
const budget = ref(500)
|
||||||
|
const showBudget = ref(false)
|
||||||
|
|
||||||
|
// Apply user preferences when available
|
||||||
|
watch(homeAirports, (airports) => {
|
||||||
|
if (airports.length && !departures.value) {
|
||||||
|
departures.value = airports.join(',')
|
||||||
|
}
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
watch(defaultPassengers, (p) => {
|
||||||
|
if (p.adult > 0) passengers.value = { ...p }
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const showDestination = computed(() => mode.value !== 'explore')
|
||||||
|
const showDateTo = computed(() => mode.value !== 'oneway')
|
||||||
|
const showStayDuration = computed(() => ['roundtrip', 'multicity'].includes(mode.value))
|
||||||
|
const isWeekend = computed(() => mode.value === 'weekend')
|
||||||
|
const isExplore = computed(() => mode.value === 'explore')
|
||||||
|
|
||||||
|
function submit() {
|
||||||
|
emit('search', {
|
||||||
|
mode: mode.value,
|
||||||
|
departures: departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
|
||||||
|
destination: destination.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean),
|
||||||
|
dateFrom: dateFrom.value,
|
||||||
|
dateTo: dateTo.value || dateFrom.value,
|
||||||
|
stayMinDays: stayMinDays.value,
|
||||||
|
stayMaxDays: stayMaxDays.value,
|
||||||
|
passengers: passengers.value,
|
||||||
|
maxStops: maxStops.value,
|
||||||
|
budget: showBudget.value ? budget.value : null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<form class="space-y-4" @submit.prevent="submit">
|
||||||
|
<SearchModeTabs v-model="mode" />
|
||||||
|
|
||||||
|
<div :class="compact ? 'space-y-3' : 'space-y-4'">
|
||||||
|
<!-- Origen -->
|
||||||
|
<UFormField label="Origen">
|
||||||
|
<SearchAirportInput
|
||||||
|
v-model="departures"
|
||||||
|
placeholder="Buscar aeropuerto..."
|
||||||
|
icon="i-lucide-plane-takeoff"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Destino (no en modo explorar) -->
|
||||||
|
<UFormField v-if="showDestination" label="Destino">
|
||||||
|
<SearchAirportInput
|
||||||
|
v-model="destination"
|
||||||
|
placeholder="NTE"
|
||||||
|
icon="i-lucide-plane-landing"
|
||||||
|
multiple
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Fechas -->
|
||||||
|
<SearchDateRangePicker
|
||||||
|
v-model:date-from="dateFrom"
|
||||||
|
v-model:date-to="dateTo"
|
||||||
|
:single-date="!showDateTo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Estancia (roundtrip, multicity) -->
|
||||||
|
<SearchStayDurationPicker
|
||||||
|
v-if="showStayDuration"
|
||||||
|
v-model:min-days="stayMinDays"
|
||||||
|
v-model:max-days="stayMaxDays"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Weekend hint -->
|
||||||
|
<UAlert
|
||||||
|
v-if="isWeekend"
|
||||||
|
color="info"
|
||||||
|
icon="i-lucide-info"
|
||||||
|
title="Busca vuelos de viernes a domingo automaticamente"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Explore hint -->
|
||||||
|
<UAlert
|
||||||
|
v-if="isExplore"
|
||||||
|
color="info"
|
||||||
|
icon="i-lucide-compass"
|
||||||
|
title="Descubre destinos baratos desde tu aeropuerto"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Pasajeros en popover -->
|
||||||
|
<UFormField label="Pasajeros">
|
||||||
|
<UPopover>
|
||||||
|
<UButton
|
||||||
|
:label="`${passengers.adult + passengers.child + passengers.infant} pasajero(s)`"
|
||||||
|
icon="i-lucide-users"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
block
|
||||||
|
class="justify-start"
|
||||||
|
/>
|
||||||
|
<template #content>
|
||||||
|
<div class="p-4 w-64">
|
||||||
|
<PassengerPicker v-model="passengers" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPopover>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<!-- Opciones avanzadas -->
|
||||||
|
<div class="flex flex-wrap gap-4 items-end">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs text-muted mb-1">Escalas</p>
|
||||||
|
<SearchMaxStopsFilter v-model="maxStops" />
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
:label="showBudget ? 'Ocultar presupuesto' : 'Presupuesto max'"
|
||||||
|
icon="i-lucide-wallet"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
@click="showBudget = !showBudget"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SearchBudgetSlider v-if="showBudget" v-model="budget" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
:label="isExplore ? 'Explorar destinos' : 'Buscar vuelos'"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
size="lg"
|
||||||
|
block
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</template>
|
||||||
133
app/components/TripCard.vue
Normal file
133
app/components/TripCard.vue
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Trip } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
const props = defineProps<{ trip: Trip }>()
|
||||||
|
defineEmits<{ select: [trip: Trip] }>()
|
||||||
|
|
||||||
|
const { showOriginTime } = useOriginTime()
|
||||||
|
|
||||||
|
// Timezone offset (in seconds) of the origin airport: local - UTC
|
||||||
|
const originTzOffset = computed(() => {
|
||||||
|
const seg = props.trip.legs[0]?.segments[0]
|
||||||
|
if (!seg) return 0
|
||||||
|
return seg.departureTimestamp - seg.departureUtcTimestamp
|
||||||
|
})
|
||||||
|
|
||||||
|
const departureCode = computed(() => props.trip.legs[0]?.segments[0]?.departureCode ?? '')
|
||||||
|
const arrivalCode = computed(() => props.trip.legs[0]?.segments.at(-1)?.arrivalCode ?? '')
|
||||||
|
const departureDate = computed(() => props.trip.legs[0]?.segments[0]?.departureDate ?? '')
|
||||||
|
const routeSummary = computed(() => {
|
||||||
|
const codes = props.trip.legs.map(l => l.segments[0]?.departureCode).filter(Boolean)
|
||||||
|
const lastArr = props.trip.legs.at(-1)?.segments.at(-1)?.arrivalCode
|
||||||
|
if (lastArr) codes.push(lastArr)
|
||||||
|
return codes.join(' > ')
|
||||||
|
})
|
||||||
|
|
||||||
|
// Total flight time across all legs (sum of each segment's flight duration, using UTC)
|
||||||
|
const totalFlightMs = computed(() => {
|
||||||
|
let ms = 0
|
||||||
|
for (const leg of props.trip.legs) {
|
||||||
|
for (const seg of leg.segments) {
|
||||||
|
ms += (seg.arrivalUtcTimestamp - seg.departureUtcTimestamp) * 1000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
})
|
||||||
|
|
||||||
|
// Time at destination: from arrival of last segment of outbound leg to departure of first segment of return leg (using UTC)
|
||||||
|
const stayInfo = computed(() => {
|
||||||
|
const legs = props.trip.legs
|
||||||
|
if (legs.length < 2) return null
|
||||||
|
|
||||||
|
const arrivalUtc = legs[0].segments.at(-1)?.arrivalUtcTimestamp
|
||||||
|
const departureUtc = legs[1].segments[0]?.departureUtcTimestamp
|
||||||
|
if (!arrivalUtc || !departureUtc) return null
|
||||||
|
|
||||||
|
const stayMs = (departureUtc - arrivalUtc) * 1000
|
||||||
|
if (stayMs <= 0) return null
|
||||||
|
|
||||||
|
const stayHours = stayMs / 3600000
|
||||||
|
const fullDays = Math.floor(stayHours / 24)
|
||||||
|
const remainingHours = Math.round(stayHours - fullDays * 24)
|
||||||
|
const nights = fullDays
|
||||||
|
|
||||||
|
return { nights, fullDays, remainingHours, stayMs }
|
||||||
|
})
|
||||||
|
|
||||||
|
// Total trip days: from first departure to last arrival (local dates for calendar/vacation planning)
|
||||||
|
const totalTripDays = computed(() => {
|
||||||
|
const legs = props.trip.legs
|
||||||
|
if (!legs.length) return null
|
||||||
|
|
||||||
|
const firstDep = legs[0].segments[0]?.departureDate
|
||||||
|
const lastArr = legs.at(-1)?.segments.at(-1)?.arrivalDate
|
||||||
|
if (!firstDep || !lastArr) return null
|
||||||
|
|
||||||
|
const depDate = new Date(firstDep)
|
||||||
|
const arrDate = new Date(lastArr)
|
||||||
|
|
||||||
|
// Calendar days: count from departure day to arrival day inclusive
|
||||||
|
const depDay = new Date(depDate.getFullYear(), depDate.getMonth(), depDate.getDate())
|
||||||
|
const arrDay = new Date(arrDate.getFullYear(), arrDate.getMonth(), arrDate.getDate())
|
||||||
|
const calendarDays = Math.round((arrDay.getTime() - depDay.getTime()) / 86400000) + 1
|
||||||
|
|
||||||
|
return calendarDays
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDuration(ms: number) {
|
||||||
|
const totalMin = Math.round(ms / 60000)
|
||||||
|
const h = Math.floor(totalMin / 60)
|
||||||
|
const m = totalMin % 60
|
||||||
|
return h > 0 ? `${h}h ${m}m` : `${m}m`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard class="hover:ring-primary-500 transition-all cursor-pointer" @click="$emit('select', trip)">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex-1 space-y-1">
|
||||||
|
<FlightLeg v-for="(leg, i) in trip.legs" :key="i" :leg="leg" :index="i" :origin-tz-offset="originTzOffset" :show-origin-time="showOriginTime" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-right shrink-0 pl-4 border-l border-neutral-200 dark:border-neutral-700 space-y-1">
|
||||||
|
<p class="text-2xl font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ trip.totalCost.toFixed(0) }}<span class="text-sm font-normal ml-0.5">€</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p class="text-xs text-muted flex items-center gap-1 justify-end">
|
||||||
|
<UIcon name="i-lucide-plane" class="text-xs" />
|
||||||
|
{{ formatDuration(totalFlightMs) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<template v-if="stayInfo">
|
||||||
|
<p class="text-xs text-muted flex items-center gap-1 justify-end">
|
||||||
|
<UIcon name="i-lucide-map-pin" class="text-xs" />
|
||||||
|
{{ stayInfo.fullDays }}d {{ stayInfo.remainingHours }}h en destino
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted flex items-center gap-1 justify-end">
|
||||||
|
<UIcon name="i-lucide-moon" class="text-xs" />
|
||||||
|
{{ stayInfo.nights }} noche{{ stayInfo.nights !== 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<p v-if="totalTripDays" class="text-xs text-muted flex items-center gap-1 justify-end">
|
||||||
|
<UIcon name="i-lucide-calendar-range" class="text-xs" />
|
||||||
|
{{ totalTripDays }} dia{{ totalTripDays !== 1 ? 's' : '' }} total
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 justify-end pt-1">
|
||||||
|
<DetailWatchlistToggle
|
||||||
|
:booking-token="trip.bookingToken"
|
||||||
|
:route-summary="routeSummary"
|
||||||
|
:departure-code="departureCode"
|
||||||
|
:arrival-code="arrivalCode"
|
||||||
|
:departure-date="departureDate"
|
||||||
|
:price="trip.totalCost"
|
||||||
|
:passengers="{ adult: 1, child: 0, infant: 0 }"
|
||||||
|
/>
|
||||||
|
<UButton size="xs" label="Ver" trailing-icon="i-lucide-arrow-right" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
71
app/components/auth/LoginForm.vue
Normal file
71
app/components/auth/LoginForm.vue
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { login, register, loginWithGoogle, loading, error } = useAuth()
|
||||||
|
|
||||||
|
const mode = ref<'login' | 'register'>('login')
|
||||||
|
const email = ref('')
|
||||||
|
const password = ref('')
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const success = mode.value === 'login'
|
||||||
|
? await login(email.value, password.value)
|
||||||
|
: await register(email.value, password.value)
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
await navigateTo('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="max-w-sm mx-auto space-y-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<h1 class="text-2xl font-bold">
|
||||||
|
{{ mode === 'login' ? 'Iniciar sesion' : 'Crear cuenta' }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-muted mt-1">
|
||||||
|
{{ mode === 'login' ? 'Accede a tu cuenta de Vuelato' : 'Registrate para guardar vuelos' }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
label="Continuar con Google"
|
||||||
|
icon="i-simple-icons-google"
|
||||||
|
color="neutral"
|
||||||
|
variant="outline"
|
||||||
|
block
|
||||||
|
@click="loginWithGoogle"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<USeparator label="o" />
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="onSubmit">
|
||||||
|
<UFormField label="Email">
|
||||||
|
<UInput v-model="email" type="email" placeholder="tu@email.com" icon="i-lucide-mail" required />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Contrasena">
|
||||||
|
<UInput v-model="password" type="password" placeholder="••••••••" icon="i-lucide-lock" required :minlength="6" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UAlert v-if="error" color="error" :title="error" icon="i-lucide-alert-circle" />
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
type="submit"
|
||||||
|
:label="mode === 'login' ? 'Iniciar sesion' : 'Crear cuenta'"
|
||||||
|
:loading="loading"
|
||||||
|
block
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="text-center text-sm">
|
||||||
|
<template v-if="mode === 'login'">
|
||||||
|
No tienes cuenta?
|
||||||
|
<UButton variant="link" label="Registrate" @click="mode = 'register'" />
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
Ya tienes cuenta?
|
||||||
|
<UButton variant="link" label="Inicia sesion" @click="mode = 'login'" />
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
46
app/components/auth/UserMenu.vue
Normal file
46
app/components/auth/UserMenu.vue
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { user, logout } = useAuth()
|
||||||
|
|
||||||
|
const items = computed(() => [
|
||||||
|
[{
|
||||||
|
label: user.value?.email ?? '',
|
||||||
|
disabled: true
|
||||||
|
}],
|
||||||
|
[{
|
||||||
|
label: 'Watchlist',
|
||||||
|
icon: 'i-lucide-heart',
|
||||||
|
to: '/watchlist'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Preferencias',
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
to: '/settings'
|
||||||
|
}],
|
||||||
|
[{
|
||||||
|
label: 'Cerrar sesion',
|
||||||
|
icon: 'i-lucide-log-out',
|
||||||
|
click: logout
|
||||||
|
}]
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="user">
|
||||||
|
<UDropdownMenu :items="items">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-user"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
:label="user.email?.split('@')[0]"
|
||||||
|
/>
|
||||||
|
</UDropdownMenu>
|
||||||
|
</div>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
to="/auth"
|
||||||
|
label="Entrar"
|
||||||
|
icon="i-lucide-log-in"
|
||||||
|
variant="ghost"
|
||||||
|
color="neutral"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
145
app/components/detail/FlightTracker.vue
Normal file
145
app/components/detail/FlightTracker.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
flightCode: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const info = ref<any>(null)
|
||||||
|
const fr24Url = ref<string | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
|
||||||
|
async function loadInfo() {
|
||||||
|
if (loaded.value) return
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await $fetch<any>('/api/flight-info', {
|
||||||
|
query: { flightno: props.flightCode }
|
||||||
|
})
|
||||||
|
fr24Url.value = data.fr24Url || `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
|
||||||
|
if (data.found) info.value = data.flight
|
||||||
|
} catch {
|
||||||
|
fr24Url.value = `https://www.flightradar24.com/data/flights/${props.flightCode.toLowerCase()}`
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
loaded.value = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAltitude(ft: number) {
|
||||||
|
return `${Math.round(ft * 0.3048)}m (FL${Math.round(ft / 100)})`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSpeed(knots: number) {
|
||||||
|
return `${Math.round(knots * 1.852)} km/h`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDelay(min: number | null) {
|
||||||
|
if (min == null || min === 0) return null
|
||||||
|
if (min > 0) return `+${min} min`
|
||||||
|
return `${min} min`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Toggle button -->
|
||||||
|
<UButton
|
||||||
|
:label="loading ? 'Cargando...' : (info ? 'Info del vuelo' : 'Ver info en vivo')"
|
||||||
|
:icon="info ? 'i-lucide-radar' : 'i-lucide-radio'"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
:loading="loading"
|
||||||
|
@click="loadInfo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Flight info panel -->
|
||||||
|
<Transition
|
||||||
|
enter-active-class="transition-all duration-200 ease-out"
|
||||||
|
enter-from-class="opacity-0 max-h-0"
|
||||||
|
enter-to-class="opacity-100 max-h-96"
|
||||||
|
leave-active-class="transition-all duration-150 ease-in"
|
||||||
|
leave-from-class="opacity-100 max-h-96"
|
||||||
|
leave-to-class="opacity-0 max-h-0"
|
||||||
|
>
|
||||||
|
<div v-if="info" class="mt-2 overflow-hidden">
|
||||||
|
<div class="rounded-lg bg-neutral-50 dark:bg-neutral-800/50 p-3 space-y-2 text-sm">
|
||||||
|
<!-- Status -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="w-2 h-2 rounded-full"
|
||||||
|
:class="info.onGround ? 'bg-amber-500' : info.altitude > 0 ? 'bg-green-500 animate-pulse' : 'bg-neutral-400'"
|
||||||
|
/>
|
||||||
|
<span class="font-medium">{{ info.status }}</span>
|
||||||
|
</div>
|
||||||
|
<a
|
||||||
|
:href="info.fr24Url"
|
||||||
|
target="_blank"
|
||||||
|
class="text-xs text-primary-500 hover:underline"
|
||||||
|
>
|
||||||
|
Flightradar24
|
||||||
|
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aircraft & airline -->
|
||||||
|
<div class="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div v-if="info.aircraft">
|
||||||
|
<span class="text-muted">Avion</span>
|
||||||
|
<p class="font-medium">{{ info.aircraftAge || info.aircraft }}</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="info.registration">
|
||||||
|
<span class="text-muted">Matricula</span>
|
||||||
|
<p class="font-medium">{{ info.registration }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Live data (if in flight) -->
|
||||||
|
<div v-if="info.altitude > 0 && !info.onGround" class="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted">Altitud</span>
|
||||||
|
<p class="font-medium">{{ formatAltitude(info.altitude) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted">Velocidad</span>
|
||||||
|
<p class="font-medium">{{ formatSpeed(info.speed) }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted">Rumbo</span>
|
||||||
|
<p class="font-medium">{{ info.heading }}°</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Delays -->
|
||||||
|
<div v-if="formatDelay(info.departureDelay) || formatDelay(info.arrivalDelay)" class="flex gap-4 text-xs">
|
||||||
|
<div v-if="formatDelay(info.departureDelay)">
|
||||||
|
<span class="text-muted">Retraso salida</span>
|
||||||
|
<p class="font-medium" :class="info.departureDelay > 0 ? 'text-red-500' : 'text-green-500'">
|
||||||
|
{{ formatDelay(info.departureDelay) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="formatDelay(info.arrivalDelay)">
|
||||||
|
<span class="text-muted">Retraso llegada</span>
|
||||||
|
<p class="font-medium" :class="info.arrivalDelay > 0 ? 'text-red-500' : 'text-green-500'">
|
||||||
|
{{ formatDelay(info.arrivalDelay) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<!-- Not found — link to FR24 anyway -->
|
||||||
|
<div v-if="loaded && !info && !loading" class="mt-1">
|
||||||
|
<a
|
||||||
|
:href="fr24Url"
|
||||||
|
target="_blank"
|
||||||
|
class="text-xs text-muted hover:text-primary-500 transition-colors"
|
||||||
|
>
|
||||||
|
No hay datos en vivo · Ver historial en Flightradar24
|
||||||
|
<UIcon name="i-lucide-external-link" class="inline text-[10px]" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
49
app/components/detail/ItineraryTimeline.vue
Normal file
49
app/components/detail/ItineraryTimeline.vue
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Trip } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
defineProps<{ trip: Trip }>()
|
||||||
|
|
||||||
|
function formatFullDate(d: string) {
|
||||||
|
return new Date(d).toLocaleDateString('es-ES', { weekday: 'short', day: 'numeric', month: 'long' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function legDuration(leg: Trip['legs'][0]) {
|
||||||
|
const segs = leg.segments
|
||||||
|
if (!segs.length) return ''
|
||||||
|
const depMs = segs[0].departureTimestamp * 1000
|
||||||
|
const arrMs = segs[segs.length - 1].arrivalTimestamp * 1000
|
||||||
|
const diffMin = Math.round((arrMs - depMs) / 60000)
|
||||||
|
const h = Math.floor(diffMin / 60)
|
||||||
|
const m = diffMin % 60
|
||||||
|
return `${h}h ${m}m`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UCard v-for="(leg, i) in trip.legs" :key="i">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UBadge :label="i === 0 ? 'Ida' : 'Vuelta'" :color="i === 0 ? 'primary' : 'info'" variant="subtle" />
|
||||||
|
<span class="text-sm text-neutral-500">{{ formatFullDate(leg.segments[0].departureDate) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted">
|
||||||
|
<UIcon name="i-lucide-timer" class="text-xs" />
|
||||||
|
{{ legDuration(leg) }}
|
||||||
|
<template v-if="leg.segments.length > 1">
|
||||||
|
· {{ leg.segments.length - 1 }} escala{{ leg.segments.length > 2 ? 's' : '' }}
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<DetailSegmentCard
|
||||||
|
v-for="(seg, j) in leg.segments"
|
||||||
|
:key="j"
|
||||||
|
:segment="seg"
|
||||||
|
:show-divider="j > 0"
|
||||||
|
/>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
44
app/components/detail/PriceVerifier.vue
Normal file
44
app/components/detail/PriceVerifier.vue
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { PassengersCount } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
bookingToken: string
|
||||||
|
originalPrice: number
|
||||||
|
passengers: PassengersCount
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { checkPrice } = useFlightSearch()
|
||||||
|
const checkedPrice = ref<number | null>(null)
|
||||||
|
const checking = ref(false)
|
||||||
|
|
||||||
|
async function verify() {
|
||||||
|
checking.value = true
|
||||||
|
try {
|
||||||
|
const data = await checkPrice(props.bookingToken, props.passengers)
|
||||||
|
checkedPrice.value = data.trip.totalCost
|
||||||
|
} catch {
|
||||||
|
checkedPrice.value = -1
|
||||||
|
} finally {
|
||||||
|
checking.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-semibold">Verificar precio actual</h3>
|
||||||
|
<p class="text-sm text-neutral-500">Comprueba si el precio sigue disponible</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<template v-if="checkedPrice !== null">
|
||||||
|
<UBadge v-if="checkedPrice === -1" label="No disponible" color="error" />
|
||||||
|
<UBadge v-else-if="checkedPrice <= originalPrice" :label="`${checkedPrice.toFixed(0)}€`" color="success" />
|
||||||
|
<UBadge v-else :label="`${checkedPrice.toFixed(0)}€ (subio)`" color="warning" />
|
||||||
|
</template>
|
||||||
|
<UButton label="Verificar" icon="i-lucide-refresh-cw" :loading="checking" @click="verify" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
57
app/components/detail/RelatedFlights.vue
Normal file
57
app/components/detail/RelatedFlights.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Trip } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { trips, loading, fetchRouteFlights } = useRouteFlights()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.from && props.to) {
|
||||||
|
fetchRouteFlights(props.from, props.to)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatTime(d: string) {
|
||||||
|
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(d: string) {
|
||||||
|
return new Date(d).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="loading || trips.length > 0">
|
||||||
|
<h3 class="font-semibold mb-3">Otros vuelos {{ from }} → {{ to }}</h3>
|
||||||
|
<div v-if="loading" class="space-y-2">
|
||||||
|
<USkeleton v-for="i in 3" :key="i" class="h-12" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="space-y-2">
|
||||||
|
<div
|
||||||
|
v-for="(trip, i) in trips.slice(0, 5)"
|
||||||
|
:key="i"
|
||||||
|
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<span class="font-medium">
|
||||||
|
{{ formatTime(trip.legs[0]?.segments[0]?.departureDate) }}
|
||||||
|
→
|
||||||
|
{{ formatTime(trip.legs[0]?.segments.at(-1)?.arrivalDate || '') }}
|
||||||
|
</span>
|
||||||
|
<span class="text-muted">
|
||||||
|
{{ formatDate(trip.legs[0]?.segments[0]?.departureDate) }}
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted">
|
||||||
|
{{ trip.legs[0]?.segments[0]?.company?.code }}{{ trip.legs[0]?.segments[0]?.number }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ trip.totalCost.toFixed(0) }}€
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
164
app/components/detail/SegmentCard.vue
Normal file
164
app/components/detail/SegmentCard.vue
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Segment } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
segment: Segment
|
||||||
|
showDivider?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { resolve } = useAirlineNames()
|
||||||
|
const { getBookingUrl, getAirlineWebsite } = useBookingUrl()
|
||||||
|
|
||||||
|
function formatTime(d: string) {
|
||||||
|
return new Date(d).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentDate = computed(() => props.segment.departureDate.slice(0, 10))
|
||||||
|
|
||||||
|
const segmentLink = computed(() => ({
|
||||||
|
path: '/results',
|
||||||
|
query: {
|
||||||
|
mode: 'oneway',
|
||||||
|
dep: props.segment.departureCode,
|
||||||
|
dest: props.segment.arrivalCode,
|
||||||
|
from: segmentDate.value,
|
||||||
|
to: segmentDate.value,
|
||||||
|
adults: '1'
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
|
const flightCode = computed(() => `${props.segment.company.code}${props.segment.number}`)
|
||||||
|
const trackerUrl = computed(() => `https://www.flightradar24.com/data/flights/${flightCode.value.toLowerCase()}`)
|
||||||
|
const airlineName = computed(() => resolve(props.segment.company.code, props.segment.company.name))
|
||||||
|
|
||||||
|
const bookingUrl = computed(() => getBookingUrl({
|
||||||
|
airlineCode: props.segment.company.code,
|
||||||
|
origin: props.segment.departureCode,
|
||||||
|
destination: props.segment.arrivalCode,
|
||||||
|
date: props.segment.departureDate
|
||||||
|
}))
|
||||||
|
const airlineWebsite = computed(() => getAirlineWebsite(props.segment.company.code))
|
||||||
|
|
||||||
|
// Fetch cheapest price for this specific route
|
||||||
|
const segmentPrice = ref<number | null>(null)
|
||||||
|
const loadingPrice = ref(false)
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadingPrice.value = true
|
||||||
|
try {
|
||||||
|
const date = props.segment.departureDate.slice(0, 10)
|
||||||
|
const data = await $fetch<any>('/api/search', {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
departures: [props.segment.departureCode],
|
||||||
|
local: 'en',
|
||||||
|
departureDateInterval: {
|
||||||
|
begin: `${date}T00:00:00+00:00`,
|
||||||
|
end: `${date}T00:00:00+00:00`
|
||||||
|
},
|
||||||
|
stops: [{
|
||||||
|
locations: [props.segment.arrivalCode],
|
||||||
|
stayRange: { min: 0, max: 0 },
|
||||||
|
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||||
|
continueFromAny: false
|
||||||
|
}],
|
||||||
|
endInSameLocation: false,
|
||||||
|
maxStops: 0,
|
||||||
|
fixStopsOrder: false,
|
||||||
|
stopLength: { min: 0, max: 0, isSet: false },
|
||||||
|
maxResults: 1,
|
||||||
|
passengersCount: { adult: 1, child: 0, infant: 0 }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (data.trips?.length) {
|
||||||
|
segmentPrice.value = data.trips[0].totalCost
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// silently fail
|
||||||
|
} finally {
|
||||||
|
loadingPrice.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-4 py-3 -mx-2 px-2 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors"
|
||||||
|
:class="{ 'border-t border-neutral-100 dark:border-neutral-800': showDivider }"
|
||||||
|
>
|
||||||
|
<!-- Departure -->
|
||||||
|
<div class="text-center w-20">
|
||||||
|
<p class="text-xl font-semibold">{{ formatTime(segment.departureDate) }}</p>
|
||||||
|
<p class="text-sm font-medium">{{ segment.departureCode }}</p>
|
||||||
|
<p class="text-xs text-neutral-400">{{ segment.departureCity }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flight info -->
|
||||||
|
<div class="flex-1 flex flex-col items-center gap-1">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
:href="trackerUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="text-xs font-medium text-neutral-600 dark:text-neutral-300 hover:text-primary-500 transition-colors"
|
||||||
|
title="Ver en Flightradar24"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<span v-if="airlineName" class="font-medium">{{ airlineName }} · </span>
|
||||||
|
<span>{{ segment.company.code }} {{ segment.number }}</span>
|
||||||
|
<UIcon name="i-lucide-radar" class="inline ml-0.5 text-[10px] opacity-50" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="w-full h-px bg-neutral-200 dark:bg-neutral-700 relative">
|
||||||
|
<UIcon name="i-lucide-plane" class="absolute -top-2 left-1/2 -translate-x-1/2 text-primary-500" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Arrival -->
|
||||||
|
<div class="text-center w-20">
|
||||||
|
<p class="text-xl font-semibold">{{ formatTime(segment.arrivalDate) }}</p>
|
||||||
|
<p class="text-sm font-medium">{{ segment.arrivalCode }}</p>
|
||||||
|
<p class="text-xs text-neutral-400">{{ segment.arrivalCity }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Price (links to one-way search) -->
|
||||||
|
<NuxtLink :to="segmentLink" class="shrink-0 text-right w-16 hover:opacity-80 transition-opacity" @click.stop>
|
||||||
|
<template v-if="loadingPrice">
|
||||||
|
<USkeleton class="h-5 w-12 ml-auto" />
|
||||||
|
</template>
|
||||||
|
<template v-else-if="segmentPrice != null">
|
||||||
|
<p class="text-sm font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ segmentPrice.toFixed(0) }}€
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted">solo ida</p>
|
||||||
|
</template>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Flight tracker (expandable) -->
|
||||||
|
<div class="pl-24 -mt-1 mb-1">
|
||||||
|
<DetailFlightTracker :flight-code="flightCode" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Booking links -->
|
||||||
|
<div class="pl-24 -mt-1 mb-2 flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
:href="bookingUrl"
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center gap-1 text-xs font-medium text-primary-600 dark:text-primary-400 hover:underline"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-ticket" class="text-sm" />
|
||||||
|
Reservar vuelo
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
v-if="airlineWebsite"
|
||||||
|
:href="airlineWebsite"
|
||||||
|
target="_blank"
|
||||||
|
class="inline-flex items-center gap-1 text-xs text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<UIcon name="i-lucide-globe" class="text-sm" />
|
||||||
|
Web de {{ airlineName || segment.company.code }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
31
app/components/detail/ShareButton.vue
Normal file
31
app/components/detail/ShareButton.vue
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
price: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
|
||||||
|
async function share() {
|
||||||
|
const url = window.location.href
|
||||||
|
const text = `${props.title} - ${props.price.toFixed(0)}€`
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
|
await navigator.share({ title: text, url })
|
||||||
|
} else {
|
||||||
|
await navigator.clipboard.writeText(`${text}\n${url}`)
|
||||||
|
copied.value = true
|
||||||
|
setTimeout(() => { copied.value = false }, 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UButton
|
||||||
|
:label="copied ? 'Copiado!' : 'Compartir'"
|
||||||
|
:icon="copied ? 'i-lucide-check' : 'i-lucide-share-2'"
|
||||||
|
:color="copied ? 'success' : 'neutral'"
|
||||||
|
variant="outline"
|
||||||
|
@click="share"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
52
app/components/detail/WatchlistToggle.vue
Normal file
52
app/components/detail/WatchlistToggle.vue
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
bookingToken: string
|
||||||
|
routeSummary: string
|
||||||
|
departureCode: string
|
||||||
|
arrivalCode: string
|
||||||
|
departureDate: string
|
||||||
|
price: number
|
||||||
|
passengers: { adult: number; child: number; infant: number }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
const { isWatched, getWatchedItem, add, remove } = useWatchlist()
|
||||||
|
|
||||||
|
const toggling = ref(false)
|
||||||
|
const watched = computed(() => isWatched(props.bookingToken))
|
||||||
|
|
||||||
|
async function toggle() {
|
||||||
|
if (!user.value) {
|
||||||
|
await navigateTo('/auth')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toggling.value = true
|
||||||
|
if (watched.value) {
|
||||||
|
const item = getWatchedItem(props.bookingToken)
|
||||||
|
if (item) await remove(item.id)
|
||||||
|
} else {
|
||||||
|
await add({
|
||||||
|
bookingToken: props.bookingToken,
|
||||||
|
routeSummary: props.routeSummary,
|
||||||
|
departureCode: props.departureCode,
|
||||||
|
arrivalCode: props.arrivalCode,
|
||||||
|
departureDate: props.departureDate,
|
||||||
|
price: props.price,
|
||||||
|
passengers: props.passengers
|
||||||
|
})
|
||||||
|
}
|
||||||
|
toggling.value = false
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UButton
|
||||||
|
:icon="watched ? 'i-lucide-heart' : 'i-lucide-heart'"
|
||||||
|
:color="watched ? 'error' : 'neutral'"
|
||||||
|
:variant="watched ? 'soft' : 'ghost'"
|
||||||
|
:loading="toggling"
|
||||||
|
size="sm"
|
||||||
|
@click.stop="toggle"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
57
app/components/inspiration/BudgetExplorer.vue
Normal file
57
app/components/inspiration/BudgetExplorer.vue
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { InspirationItem } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
items: InspirationItem[]
|
||||||
|
from: string
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ select: [iata: string] }>()
|
||||||
|
|
||||||
|
const budget = ref(100)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold">Donde por {{ budget }}€?</h3>
|
||||||
|
<UBadge
|
||||||
|
:label="`${items.filter(i => i.minPrice <= budget).length} destinos`"
|
||||||
|
color="primary"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-4">
|
||||||
|
<URange v-model="budget" :min="15" :max="500" :step="5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="loading" class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
<USkeleton v-for="i in 6" :key="i" class="h-12" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||||
|
<button
|
||||||
|
v-for="item in items.filter(i => i.minPrice <= budget).slice(0, 12)"
|
||||||
|
:key="item.to[0]"
|
||||||
|
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-400 transition-colors text-left text-sm"
|
||||||
|
@click="$emit('select', item.to[0])"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="font-semibold">{{ from }} → {{ item.to[0] }}</span>
|
||||||
|
<span v-if="item.minStops === 0" class="text-xs text-muted ml-1">directo</span>
|
||||||
|
</span>
|
||||||
|
<span class="font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ item.minPrice.toFixed(0) }}€
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="items.filter(i => i.minPrice <= budget).length === 0 && !loading" class="text-center py-4 text-sm text-muted">
|
||||||
|
Sube el presupuesto para ver destinos
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
37
app/components/inspiration/MultiCityCard.vue
Normal file
37
app/components/inspiration/MultiCityCard.vue
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { MultiCityInspirationItem } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
item: MultiCityInspirationItem
|
||||||
|
currency?: string
|
||||||
|
resolveCountry?: (iata: string) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ select: [item: MultiCityInspirationItem] }>()
|
||||||
|
|
||||||
|
const countrySummary = computed(() => {
|
||||||
|
if (!props.resolveCountry) return ''
|
||||||
|
const unique = [...new Set(props.item.stops.map(s => props.resolveCountry!(s)).filter(Boolean))]
|
||||||
|
return unique.join(', ')
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard class="hover:ring-primary-500 transition-all cursor-pointer" @click="$emit('select', item)">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-sm">
|
||||||
|
{{ item.from }} → {{ item.stops.join(' → ') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-muted mt-1">
|
||||||
|
{{ item.stops.length }} parada{{ item.stops.length !== 1 ? 's' : '' }}
|
||||||
|
<span v-if="countrySummary"> · {{ countrySummary }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-lg font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ item.minPrice.toFixed(0) }}<span class="text-xs">€</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
144
app/components/map/FlightMap.vue
Normal file
144
app/components/map/FlightMap.vue
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { LMap, LTileLayer, LCircleMarker, LPolyline, LPopup } from '@vue-leaflet/vue-leaflet'
|
||||||
|
import 'leaflet/dist/leaflet.css'
|
||||||
|
import type { InspirationItem } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
airports: { iata: string; name: string; lat: number; lon: number; city_name: string }[]
|
||||||
|
origin: string | null
|
||||||
|
inspirations: InspirationItem[]
|
||||||
|
budget: number | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
selectOrigin: [iata: string]
|
||||||
|
selectDestination: [iata: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const zoom = ref(5)
|
||||||
|
const center = ref<[number, number]>([40.4, -3.7]) // Madrid default
|
||||||
|
|
||||||
|
// Airport lookup
|
||||||
|
const airportMap = computed(() => {
|
||||||
|
const map = new Map<string, (typeof props.airports)[0]>()
|
||||||
|
for (const a of props.airports) map.set(a.iata, a)
|
||||||
|
return map
|
||||||
|
})
|
||||||
|
|
||||||
|
// Filter inspirations by budget
|
||||||
|
const filteredInspirations = computed(() => {
|
||||||
|
if (!props.budget) return props.inspirations
|
||||||
|
return props.inspirations.filter(i => i.minPrice <= props.budget!)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Lines from origin to destinations
|
||||||
|
const routes = computed(() => {
|
||||||
|
const origin = props.origin ? airportMap.value.get(props.origin) : null
|
||||||
|
if (!origin) return []
|
||||||
|
return filteredInspirations.value
|
||||||
|
.map(insp => {
|
||||||
|
const dest = airportMap.value.get(insp.to[0])
|
||||||
|
if (!dest) return null
|
||||||
|
return {
|
||||||
|
from: [origin.lat, origin.lon] as [number, number],
|
||||||
|
to: [dest.lat, dest.lon] as [number, number],
|
||||||
|
iata: insp.to[0],
|
||||||
|
price: insp.minPrice,
|
||||||
|
stops: insp.minStops,
|
||||||
|
destName: dest.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(Boolean) as { from: [number, number]; to: [number, number]; iata: string; price: number; stops: number; destName: string }[]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Center on origin when selected
|
||||||
|
watch(() => props.origin, (iata) => {
|
||||||
|
if (iata) {
|
||||||
|
const a = airportMap.value.get(iata)
|
||||||
|
if (a) {
|
||||||
|
center.value = [a.lat, a.lon]
|
||||||
|
zoom.value = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function priceColor(price: number): string {
|
||||||
|
if (price < 30) return '#22c55e'
|
||||||
|
if (price < 60) return '#84cc16'
|
||||||
|
if (price < 100) return '#eab308'
|
||||||
|
if (price < 200) return '#f97316'
|
||||||
|
return '#ef4444'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="h-[500px] rounded-lg overflow-hidden border border-neutral-200 dark:border-neutral-700">
|
||||||
|
<LMap :zoom="zoom" :center="center" :use-global-leaflet="false">
|
||||||
|
<LTileLayer
|
||||||
|
url="https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png"
|
||||||
|
attribution="© OpenStreetMap © CARTO"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- All airports as small dots -->
|
||||||
|
<LCircleMarker
|
||||||
|
v-for="a in airports.filter(a => a.lat && a.lon)"
|
||||||
|
:key="a.iata"
|
||||||
|
:lat-lng="[a.lat, a.lon]"
|
||||||
|
:radius="origin === a.iata ? 8 : 3"
|
||||||
|
:color="origin === a.iata ? '#3b82f6' : '#94a3b8'"
|
||||||
|
:fill-opacity="origin === a.iata ? 0.8 : 0.4"
|
||||||
|
:weight="origin === a.iata ? 2 : 1"
|
||||||
|
@click="$emit('selectOrigin', a.iata)"
|
||||||
|
>
|
||||||
|
<LPopup>
|
||||||
|
<div class="text-sm">
|
||||||
|
<p class="font-semibold">{{ a.iata }} - {{ a.name }}</p>
|
||||||
|
<p v-if="a.city_name" class="text-neutral-500">{{ a.city_name }}</p>
|
||||||
|
<UButton
|
||||||
|
v-if="origin !== a.iata"
|
||||||
|
label="Buscar desde aqui"
|
||||||
|
size="xs"
|
||||||
|
class="mt-1"
|
||||||
|
@click="$emit('selectOrigin', a.iata)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</LPopup>
|
||||||
|
</LCircleMarker>
|
||||||
|
|
||||||
|
<!-- Route lines -->
|
||||||
|
<template v-for="r in routes" :key="r.iata">
|
||||||
|
<LPolyline
|
||||||
|
:lat-lngs="[r.from, r.to]"
|
||||||
|
:color="priceColor(r.price)"
|
||||||
|
:weight="2"
|
||||||
|
:opacity="0.6"
|
||||||
|
/>
|
||||||
|
<LCircleMarker
|
||||||
|
:lat-lng="r.to"
|
||||||
|
:radius="6"
|
||||||
|
:color="priceColor(r.price)"
|
||||||
|
:fill-opacity="0.8"
|
||||||
|
:weight="2"
|
||||||
|
>
|
||||||
|
<LPopup>
|
||||||
|
<div class="text-sm min-w-32">
|
||||||
|
<p class="font-semibold">{{ r.iata }} - {{ r.destName }}</p>
|
||||||
|
<p class="text-lg font-bold" :style="{ color: priceColor(r.price) }">
|
||||||
|
{{ r.price.toFixed(0) }}€
|
||||||
|
</p>
|
||||||
|
<p class="text-xs text-neutral-500">
|
||||||
|
{{ r.stops === 0 ? 'Directo' : `${r.stops} escala(s)` }}
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
label="Ver vuelos"
|
||||||
|
size="xs"
|
||||||
|
class="mt-1"
|
||||||
|
@click="$emit('selectDestination', r.iata)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</LPopup>
|
||||||
|
</LCircleMarker>
|
||||||
|
</template>
|
||||||
|
</LMap>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
app/components/map/MapControls.vue
Normal file
38
app/components/map/MapControls.vue
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const origin = defineModel<string>('origin', { default: '' })
|
||||||
|
const budget = defineModel<number | null>('budget', { default: null })
|
||||||
|
const directOnly = defineModel<boolean>('directOnly', { default: false })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
inspirationCount: number
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard>
|
||||||
|
<div class="flex flex-wrap items-end gap-4">
|
||||||
|
<UFormField label="Origen" class="w-48">
|
||||||
|
<SearchAirportInput v-model="origin" placeholder="MAD" icon="i-lucide-plane-takeoff" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="flex-1 min-w-48">
|
||||||
|
<div class="flex justify-between text-sm mb-1">
|
||||||
|
<span class="text-muted">Presupuesto</span>
|
||||||
|
<span class="font-semibold">{{ budget ? `${budget}€` : 'Sin limite' }}</span>
|
||||||
|
</div>
|
||||||
|
<URange :model-value="budget || 500" :min="20" :max="1000" :step="10" @update:model-value="budget = $event" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
:label="directOnly ? 'Solo directos' : 'Todos'"
|
||||||
|
:icon="directOnly ? 'i-lucide-arrow-right' : 'i-lucide-git-branch'"
|
||||||
|
:color="directOnly ? 'primary' : 'neutral'"
|
||||||
|
:variant="directOnly ? 'soft' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
@click="directOnly = !directOnly"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p class="text-sm text-muted">{{ inspirationCount }} destinos</p>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
18
app/components/results/LoadingMore.vue
Normal file
18
app/components/results/LoadingMore.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
polling: boolean
|
||||||
|
pollCount: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ stop: [] }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="polling" class="flex items-center justify-center gap-3 py-4">
|
||||||
|
<UIcon name="i-lucide-loader" class="animate-spin text-primary-500" />
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
Buscando mas resultados (ronda {{ pollCount }})...
|
||||||
|
</p>
|
||||||
|
<UButton label="Parar" size="xs" color="neutral" variant="ghost" @click="$emit('stop')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
158
app/components/results/ResultsFilters.vue
Normal file
158
app/components/results/ResultsFilters.vue
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
maxPrice: number | null
|
||||||
|
maxStops: number | null
|
||||||
|
airlines: string[]
|
||||||
|
departureTimeRange: [number, number]
|
||||||
|
availableAirlines: { code: string, name: string }[]
|
||||||
|
priceRange: { min: number, max: number }
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:maxPrice': [value: number | null]
|
||||||
|
'update:maxStops': [value: number | null]
|
||||||
|
'update:airlines': [value: string[]]
|
||||||
|
'update:departureTimeRange': [value: [number, number]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const priceValue = ref(props.maxPrice ?? props.priceRange.max)
|
||||||
|
const priceEnabled = ref(props.maxPrice != null)
|
||||||
|
|
||||||
|
watch(priceEnabled, (on) => {
|
||||||
|
emit('update:maxPrice', on ? priceValue.value : null)
|
||||||
|
})
|
||||||
|
watch(priceValue, (v) => {
|
||||||
|
if (priceEnabled.value) emit('update:maxPrice', v)
|
||||||
|
})
|
||||||
|
watch(() => props.maxPrice, (v) => {
|
||||||
|
if (v == null) {
|
||||||
|
priceEnabled.value = false
|
||||||
|
} else {
|
||||||
|
priceValue.value = v
|
||||||
|
priceEnabled.value = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const stopsOptions = [
|
||||||
|
{ label: 'Todos', value: null },
|
||||||
|
{ label: 'Directo', value: 0 },
|
||||||
|
{ label: 'Max 1', value: 1 },
|
||||||
|
{ label: 'Max 2', value: 2 }
|
||||||
|
]
|
||||||
|
|
||||||
|
function toggleAirline(code: string) {
|
||||||
|
const current = [...props.airlines]
|
||||||
|
const idx = current.indexOf(code)
|
||||||
|
if (idx >= 0) current.splice(idx, 1)
|
||||||
|
else current.push(code)
|
||||||
|
emit('update:airlines', current)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHour(h: number) {
|
||||||
|
return `${String(h).padStart(2, '0')}:00`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard>
|
||||||
|
<div class="space-y-5">
|
||||||
|
<!-- Price filter -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<label class="text-sm font-medium flex items-center gap-2">
|
||||||
|
<USwitch :model-value="priceEnabled" size="xs" @update:model-value="priceEnabled = $event" />
|
||||||
|
Precio max
|
||||||
|
</label>
|
||||||
|
<UInput
|
||||||
|
v-if="priceEnabled"
|
||||||
|
:model-value="priceValue"
|
||||||
|
type="number"
|
||||||
|
:min="priceRange.min"
|
||||||
|
:max="priceRange.max"
|
||||||
|
size="xs"
|
||||||
|
class="w-24 text-right"
|
||||||
|
@update:model-value="priceValue = Number($event)"
|
||||||
|
>
|
||||||
|
<template #trailing><span class="text-xs text-muted">€</span></template>
|
||||||
|
</UInput>
|
||||||
|
</div>
|
||||||
|
<URange
|
||||||
|
v-if="priceEnabled"
|
||||||
|
v-model="priceValue"
|
||||||
|
:min="priceRange.min"
|
||||||
|
:max="priceRange.max"
|
||||||
|
:step="5"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stops filter -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<p class="text-sm font-medium">Escalas</p>
|
||||||
|
<UInput
|
||||||
|
:model-value="maxStops ?? ''"
|
||||||
|
type="number"
|
||||||
|
:min="0"
|
||||||
|
:max="10"
|
||||||
|
placeholder="Sin limite"
|
||||||
|
size="xs"
|
||||||
|
class="w-28 text-right"
|
||||||
|
@update:model-value="$emit('update:maxStops', $event === '' ? null : Number($event))"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<UButton
|
||||||
|
v-for="opt in stopsOptions"
|
||||||
|
:key="String(opt.value)"
|
||||||
|
:label="opt.label"
|
||||||
|
:color="maxStops === opt.value ? 'primary' : 'neutral'"
|
||||||
|
:variant="maxStops === opt.value ? 'soft' : 'ghost'"
|
||||||
|
size="xs"
|
||||||
|
@click="$emit('update:maxStops', opt.value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Departure time -->
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium mb-1">Hora de salida</p>
|
||||||
|
<p class="text-xs text-muted mb-2">
|
||||||
|
{{ formatHour(departureTimeRange[0]) }} - {{ formatHour(departureTimeRange[1]) }}
|
||||||
|
</p>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
<URange
|
||||||
|
:model-value="departureTimeRange[0]"
|
||||||
|
:min="0"
|
||||||
|
:max="23"
|
||||||
|
class="flex-1"
|
||||||
|
@update:model-value="$emit('update:departureTimeRange', [$event, departureTimeRange[1]])"
|
||||||
|
/>
|
||||||
|
<URange
|
||||||
|
:model-value="departureTimeRange[1]"
|
||||||
|
:min="1"
|
||||||
|
:max="24"
|
||||||
|
class="flex-1"
|
||||||
|
@update:model-value="$emit('update:departureTimeRange', [departureTimeRange[0], $event])"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Airlines filter -->
|
||||||
|
<div v-if="availableAirlines.length > 0">
|
||||||
|
<p class="text-sm font-medium mb-2">Aerolineas</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<UButton
|
||||||
|
v-for="al in availableAirlines"
|
||||||
|
:key="al.code"
|
||||||
|
:label="`${al.code}${al.name ? ' · ' + al.name : ''}`"
|
||||||
|
:color="airlines.includes(al.code) ? 'primary' : 'neutral'"
|
||||||
|
:variant="airlines.includes(al.code) ? 'soft' : 'ghost'"
|
||||||
|
size="xs"
|
||||||
|
@click="toggleAirline(al.code)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted mt-1">{{ airlines.length === 0 ? 'Sin filtro de aerolinea' : 'Solo vuelos operados exclusivamente por las seleccionadas' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
99
app/components/results/ResultsToolbar.vue
Normal file
99
app/components/results/ResultsToolbar.vue
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { SortKey } from '~/composables/useResultFilters'
|
||||||
|
|
||||||
|
const sortBy = defineModel<SortKey>('sortBy', { default: 'price' })
|
||||||
|
const viewMode = defineModel<'full' | 'compact'>('viewMode', { default: 'full' })
|
||||||
|
const { showOriginTime } = useOriginTime()
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
count: number
|
||||||
|
hasActiveFilters: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
toggleFilters: []
|
||||||
|
resetFilters: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const sortOptions: { value: SortKey, label: string, icon: string }[] = [
|
||||||
|
{ value: 'price', label: 'Precio', icon: 'i-lucide-arrow-down-narrow-wide' },
|
||||||
|
{ value: 'departure', label: 'Salida', icon: 'i-lucide-clock' },
|
||||||
|
{ value: 'duration', label: 'Duracion', icon: 'i-lucide-timer' },
|
||||||
|
{ value: 'stops', label: 'Escalas', icon: 'i-lucide-git-branch' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
{{ count }} resultado{{ count !== 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
label="Limpiar filtros"
|
||||||
|
icon="i-lucide-x"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
@click="$emit('resetFilters')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<!-- Sort buttons -->
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
<UButton
|
||||||
|
v-for="opt in sortOptions"
|
||||||
|
:key="opt.value"
|
||||||
|
:label="opt.label"
|
||||||
|
:icon="opt.icon"
|
||||||
|
:color="sortBy === opt.value ? 'primary' : 'neutral'"
|
||||||
|
:variant="sortBy === opt.value ? 'soft' : 'ghost'"
|
||||||
|
size="xs"
|
||||||
|
@click="sortBy = opt.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USeparator orientation="vertical" class="h-5" />
|
||||||
|
|
||||||
|
<!-- View mode -->
|
||||||
|
<div class="flex gap-0.5">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-rows-3"
|
||||||
|
:color="viewMode === 'full' ? 'primary' : 'neutral'"
|
||||||
|
:variant="viewMode === 'full' ? 'soft' : 'ghost'"
|
||||||
|
size="xs"
|
||||||
|
@click="viewMode = 'full'"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-list"
|
||||||
|
:color="viewMode === 'compact' ? 'primary' : 'neutral'"
|
||||||
|
:variant="viewMode === 'compact' ? 'soft' : 'ghost'"
|
||||||
|
size="xs"
|
||||||
|
@click="viewMode = 'compact'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Origin time toggle -->
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-clock"
|
||||||
|
label="Hora origen"
|
||||||
|
:color="showOriginTime ? 'primary' : 'neutral'"
|
||||||
|
:variant="showOriginTime ? 'soft' : 'ghost'"
|
||||||
|
size="xs"
|
||||||
|
@click="showOriginTime = !showOriginTime"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Filter toggle -->
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-sliders-horizontal"
|
||||||
|
label="Filtros"
|
||||||
|
:color="hasActiveFilters ? 'primary' : 'neutral'"
|
||||||
|
:variant="hasActiveFilters ? 'soft' : 'ghost'"
|
||||||
|
size="xs"
|
||||||
|
@click="$emit('toggleFilters')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
39
app/components/results/TripCardCompact.vue
Normal file
39
app/components/results/TripCardCompact.vue
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Trip } from '~/server/utils/flightics'
|
||||||
|
|
||||||
|
defineProps<{ trip: Trip }>()
|
||||||
|
defineEmits<{ select: [trip: Trip] }>()
|
||||||
|
|
||||||
|
function formatTime(dateStr: string) {
|
||||||
|
return new Date(dateStr).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function legSummary(leg: Trip['legs'][0]) {
|
||||||
|
const segs = leg.segments
|
||||||
|
if (!segs.length) return ''
|
||||||
|
const dep = segs[0]
|
||||||
|
const arr = segs[segs.length - 1]
|
||||||
|
const stops = segs.length - 1
|
||||||
|
const stopsText = stops === 0 ? 'Directo' : `${stops} escala${stops > 1 ? 's' : ''}`
|
||||||
|
return `${dep.departureCode} ${formatTime(dep.departureDate)} → ${arr.arrivalCode} ${formatTime(arr.arrivalDate)} · ${stopsText}`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-primary-400 cursor-pointer transition-colors"
|
||||||
|
@click="$emit('select', trip)"
|
||||||
|
>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p v-for="(leg, i) in trip.legs" :key="i" class="text-sm truncate">
|
||||||
|
<span class="text-xs font-medium text-muted mr-1">{{ i === 0 ? 'Ida' : 'Vta' }}</span>
|
||||||
|
{{ legSummary(leg) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="shrink-0 ml-3 text-right">
|
||||||
|
<span class="text-lg font-bold text-primary-600 dark:text-primary-400">
|
||||||
|
{{ trip.totalCost.toFixed(0) }}<span class="text-xs">€</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
189
app/components/search/AirportInput.vue
Normal file
189
app/components/search/AirportInput.vue
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
placeholder?: string
|
||||||
|
icon?: string
|
||||||
|
multiple?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { loadAirports, searchAirports } = useLocations()
|
||||||
|
const query = ref('')
|
||||||
|
const results = ref<ReturnType<typeof searchAirports>>([])
|
||||||
|
const open = ref(false)
|
||||||
|
const highlightIndex = ref(0)
|
||||||
|
|
||||||
|
// Selected codes as array
|
||||||
|
const selected = ref<string[]>([])
|
||||||
|
|
||||||
|
// Init from modelValue
|
||||||
|
function parseModelValue(v: string) {
|
||||||
|
return v.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
selected.value = parseModelValue(props.modelValue)
|
||||||
|
|
||||||
|
watch(() => props.modelValue, (v) => {
|
||||||
|
const parsed = parseModelValue(v)
|
||||||
|
if (parsed.join(',') !== selected.value.join(',')) {
|
||||||
|
selected.value = parsed
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function emitValue() {
|
||||||
|
emit('update:modelValue', selected.value.join(','))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => loadAirports())
|
||||||
|
|
||||||
|
watch(query, (q) => {
|
||||||
|
results.value = searchAirports(q)
|
||||||
|
// Don't show already-selected airports
|
||||||
|
if (props.multiple) {
|
||||||
|
results.value = results.value.filter(a => !selected.value.includes(a.iata))
|
||||||
|
}
|
||||||
|
open.value = results.value.length > 0
|
||||||
|
highlightIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function select(airport: (typeof results.value)[0]) {
|
||||||
|
if (props.multiple) {
|
||||||
|
if (!selected.value.includes(airport.iata)) {
|
||||||
|
selected.value.push(airport.iata)
|
||||||
|
}
|
||||||
|
query.value = ''
|
||||||
|
} else {
|
||||||
|
selected.value = [airport.iata]
|
||||||
|
query.value = airport.iata
|
||||||
|
}
|
||||||
|
emitValue()
|
||||||
|
open.value = false
|
||||||
|
results.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeCode(code: string) {
|
||||||
|
selected.value = selected.value.filter(c => c !== code)
|
||||||
|
emitValue()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Backspace' && !query.value && props.multiple && selected.value.length) {
|
||||||
|
e.preventDefault()
|
||||||
|
selected.value.pop()
|
||||||
|
emitValue()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!open.value || results.value.length === 0) return
|
||||||
|
|
||||||
|
if (e.key === 'ArrowDown') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightIndex.value = Math.min(highlightIndex.value + 1, results.value.length - 1)
|
||||||
|
} else if (e.key === 'ArrowUp') {
|
||||||
|
e.preventDefault()
|
||||||
|
highlightIndex.value = Math.max(highlightIndex.value - 1, 0)
|
||||||
|
} else if (e.key === 'Enter') {
|
||||||
|
e.preventDefault()
|
||||||
|
select(results.value[highlightIndex.value])
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
open.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBlur() {
|
||||||
|
// If there's text typed and it looks like a code, add it
|
||||||
|
const val = query.value.trim().toUpperCase()
|
||||||
|
if (val && props.multiple) {
|
||||||
|
if (val.length >= 2) {
|
||||||
|
if (!selected.value.includes(val)) {
|
||||||
|
selected.value.push(val)
|
||||||
|
emitValue()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query.value = ''
|
||||||
|
} else if (val && !props.multiple) {
|
||||||
|
selected.value = [val]
|
||||||
|
query.value = val
|
||||||
|
emitValue()
|
||||||
|
}
|
||||||
|
setTimeout(() => { open.value = false; results.value = [] }, 150)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For single mode, keep query in sync
|
||||||
|
watch(selected, (codes) => {
|
||||||
|
if (!props.multiple && codes.length) {
|
||||||
|
query.value = codes[0]
|
||||||
|
}
|
||||||
|
}, { deep: true })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Multiple mode: badges + input -->
|
||||||
|
<div
|
||||||
|
v-if="multiple"
|
||||||
|
class="flex flex-wrap items-center gap-1 rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-2 py-1.5 focus-within:ring-2 focus-within:ring-primary-500 transition-shadow"
|
||||||
|
>
|
||||||
|
<UBadge
|
||||||
|
v-for="code in selected"
|
||||||
|
:key="code"
|
||||||
|
:label="code"
|
||||||
|
color="primary"
|
||||||
|
variant="subtle"
|
||||||
|
size="sm"
|
||||||
|
class="cursor-pointer"
|
||||||
|
@click="removeCode(code)"
|
||||||
|
>
|
||||||
|
<template #trailing>
|
||||||
|
<UIcon name="i-lucide-x" class="text-xs" />
|
||||||
|
</template>
|
||||||
|
</UBadge>
|
||||||
|
<input
|
||||||
|
v-model="query"
|
||||||
|
:placeholder="selected.length ? '' : placeholder || 'Buscar aeropuerto...'"
|
||||||
|
class="flex-1 min-w-24 bg-transparent outline-none text-sm py-0.5"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="results = searchAirports(query); open = results.length > 0"
|
||||||
|
@blur="onBlur"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Single mode -->
|
||||||
|
<UInput
|
||||||
|
v-else
|
||||||
|
v-model="query"
|
||||||
|
:placeholder="placeholder || 'Buscar aeropuerto...'"
|
||||||
|
:icon="icon || 'i-lucide-plane'"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="results = searchAirports(query); open = results.length > 0"
|
||||||
|
@blur="onBlur"
|
||||||
|
@keydown="onKeydown"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Dropdown -->
|
||||||
|
<div
|
||||||
|
v-if="open && results.length"
|
||||||
|
class="absolute z-50 mt-1 w-full bg-white dark:bg-neutral-900 border border-neutral-200 dark:border-neutral-700 rounded-lg shadow-lg max-h-60 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
v-for="(apt, i) in results"
|
||||||
|
:key="apt.iata"
|
||||||
|
type="button"
|
||||||
|
class="w-full text-left px-3 py-2 flex items-center justify-between text-sm"
|
||||||
|
:class="i === highlightIndex ? 'bg-primary-50 dark:bg-primary-900/20' : 'hover:bg-neutral-100 dark:hover:bg-neutral-800'"
|
||||||
|
@mousedown.prevent="select(apt)"
|
||||||
|
@mouseenter="highlightIndex = i"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="font-semibold">{{ apt.iata }}</span>
|
||||||
|
<span class="text-muted ml-2">{{ apt.name }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="apt.city_name" class="text-xs text-muted">{{ apt.city_name }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
13
app/components/search/BudgetSlider.vue
Normal file
13
app/components/search/BudgetSlider.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<number>({ default: 500 })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<div class="flex justify-between text-sm">
|
||||||
|
<span class="text-muted">Presupuesto max</span>
|
||||||
|
<span class="font-semibold">{{ model }}€</span>
|
||||||
|
</div>
|
||||||
|
<URange v-model="model" :min="20" :max="2000" :step="10" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
27
app/components/search/DateRangePicker.vue
Normal file
27
app/components/search/DateRangePicker.vue
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const dateFrom = defineModel<string>('dateFrom', { default: '' })
|
||||||
|
const dateTo = defineModel<string>('dateTo', { default: '' })
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
singleDate?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
watch(dateFrom, (from) => {
|
||||||
|
if (from && dateTo.value && dateTo.value < from) {
|
||||||
|
dateTo.value = from
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<UFormField :label="singleDate ? 'Fecha' : 'Desde'">
|
||||||
|
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" required />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField v-if="!singleDate" label="Hasta">
|
||||||
|
<UInput v-model="dateTo" type="date" :min="dateFrom || today" required />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
24
app/components/search/MaxStopsFilter.vue
Normal file
24
app/components/search/MaxStopsFilter.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<number | null>({ default: null })
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ label: 'Cualquiera', value: null },
|
||||||
|
{ label: 'Directo', value: 0 },
|
||||||
|
{ label: 'Max 1', value: 1 },
|
||||||
|
{ label: 'Max 2', value: 2 }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<UButton
|
||||||
|
v-for="opt in options"
|
||||||
|
:key="String(opt.value)"
|
||||||
|
:label="opt.label"
|
||||||
|
:color="model === opt.value ? 'primary' : 'neutral'"
|
||||||
|
:variant="model === opt.value ? 'soft' : 'ghost'"
|
||||||
|
size="xs"
|
||||||
|
@click="model = opt.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
26
app/components/search/SearchModeTabs.vue
Normal file
26
app/components/search/SearchModeTabs.vue
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const model = defineModel<string>({ default: 'roundtrip' })
|
||||||
|
|
||||||
|
const modes = [
|
||||||
|
{ value: 'roundtrip', label: 'Ida y vuelta', icon: 'i-lucide-repeat' },
|
||||||
|
{ value: 'oneway', label: 'Solo ida', icon: 'i-lucide-arrow-right' },
|
||||||
|
{ value: 'multicity', label: 'Multi-ciudad', icon: 'i-lucide-route' },
|
||||||
|
{ value: 'weekend', label: 'Finde', icon: 'i-lucide-calendar-days' },
|
||||||
|
{ value: 'explore', label: 'Explorar', icon: 'i-lucide-compass' }
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex gap-1 flex-wrap">
|
||||||
|
<UButton
|
||||||
|
v-for="m in modes"
|
||||||
|
:key="m.value"
|
||||||
|
:label="m.label"
|
||||||
|
:icon="m.icon"
|
||||||
|
:color="model === m.value ? 'primary' : 'neutral'"
|
||||||
|
:variant="model === m.value ? 'solid' : 'ghost'"
|
||||||
|
size="sm"
|
||||||
|
@click="model = m.value"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
15
app/components/search/StayDurationPicker.vue
Normal file
15
app/components/search/StayDurationPicker.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const minDays = defineModel<number>('minDays', { default: 2 })
|
||||||
|
const maxDays = defineModel<number>('maxDays', { default: 6 })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<UFormField label="Estancia min (dias)">
|
||||||
|
<UInput v-model.number="minDays" type="number" :min="1" :max="maxDays" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Estancia max (dias)">
|
||||||
|
<UInput v-model.number="maxDays" type="number" :min="minDays" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
205
app/components/tracking/CreateTrackingForm.vue
Normal file
205
app/components/tracking/CreateTrackingForm.vue
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const emit = defineEmits<{
|
||||||
|
created: []
|
||||||
|
cancel: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { create } = useTrackedSearches()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const departures = ref('')
|
||||||
|
const destination = ref('')
|
||||||
|
const dateFrom = ref('')
|
||||||
|
const dateTo = ref('')
|
||||||
|
const stayMinDays = ref(2)
|
||||||
|
const stayMaxDays = ref(7)
|
||||||
|
const passengers = ref({ adult: 1, child: 0, infant: 0 })
|
||||||
|
const intervalHours = ref(24)
|
||||||
|
const expiresAt = ref('')
|
||||||
|
const submitting = ref(false)
|
||||||
|
const error = ref('')
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
watch(dateFrom, (from) => {
|
||||||
|
if (from && dateTo.value && dateTo.value < from) {
|
||||||
|
dateTo.value = from
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const intervalOptions = [
|
||||||
|
{ label: 'Cada 6 horas', value: 6 },
|
||||||
|
{ label: 'Cada 12 horas', value: 12 },
|
||||||
|
{ label: 'Diario (24h)', value: 24 },
|
||||||
|
{ label: 'Cada 2 dias', value: 48 },
|
||||||
|
{ label: 'Semanal', value: 168 }
|
||||||
|
]
|
||||||
|
|
||||||
|
// Cargar preferencias de usuario
|
||||||
|
const { profile } = useUserPreferences()
|
||||||
|
watch(profile, (p) => {
|
||||||
|
if (p?.home_airports?.length) {
|
||||||
|
departures.value = p.home_airports.join(',')
|
||||||
|
}
|
||||||
|
if (p?.default_adults) passengers.value.adult = p.default_adults
|
||||||
|
if (p?.default_children) passengers.value.child = p.default_children
|
||||||
|
if (p?.default_infants) passengers.value.infant = p.default_infants
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
function buildRouteSummary() {
|
||||||
|
const dep = departures.value.split(',').filter(Boolean).join(',')
|
||||||
|
const dest = destination.value || '?'
|
||||||
|
return `${dep} > ${dest}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchParams() {
|
||||||
|
const depList = departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||||
|
const dest = destination.value.trim().toUpperCase()
|
||||||
|
|
||||||
|
const now = new Date()
|
||||||
|
const defaultFrom = now.toISOString().slice(0, 10)
|
||||||
|
const defaultTo = new Date(now.getTime() + 30 * 86400000).toISOString().slice(0, 10)
|
||||||
|
const from = dateFrom.value || defaultFrom
|
||||||
|
const to = dateTo.value || defaultTo
|
||||||
|
|
||||||
|
const isOneWay = from === to
|
||||||
|
|
||||||
|
const stops = isOneWay
|
||||||
|
? [{
|
||||||
|
locations: [dest],
|
||||||
|
stayRange: { min: 0, max: 0 },
|
||||||
|
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||||
|
continueFromAny: false
|
||||||
|
}]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
locations: [dest],
|
||||||
|
stayRange: { min: stayMinDays.value * 24, max: stayMaxDays.value * 24 },
|
||||||
|
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||||
|
continueFromAny: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locations: depList,
|
||||||
|
stayRange: { min: 0, max: 0 },
|
||||||
|
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||||
|
continueFromAny: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
departures: depList,
|
||||||
|
local: 'en',
|
||||||
|
departureDateInterval: {
|
||||||
|
begin: `${from}T00:00:00+00:00`,
|
||||||
|
end: `${to}T00:00:00+00:00`
|
||||||
|
},
|
||||||
|
stops,
|
||||||
|
endInSameLocation: !isOneWay,
|
||||||
|
maxStops: null,
|
||||||
|
fixStopsOrder: false,
|
||||||
|
stopLength: { min: 0, max: 0, isSet: false },
|
||||||
|
maxResults: 45,
|
||||||
|
passengersCount: passengers.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (!departures.value || !destination.value || !name.value) {
|
||||||
|
error.value = 'Rellena nombre, origen y destino'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting.value = true
|
||||||
|
error.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
await create({
|
||||||
|
name: name.value,
|
||||||
|
searchParams: buildSearchParams(),
|
||||||
|
routeSummary: buildRouteSummary(),
|
||||||
|
intervalHours: intervalHours.value,
|
||||||
|
expiresAt: expiresAt.value || undefined
|
||||||
|
})
|
||||||
|
emit('created')
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { data?: { message?: string } }
|
||||||
|
error.value = err?.data?.message || 'Error al crear seguimiento'
|
||||||
|
} finally {
|
||||||
|
submitting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold">Nuevo seguimiento de precios</h3>
|
||||||
|
<UButton icon="i-lucide-x" color="neutral" variant="ghost" size="xs" @click="emit('cancel')" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<UFormField label="Nombre" required>
|
||||||
|
<UInput v-model="name" placeholder="Ej: Madrid-Londres julio" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<UFormField label="Origen" required>
|
||||||
|
<SearchAirportInput v-model="departures" placeholder="Aeropuerto origen" icon="i-lucide-plane-takeoff" multiple />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Destino" required>
|
||||||
|
<SearchAirportInput v-model="destination" placeholder="Aeropuerto destino" icon="i-lucide-plane-landing" multiple />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<UFormField label="Fecha desde">
|
||||||
|
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Fecha hasta">
|
||||||
|
<UInput v-model="dateTo" type="date" :min="dateFrom || today" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<UFormField label="Estancia minima (dias)">
|
||||||
|
<UInput v-model.number="stayMinDays" type="number" :min="1" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Estancia maxima (dias)">
|
||||||
|
<UInput v-model.number="stayMaxDays" type="number" :min="1" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UFormField label="Pasajeros">
|
||||||
|
<PassengerPicker v-model="passengers" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<USeparator />
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<UFormField label="Frecuencia de busqueda">
|
||||||
|
<select
|
||||||
|
v-model.number="intervalHours"
|
||||||
|
class="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Expira el (opcional)">
|
||||||
|
<UInput v-model="expiresAt" type="date" :min="today" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UAlert v-if="error" :title="error" color="error" icon="i-lucide-alert-circle" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-end gap-2">
|
||||||
|
<UButton label="Cancelar" color="neutral" variant="outline" @click="emit('cancel')" />
|
||||||
|
<UButton label="Crear seguimiento" icon="i-lucide-plus" :loading="submitting" @click="onSubmit" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
112
app/components/tracking/PriceChart.vue
Normal file
112
app/components/tracking/PriceChart.vue
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { Line } from 'vue-chartjs'
|
||||||
|
import type { ChartOptions } from 'chart.js'
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
PointElement,
|
||||||
|
LineElement,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js'
|
||||||
|
|
||||||
|
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler)
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
snapshots: Array<{
|
||||||
|
cheapest_price: number
|
||||||
|
avg_price: number | null
|
||||||
|
median_price: number | null
|
||||||
|
total_results: number
|
||||||
|
recorded_at: string
|
||||||
|
}>
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const labels = props.snapshots.map(s =>
|
||||||
|
new Date(s.recorded_at).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'Precio mas barato',
|
||||||
|
data: props.snapshots.map(s => s.cheapest_price),
|
||||||
|
borderColor: '#16a34a',
|
||||||
|
backgroundColor: 'rgba(22, 163, 74, 0.1)',
|
||||||
|
fill: true,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Precio medio',
|
||||||
|
data: props.snapshots.map(s => s.avg_price),
|
||||||
|
borderColor: '#9ca3af',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderDash: [],
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Mediana',
|
||||||
|
data: props.snapshots.map(s => s.median_price),
|
||||||
|
borderColor: '#d1d5db',
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
borderDash: [5, 5],
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 4
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const chartOptions: ChartOptions<'line'> = {
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom' as const,
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
padding: 16
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(0)}\u20AC`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
y: {
|
||||||
|
beginAtZero: false,
|
||||||
|
ticks: {
|
||||||
|
callback: (value) => `${value}\u20AC`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
intersect: false,
|
||||||
|
mode: 'index' as const
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div v-if="snapshots.length < 2" class="text-center py-8">
|
||||||
|
<UIcon name="i-lucide-chart-line" class="text-3xl text-neutral-300 mb-2" />
|
||||||
|
<p class="text-neutral-500 text-sm">Se necesitan al menos 2 puntos de datos para mostrar el grafico</p>
|
||||||
|
</div>
|
||||||
|
<div v-else class="h-72">
|
||||||
|
<Line :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
96
app/components/tracking/RunHistory.vue
Normal file
96
app/components/tracking/RunHistory.vue
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
runs: SearchRun[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function statusBadge(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'completed': return { label: 'Completado', color: 'success' as const }
|
||||||
|
case 'running': return { label: 'Ejecutando', color: 'info' as const }
|
||||||
|
case 'failed': return { label: 'Error', color: 'error' as const }
|
||||||
|
default: return { label: 'Pendiente', color: 'neutral' as const }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string) {
|
||||||
|
return new Date(date).toLocaleString('es-ES', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatShortDate(dateStr: string) {
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
return d.toLocaleDateString('es-ES', { day: 'numeric', month: 'short' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function duration(start: string | null, end: string | null) {
|
||||||
|
if (!start || !end) return '-'
|
||||||
|
const ms = new Date(end).getTime() - new Date(start).getTime()
|
||||||
|
if (ms < 1000) return `${ms}ms`
|
||||||
|
return `${(ms / 1000).toFixed(1)}s`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<h3 class="font-semibold text-sm mb-3">Historial de ejecuciones</h3>
|
||||||
|
|
||||||
|
<div v-if="runs.length === 0" class="text-center py-6">
|
||||||
|
<p class="text-sm text-neutral-500">Aun no hay ejecuciones</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UCard v-for="run in runs" :key="run.id" class="!p-3">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||||
|
<UBadge :label="statusBadge(run.status).label" :color="statusBadge(run.status).color" size="xs" />
|
||||||
|
<span class="text-xs text-muted">{{ formatDate(run.created_at) }}</span>
|
||||||
|
<UBadge v-if="run.from_cache" label="Cache" color="neutral" variant="outline" size="xs" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3 text-sm shrink-0">
|
||||||
|
<span v-if="run.cheapest_price != null" class="font-medium">
|
||||||
|
{{ run.cheapest_price.toFixed(0) }}€
|
||||||
|
</span>
|
||||||
|
<span v-if="run.total_trips_found > 0" class="text-xs text-muted">
|
||||||
|
{{ run.total_trips_found }} vuelos
|
||||||
|
</span>
|
||||||
|
<span class="text-xs text-muted">
|
||||||
|
{{ duration(run.started_at, run.completed_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
<p v-if="run.error_message" class="text-xs text-red-500 mt-1">
|
||||||
|
{{ run.error_message }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Top trips preview -->
|
||||||
|
<div v-if="run.top_trips && run.top_trips.length > 0" class="mt-2 space-y-1.5">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="(trip, i) in run.top_trips.slice(0, 3)"
|
||||||
|
:key="i"
|
||||||
|
:to="trip.bookingToken ? `/detail/${encodeURIComponent(trip.bookingToken)}?adults=1` : undefined"
|
||||||
|
class="block text-xs text-muted rounded px-1.5 py-1 -mx-1.5 transition-colors"
|
||||||
|
:class="trip.bookingToken ? 'hover:bg-neutral-100 dark:hover:bg-neutral-800 cursor-pointer' : ''"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium text-foreground">{{ trip.price?.toFixed(0) }}€</span>
|
||||||
|
<div class="flex flex-wrap gap-x-3 gap-y-0.5 flex-1">
|
||||||
|
<span v-for="(leg, j) in trip.legs" :key="j" class="flex items-center gap-1">
|
||||||
|
<UIcon :name="j === 0 ? 'i-lucide-plane-takeoff' : 'i-lucide-plane-landing'" class="text-[10px]" />
|
||||||
|
{{ leg.from }} > {{ leg.to }}
|
||||||
|
<span v-if="leg.departure" class="text-muted">{{ formatShortDate(leg.departure) }}</span>
|
||||||
|
<span v-if="leg.airlines?.length" class="text-muted">({{ leg.airlines.join(', ') }})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<UIcon v-if="trip.bookingToken" name="i-lucide-arrow-right" class="text-neutral-400 text-xs shrink-0" />
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
124
app/components/tracking/TrackedSearchCard.vue
Normal file
124
app/components/tracking/TrackedSearchCard.vue
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
search: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
route_summary: string
|
||||||
|
interval_hours: number
|
||||||
|
is_active: boolean
|
||||||
|
next_run_at: string | null
|
||||||
|
last_run_at: string | null
|
||||||
|
run_count: number
|
||||||
|
last_error: string | null
|
||||||
|
expires_at: string | null
|
||||||
|
latest_snapshot: {
|
||||||
|
cheapest_price: number
|
||||||
|
avg_price: number | null
|
||||||
|
total_results: number
|
||||||
|
recorded_at: string
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
toggle: [id: string, active: boolean]
|
||||||
|
remove: [id: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function statusBadge() {
|
||||||
|
if (!props.search.is_active) return { label: 'Pausada', color: 'neutral' as const }
|
||||||
|
if (props.search.last_error) return { label: 'Error', color: 'error' as const }
|
||||||
|
if (props.search.expires_at && new Date(props.search.expires_at) < new Date()) return { label: 'Expirada', color: 'warning' as const }
|
||||||
|
return { label: 'Activa', color: 'success' as const }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInterval(hours: number) {
|
||||||
|
if (hours < 24) return `Cada ${hours}h`
|
||||||
|
if (hours === 24) return 'Diario'
|
||||||
|
if (hours === 48) return 'Cada 2 dias'
|
||||||
|
if (hours === 168) return 'Semanal'
|
||||||
|
return `Cada ${Math.round(hours / 24)} dias`
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(date: string) {
|
||||||
|
const diff = Date.now() - new Date(date).getTime()
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 60) return `hace ${mins}min`
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
if (hours < 24) return `hace ${hours}h`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `hace ${days}d`
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeUntil(date: string) {
|
||||||
|
const diff = new Date(date).getTime() - Date.now()
|
||||||
|
if (diff < 0) return 'pendiente'
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 60) return `en ${mins}min`
|
||||||
|
const hours = Math.floor(mins / 60)
|
||||||
|
if (hours < 24) return `en ${hours}h`
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
return `en ${days}d`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 p-4 hover:ring-1 hover:ring-primary-500 transition-all">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<NuxtLink :to="`/tracking/${search.id}`" class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<p class="font-semibold truncate">{{ search.name }}</p>
|
||||||
|
<UBadge :label="statusBadge().label" :color="statusBadge().color" size="xs" />
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-muted mb-1">{{ search.route_summary }}</p>
|
||||||
|
<div class="flex items-center gap-3 text-xs text-muted flex-wrap">
|
||||||
|
<span class="flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-timer" class="text-xs" />
|
||||||
|
{{ formatInterval(search.interval_hours) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="search.run_count > 0" class="flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-activity" class="text-xs" />
|
||||||
|
{{ search.run_count }} ejecucion{{ search.run_count !== 1 ? 'es' : '' }}
|
||||||
|
</span>
|
||||||
|
<span v-if="search.last_run_at" class="flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-clock" class="text-xs" />
|
||||||
|
{{ timeAgo(search.last_run_at) }}
|
||||||
|
</span>
|
||||||
|
<span v-if="search.is_active && search.next_run_at" class="flex items-center gap-1">
|
||||||
|
<UIcon name="i-lucide-calendar-clock" class="text-xs" />
|
||||||
|
Proxima: {{ timeUntil(search.next_run_at) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Precio actual -->
|
||||||
|
<NuxtLink :to="`/tracking/${search.id}`" class="text-right shrink-0">
|
||||||
|
<div v-if="search.latest_snapshot" class="mb-1">
|
||||||
|
<p class="text-lg font-bold">{{ search.latest_snapshot.cheapest_price.toFixed(0) }}€</p>
|
||||||
|
<p class="text-xs text-muted">{{ search.latest_snapshot.total_results }} resultados</p>
|
||||||
|
</div>
|
||||||
|
<p v-else class="text-sm text-muted">Sin datos</p>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Acciones -->
|
||||||
|
<div class="flex flex-col gap-1 shrink-0">
|
||||||
|
<UButton
|
||||||
|
:icon="search.is_active ? 'i-lucide-pause' : 'i-lucide-play'"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
:title="search.is_active ? 'Pausar' : 'Reanudar'"
|
||||||
|
@click="emit('toggle', search.id, !search.is_active)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
title="Eliminar"
|
||||||
|
@click="emit('remove', search.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
191
app/components/tracking/TrackingConfig.vue
Normal file
191
app/components/tracking/TrackingConfig.vue
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const props = defineProps<{
|
||||||
|
search: {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
interval_hours: number
|
||||||
|
is_active: boolean
|
||||||
|
expires_at: string | null
|
||||||
|
search_params: Record<string, unknown>
|
||||||
|
route_summary: string
|
||||||
|
}
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
updated: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { update } = useTrackedSearches()
|
||||||
|
|
||||||
|
const name = ref(props.search.name)
|
||||||
|
const intervalHours = ref(props.search.interval_hours)
|
||||||
|
const isActive = ref(props.search.is_active)
|
||||||
|
const expiresAt = ref(props.search.expires_at?.slice(0, 10) || '')
|
||||||
|
const saving = ref(false)
|
||||||
|
const today = new Date().toISOString().slice(0, 10)
|
||||||
|
|
||||||
|
// Parametros de busqueda editables
|
||||||
|
const params = props.search.search_params
|
||||||
|
const departures = ref((params.departures as string[])?.join(',') || '')
|
||||||
|
const destination = ref('')
|
||||||
|
const dateFrom = ref('')
|
||||||
|
const dateTo = ref('')
|
||||||
|
|
||||||
|
watch(dateFrom, (from) => {
|
||||||
|
if (from && dateTo.value && dateTo.value < from) {
|
||||||
|
dateTo.value = from
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const stayMinDays = ref(2)
|
||||||
|
const stayMaxDays = ref(7)
|
||||||
|
|
||||||
|
// Extraer destino y fechas de los search_params guardados
|
||||||
|
const interval = params.departureDateInterval as { begin?: string; end?: string } | undefined
|
||||||
|
if (interval?.begin) dateFrom.value = interval.begin.slice(0, 10)
|
||||||
|
if (interval?.end) dateTo.value = interval.end.slice(0, 10)
|
||||||
|
|
||||||
|
const stops = params.stops as Array<{ locations: string[]; stayRange?: { min: number; max: number } }> | undefined
|
||||||
|
if (stops?.[0]?.locations?.length) destination.value = stops[0].locations.join(',')
|
||||||
|
if (stops?.[0]?.stayRange) {
|
||||||
|
stayMinDays.value = Math.round((stops[0].stayRange.min || 0) / 24) || 2
|
||||||
|
stayMaxDays.value = Math.round((stops[0].stayRange.max || 0) / 24) || 7
|
||||||
|
}
|
||||||
|
|
||||||
|
const intervalOptions = [
|
||||||
|
{ label: 'Cada 6 horas', value: 6 },
|
||||||
|
{ label: 'Cada 12 horas', value: 12 },
|
||||||
|
{ label: 'Diario (24h)', value: 24 },
|
||||||
|
{ label: 'Cada 2 dias', value: 48 },
|
||||||
|
{ label: 'Semanal', value: 168 }
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildSearchParams() {
|
||||||
|
const depList = departures.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||||
|
const destList = destination.value.split(',').map(s => s.trim().toUpperCase()).filter(Boolean)
|
||||||
|
|
||||||
|
const from = dateFrom.value || new Date().toISOString().slice(0, 10)
|
||||||
|
const to = dateTo.value || new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10)
|
||||||
|
const isOneWay = from === to
|
||||||
|
|
||||||
|
const newStops = isOneWay
|
||||||
|
? [{
|
||||||
|
locations: destList,
|
||||||
|
stayRange: { min: 0, max: 0 },
|
||||||
|
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||||
|
continueFromAny: false
|
||||||
|
}]
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
locations: destList,
|
||||||
|
stayRange: { min: stayMinDays.value * 24, max: stayMaxDays.value * 24 },
|
||||||
|
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||||
|
continueFromAny: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
locations: depList,
|
||||||
|
stayRange: { min: 0, max: 0 },
|
||||||
|
stayDateRange: { begin: '0001-01-01T00:00:00', end: '0001-01-01T00:00:00' },
|
||||||
|
continueFromAny: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
...params,
|
||||||
|
departures: depList,
|
||||||
|
departureDateInterval: {
|
||||||
|
begin: `${from}T00:00:00+00:00`,
|
||||||
|
end: `${to}T00:00:00+00:00`
|
||||||
|
},
|
||||||
|
stops: newStops,
|
||||||
|
endInSameLocation: !isOneWay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
const depList = departures.value.split(',').filter(Boolean)
|
||||||
|
const destList = destination.value.split(',').filter(Boolean)
|
||||||
|
const routeSummary = `${depList.join(',')} > ${destList.join(',')}`
|
||||||
|
|
||||||
|
await update(props.search.id, {
|
||||||
|
name: name.value,
|
||||||
|
interval_hours: intervalHours.value,
|
||||||
|
is_active: isActive.value,
|
||||||
|
expires_at: expiresAt.value || null,
|
||||||
|
search_params: buildSearchParams(),
|
||||||
|
route_summary: routeSummary
|
||||||
|
})
|
||||||
|
emit('updated')
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<h3 class="font-semibold text-sm">Configuracion</h3>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<UFormField label="Nombre">
|
||||||
|
<UInput v-model="name" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<UFormField label="Origen">
|
||||||
|
<SearchAirportInput v-model="departures" placeholder="MAD" icon="i-lucide-plane-takeoff" multiple />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Destino">
|
||||||
|
<SearchAirportInput v-model="destination" placeholder="BCN" icon="i-lucide-plane-landing" multiple />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<UFormField label="Fecha desde">
|
||||||
|
<UInput v-model="dateFrom" type="date" :min="today" :max="dateTo || undefined" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Fecha hasta">
|
||||||
|
<UInput v-model="dateTo" type="date" :min="dateFrom || today" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<UFormField label="Estancia min (dias)">
|
||||||
|
<UInput v-model.number="stayMinDays" type="number" :min="1" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
<UFormField label="Estancia max (dias)">
|
||||||
|
<UInput v-model.number="stayMaxDays" type="number" :min="1" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<USeparator />
|
||||||
|
|
||||||
|
<UFormField label="Frecuencia">
|
||||||
|
<select
|
||||||
|
v-model.number="intervalHours"
|
||||||
|
class="w-full rounded-md border border-neutral-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<option v-for="opt in intervalOptions" :key="opt.value" :value="opt.value">
|
||||||
|
{{ opt.label }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField label="Expira el">
|
||||||
|
<UInput v-model="expiresAt" type="date" :min="today" class="w-full" />
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm">Activa</span>
|
||||||
|
<USwitch v-model="isActive" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<UButton label="Guardar cambios" size="sm" :loading="saving" @click="save" />
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</template>
|
||||||
36
app/composables/useAirlineNames.ts
Normal file
36
app/composables/useAirlineNames.ts
Normal 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 }
|
||||||
|
}
|
||||||
43
app/composables/useAuth.ts
Normal file
43
app/composables/useAuth.ts
Normal 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 }
|
||||||
|
}
|
||||||
107
app/composables/useBookingUrl.ts
Normal file
107
app/composables/useBookingUrl.ts
Normal 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 }
|
||||||
|
}
|
||||||
32
app/composables/useDestinationImages.ts
Normal file
32
app/composables/useDestinationImages.ts
Normal 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 }
|
||||||
|
}
|
||||||
206
app/composables/useFlightSearch.ts
Normal file
206
app/composables/useFlightSearch.ts
Normal 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 }
|
||||||
|
}
|
||||||
67
app/composables/useLocations.ts
Normal file
67
app/composables/useLocations.ts
Normal 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 }
|
||||||
|
}
|
||||||
18
app/composables/useOriginTime.ts
Normal file
18
app/composables/useOriginTime.ts
Normal 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 }
|
||||||
|
}
|
||||||
44
app/composables/useRecentSearches.ts
Normal file
44
app/composables/useRecentSearches.ts
Normal 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 }
|
||||||
|
}
|
||||||
144
app/composables/useResultFilters.ts
Normal file
144
app/composables/useResultFilters.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/composables/useRouteFlights.ts
Normal file
26
app/composables/useRouteFlights.ts
Normal 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 }
|
||||||
|
}
|
||||||
115
app/composables/useTrackedSearches.ts
Normal file
115
app/composables/useTrackedSearches.ts
Normal 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 }
|
||||||
|
}
|
||||||
62
app/composables/useUserPreferences.ts
Normal file
62
app/composables/useUserPreferences.ts
Normal 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 }
|
||||||
|
}
|
||||||
135
app/composables/useWatchlist.ts
Normal file
135
app/composables/useWatchlist.ts
Normal 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
16
app/pages/auth.vue
Normal 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>
|
||||||
17
app/pages/auth/confirm.vue
Normal file
17
app/pages/auth/confirm.vue
Normal 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>
|
||||||
117
app/pages/detail/[token].vue
Normal file
117
app/pages/detail/[token].vue
Normal 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) }}€
|
||||||
|
</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
141
app/pages/explore.vue
Normal 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">€</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
197
app/pages/index.vue
Normal 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
211
app/pages/multi-city.vue
Normal 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
202
app/pages/results.vue
Normal 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
149
app/pages/route/[slug].vue
Normal 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">€</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
59
app/pages/search.vue
Normal 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
132
app/pages/settings.vue
Normal 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
159
app/pages/tracking/[id].vue
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const { trackedSearches, getHistory, getRuns, remove, fetchAll, update } = useTrackedSearches()
|
||||||
|
|
||||||
|
const id = route.params.id as string
|
||||||
|
|
||||||
|
useSeoMeta({ title: 'Vuelato - Detalle seguimiento' })
|
||||||
|
|
||||||
|
watch(user, (u) => {
|
||||||
|
if (!u) navigateTo('/auth')
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const search = computed(() => trackedSearches.value.find(s => s.id === id))
|
||||||
|
const snapshots = ref<PriceSnapshot[]>([])
|
||||||
|
const runs = ref<SearchRun[]>([])
|
||||||
|
const loadingHistory = ref(true)
|
||||||
|
const loadError = ref(false)
|
||||||
|
const days = ref(30)
|
||||||
|
|
||||||
|
async function loadData() {
|
||||||
|
loadingHistory.value = true
|
||||||
|
loadError.value = false
|
||||||
|
try {
|
||||||
|
const [h, r] = await Promise.all([
|
||||||
|
getHistory(id, days.value),
|
||||||
|
getRuns(id, 20)
|
||||||
|
])
|
||||||
|
snapshots.value = h
|
||||||
|
runs.value = r
|
||||||
|
} catch {
|
||||||
|
loadError.value = true
|
||||||
|
} finally {
|
||||||
|
loadingHistory.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(days, () => loadData())
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await fetchAll()
|
||||||
|
loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Stats computadas
|
||||||
|
const stats = computed(() => {
|
||||||
|
if (snapshots.value.length === 0) return null
|
||||||
|
const prices = snapshots.value.map(s => s.cheapest_price)
|
||||||
|
const min = Math.min(...prices)
|
||||||
|
const max = Math.max(...prices)
|
||||||
|
const avg = Math.round(prices.reduce((s, p) => s + p, 0) / prices.length)
|
||||||
|
const current = prices[prices.length - 1] ?? 0
|
||||||
|
const previous = prices.length > 1 ? (prices[prices.length - 2] ?? current) : current
|
||||||
|
const trend = current - previous
|
||||||
|
|
||||||
|
return { current, min, max, avg, trend }
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onRemove() {
|
||||||
|
await remove(id)
|
||||||
|
router.push('/tracking')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfigUpdated() {
|
||||||
|
await fetchAll()
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="user">
|
||||||
|
<!-- Loading si aun no cargo la search -->
|
||||||
|
<div v-if="!search" class="max-w-3xl mx-auto py-12">
|
||||||
|
<USkeleton class="h-8 w-64 mb-4" />
|
||||||
|
<USkeleton class="h-72 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<UPageHero :title="search.name" :description="search.route_summary">
|
||||||
|
<template #actions>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<UButton label="Volver" icon="i-lucide-arrow-left" variant="outline" color="neutral" to="/tracking" />
|
||||||
|
<UButton label="Eliminar" icon="i-lucide-trash-2" variant="outline" color="error" @click="onRemove" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UPageHero>
|
||||||
|
|
||||||
|
<UPageSection>
|
||||||
|
<div class="max-w-4xl mx-auto space-y-6">
|
||||||
|
<!-- Stats -->
|
||||||
|
<div v-if="stats" class="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
|
<UCard class="text-center">
|
||||||
|
<p class="text-xs text-muted mb-1">Precio actual</p>
|
||||||
|
<p class="text-xl font-bold">{{ stats.current?.toFixed(0) }}€</p>
|
||||||
|
<p v-if="stats.trend !== 0" class="text-xs" :class="stats.trend < 0 ? 'text-green-600' : 'text-red-500'">
|
||||||
|
<UIcon :name="stats.trend < 0 ? 'i-lucide-trending-down' : 'i-lucide-trending-up'" class="text-xs" />
|
||||||
|
{{ stats.trend > 0 ? '+' : '' }}{{ stats.trend.toFixed(0) }}€
|
||||||
|
</p>
|
||||||
|
</UCard>
|
||||||
|
<UCard class="text-center">
|
||||||
|
<p class="text-xs text-muted mb-1">Minimo historico</p>
|
||||||
|
<p class="text-xl font-bold text-green-600">{{ stats.min?.toFixed(0) }}€</p>
|
||||||
|
</UCard>
|
||||||
|
<UCard class="text-center">
|
||||||
|
<p class="text-xs text-muted mb-1">Maximo historico</p>
|
||||||
|
<p class="text-xl font-bold text-red-500">{{ stats.max?.toFixed(0) }}€</p>
|
||||||
|
</UCard>
|
||||||
|
<UCard class="text-center">
|
||||||
|
<p class="text-xs text-muted mb-1">Precio medio</p>
|
||||||
|
<p class="text-xl font-bold">{{ stats.avg?.toFixed(0) }}€</p>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chart -->
|
||||||
|
<UCard>
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="font-semibold">Evolucion de precios</h3>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<UButton
|
||||||
|
v-for="d in [7, 14, 30, 60]"
|
||||||
|
:key="d"
|
||||||
|
:label="`${d}d`"
|
||||||
|
size="xs"
|
||||||
|
:variant="days === d ? 'solid' : 'ghost'"
|
||||||
|
:color="days === d ? 'primary' : 'neutral'"
|
||||||
|
@click="days = d"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div v-if="loadingHistory" class="flex justify-center py-12">
|
||||||
|
<USkeleton class="h-64 w-full" />
|
||||||
|
</div>
|
||||||
|
<div v-else-if="loadError" class="text-center py-8">
|
||||||
|
<p class="text-sm text-red-500">Error al cargar datos</p>
|
||||||
|
<UButton label="Reintentar" variant="outline" size="sm" class="mt-2" @click="loadData" />
|
||||||
|
</div>
|
||||||
|
<ClientOnly v-else>
|
||||||
|
<TrackingPriceChart :snapshots="snapshots" />
|
||||||
|
</ClientOnly>
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Config + Runs side by side on desktop -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div class="lg:col-span-1">
|
||||||
|
<TrackingConfig :search="search" @updated="onConfigUpdated" />
|
||||||
|
</div>
|
||||||
|
<div class="lg:col-span-2">
|
||||||
|
<TrackingRunHistory :runs="runs" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UPageSection>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
87
app/pages/tracking/index.vue
Normal file
87
app/pages/tracking/index.vue
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
const { trackedSearches, loading, update, remove, fetchAll } = useTrackedSearches()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const showCreateForm = ref(false)
|
||||||
|
|
||||||
|
useSeoMeta({ title: 'Vuelato - Seguimiento de precios' })
|
||||||
|
|
||||||
|
watch(user, (u) => {
|
||||||
|
if (!u) navigateTo('/auth')
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function onToggle(id: string, active: boolean) {
|
||||||
|
await update(id, { is_active: active })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRemove(id: string) {
|
||||||
|
await remove(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDetail(id: string) {
|
||||||
|
router.push(`/tracking/${id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCreated() {
|
||||||
|
showCreateForm.value = false
|
||||||
|
fetchAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeCount = computed(() => trackedSearches.value.filter(s => s.is_active).length)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="user">
|
||||||
|
<UPageHero title="Seguimiento de precios" description="Busquedas automaticas que monitorizan fluctuaciones de precio" />
|
||||||
|
|
||||||
|
<UPageSection>
|
||||||
|
<div class="max-w-3xl mx-auto space-y-4">
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-muted">
|
||||||
|
{{ trackedSearches.length }} seguimiento{{ trackedSearches.length !== 1 ? 's' : '' }}
|
||||||
|
<span v-if="activeCount > 0"> ({{ activeCount }} activo{{ activeCount !== 1 ? 's' : '' }})</span>
|
||||||
|
</p>
|
||||||
|
<UButton
|
||||||
|
v-if="!showCreateForm"
|
||||||
|
label="Nuevo seguimiento"
|
||||||
|
icon="i-lucide-plus"
|
||||||
|
size="sm"
|
||||||
|
@click="showCreateForm = true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create form -->
|
||||||
|
<TrackingCreateTrackingForm
|
||||||
|
v-if="showCreateForm"
|
||||||
|
@created="onCreated"
|
||||||
|
@cancel="showCreateForm = false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="space-y-3">
|
||||||
|
<USkeleton v-for="i in 3" :key="i" class="h-28 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="trackedSearches.length === 0 && !showCreateForm" class="text-center py-12">
|
||||||
|
<UIcon name="i-lucide-bell" class="text-4xl text-neutral-300 mb-2" />
|
||||||
|
<p class="text-neutral-500">No tienes busquedas en seguimiento</p>
|
||||||
|
<p class="text-sm text-neutral-400 mt-1">Crea una para monitorizar precios automaticamente</p>
|
||||||
|
<UButton label="Crear seguimiento" icon="i-lucide-plus" class="mt-3" @click="showCreateForm = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Search cards -->
|
||||||
|
<TrackingTrackedSearchCard
|
||||||
|
v-for="search in trackedSearches"
|
||||||
|
:key="search.id"
|
||||||
|
:search="search"
|
||||||
|
@toggle="onToggle"
|
||||||
|
@remove="onRemove"
|
||||||
|
@detail="onDetail"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</UPageSection>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
201
app/pages/watchlist.vue
Normal file
201
app/pages/watchlist.vue
Normal file
@@ -0,0 +1,201 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const user = useSupabaseUser()
|
||||||
|
const { items, loading, checkPrice, checkAll, remove } = useWatchlist()
|
||||||
|
const { searches } = useRecentSearches()
|
||||||
|
const { create: createTracking } = useTrackedSearches()
|
||||||
|
const { buildPayload } = useFlightSearch()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const checkingAll = ref(false)
|
||||||
|
const checkingId = ref<string | null>(null)
|
||||||
|
|
||||||
|
useSeoMeta({ title: 'Vuelato - Watchlist' })
|
||||||
|
|
||||||
|
// Redirect to auth if not logged in
|
||||||
|
watch(user, (u) => {
|
||||||
|
if (!u) navigateTo('/auth')
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
async function onCheckPrice(item: any) {
|
||||||
|
checkingId.value = item.id
|
||||||
|
await checkPrice(item)
|
||||||
|
checkingId.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCheckAll() {
|
||||||
|
checkingAll.value = true
|
||||||
|
await checkAll()
|
||||||
|
checkingAll.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusColor(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'price_down': return 'success'
|
||||||
|
case 'price_up': return 'warning'
|
||||||
|
case 'unavailable': return 'error'
|
||||||
|
case 'available': return 'info'
|
||||||
|
default: return 'neutral'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusLabel(status: string) {
|
||||||
|
switch (status) {
|
||||||
|
case 'price_down': return 'Bajo'
|
||||||
|
case 'price_up': return 'Subio'
|
||||||
|
case 'unavailable': return 'No disponible'
|
||||||
|
case 'available': return 'Disponible'
|
||||||
|
default: return 'Guardado'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaySearch(s: any) {
|
||||||
|
const p = s.search_params
|
||||||
|
router.push({
|
||||||
|
path: '/results',
|
||||||
|
query: {
|
||||||
|
mode: p.mode || s.search_mode,
|
||||||
|
dep: p.departures?.join(','),
|
||||||
|
dest: Array.isArray(p.destination) ? p.destination.join(',') : p.destination,
|
||||||
|
from: p.dateFrom,
|
||||||
|
to: p.dateTo,
|
||||||
|
smin: p.stayMinDays,
|
||||||
|
smax: p.stayMaxDays,
|
||||||
|
adults: p.passengers?.adult,
|
||||||
|
children: p.passengers?.child,
|
||||||
|
infants: p.passengers?.infant
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trackSearch(s: any) {
|
||||||
|
const p = s.search_params
|
||||||
|
await createTracking({
|
||||||
|
name: s.route_summary || 'Busqueda sin nombre',
|
||||||
|
searchParams: buildPayload({
|
||||||
|
departures: p.departures || [],
|
||||||
|
destination: Array.isArray(p.destination) ? p.destination : (p.destination || '').split(',').filter(Boolean),
|
||||||
|
dateFrom: p.dateFrom || '',
|
||||||
|
dateTo: p.dateTo || '',
|
||||||
|
stayMinDays: p.stayMinDays || 2,
|
||||||
|
stayMaxDays: p.stayMaxDays || 6,
|
||||||
|
passengers: p.passengers || { adult: 1, child: 0, infant: 0 }
|
||||||
|
}),
|
||||||
|
routeSummary: s.route_summary || 'Sin ruta',
|
||||||
|
intervalHours: 24
|
||||||
|
})
|
||||||
|
navigateTo('/tracking')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="user">
|
||||||
|
<UPageHero title="Watchlist" description="Vuelos guardados y seguimiento de precios" />
|
||||||
|
|
||||||
|
<UPageSection>
|
||||||
|
<div class="max-w-3xl mx-auto space-y-4">
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<p class="text-sm text-muted">{{ items.length }} vuelo{{ items.length !== 1 ? 's' : '' }} guardado{{ items.length !== 1 ? 's' : '' }}</p>
|
||||||
|
<UButton
|
||||||
|
v-if="items.length > 0"
|
||||||
|
label="Verificar todos"
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
:loading="checkingAll"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
@click="onCheckAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="loading" class="space-y-3">
|
||||||
|
<USkeleton v-for="i in 3" :key="i" class="h-24 w-full" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<div v-else-if="items.length === 0" class="text-center py-12">
|
||||||
|
<UIcon name="i-lucide-heart" class="text-4xl text-neutral-300 mb-2" />
|
||||||
|
<p class="text-neutral-500">No tienes vuelos guardados</p>
|
||||||
|
<UButton to="/search" label="Buscar vuelos" class="mt-3" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Items -->
|
||||||
|
<UCard v-for="item in items" :key="item.id">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<p class="font-semibold">{{ item.route_summary }}</p>
|
||||||
|
<UBadge :label="statusLabel(item.price_status)" :color="statusColor(item.price_status)" size="xs" />
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3 text-sm text-muted">
|
||||||
|
<span v-if="item.departure_date">
|
||||||
|
{{ new Date(item.departure_date).toLocaleDateString('es-ES', { day: 'numeric', month: 'short' }) }}
|
||||||
|
</span>
|
||||||
|
<span>Original: {{ item.original_price.toFixed(0) }}€</span>
|
||||||
|
<span v-if="item.current_price != null && item.current_price !== item.original_price"
|
||||||
|
:class="item.current_price < item.original_price ? 'text-green-600' : 'text-red-500'"
|
||||||
|
>
|
||||||
|
Actual: {{ item.current_price.toFixed(0) }}€
|
||||||
|
</span>
|
||||||
|
<span v-if="item.last_checked_at" class="text-xs">
|
||||||
|
Verificado {{ new Date(item.last_checked_at).toLocaleDateString('es-ES') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1 shrink-0">
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-refresh-cw"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
:loading="checkingId === item.id"
|
||||||
|
@click="onCheckPrice(item)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
:to="`/detail/${encodeURIComponent(item.booking_token)}?price=${item.original_price}&adults=${item.passengers_adult}&children=${item.passengers_child}&infants=${item.passengers_infant}`"
|
||||||
|
icon="i-lucide-external-link"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-trash-2"
|
||||||
|
color="error"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
@click="remove(item.id)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</UPageSection>
|
||||||
|
|
||||||
|
<!-- Recent searches -->
|
||||||
|
<UPageSection v-if="searches.length > 0" title="Busquedas recientes">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="flex gap-2 flex-wrap">
|
||||||
|
<div v-for="s in searches.slice(0, 8)" :key="s.id" class="flex items-center gap-0.5">
|
||||||
|
<UButton
|
||||||
|
:label="s.route_summary"
|
||||||
|
icon="i-lucide-history"
|
||||||
|
color="neutral"
|
||||||
|
variant="soft"
|
||||||
|
size="sm"
|
||||||
|
@click="replaySearch(s)"
|
||||||
|
/>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-bell-plus"
|
||||||
|
color="neutral"
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
title="Hacer seguimiento"
|
||||||
|
@click="trackSearch(s)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</UPageSection>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
129
docker-compose.yml
Normal file
129
docker-compose.yml
Normal 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
6
eslint.config.mjs
Normal 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
318
explore.mjs
Normal 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
29
nuxt.config.ts
Normal 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
16789
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
41
package.json
Normal file
41
package.json
Normal 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"
|
||||||
|
}
|
||||||
130
plans/search-queue-tracking.md
Normal file
130
plans/search-queue-tracking.md
Normal 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
393
plans/vuelato-plan.md
Normal 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
11571
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
ignoredBuiltDependencies:
|
||||||
|
- '@parcel/watcher'
|
||||||
|
- '@tailwindcss/oxide'
|
||||||
|
- esbuild
|
||||||
|
- unrs-resolver
|
||||||
|
- vue-demi
|
||||||
23
proto/inspirations.proto
Normal file
23
proto/inspirations.proto
Normal 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
61
proto/locations.proto
Normal 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
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
13
renovate.json
Normal file
13
renovate.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"extends": [
|
||||||
|
"github>nuxt/renovate-config-nuxt"
|
||||||
|
],
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true
|
||||||
|
},
|
||||||
|
"packageRules": [{
|
||||||
|
"matchDepTypes": ["resolutions"],
|
||||||
|
"enabled": false
|
||||||
|
}],
|
||||||
|
"postUpdateOptions": ["pnpmDedupe"]
|
||||||
|
}
|
||||||
520
scripts/discover-booking-urls.ts
Normal file
520
scripts/discover-booking-urls.ts
Normal 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)
|
||||||
|
})
|
||||||
14
server/api/airlines.get.ts
Normal file
14
server/api/airlines.get.ts
Normal 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
4
server/api/check.post.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { bookingToken, local, passengersCount } = await readBody(event)
|
||||||
|
return checkTrip(bookingToken, local, passengersCount)
|
||||||
|
})
|
||||||
3
server/api/countries.get.ts
Normal file
3
server/api/countries.get.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineCachedEventHandler(async () => {
|
||||||
|
return getCountries()
|
||||||
|
}, { maxAge: 60 * 60 * 24 }) // cache 24h
|
||||||
64
server/api/destination-image.get.ts
Normal file
64
server/api/destination-image.get.ts
Normal 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
|
||||||
|
})
|
||||||
4
server/api/detail.post.ts
Normal file
4
server/api/detail.post.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const { bookingToken, local, passengersCount } = await readBody(event)
|
||||||
|
return getTripDetail(bookingToken, local, passengersCount)
|
||||||
|
})
|
||||||
100
server/api/flight-info.get.ts
Normal file
100
server/api/flight-info.get.ts
Normal 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
|
||||||
4
server/api/inspirations.get.ts
Normal file
4
server/api/inspirations.get.ts
Normal 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')
|
||||||
|
})
|
||||||
3
server/api/locations.get.ts
Normal file
3
server/api/locations.get.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineCachedEventHandler(async () => {
|
||||||
|
return getLocations()
|
||||||
|
}, { maxAge: 60 * 60 * 24 }) // cache 24h
|
||||||
4
server/api/multi-city-inspirations.post.ts
Normal file
4
server/api/multi-city-inspirations.post.ts
Normal 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
16
server/api/profile.get.ts
Normal 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
|
||||||
|
})
|
||||||
28
server/api/profile.patch.ts
Normal file
28
server/api/profile.patch.ts
Normal 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
|
||||||
|
})
|
||||||
4
server/api/route-flights.post.ts
Normal file
4
server/api/route-flights.post.ts
Normal 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
52
server/api/search.post.ts
Normal 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
|
||||||
|
})
|
||||||
25
server/api/sync/airlines.post.ts
Normal file
25
server/api/sync/airlines.post.ts
Normal 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 }
|
||||||
|
})
|
||||||
59
server/api/sync/locations.post.ts
Normal file
59
server/api/sync/locations.post.ts
Normal 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 }
|
||||||
|
})
|
||||||
22
server/api/tracking/[id].delete.ts
Normal file
22
server/api/tracking/[id].delete.ts
Normal 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
Reference in New Issue
Block a user