diff --git a/.env b/.env deleted file mode 100644 index fd9093b..0000000 --- a/.env +++ /dev/null @@ -1 +0,0 @@ -NEXT_PUBLIC_STRIPE_KEY=pk_test_51O4Rq2HaexiOGySSjx149ZWEGTYULFYOe4uEadG1ivy2ugkXTtKCZoAqA1oboAQB65r6mmP4zcu1xCBq2vW8xwWW002NpKJvTI \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e9f20c7 --- /dev/null +++ b/.env.example @@ -0,0 +1,103 @@ +# ============================================================================== +# PLANTILLA DE CONFIGURACIÓN - PLUM VIAJES +# ============================================================================== +# +# Este archivo sirve como plantilla para configurar las variables de entorno. +# Copia este archivo como .env y completa los valores reales. +# + +# --- URLs de la Aplicación --- +NEXT_PUBLIC_URL=http://localhost:3000 +NEXT_PUBLIC_SITE_URL="https://tu-dominio.com" +NEXT_PUBLIC_BACKEND_URL="https://tu-dominio.com" +SANITY_STUDIO_URL=http://localhost:3000 +SANITY_STUDIO_NEXT_BACKEND="https://tu-dominio.com" + +# --- API Keys Internas --- +# Clave principal para uso interno del proyecto +NEXT_PUBLIC_API_KEY=tu_api_key_interna_aqui + +# Clave para Sanity Studio +SANITY_STUDIO_NEXT_API_KEY=tu_sanity_api_key_aqui +SANITY_API_KEY=tu_sanity_api_key_aqui + +# --- API Keys Externas (Para Proveedores Externos) --- +# Formato: EXTERNAL_API_KEY_[NOMBRE_PROVEEDOR]=sk_clave_segura +# Genera las claves con: node -e "console.log('sk_' + require('crypto').randomBytes(32).toString('hex'))" + +# Ejemplo para partners: +EXTERNAL_API_KEY_PLUM_PARTNERS=sk_partner_clave_generada_aqui + +# Agrega aquí nuevas API keys para proveedores externos: +# EXTERNAL_API_KEY_BOOKING_PARTNER=sk_booking_nueva_clave_aqui +# EXTERNAL_API_KEY_TRAVEL_AGENCY=sk_agency_nueva_clave_aqui +# EXTERNAL_API_KEY_INTEGRATION_SERVICE=sk_integration_nueva_clave_aqui + +# --- API Security --- +JWT_SECRET=tu-jwt-secret-minimo-32-caracteres-aqui +API_KEYS=plum-web-client,plum-sanity-studio,plum-mobile-app + +# --- Sanity CMS --- +NEXT_PUBLIC_SANITY_PROJECT_ID=tu_project_id +NEXT_PUBLIC_SANITY_DATASET=production +NEXT_PUBLIC_SANITY_API_VERSION=2024-07-15 +SANITY_STUDIO_AUTH_TOKEN=tu_sanity_auth_token_aqui +NEXT_PUBLIC_SANITY_AUTH_TOKEN=tu_sanity_auth_token_aqui + +# --- Redis Database --- +UPSTASH_REDIS_REST_URL=https://tu-redis-url.upstash.io +UPSTASH_REDIS_REST_TOKEN=tu_redis_token_aqui +REDIS_URL="redis://tu-url-redis-aqui" + +# --- Servicios Externos --- +# reCAPTCHA +NEXT_PUBLIC_RECAPTCHA_KEY=tu_recaptcha_key_aqui + +# Stripe +NEXT_PUBLIC_STRIPE_KEY=pk_test_tu_stripe_key_aqui + +# MongoDB +NEXT_PUBLIC_MONGO_SRV=mongodb+srv://usuario:password@cluster.mongodb.net/database + +# Resend (Email) +NEXT_PUBLIC_RESEND_API_KEY=re_tu_resend_key_aqui + +# --- Configuración de Aplicación --- +# Modo mantenimiento (descomenta y ajusta si es necesario) +# NEXT_PUBLIC_MAINTENANCE_MODE=false + +# --- Proveedores de Viajes --- +# Julia Tours +JULIA_TOURS_URL=http://ycixweb.juliatours.com.ar/WSJULIADEMO/WSJULIA.asmx +JULIA_TOURS_USER=tu_usuario_julia +JULIA_TOURS_PASSWORD=tu_password_julia +JULIA_TOURS_AGENCY_ID=tu_agency_id + +# OLA (Online Travel Agency) +OLA_URL=https://tu-url-ola.com.ar/endpoint?wsdl +OLA_USERNAME=tu_usuario_ola +OLA_UI_PASSWORD=tu_password_ola +OLA_API_KEY=tu_ola_api_key + +# OLA para Sanity Studio +SANITY_STUDIO_OLA_URL=https://tu-url-ola.com.ar/endpoint?wsdl +SANITY_STUDIO_OLA_USERNAME=tu_usuario_ola +SANITY_STUDIO_OLA_UI_PASSWORD=tu_password_ola +SANITY_STUDIO_OLA_API_KEY=tu_ola_api_key + +# ============================================================================== +# INSTRUCCIONES: +# +# 1. Para agregar un nuevo proveedor externo que consuma nuestras APIs: +# - Genera una API key: node -e "console.log('sk_' + require('crypto').randomBytes(32).toString('hex'))" +# - Agrega: EXTERNAL_API_KEY_[NOMBRE_PROVEEDOR]=sk_clave_generada +# - Reinicia el servidor +# - Entrega la API key al proveedor +# +# 2. El proveedor debe usar la API key en sus requests: +# Authorization: Bearer sk_clave_generada +# o +# X-API-Key: sk_clave_generada +# +# 3. Para más información consulta: API_AUTHENTICATION.md +# ============================================================================== diff --git a/.gitignore b/.gitignore index fd3dbb5..ac166fb 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ # misc .DS_Store *.pem +/dist # debug npm-debug.log* @@ -27,6 +28,7 @@ yarn-error.log* # local env files .env*.local +.env # vercel .vercel diff --git a/.vercelignore b/.vercelignore index d3037ae..e69de29 100644 --- a/.vercelignore +++ b/.vercelignore @@ -1 +0,0 @@ -sanity \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..ee3bdd7 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,28 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Next.js: debug server-side", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev" + }, + { + "name": "Next.js: debug client-side", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000" + }, + { + "name": "Next.js: debug full stack", + "type": "node-terminal", + "request": "launch", + "command": "npm run dev", + "serverReadyAction": { + "pattern": "- Local:.+(https?://.+)", + "uriFormat": "%s", + "action": "debugWithChrome" + } + } + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..b8ecf9e --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "workbench.colorCustomizations": { + "activityBar.background": "#1e703025", + "titleBar.activeBackground": "#1370271f", + "titleBar.activeForeground": "#FDF9FE" + } +} \ No newline at end of file diff --git a/API_AUTHENTICATION.md b/API_AUTHENTICATION.md new file mode 100644 index 0000000..c52405a --- /dev/null +++ b/API_AUTHENTICATION.md @@ -0,0 +1,362 @@ +# 🔐 Sistema de Autenticación de APIs - Plum Viajes + +Este documento explica cómo configurar y gestionar API keys para proveedores externos que deseen consumir las APIs de Plum Viajes. + +## 📋 Tabla de Contenidos + +- [Descripción General](#descripción-general) +- [Tipos de Autenticación](#tipos-de-autenticación) +- [Configuración de API Keys](#configuración-de-api-keys) +- [Cómo Generar una Nueva API Key](#cómo-generar-una-nueva-api-key) +- [Uso para Proveedores Externos](#uso-para-proveedores-externos) +- [Ejemplos de Implementación](#ejemplos-de-implementación) +- [Monitoreo y Seguridad](#monitoreo-y-seguridad) +- [Troubleshooting](#troubleshooting) + +## 🎯 Descripción General + +El sistema de autenticación de Plum Viajes permite tres tipos de acceso: + +1. **🏠 Acceso Interno**: Para el propio proyecto y componentes internos +2. **🤝 Acceso Externo**: Para proveedores y partners externos +3. **🌐 Acceso Público**: Para endpoints específicos sin autenticación + +## 🔑 Tipos de Autenticación + +### 1. Acceso Interno + +- **Quién**: Componentes del propio sistema (frontend, Sanity Studio, etc.) +- **Cómo**: Detección automática por origen o API keys internas +- **Rate Limit**: 1000 requests/minuto + +### 2. Acceso Externo + +- **Quién**: Proveedores, partners, servicios externos +- **Cómo**: API key en header `Authorization` o `X-API-Key` +- **Rate Limit**: 100 requests/minuto + +### 3. Acceso Público + +- **Quién**: Endpoints públicos específicos +- **Cómo**: Sin autenticación +- **Rate Limit**: 10 requests/minuto + +## ⚙️ Configuración de API Keys + +Las API keys se configuran en el archivo `.env` usando el siguiente formato: + +```bash +# API Keys Internas (ya configuradas) +NEXT_PUBLIC_API_KEY=pk_4770bb19d7f298fd312bc1bdc97ec39a8fdfc130304235ab88e6a345809728be2 +SANITY_STUDIO_NEXT_API_KEY=sanity_4640354b374a3418a65b4fe2bd50c8630fc9e36402cce2f921356b77c50c4c28 + +# API Keys Externas (para proveedores) +EXTERNAL_API_KEY_[NOMBRE_PROVEEDOR]=valor_de_la_clave +``` + +### Ejemplos de Configuración: + +```bash +# Para un proveedor específico +EXTERNAL_API_KEY_BOOKING_PARTNER=sk_booking_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6 + +# Para un partner comercial +EXTERNAL_API_KEY_TRAVEL_AGENCY_ABC=sk_agency_9876543210fedcba1234567890abcdef + +# Para un servicio de integración +EXTERNAL_API_KEY_INTEGRATION_SERVICE=sk_integration_abcdef1234567890fedcba0987654321 +``` + +## 🚀 Cómo Generar una Nueva API Key + +### Paso 1: Generar la API Key + +**Opción A: Usando Node.js (Recomendado)** + +```bash +# Generar una API key segura con prefijo +node -e "console.log('sk_' + require('crypto').randomBytes(32).toString('hex'))" +``` + +**Opción B: Usando comando** + +```bash +# Generar UUID +node -e "console.log('sk_' + require('crypto').randomUUID().replace(/-/g, ''))" +``` + +**Ejemplo de salida:** + +``` +sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6 +``` + +### Paso 2: Agregar al archivo .env + +1. Abre el archivo `.env` en la raíz del proyecto +2. Agrega la nueva variable siguiendo el formato: + +```bash +# Nuevo proveedor: TravelCorp +EXTERNAL_API_KEY_TRAVELCORP=sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6 +``` + +### Paso 3: Reiniciar el Servidor + +```bash +# Detener el servidor y reiniciar +npm run dev +# o +yarn dev +``` + +### Paso 4: Verificar la Configuración + +Puedes verificar que la API key fue configurada correctamente accediendo (internamente) a: + +``` +GET /api/auth/status +``` + +## 👥 Uso para Proveedores Externos + +### Headers de Autenticación + +Los proveedores externos pueden usar cualquiera de estos formatos: + +**Opción 1: Authorization Bearer (Recomendado)** + +```javascript +fetch("https://plum-viajes.vercel.app/api/packages", { + headers: { + Authorization: + "Bearer sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6", + "Content-Type": "application/json", + }, +}); +``` + +**Opción 2: X-API-Key Header** + +```javascript +fetch("https://plum-viajes.vercel.app/api/packages", { + headers: { + "X-API-Key": + "sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6", + "Content-Type": "application/json", + }, +}); +``` + +### Endpoints Disponibles + +| Endpoint | Método | Descripción | Autenticación | +| ---------------------------- | ------ | ------------------------ | ------------- | +| `/api/packages` | GET | Listar paquetes | Requerida | +| `/api/packages/availability` | POST | Consultar disponibilidad | Requerida | +| `/api/packages/detail` | POST | Detalle de paquete | Requerida | +| `/api/hotels` | GET | Listar hoteles | Requerida | +| `/api/cities` | GET | Listar ciudades | Requerida | +| `/api/airlines` | GET | Listar aerolíneas | Requerida | + +## 💻 Ejemplos de Implementación + +### JavaScript/Node.js + +```javascript +const PLUM_API_KEY = + "sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6"; +const BASE_URL = "https://plum-viajes.vercel.app/api"; + +async function getPackages() { + try { + const response = await fetch(`${BASE_URL}/packages`, { + headers: { + Authorization: `Bearer ${PLUM_API_KEY}`, + "Content-Type": "application/json", + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + return data; + } catch (error) { + console.error("Error fetching packages:", error); + throw error; + } +} + +async function checkAvailability(searchParams) { + try { + const response = await fetch(`${BASE_URL}/packages/availability`, { + method: "POST", + headers: { + Authorization: `Bearer ${PLUM_API_KEY}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(searchParams), + }); + + const data = await response.json(); + return data; + } catch (error) { + console.error("Error checking availability:", error); + throw error; + } +} +``` + +### Python + +```python +import requests +import json + +PLUM_API_KEY = 'sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6' +BASE_URL = 'https://plum-viajes.vercel.app/api' + +def get_packages(): + headers = { + 'Authorization': f'Bearer {PLUM_API_KEY}', + 'Content-Type': 'application/json' + } + + response = requests.get(f'{BASE_URL}/packages', headers=headers) + response.raise_for_status() + return response.json() + +def check_availability(search_params): + headers = { + 'Authorization': f'Bearer {PLUM_API_KEY}', + 'Content-Type': 'application/json' + } + + response = requests.post( + f'{BASE_URL}/packages/availability', + headers=headers, + json=search_params + ) + response.raise_for_status() + return response.json() +``` + +### cURL + +```bash +# Obtener paquetes +curl -X GET "https://plum-viajes.vercel.app/api/packages" \ + -H "Authorization: Bearer sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6" \ + -H "Content-Type: application/json" + +# Consultar disponibilidad +curl -X POST "https://plum-viajes.vercel.app/api/packages/availability" \ + -H "Authorization: Bearer sk_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8s9t0u1v2w3x4y5z6a1b2c3d4e5f6" \ + -H "Content-Type: application/json" \ + -d '{ + "departureCity": "BUE", + "arrivalCity": "MIA", + "startDate": "2025-12-01", + "endDate": "2025-12-15", + "occupancy": "2|0" + }' +``` + +## 🔍 Monitoreo y Seguridad + +### Endpoint de Estado (Solo Interno) + +``` +GET /api/auth/status +``` + +Retorna información sobre: + +- Estado de configuración +- Estadísticas de API keys +- Proveedores configurados +- Rate limits actuales + +### Rate Limits por Tipo + +- **Interno**: 1000 requests/minuto +- **Externo**: 100 requests/minuto +- **Público**: 10 requests/minuto + +### Logs de Seguridad + +El sistema automáticamente registra: + +- Intentos de autenticación fallidos +- Uso excesivo de rate limits +- API keys inválidas o expiradas + +### Buenas Prácticas + +1. **🔐 Mantén las API keys seguras**: No las incluyas en código público +2. **🔄 Rota las API keys regularmente**: Especialmente si sospechas compromiso +3. **📊 Monitorea el uso**: Revisa logs regularmente +4. **⚡ Respeta los rate limits**: Implementa retry logic con backoff +5. **🛡️ Usa HTTPS siempre**: Para todas las llamadas a la API + +## 🚨 Troubleshooting + +### Error 401: Unauthorized + +```json +{ + "error": true, + "message": "Authentication failed", + "code": "AUTH_FAILED" +} +``` + +**Solución**: Verifica que la API key sea correcta y esté en el header correcto. + +### Error 429: Too Many Requests + +```json +{ + "error": "Too many requests" +} +``` + +**Solución**: Has excedido el rate limit. Espera antes de hacer más requests. + +### Error 403: CORS Error + +```json +{ + "error": "CORS Error: Origin not allowed" +} +``` + +**Solución**: Contacta al equipo de Plum Viajes para agregar tu dominio a la lista de orígenes permitidos. + +### API Key no funciona después de crearla + +1. Verifica que el formato sea correcto: `EXTERNAL_API_KEY_[NOMBRE]=valor` +2. Reinicia el servidor después de agregar la variable +3. Verifica que no haya espacios o caracteres especiales en el nombre del proveedor + +## 📞 Soporte + +Para solicitar una nueva API key o reportar problemas: + +1. **Email**: [email de soporte] +2. **Documentación técnica**: Este documento +3. **Status page**: `/api/auth/status` (solo acceso interno) + +## 📝 Changelog + +### 2025-07-02 + +- ✅ Documentación inicial del sistema de autenticación +- ✅ Ejemplos de implementación en múltiples lenguajes +- ✅ Guía completa de configuración y troubleshooting + +--- + +**¿Necesitas ayuda?** Contacta al equipo de desarrollo de Plum Viajes. diff --git a/README.md b/README.md index 676414b..d807146 100644 --- a/README.md +++ b/README.md @@ -1,65 +1 @@ -### Installation Command: - -```bash -npm i stripe use-shopping-cart next-sanity @stripe/stripe-js @sanity/image-url --force -``` - -## Hero Images: -https://github.com/ski043/nextjs-commerce-tutorial/tree/main/public/HeroImages - -## Products: - -#Product One: -Nike Air VaporMax 2023 Flyknit - -Price: 200 - -Category: Men - -description: -Elevate your sneaker game to new heights with the latest evolution of the iconic Air VaporMax series. The 2023 Flyknit combines cutting-edge technology, exceptional comfort, and bold style. Its innovative Flyknit upper offers a second-skin fit, ensuring a snug yet breathable feel with every step. The renowned VaporMax sole unit delivers unparalleled cushioning and responsiveness, providing a smooth ride that's perfect for both athletic performance and street-style fashion. - -images: https://github.com/ski043/nextjs-commerce-tutorial/tree/main/public/ProductOne - - -#Product Two: -Nike Sportswear Phoenix Fleece - -Price: 35 - -Category: Women - -Description: -Crafted with a blend of warmth and style, the Phoenix Fleece is a versatile addition to your wardrobe. Its soft and cozy fleece fabric offers a perfect balance of comfort and durability, making it ideal for cool days and relaxed outings. With a modern, sporty design and the iconic Nike Swoosh, this fleece adds a touch of urban flair to your look. Whether you're hitting the gym or hanging out with friends, the Nike Sportswear Phoenix Fleece keeps you both cozy and stylish. Elevate your everyday wear with this classic piece of Nike Sportswear. - -images: https://github.com/ski043/nextjs-commerce-tutorial/tree/main/public/ProductTwo - -#Product Three: -Nike Air Force 1 '07 - -Price: 85 - -Category: Teens - -Description: -The Nike Air Force 1 '07 represents a legend in the world of sneakers. With a design that transcends generations, this classic silhouette has remained a symbol of street-style culture for over three decades. Its white leather upper and clean lines are a canvas for self-expression, allowing you to pair it with any outfit, from casual to chic. - -Images: https://github.com/ski043/nextjs-commerce-tutorial/tree/main/public/ProductThree - -#Product Four -Nike Windrunner - -Price: 200 - -Category: Men - -Description: -The Nike Windrunner is more than just a jacket; it's a symbol of enduring style and performance. With a design that has stood the test of time, this lightweight and versatile outerwear piece is your go-to choice for brisk mornings, breezy afternoons, and everything in between. Its distinctive chevron design on the chest pays homage to its heritage, while the modern materials and construction ensure it's ready for the demands of today. - -Images: https://github.com/ski043/nextjs-commerce-tutorial/tree/main/public/ProductFour - -## Deploy on Vercel - -The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. - -Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +### Plum Viajes diff --git a/app/[[...slug]]/page.jsx b/app/[[...slug]]/page.jsx new file mode 100644 index 0000000..49a48bd --- /dev/null +++ b/app/[[...slug]]/page.jsx @@ -0,0 +1,84 @@ +import imageUrlBuilder from "@sanity/image-url"; +import React from "react"; +import RenderSections from "../components/RenderSections"; +import { getData } from "../actions/sanity"; +import { client } from "../lib/client"; + +export async function generateMetadata(props, parent) { + const params = await props.params; + const data = await getData(params); + + if (!data) { + return { + title: "Landing no encontrada", + description: "La página solicitada no se encontró.", + manifest: "/manifest.json", + }; + } + + const { + title = "Missing title", + description, + disallowRobots, + openGraphImage, + content = [], + config = {}, + slug, + } = data; + + const builder = imageUrlBuilder(client); + + const openGraphImages = openGraphImage + ? [ + { + url: builder.image(openGraphImage).width(800).height(600).url(), + width: 800, + height: 600, + alt: title, + }, + { + // Tamaño recomendado para Facebook + url: builder.image(openGraphImage).width(1200).height(630).url(), + width: 1200, + height: 630, + alt: title, + }, + { + // Cuadrado 1:1 + url: builder.image(openGraphImage).width(600).height(600).url(), + width: 600, + height: 600, + alt: title, + }, + ] + : []; + + return { + title, + titleTemplate: `%s | ${config.title}`, + description, + canonical: config.url && `${config.url}/${slug}`, + manifest: "/manifest.json", + openGraph: { + images: openGraphImages, + }, + noindex: { disallowRobots }, + }; +} + +export default async function LandingPage(props) { + const params = await props.params; + const data = await getData(params); + + // Verifica si no se obtuvo data o no hay contenido + if (!data || !data.content || data.content.length === 0) { + return ( +
+

Landing no encontrada

+

No se encontró contenido para la página solicitada.

+
+ ); + } + + return ; +} diff --git a/app/[category]/page.tsx b/app/[category]/page.tsx deleted file mode 100644 index ddc333a..0000000 --- a/app/[category]/page.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import Link from "next/link"; -import { simplifiedProduct } from "../interface"; -import { client } from "../lib/sanity"; -import Image from "next/image"; - -async function getData(cateogry: string) { - const query = `*[_type == "product" && category->name == "${cateogry}"] { - _id, - "imageUrl": images[0].asset->url, - price, - name, - "slug": slug.current, - "categoryName": category->name - }`; - - const data = await client.fetch(query); - - return data; -} - -export const dynamic = "force-dynamic"; - -export default async function CategoryPage({ - params, -}: { - params: { category: string }; -}) { - const data: simplifiedProduct[] = await getData(params.category); - - return ( -
-
-
-

- Our Products for {params.category} -

-
- -
- {data.map((product) => ( -
-
- Product image -
- -
-
-

- - {product.name} - -

-

- {product.categoryName} -

-
-

- ${product.price} -

-
-
- ))} -
-
-
- ); -} diff --git a/app/actions/forms.js b/app/actions/forms.js new file mode 100644 index 0000000..4e05422 --- /dev/null +++ b/app/actions/forms.js @@ -0,0 +1,23 @@ +"use server"; + +import ContactService from "../services/contact.service"; + +export async function submitContactForm(formData, consumer = "contact") { + try { + const sendEmailResponse = await ContactService.sendMail(formData, consumer); + return sendEmailResponse; + } catch (error) { + return { success: false, error }; + } +} + +export async function submitAgentContactForm(formData) { + try { + const sendEmailResponse = await ContactService.sendMail(formData, "agent"); + return sendEmailResponse; + } catch (error) { + return { success: false, error }; + } +} + +export async function submitGenericForm(formData) {} diff --git a/app/actions/sanity.js b/app/actions/sanity.js new file mode 100644 index 0000000..2d32554 --- /dev/null +++ b/app/actions/sanity.js @@ -0,0 +1,54 @@ +import { groq } from "next-sanity"; +import { sanityFetch } from "../lib/sanityFetch"; +import { getSlugVariations, slugParamToPath } from "../../utils/urls"; + +export async function getData(params) { + let data; + const pageFragment = groq` +..., +content[] { + ..., + cta { + ..., + route-> + }, + ctas[] { + ..., + route-> + } +}`; + + const slug = slugParamToPath(params?.slug); + const pageQuery = groq`*[_type == "route" && slug.current in $possibleSlugs]{ + page-> { + ${pageFragment} + } + }`; + + // Frontpage - fetch the linked `frontpage` from the global configuration document. + if (slug === "/") { + const frontQuery = groq` + *[_id == "siteConfig"][0]{ + frontpage -> { + ${pageFragment} + } + } + `; + data = await sanityFetch({ query: frontQuery }).then((res) => + res?.frontpage ? { ...res.frontpage, slug } : undefined + ); + } else { + // Regular route + data = await sanityFetch({ + query: pageQuery, + params: { possibleSlugs: getSlugVariations(slug) }, + }).then((res) => { + return res[0]?.page ? { ...res[0].page, slug } : undefined; + }); + } + + if (!data?._type === "page") { + throw new Error("page not found"); + } + return data; +} diff --git a/app/api/airline/[airlineId]/route.js b/app/api/airline/[airlineId]/route.js new file mode 100644 index 0000000..ba72f39 --- /dev/null +++ b/app/api/airline/[airlineId]/route.js @@ -0,0 +1,14 @@ +import { Airlines } from "../../services/airlines.service"; +import { ApiUtils } from "../../services/apiUtils.service"; + +export async function GET(req, props) { + const params = await props.params; + const { airlineId } = params; + const airlineArrResponse = await ApiUtils.requestHandler( + Airlines.getByCode(airlineId), + "airlines" + ); + const airlineResponse = airlineArrResponse[0]; + + return Response.json(airlineResponse); +} diff --git a/app/api/airlines/route.js b/app/api/airlines/route.js new file mode 100644 index 0000000..4a2c870 --- /dev/null +++ b/app/api/airlines/route.js @@ -0,0 +1,6 @@ +import { Airlines } from "../services/airlines.service"; + +export async function GET(request) { + const response = await Airlines.get(); + return Response.json(response); +} diff --git a/app/api/auth/status/route.js b/app/api/auth/status/route.js new file mode 100644 index 0000000..9dbba41 --- /dev/null +++ b/app/api/auth/status/route.js @@ -0,0 +1,27 @@ +import { auth, validateConfig } from "../../../lib/auth/index.js"; + +/** + * Authentication system status endpoint + * Internal-only for monitoring the auth system + */ +export async function GET(request, context) { + const configValidation = validateConfig(); + const stats = auth.keyManager.getStats(); + + return Response.json({ + status: "ok", + timestamp: new Date().toISOString(), + config: { + isValid: configValidation.isValid, + errors: configValidation.errors, + warnings: configValidation.warnings, + externalKeysConfigured: configValidation.externalKeysCount, + }, + stats: { + totalExternalKeys: stats.totalExternalKeys, + activeExternalKeys: stats.activeExternalKeys, + providers: stats.providers, + }, + version: "1.0.0", + }); +} diff --git a/app/api/cities/autocomplete/route.js b/app/api/cities/autocomplete/route.js new file mode 100644 index 0000000..4c8c4d2 --- /dev/null +++ b/app/api/cities/autocomplete/route.js @@ -0,0 +1,37 @@ +import CACHE from "../../../constants/cachePolicies"; +import { ApiUtils } from "../../services/apiUtils.service"; + +export async function GET(req) { + const { searchParams } = new URL(req.url); + const query = searchParams.get("query"); + //const input = searchParams.get("input"); + /* if (input === "arrivalCity") { */ + const host = req.headers.get("host"); // Obtiene el host actual (ej. plum-viajes.vercel.app) + const citiesSearch = await ApiUtils.requestHandler( + fetch( + `${!host.includes("localhost") ? `https` : `http`}://${host}/api/cities/byName?name=${query}`, + { + method: "GET", + next: { + revalidate: CACHE.revalidation.cities, + }, + headers: ApiUtils.getCommonHeaders(), + } + ), + "GET | Autocomplete Api" + ); + const citiesResponse = await citiesSearch.json(); + if (!citiesResponse || citiesResponse.length === 0) { + throw new Error("No cities found"); + } + const autocompleteResponse = citiesResponse.map( + ({ _id, name, country_name, region_name, iata_code }) => ({ + id: _id, + name, + label: `${name}, ${country_name}`, + value: iata_code, + }) + ); + + return Response.json(autocompleteResponse); +} diff --git a/app/api/cities/byCode/route.js b/app/api/cities/byCode/route.js new file mode 100644 index 0000000..e334e40 --- /dev/null +++ b/app/api/cities/byCode/route.js @@ -0,0 +1,14 @@ +import SanityService from "../../services/sanity.service"; + +export async function GET(req) { + const { searchParams } = new URL(req.url); + const code = searchParams.get("code"); + + const response = await SanityService.getFromSanity( + `*[_type == "city" && iata_code == "${code}"]` + ); + + if (response?.error) Response.json(response?.error); + + return Response.json(response); +} diff --git a/app/api/cities/byName/route.js b/app/api/cities/byName/route.js new file mode 100644 index 0000000..1a62f18 --- /dev/null +++ b/app/api/cities/byName/route.js @@ -0,0 +1,13 @@ +import SanityService from "../../services/sanity.service"; + +export async function GET(req) { + const { searchParams } = new URL(req.url); + const name = searchParams.get("name"); + const sanityResponse = await SanityService.getFromSanity( + `*[_type == "city" && name match "*${name}*"]` + ); + + if (sanityResponse?.error) Response.json(sanityResponse?.error); + + return Response.json(sanityResponse); +} diff --git a/app/api/cities/route.js b/app/api/cities/route.js new file mode 100644 index 0000000..8847f4c --- /dev/null +++ b/app/api/cities/route.js @@ -0,0 +1,9 @@ +import SanityService from "../services/sanity.service"; + +export async function GET(req) { + const response = await SanityService.getFromSanity(" *[_type == 'city'] "); + + if (response?.error) return Response.json(response?.error); + + return Response.json(response); +} diff --git a/app/api/contact/route.js b/app/api/contact/route.js new file mode 100644 index 0000000..6bc3a4f --- /dev/null +++ b/app/api/contact/route.js @@ -0,0 +1,3 @@ +export function POST(request) { + Response.status(200).json({ message: "Hello from Next.js!" }); +} diff --git a/app/api/cron/clean-old-tagged-packages/route.js b/app/api/cron/clean-old-tagged-packages/route.js new file mode 100644 index 0000000..5fd70cc --- /dev/null +++ b/app/api/cron/clean-old-tagged-packages/route.js @@ -0,0 +1,163 @@ +import { auth } from "../../../lib/auth/index.js"; +import SanityService from "../../services/sanity.service.js"; + +/** + * Cron Job: Limpiar paquetes etiquetados antiguos + * + * Elimina todos los documentos del schema 'taggedPackages' + * cuya fecha departureFrom sea anterior a hoy. + * + * Ejecuta diariamente a las 00:00 UTC via Vercel Cron + */ + +async function cleanOldTaggedPackagesHandler(request, context) { + try { + console.log("🧹 Iniciando limpieza de taggedPackages antiguos..."); + + // Obtener la fecha actual en formato ISO (YYYY-MM-DD) + const today = new Date(); + const todayISO = today.toISOString().split("T")[0]; + + console.log(`📅 Fecha de referencia: ${todayISO}`); + + // Query para encontrar taggedPackages con departureFrom anterior a hoy + const queryOldPackages = `*[ + _type == "taggedPackages" && + departureFrom < "${todayISO}" + ]`; + + // Obtener los documentos que se van a eliminar (para logging) + const oldPackages = await SanityService.getFromSanity(queryOldPackages); + + if (!oldPackages || oldPackages.length === 0) { + console.log("✅ No se encontraron taggedPackages antiguos para eliminar"); + return Response.json({ + success: true, + message: "No hay paquetes antiguos para eliminar", + deleted: 0, + executedAt: new Date().toISOString(), + }); + } + + console.log( + `🗑️ Encontrados ${oldPackages.length} taggedPackages para eliminar:` + ); + oldPackages.forEach((pkg) => { + console.log( + ` - ID: ${pkg._id}, departureFrom: ${pkg.departureFrom}, title: ${pkg.title || "Sin título"}` + ); + }); + + // Eliminar los documentos antiguos + const deleteResult = await SanityService.deleteByQuery(queryOldPackages); + + console.log( + `✅ Limpieza completada: ${deleteResult.deleted} documentos eliminados` + ); + + return Response.json({ + success: true, + message: `Limpieza completada exitosamente`, + deleted: deleteResult.deleted, + deletedPackages: oldPackages.map((pkg) => ({ + id: pkg._id, + title: pkg.title || "Sin título", + departureFrom: pkg.departureFrom, + })), + executedAt: new Date().toISOString(), + }); + } catch (error) { + console.error("❌ Error en limpieza de taggedPackages:", error); + + return Response.json( + { + success: false, + error: "Error interno en la limpieza", + message: error.message, + executedAt: new Date().toISOString(), + }, + { status: 500 } + ); + } +} + +/** + * Verificar que la request viene de Vercel Cron + */ +function isValidCronRequest(request) { + // Vercel incluye este header en requests de cron jobs + const cronSecret = request.headers.get("authorization"); + const userAgent = request.headers.get("user-agent"); + + // Verificar que viene de Vercel Cron + if (userAgent && userAgent.includes("vercel")) { + return true; + } + + // También permitir si tiene una API key válida (para testing manual) + if (cronSecret) { + return true; + } + + return false; +} + +/** + * Handler principal con autenticación específica para cron + */ +async function cronHandler(request, context) { + // Verificación específica para cron jobs + if (!isValidCronRequest(request)) { + return Response.json( + { + error: "Acceso no autorizado", + message: + "Esta ruta solo es accesible via Vercel Cron o con API key válida", + }, + { status: 403 } + ); + } + + return cleanOldTaggedPackagesHandler(request, context); +} + +// Solo permitir POST (Vercel Cron usa POST) +export async function POST(request, context) { + return cronHandler(request, context); +} +console.log("das"); + +// GET para testing manual con autenticación +export async function GET(request, context) { + console.log("🧪 Ejecutando limpieza manual (modo testing)"); + + // Verificar autenticación interna para testing manual + const authHeader = request.headers.get("authorization"); + if (!authHeader || !authHeader.startsWith("Bearer ")) { + return Response.json( + { + error: "No autorizado", + message: "Se requiere autenticación para acceso manual", + }, + { status: 401 } + ); + } + + const token = authHeader.split(" ")[1]; + const { PLUM_INTERNAL_API_KEY } = process.env; + + if (!token || token !== PLUM_INTERNAL_API_KEY) { + return Response.json( + { + error: "Token inválido", + message: "API key interna requerida para testing manual", + }, + { status: 403 } + ); + } + + return cleanOldTaggedPackagesHandler(request, context); +} + +// Exportar el POST sin wrapper de auth (manejo manual) +// GET sí usa auth.internal para testing desde el proyecto diff --git a/app/api/crypto/departureId/route.js b/app/api/crypto/departureId/route.js new file mode 100644 index 0000000..1d36f1c --- /dev/null +++ b/app/api/crypto/departureId/route.js @@ -0,0 +1,14 @@ +import CryptoService from "../../services/cypto.service"; + +export async function POST(req) { + const body = await req.json(); + const { provider, departureFrom } = body; + const cryptoDepartureId = CryptoService.generateDepartureId( + provider, + departureFrom + ); + + return Response.json({ + departureId: cryptoDepartureId, + }); +} diff --git a/app/api/filters/route.js b/app/api/filters/route.js new file mode 100644 index 0000000..a6bc84f --- /dev/null +++ b/app/api/filters/route.js @@ -0,0 +1,96 @@ +import { capitalizeFirstLetter } from "../../helpers/strings"; +import { Filters } from "../services/filters.service"; + +/** + * Función recursiva para obtener valores a partir de un objeto y una ruta (path) + * que puede incluir notación dot y arrays mediante "[]". + * + * @param {Object|Array} obj - Objeto o array actual. + * @param {Array} paths - Array de segmentos del camino. + * @returns {Array} - Array de valores encontrados. + */ +function getValuesAtPath(obj, paths) { + if (paths.length === 0) { + return [obj]; + } + + const [currentPath, ...restPaths] = paths; + let results = []; + + if (typeof obj === "undefined" || obj === null) { + return results; + } + + if (currentPath.endsWith("[]")) { + const key = currentPath.slice(0, -2); + const arr = obj[key]; + if (!Array.isArray(arr)) { + return []; + } + arr.forEach((item) => { + results = results.concat(getValuesAtPath(item, restPaths)); + }); + } else { + const nextObj = obj[currentPath]; + results = results.concat(getValuesAtPath(nextObj, restPaths)); + } + + return results; +} + +/** + * Extrae los valores de un paquete basándose en la ruta (grouper) definida en la configuración del filtro. + * + * @param {Array} availability - Lista de paquetes disponibles. + * @param {string} grouper - Ruta con notación dot y "[]" para arrays. + * @returns {Array} - Valores únicos extraídos, normalizados a minúsculas. + */ +function extractFilterValues(availability, grouper) { + const result = new Set(); + const paths = grouper.split("."); + + availability.forEach((pkg) => { + const values = getValuesAtPath(pkg, paths); + values.forEach((value) => { + if (value !== undefined && value !== null) { + result.add(String(value).toLowerCase()); + } + }); + }); + + return Array.from(result); +} + +/** + * Construye dinámicamente los filtros basados en la disponibilidad y la configuración definida. + * + * @param {Array} availability - Lista de paquetes disponibles. + * @returns {Array} - Lista de filtros construidos dinámicamente. + */ +function buildFilters(availability) { + const config = Filters.config; // Configuración de filtros desde el servicio + return config.map((filter) => { + const values = extractFilterValues(availability, filter.grouper); + return { + id: filter.id, + title: filter.title, + type: filter.type, + items: values.map((value) => ({ + label: capitalizeFirstLetter(value), + value: value, + })), + }; + }); +} + +/** + * API que recibe la disponibilidad y devuelve los filtros dinámicos basados en ella. + * + * @param {Request} req - Solicitud HTTP entrante. + * @returns {Promise} - Filtros generados dinámicamente en formato JSON. + */ +export async function POST(req) { + const { availability } = await req.json(); + const filters = buildFilters(availability); + return Response.json(filters); +} diff --git a/app/api/hotel/[hotelId]/route.js b/app/api/hotel/[hotelId]/route.js new file mode 100644 index 0000000..9d15ded --- /dev/null +++ b/app/api/hotel/[hotelId]/route.js @@ -0,0 +1,9 @@ +import { Hotels } from "../../services/hotels.service"; + +export async function GET(req, props) { + const params = await props.params; + const { hotelId } = params; + const baseHotelsResponse = await Hotels.getById(hotelId); + + return Response.json(baseHotelsResponse[0]); +} diff --git a/app/api/hotels/byName/route.js b/app/api/hotels/byName/route.js new file mode 100644 index 0000000..4ce7e8d --- /dev/null +++ b/app/api/hotels/byName/route.js @@ -0,0 +1,9 @@ +import { ApiUtils } from "../../services/apiUtils.service"; +import { Hotels } from "../../services/hotels.service"; + +export async function GET(req) { + const { searchParams } = new URL(req.url); + const name = searchParams.get("name"); + const response = await ApiUtils.requestHandler(Hotels.getByNameIlike(name)); + return Response.json(response); +} diff --git a/app/api/hotels/route.js b/app/api/hotels/route.js new file mode 100644 index 0000000..f097903 --- /dev/null +++ b/app/api/hotels/route.js @@ -0,0 +1,6 @@ +import { Hotels } from "../services/hotels.service"; + +export async function GET(request) { + const response = await Hotels.get(); + return Response.json(response); +} diff --git a/app/api/landing/destination/route.js b/app/api/landing/destination/route.js new file mode 100644 index 0000000..a7ad216 --- /dev/null +++ b/app/api/landing/destination/route.js @@ -0,0 +1,28 @@ +import { groq } from "next-sanity"; +import SanityService from "../../services/sanity.service"; + +async function getPkgLandingData(destination) { + const cityData = await SanityService.getFromSanity( + groq`*[_type == "city" && (country_name match "*${destination}*" || region_name match "*${destination}*" || name match "*${destination}*")] { + name, + country_name, + iata_code, + region_name, + "images": coalesce(images[].asset->url, []) + }` + ); + + return cityData; +} + +export async function POST(req) { + const body = await req.json(); + const { product, destination } = body; + switch (product) { + case "packages": + const response = await getPkgLandingData(destination); + return Response.json(response); + default: + return Response.json({}); + } +} diff --git a/app/api/lib/redis.js b/app/api/lib/redis.js new file mode 100644 index 0000000..5043245 --- /dev/null +++ b/app/api/lib/redis.js @@ -0,0 +1,8 @@ +import { Redis } from "@upstash/redis"; + +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, +}); + +export default redis; diff --git a/app/api/packages/availability/pbase/route.js b/app/api/packages/availability/pbase/route.js new file mode 100644 index 0000000..546c5a7 --- /dev/null +++ b/app/api/packages/availability/pbase/route.js @@ -0,0 +1,223 @@ +import { groq } from "next-sanity"; +import { Julia } from "../../../services/julia.service"; +import { ProviderService } from "../../../services/providers"; +import { OLA } from "../../../services/ola.service"; +import CryptoService from "../../../services/cypto.service"; +import PackageApiService from "../../../services/packages.service"; +import { sanityFetch } from "../../../../lib/sanityFetch"; + +/** + * Obtiene paquetes disponibles desde el proveedor Plum usando Sanity. + * + * @param {Object} params - Parámetros de búsqueda (ciudad de llegada, salida, fechas). + * @returns {Promise} - Respuesta con los paquetes disponibles. + */ +async function fetchPlumPackages({ + arrivalCity, + departureCity, + startDate, + endDate, +}) { + // Consulta GROQ para obtener paquetes desde Sanity + const pkgAvailQuery = groq`*[_type == "packages" + && "${departureCity}" in origin + && "${arrivalCity}" in destination[]->iata_code + && now() >= validDateFrom + && now() <= validDateTo + && active == true] { + ..., + "subtitle" : "Paquetes a " + origin[0] + " con aéreo " + departures[0].typeRt1 + " de " + departures[0].airlineRt1->name, + + // Filtramos las salidas que tienen fechas válidas + "departures": departures[departureFrom >= "${startDate}" && departureFrom < "${endDate}"] { + ..., + + // Desreferenciar el array de hoteles + "hotels": hotels[]-> { + _id, + name, + stars, + description, + latitude, + longitude, + plum_id, + + // Desreferenciamos la ciudad + "city": city_id-> { + iata_code, + name, + country_name + }, + // TODO: El mealPlan debe ir por hotel, no debería ser el mismo para todos. + "mealPlan": ^.mealPlan, + // TODO: No obtener el roomtype del tipo de price. Debemos refactorizar esto y ver si es necesario + // Incluir el tipo de habitación. + "roomType": ^.prices[0].type, + "roomSize": ^.roomSize, + }, + + "airlineRt1": airlineRt1-> { + code, + name + }, + "airlineRt2": airlineRt2-> { + code, + name + } + } + }`; + + const sanityQuery = await sanityFetch({ query: pkgAvailQuery }); + const pkgAvailResponse = await sanityQuery; + const onlyPkgWithDepartures = pkgAvailResponse.filter( + (pkg) => pkg.departures.length > 0 + ); + + // Ordenar las departures para que las agotadas se envíen al final + const sortedPkgWithDepartures = onlyPkgWithDepartures.map((pkg) => { + const sortedDepartures = pkg.departures.sort((a, b) => { + return a.departureSeats === 0 ? 1 : b.departureSeats === 0 ? -1 : 0; + }); + return { + ...pkg, + departures: sortedDepartures, + }; + }); + + // Generar departureId en cada objeto de departures sin alterar la estructura + const pkgWithIdentifiedDepartures = sortedPkgWithDepartures.map((pkg) => { + return { + ...pkg, + departures: pkg.departures.map((departure) => ({ + ...departure, + id: CryptoService.generateDepartureId("plum", departure.departureFrom), + })), + }; + }); + + const departuresGroup = PackageApiService.departures.plum.getDeparturesGroup( + pkgWithIdentifiedDepartures + ); + + // Mapeamos la respuesta para adaptarla a nuestro formato interno + const mapResponse = { + packages: ProviderService.mapper( + pkgWithIdentifiedDepartures, + "plum", + "avail" + ), + departuresGroup, + }; + + return Response.json(mapResponse); +} + +/** + * Obtiene paquetes disponibles desde el proveedor OLA. + * + * @param {Object} searchParams - Parámetros de búsqueda (ciudad de llegada, salida, fechas, habitaciones). + * @returns {Promise} - Paquetes disponibles agrupados por OLA. + */ +async function fetchOlaPackages(searchParams) { + const getPackagesFaresRequest = OLA.avail.getRequest(searchParams); + + try { + const olaAvailRequest = await fetch( + OLA.avail.url(), + OLA.avail.options(getPackagesFaresRequest) + ); + const olaAvailResponse = await olaAvailRequest.json(); + + const pkgWithIdentifiedDepartures = olaAvailResponse.map((pkg) => { + return { + ...pkg, + id: CryptoService.generateDepartureId( + "ola", + pkg.Flight.Trips.Trip[0].DepartureDate + ), + }; + }); + + const departuresGroup = PackageApiService.departures.ola.getDeparturesGroup( + pkgWithIdentifiedDepartures + ); + + const mapResponse = ProviderService.mapper( + pkgWithIdentifiedDepartures, + "ola", + "avail" + ); + + const groupedResponse = ProviderService.ola.grouper(mapResponse); + + const response = { + packages: groupedResponse, + departuresGroup, + }; + + return response; + } catch (error) { + console.error("Error fetching OLA packages", error); + } +} + +/** + * Obtiene paquetes disponibles desde el proveedor Julia. + * + * @param {Object} searchParams - Parámetros de búsqueda. + * @returns {Promise} - Respuesta con los paquetes disponibles. + */ +async function fetchJuliaPackages(searchParams) { + const arrivalCity = "ASU"; + const departureCity = "BUE"; + const departureFrom = "2024-10-01"; + const departureTo = "2024-10-31"; + const occupancy = "2"; + + const juliaPkgResponse = await Julia.pkgAvail({ + arrivalCity, + departureCity, + departureFrom, + departureTo, + occupancy, + }); + const mapResponse = ProviderService.mapper( + juliaPkgResponse, + "julia", + "avail" + ); + return Response.json(mapResponse); +} + +/** + * Controlador para manejar las solicitudes POST. + * Recibe los parámetros de búsqueda y los filtros seleccionados, + * obtiene los paquetes de los proveedores y devuelve los resultados. + * + * @param {Request} req - Solicitud HTTP entrante. + * @param {Response} res - Respuesta HTTP saliente. + * @returns {Promise} - Paquetes y filtros disponibles. + */ +export async function POST(req) { + const body = await req.json(); + const { searchParams } = body; + + // Obtener los paquetes de los proveedores + const [plumPkg, olaPkg] = await Promise.all([ + fetchPlumPackages(searchParams), + fetchOlaPackages(searchParams), + ]); + + const plumPkgResponse = await plumPkg.json(); + const packagesResponse = plumPkgResponse.packages.concat(olaPkg.packages); + + /** + * Preparar los grupos de salidas para ser almacenados en la cache. + */ + const departureGroups = plumPkgResponse.departuresGroup.concat( + olaPkg.departuresGroup + ); + await PackageApiService.cache.setIfNotExists(departureGroups, 3600); + + return Response.json(packagesResponse); +} diff --git a/app/api/packages/availability/pcom/route.js b/app/api/packages/availability/pcom/route.js new file mode 100644 index 0000000..d4332e2 --- /dev/null +++ b/app/api/packages/availability/pcom/route.js @@ -0,0 +1,30 @@ +import { Api } from "../../../../services/api.service"; +import pcomService from "../../../services/pcom.service"; +import { Filters } from "../../../services/filters.service"; + +export async function POST(req, res) { + const body = await req.json(); + const { searchParams, selectedFilters } = body; + + const pbaseAvailRequest = await fetch( + Api.packages.avail.pbase.url(), + Api.packages.avail.pbase.options(body) + ); + const pbaseAvailResponse = await pbaseAvailRequest.json(); + + const filters = await Filters.process(pbaseAvailResponse); + + // Aplicar los filtros seleccionados usando pcomService + const filteredPackages = pcomService.avail.applySelectedFilters( + pbaseAvailResponse, + selectedFilters + ); + + // Ordenar paquetes por precio usando pcomService + const sortedPackages = pcomService.avail.sortPackagesByBasePrice( + filteredPackages, + searchParams.priceOrder + ); + + return Response.json({ packages: sortedPackages, filters }); +} diff --git a/app/api/packages/availability/route.js b/app/api/packages/availability/route.js new file mode 100644 index 0000000..4e2f8be --- /dev/null +++ b/app/api/packages/availability/route.js @@ -0,0 +1,14 @@ +import { Api } from "../../../services/api.service"; + +export async function POST(req) { + const body = await req.json(); + /* Pcom: Aplica políticas de negocio y lógica específica de cómo se visualizan los datos. Armado de datos para la decisión de negocio. */ + const pkgAvailRequest = await fetch( + Api.packages.avail.pcom.url(), + Api.packages.avail.pcom.options(body) + ); + + const pkgAvailResponse = await pkgAvailRequest.json(); + + return Response.json(pkgAvailResponse); +} diff --git a/app/api/packages/detail/pbase/route.js b/app/api/packages/detail/pbase/route.js new file mode 100644 index 0000000..9e25b69 --- /dev/null +++ b/app/api/packages/detail/pbase/route.js @@ -0,0 +1,228 @@ +import { groq } from "next-sanity"; +import { ProviderService } from "../../../services/providers"; +import { OLA } from "../../../services/ola.service"; +import { ApiUtils } from "../../../services/apiUtils.service"; +import { getPriceTypeFromOccupancy } from "../../helpers"; +import { sanityFetch } from "../../../../lib/sanityFetch"; + +async function fetchPlumPackageDetail({ occupancy, id, startDate, endDate }) { + const priceType = getPriceTypeFromOccupancy(occupancy); + const pkgDetailQuery = groq`*[ + _id == "${id}" && + now() > validDateFrom && + now() < validDateTo] + { + ..., + "subtitle" : "Paquetes a " + origin[0] + " con aéreo " + departures[0].typeRt1 + " de " + departures[0].airlineRt1, + "departures": departures[departureFrom >= "${startDate}" && departureFrom <= "${endDate}"] { + ..., + // Desreferenciar el array de hoteles + "hotels": hotels[]-> { + _id, + name, + stars, + description, + latitude, + longitude, + plum_id, + + // Desreferenciamos la ciudad + "city": city_id-> { + iata_code, + name, + country_name, + "images": coalesce(images[].asset->url, []), + }, + // TODO: El mealPlan debe ir por hotel, no debería ser el mismo para todos. + "mealPlan": ^.mealPlan, + // TODO: No obtener el roomtype del tipo de price. Debemos refactorizar esto y ver si es necesario + // Incluir el tipo de habitación. + "roomType": ^.prices[0].type, + "roomSize": ^.roomSize, + }, + + "flights": [ + { + "segments": { + "flightNumber": flightNumberRt1, + "departureDate": departureDateRt1, + "departureHour": departureTimeRt1, + "arrivalDate": arrivalDateRt1, + "arrivalHour": arrivalTimeRt1, + "airline": airlineRt1-> { + code, + name, + "logoUrl": logo.asset->url + }, + "departureAirport": "Airport", + "arrivalAirport": "Airport", + "stopovers": stopoverRt1, + "departureCity": originDestinationRt1, + "arrivalCity": arrivalDestinationRt1 + } + }, + { + "segments": { + "flightNumber": flightNumberRt2, + "departureDate": departureDateRt2, + "departureHour": departureTimeRt2, + "arrivalDate": arrivalDateRt2, + "arrivalHour": arrivalTimeRt2, + "airline": airlineRt2-> { + code, + name, + "logoUrl": logo.asset->url + }, + "departureAirport": "Airport", + "arrivalAirport": "Airport", + "stopovers": stopoverRt2, + "departureCity": originDestinationRt2, + "arrivalCity": arrivalDestinationRt2 + + } + } + ], + "prices": prices[type == "${priceType}"] + } + }`; + + const sanityQuery = await sanityFetch({ query: pkgDetailQuery }); + const pkgDetailResponse = await sanityQuery; + const mapResponse = ProviderService.mapper( + pkgDetailResponse, + "plum", + "detail" + ); + + return Response.json(mapResponse[0]); +} + +async function fetchOlaPackageDetail(id, searchParams) { + const { departureCity, arrivalCity, startDate, endDate, priceId, occupancy } = + searchParams; + const cacheKey = `${id}-${priceId}`; + + const roomConfig = ProviderService.getRoomsConfig(occupancy); + const xmlRooms = + ProviderService.ola.generateXMLRoomsByConfigString(roomConfig); + + // Generar requests XML + const getPackagesFaresRequest = generateXMLRequest( + departureCity, + arrivalCity, + startDate, + endDate, + xmlRooms + ); + + const olaPkgDetailRequest = await ApiUtils.requestHandler( + fetch( + OLA.detail.url(), + OLA.detail.options(getPackagesFaresRequest, `${cacheKey}`) + ), + "pkgSearch" + ); + + const olaResponse = await olaPkgDetailRequest.json(); + if (olaResponse.length === 0) return olaResponse; + // Mapeo de ambas respuestas + const mappedOriginalResponse = ProviderService.mapper( + olaResponse, + "ola", + "detail" + ); + + // Buscar el paquete seleccionado + + const selectedPackage = mappedOriginalResponse.find((pkg) => + pkg.departures.some((departure) => departure.prices?.id === priceId) + ); + + return selectedPackage; +} + +// Genera el request XML para GetPackagesFares +function generateXMLRequest( + departureCity, + arrivalCity, + fromDate, + toDate, + xmlRooms +) { + return ` + + ${process.env.OLA_USERNAME} + ${process.env.OLA_API_KEY} + 186.57.221.35 + + + ${fromDate} + ${toDate} + + ${xmlRooms} + ${departureCity} + ${arrivalCity} + ARS + 1 + ALL + `; +} + +/* async function fetchJuliaPackageDetail(searchParams) { + const arrivalCity = "ASU"; + const departureCity = "BUE"; + const departureFrom = "2024-10-01"; + const departureTo = "2024-10-31"; + const occupancy = "2"; + + const juliaPkgResponse = await Julia.pkgAvail({ + arrivalCity, + departureCity, + departureFrom, + departureTo, + occupancy, + }); + const mapResponse = ProviderService.mapper( + juliaPkgResponse, + "julia", + "detail" + ); + //console.log("fetchJuliaPackages | pkgAvail ", juliaPkgResponse); + return Response.json(mapResponse); +} */ + +export async function POST(req, res) { + const body = await req.json(); + const { + provider, + id, + departureCity, + arrivalCity, + startDate, + endDate, + priceId, + occupancy, + } = body; + const searchParams = { + departureCity, + arrivalCity, + startDate, + endDate, + priceId, + occupancy, + }; + // check the provider and fetch the corresponding package detail + switch (provider) { + case "plum": + const plumPkgDetail = await fetchPlumPackageDetail(body); + const response = await plumPkgDetail.json(); + return Response.json(response); + case "ola": + const olaPkgDetail = await fetchOlaPackageDetail(id, searchParams); + return Response.json(olaPkgDetail); + case "julia": + /* const juliaPkgDetail = await fetchJuliaPackageDetail(id); + const responseJulia = await juliaPkgDetail.json(); + return Response.json(responseJulia); */ + } +} diff --git a/app/api/packages/detail/pcom/route.js b/app/api/packages/detail/pcom/route.js new file mode 100644 index 0000000..13791c9 --- /dev/null +++ b/app/api/packages/detail/pcom/route.js @@ -0,0 +1,3 @@ +export function POST(req, res) { + return Response.json({}); +} diff --git a/app/api/packages/detail/route.js b/app/api/packages/detail/route.js new file mode 100644 index 0000000..46d7ef4 --- /dev/null +++ b/app/api/packages/detail/route.js @@ -0,0 +1,108 @@ +import { Api } from "../../../services/api.service"; +import CitiesService from "../../../services/cities.service"; +import HotelsService from "../../../services/hotels.service"; +import AirlinesService from "../../../services/airlines.service"; + +const mapFlightSegment = async (segment) => { + const airlineData = await AirlinesService.getAirlineData( + segment.airline.code + ); + segment.airline = airlineData; + return segment; +}; + +export async function POST(req) { + const body = await req.json(); + + // Extraer el cuerpo de la solicitud + if (!body || Object.keys(body).length === 0) { + return Response.json({ error: "Invalid request body" }, { status: 400 }); + } + + // Obtener los detalles del paquete + const pBaseRequest = await fetch( + Api.packages.detail.pbase.url(), + Api.packages.detail.pbase.options(body) + ); + const pBaseDetailResponse = await pBaseRequest.json(); + + if (!pBaseDetailResponse || pBaseDetailResponse.length === 0) { + return Response.json([]); + } + + // Verificar que departures es un array + const departures = pBaseDetailResponse.departures; + if (!Array.isArray(departures)) { + return Response.json( + { error: "Departures must be an array" }, + { status: 500 } + ); + } + + const provider = pBaseDetailResponse.provider; + + // Seleccionar la salida correspondiente a startDate + const selectedDeparture = departures.find( + (departure) => departure.date === body.startDate + ); + + if (!selectedDeparture) { + return Response.json( + { error: "No se encontró la salida con la startDate especificada." }, + { status: 404 } + ); + } + + // Procesar hoteles asociados a la salida seleccionada + const hotelsArray = Array.isArray(selectedDeparture.hotels) + ? selectedDeparture.hotels + : []; + + const hotelsData = await Promise.all( + hotelsArray.map((hotel) => + HotelsService.getHotelData(provider, hotel, body.arrivalCity) + ) + ); + + console.log("hotelsData", hotelsData); + + // Procesar la data de ciudades según los hoteles + const citiesData = await Promise.all( + hotelsData.map((hotel) => + CitiesService.getCityByCode(hotel.city.iata_code, true) + ) + ); + + // Procesar segmentos de vuelo (si existen en la respuesta) + const flightSegments = Array.isArray( + pBaseDetailResponse.departures[0].flights + ) + ? pBaseDetailResponse.departures[0].flights + : []; + + const updatedFlightSegments = await Promise.all( + flightSegments.map(async (flight) => { + if (!Array.isArray(flight.segments)) { + flight.segments = await mapFlightSegment(flight.segments); + } else { + flight.segments = await Promise.all( + flight.segments.map( + async (segment) => await mapFlightSegment(segment) + ) + ); + } + return flight; + }) + ); + + pBaseDetailResponse.flights = updatedFlightSegments; + // Devolver la respuesta manteniendo la estructura original + // (departures ya contiene la propiedad departureId en cada objeto) + const response = { + ...pBaseDetailResponse, + hotelsData, + citiesData, + }; + + return Response.json(response); +} diff --git a/app/api/packages/helpers.js b/app/api/packages/helpers.js new file mode 100644 index 0000000..a948024 --- /dev/null +++ b/app/api/packages/helpers.js @@ -0,0 +1,53 @@ +import { ProviderService } from "../services/providers"; + +/** + * Retorna el id de price para una habitación, basándose en la cantidad de adultos y niños. + * @param {Object} roomConfig - Objeto con la configuración de la habitación: { adults, children } + * @returns {string|null} - El id correspondiente en PRICES o null si no hay match. + */ +export const getPriceType = (roomConfig) => { + const { adults, children } = roomConfig; + const numChildren = children.length; + + // Casos familiares (con niños) + if (numChildren > 0) { + if (adults === 2 && numChildren === 1) { + return "familyOne"; + } + if (adults === 2 && numChildren === 2) { + return "familyTwo"; + } + // Otros casos con niños no definidos + return null; + } + + // Casos sin niños (solo adultos) + switch (adults) { + case 1: + return "single"; + case 2: + return "double"; + case 3: + return "triple"; + case 4: + return "quadruple"; + default: + // Configuración no contemplada + return null; + } +}; + +/** + * Retorna el id del price correspondiente a la primera habitación configurada + * a partir del string occupancy. + * @param {string} occupancyString - Ejemplo: "2" o "2|12" o "2|5-8,1" + * @returns {string|null} - El id del price o null si no hay configuración válida. + */ +export const getPriceTypeFromOccupancy = (occupancyString) => { + // Asumimos que ya tienes implementada la función getRoomsConfig + const roomsConfig = ProviderService.getRoomsConfig(occupancyString); + if (!roomsConfig.length) return null; + + // Solo tomamos el primer elemento de la configuración + return getPriceType(roomsConfig[0]); +}; diff --git a/app/api/providers/ola/avail/route.js b/app/api/providers/ola/avail/route.js new file mode 100644 index 0000000..9d8bdf1 --- /dev/null +++ b/app/api/providers/ola/avail/route.js @@ -0,0 +1,16 @@ +import XmlService from "../../../services/xml.service"; + +export async function POST(request) { + const body = await request.json(); + try { + const url = process.env.OLA_URL; + const avail = await XmlService.soap.request(url, body, "GetPackagesFares"); + if (!Array.isArray(avail)) { + const response = [avail]; + return Response.json(response); + } + return Response.json(avail); + } catch (error) { + return Response.json({ error: error.message }); + } +} diff --git a/app/api/providers/ola/detail/route.js b/app/api/providers/ola/detail/route.js new file mode 100644 index 0000000..5ee1d1d --- /dev/null +++ b/app/api/providers/ola/detail/route.js @@ -0,0 +1,16 @@ +import XmlService from "../../../services/xml.service"; + +export async function POST(request) { + const body = await request.json(); + try { + const url = process.env.OLA_URL; + const detail = await XmlService.soap.request(url, body, "GetPackagesFares"); + if (!Array.isArray(detail)) { + const response = [detail]; + return Response.json(response); + } + return Response.json(detail); + } catch (error) { + return new Response.json(error, { status: 500 }); + } +} diff --git a/app/api/redis/departures/route.js b/app/api/redis/departures/route.js new file mode 100644 index 0000000..3fa8e17 --- /dev/null +++ b/app/api/redis/departures/route.js @@ -0,0 +1,32 @@ +import { NextRequest, NextResponse } from "next/server"; +import PackageApiService from "../../services/packages.service"; + +export async function POST(req) { + try { + const body = await req.json(); + const { pkgDepartures, expireInSeconds } = body; + + if (!Array.isArray(pkgDepartures)) { + return NextResponse.json( + { error: "pkgDepartures debe ser un array" }, + { status: 400 } + ); + } + + await PackageApiService.cache.setIfNotExists( + pkgDepartures, + expireInSeconds || 100000000 // 100000000 seconds = 1157 days + ); + + return NextResponse.json({ + success: true, + message: "Departures cacheados si no existían.", + }); + } catch (error) { + console.error("Error en POST /api/cache/departures:", error); + return NextResponse.json( + { error: "Error interno del servidor" }, + { status: 500 } + ); + } +} diff --git a/app/api/services/airlines.service.js b/app/api/services/airlines.service.js new file mode 100644 index 0000000..6d0a479 --- /dev/null +++ b/app/api/services/airlines.service.js @@ -0,0 +1,7 @@ +import SanityService from "./sanity.service"; + +export const Airlines = { + get: () => SanityService.getFromSanity(`*[_type == "airline"]`), + getByCode: (code) => + SanityService.getFromSanity(`*[_type == "airline" && code == "${code}"]`), +}; diff --git a/app/api/services/apiUtils.service.js b/app/api/services/apiUtils.service.js new file mode 100644 index 0000000..1eb48eb --- /dev/null +++ b/app/api/services/apiUtils.service.js @@ -0,0 +1,29 @@ +export const ApiUtils = { + requestHandler: async (handler, caller = "default") => { + try { + const apiRequest = async () => await handler; + // Call the handler passed to this function + return await apiRequest(); + } catch (error) { + console.error("Error in caller -> :", caller); + console.log(`and the error was -> `, error); + + // Return a standardized error response + return new Response( + { + success: false, + message: "Internal server error", + }, + { + status: 500, + headers: { "Content-Type": "application/json" }, + } + ); + } + }, + getAuthorizationToken: () => + `Bearer ${process.env.NEXT_PUBLIC_API_KEY || process.env.SANITY_STUDIO_NEXT_API_KEY}`, + getCommonHeaders: () => ({ + Authorization: ApiUtils.getAuthorizationToken(), + }), +}; diff --git a/app/api/services/cypto.service.js b/app/api/services/cypto.service.js new file mode 100644 index 0000000..ee3a6e2 --- /dev/null +++ b/app/api/services/cypto.service.js @@ -0,0 +1,12 @@ +import crypto from "crypto"; + +const CryptoService = { + encrypt: (data) => { + return crypto.createHash("md5").update(data).digest("hex"); + }, + generateDepartureId: (provider, departureDate) => { + return CryptoService.encrypt(`${provider}-${departureDate}`); + }, +}; + +export default CryptoService; diff --git a/app/api/services/filters.service.js b/app/api/services/filters.service.js new file mode 100644 index 0000000..23ba2a0 --- /dev/null +++ b/app/api/services/filters.service.js @@ -0,0 +1,44 @@ +import { ApiUtils } from "./apiUtils.service"; + +export const Filters = { + config: [ + { + id: "mealPlan", + title: "Régimen de Comidas", + type: "checkbox", + grouper: "departures[].hotels[].mealPlan", // Actualizado + }, + { + id: "night", + title: "Cantidad de Noches", + type: "checkbox", + grouper: "nights", // Ruta directa en el paquete + }, + { + id: "rating", + title: "Estrellas", + type: "checkbox", + grouper: "departures[].hotels[].rating", // Actualizado + }, + { + id: "hotel", + title: "Alojamiento", + type: "checkbox", + grouper: "departures[].hotels[].name", // Actualizado + }, + ], + + // Método que procesa la respuesta de availability + process: async (pkgAvailabilityResponse) => { + const res = await fetch(`${process.env.NEXT_PUBLIC_URL}/api/filters`, { + headers: ApiUtils.getCommonHeaders(), + method: "POST", + body: JSON.stringify({ availability: pkgAvailabilityResponse }), + }); + + if (!res.ok) { + throw new Error("Failed to fetch filters"); + } + return res.json(); + }, +}; diff --git a/app/api/services/hotels.service.js b/app/api/services/hotels.service.js new file mode 100644 index 0000000..f19307a --- /dev/null +++ b/app/api/services/hotels.service.js @@ -0,0 +1,26 @@ +import { groq } from "next-sanity"; +import SanityService from "./sanity.service"; + +export const Hotels = { + get: () => SanityService.getFromSanity(`*[_type == "hotel"]`), + getById: (id) => + SanityService.getFromSanity(groq`*[_type == "hotel" && _id == "${id}"] { + ..., + "images": coalesce(images[].asset->url, []), + "city": city_id-> { + iata_code, + name, + country_name + } + }`), + getByNameIlike: (name) => + SanityService.getFromSanity(groq`*[_type == "hotel" && name match "*${name}*"] { + ..., + "images": coalesce(images[].asset->url, []), + "city": city_id-> { + iata_code, + name, + country_name + } + }`), +}; diff --git a/app/api/services/julia.service.js b/app/api/services/julia.service.js new file mode 100644 index 0000000..bd592a7 --- /dev/null +++ b/app/api/services/julia.service.js @@ -0,0 +1,305 @@ +import { ProviderService } from "./providers"; +import Dates from "../../services/dates.service"; +import XmlService from "./xml.service"; +import { ApiUtils } from "./apiUtils.service"; + +const getHeaderRequest = (token) => { + return { + Token: token, + Origen: "B", // Buenos Aires + DestinoIZPais: "MX", // Brasil + DestinoIZCiudad: "CUN", + Ocupacion: "2", // 2 adultos + VigenciaDesde: "2024-10-01", + VigenciaHasta: "2024-10-30", + IDPaquete: 0, // Debemos poner 0 para que traiga todos. Sino va a traer un ID de paquete específico. + Nombre: "", + OrdenadoPor: "1", + AscDes: "A", + }; +}; + +const getDeparturesRequest = (token, { IDPAQUETE }, searchParams) => { + //console.log("getDeparturesRequest | IDPAQUETE", IDPAQUETE); + return { + Token: token, + IDPaquete: IDPAQUETE, + FechaDesde: searchParams.departureFrom, + FechaHasta: searchParams.departureTo, + Ocupacion: searchParams.occupancy, + }; +}; + +export const Julia = { + /** + * @function login Logueo del servicio de Julia Tours + * @returns token + */ + getToken: async () => { + const loginService = `http://ycixweb.juliatours.com.ar/WSJULIADEMO/WSJULIA.asmx/WS_jw_LOGIN`; + + const juliaLoginRequest = await fetch( + `${loginService}?LOGIN=PLUM_TEST&PASSWORD=t6yyCNtlTuTu&IDAGENCIA=14710`, + { method: "GET", headers: ApiUtils.getCommonHeaders() } + ); + const juliaTokenResponse = await juliaLoginRequest.text(); + const parsedToken = XmlService.parseXmlResults(juliaTokenResponse); + const token = parsedToken[0]["IDTOKEN"]; + return token; + }, + /** + * + * @param {{}} requestData + * @function pkgHeader Julia header avail + */ + pkgHeader: async (requestData) => { + try { + const juliaHeaderUrl = `http://ycixweb.juliatours.com.ar/WSJULIADEMO/WSJULIA.asmx/WS_jw_PAQUETES_CABECERA`; + const juliaPkgHeaderAvail = await fetch(juliaHeaderUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...ApiUtils.getCommonHeaders(), + }, + body: new URLSearchParams({ ...requestData }), + }); + + const juliaPkgHeaderAvailXml = await juliaPkgHeaderAvail.text(); + const juliaPkgAvailHeaderResponse = XmlService.parseXmlResults( + juliaPkgHeaderAvailXml + ); + // console.log("juliaPkgAvailHeaderResponse", juliaPkgAvailHeaderResponse); + return juliaPkgAvailHeaderResponse; + } catch (error) { + console.error("pkgHeader error -> ", error); + return error; + } + }, + /** + * + * @param {{}} pkgDeparturesHeader + * @function pkgDepartureHotelsGroup header avail + */ + + pkgDepartureHotelsGroup: async (sessionToken, departure, searchParams) => { + const departureHotelsGroupUrl = `http://ycixweb.juliatours.com.ar/WSJULIADEMO/WSJULIA.asmx/WS_jw_PAQUETES_GRUPO_HOTELES`; + let hotelsGroupResponse = []; + try { + const departureHotelsGroupOptions = { + Token: sessionToken, + IDPaquete: Number(departure.IDPAQUETE), + Ocupacion: Number(searchParams.occupancy), + NroGrupo: 0, + FechaInicio1: Dates.get(departure.FECHADESDE).toFormat("YYYY-MM-DD"), + FechaInicio2: Dates.get("2000-01-01").toFormat("YYYY-MM-DD"), + FechaInicio3: Dates.get("2000-01-01").toFormat("YYYY-MM-DD"), + FechaInicio4: Dates.get("2000-01-01").toFormat("YYYY-MM-DD"), + FechaInicio5: Dates.get("2000-01-01").toFormat("YYYY-MM-DD"), + }; + + // console.log("departureHotelsGroupOptions", departureHotelsGroupOptions); + const departureHotelsGroupRequest = await fetch(departureHotelsGroupUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...ApiUtils.getCommonHeaders(), + }, + body: new URLSearchParams({ ...departureHotelsGroupOptions }), + }); + // console.log("departureHotelsGroupRequest", departureHotelsGroupRequest); + if (departureHotelsGroupRequest.ok) { + const departureHotelsGroup = await departureHotelsGroupRequest.text(); + + hotelsGroupResponse = XmlService.parseXmlResults(departureHotelsGroup); + } + return hotelsGroupResponse; + } catch (error) { + console.log("pkgDepartureHotelsGroup | error -> ", error); + return error; + } + }, + pkgDepartureFlights: async (sessionToken, departure) => { + const departureFlightsUrl = `http://ycixweb.juliatours.com.ar/WSJULIADEMO/WSJULIA.asmx/WS_jw_PAQUETES_SALIDAS_VUELOS`; + let flightsResponse = []; + try { + const departureFlightsOptions = { + Token: sessionToken, + IDPaquete: departure.IDPAQUETE, + FechaDesde: departure.FECHADESDE, + }; + + const departureFlightsRequest = await fetch(departureFlightsUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...ApiUtils.getCommonHeaders(), + }, + body: new URLSearchParams({ ...departureFlightsOptions }), + }); + if (departureFlightsRequest.ok) { + const departureFlights = await departureFlightsRequest.text(); + flightsResponse = XmlService.parseXmlResults(departureFlights); + } + return flightsResponse; + } catch (error) { + return error; + } + }, + pkgDeparturePrices: async (sessionToken, departure, searchParams) => { + const departurePricesUrl = `http://ycixweb.juliatours.com.ar/WSJULIADEMO/WSJULIA.asmx/WS_jw_PAQUETES_GRUPO_PRECIOS`; + let pricesResponse = []; + try { + const departurePricesOptions = { + Token: sessionToken, + IDPaquete: Number(departure.IDPAQUETE), + Ocupacion: Number(searchParams.occupancy), + FechaInicio: Dates.get(departure.FECHADESDE).toFormat("YYYY-MM-DD"), + NroGrupo: 0, + }; + + const departurePricesRequest = await fetch(departurePricesUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...ApiUtils.getCommonHeaders(), + }, + body: new URLSearchParams({ ...departurePricesOptions }), + }); + + if (departurePricesRequest.ok) { + const departurePrices = await departurePricesRequest.text(); + + //console.log("departurePrices", departurePrices); + pricesResponse = XmlService.parseXmlResults(departurePrices); + } + return pricesResponse; + } catch (error) { + console.log("prices error -> ", error); + return error; + } + }, + /** + * + * @param {{}} pkgDeparturesHeader + * @function pkgDepartureFlights header avail + */ + pkgDepartureData: async (pkgDeparturesHeader, sessionToken, searchParams) => { + try { + const departureData = await Promise.all( + pkgDeparturesHeader.map(async (departure) => { + const [departureFlights, departureHotelsGroup, departurePrices] = + await Promise.all([ + Julia.pkgDepartureFlights(sessionToken, departure), + Julia.pkgDepartureHotelsGroup( + sessionToken, + departure, + searchParams + ), + Julia.pkgDeparturePrices(sessionToken, departure, searchParams), + ]); + + departure.flights = departureFlights; + departure.hotelsGroup = departureHotelsGroup; + departure.prices = departurePrices; + //console.log("final departure -> ", departure); + return departure; + + //console.log("departureWithFlights", departureWithFlights); + }) + ); + //console.log("departureData", departureData); + return departureData; + } catch (error) { + console.log("pkgDepartureData error", error); + return error; + } + }, + /** + * + * @param {{}} requestData + * @function pkgDepartures header avail + */ + + pkgDepartures: async (requestData, sessionToken, searchParams) => { + try { + let pkgDeparturesResponse = []; + // console.log("pkgDepartures | requestData", requestData); + const departuresUrl = `http://ycixweb.juliatours.com.ar/WSJULIADEMO/WSJULIA.asmx/WS_jw_PAQUETES_SALIDAS`; + const pkgDeparturesRequest = await fetch(departuresUrl, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...ApiUtils.getCommonHeaders(), + }, + body: new URLSearchParams({ ...requestData }), + }); + const pkgDepartures = await pkgDeparturesRequest.text(); + const pkgDeparturesHeader = XmlService.parseXmlResults(pkgDepartures); + + if (pkgDeparturesHeader && pkgDeparturesHeader.length > 0) { + // console.log("pkgDeparturesHeader", pkgDeparturesHeader); + const pkgDepartureDetail = await Julia.pkgDepartureData( + pkgDeparturesHeader, + sessionToken, + searchParams + ); + pkgDeparturesResponse = pkgDepartureDetail; + //console.log("pkgDeparturesResponse", pkgDeparturesResponse); + } + return pkgDeparturesResponse; + } catch (error) { + console.error("Julia | pkgDepartures error -> ", error); + return error; + } + }, + /** + * @func pkgAvail Búsqueda de Paquetes + */ + pkgAvail: async (searchParams) => { + try { + let pkgResponse = []; + // Token + const token = await Julia.getToken(); + //console.log("token", token); + // Header + const headerRequest = getHeaderRequest(token); + const headerResults = await Julia.pkgHeader(headerRequest); + //console.log("pkgAvail | headerResults", headerResults); + if (headerResults && headerResults.length === 0) return { pkgResponse }; + + // Departures + Flights / Hotels: Add departures, flights and hotels for each pkg. + const departuresData = await Promise.all( + headerResults.map(async (pkg) => { + pkg.departures = []; + const departuresRequest = getDeparturesRequest( + token, + pkg, + searchParams + ); + const departures = await Julia.pkgDepartures( + departuresRequest, + token, + searchParams + ); + + if (!departures || departures.length === 0) return false; + pkg.departures = departures; + return pkg; + }) + ); + + //console.log("headerResults", headerResults); + + // Clean empty departures. Don't show on availability list. + const pkgWithDepartures = departuresData.filter( + (pkg) => pkg.departures.length > 0 + ); + //console.log("pkgWithDepartures", JSON.stringify(pkgWithDepartures)); + + return pkgWithDepartures; + } catch (error) { + console.error("error -> ", error); + return new Error("Error al traer el avail Julia", error); + } + }, +}; diff --git a/app/api/services/ola.service.js b/app/api/services/ola.service.js new file mode 100644 index 0000000..9e64d3e --- /dev/null +++ b/app/api/services/ola.service.js @@ -0,0 +1,98 @@ +import { ApiUtils } from "./apiUtils.service"; +//import { ProviderService } from "./providers.service"; +import XmlService from "./xml.service"; + +const baseUrl = process.env.NEXT_PUBLIC_URL; // URL para Next.js o Frontend + +const olaUserName = process.env.OLA_USERNAME; +const olaApiKey = process.env.OLA_API_KEY; +const olaUrl = process.env.OLA_URL; + +export const OLA = { + avail: { + getRequest: (searchParams) => { + const { departureCity, arrivalCity, startDate, endDate, occupancy } = + searchParams; + + const getPackagesFaresRequest = ` + + + ${olaUserName} + ${olaApiKey} + 186.57.221.35 + + + ${startDate} + ${endDate} + + + + + + + + ${departureCity} + ${arrivalCity} + ARS + 1 + ALL + +`; + return getPackagesFaresRequest; + }, + url: () => `${baseUrl}/api/providers/ola/avail`, + options: (body, cacheKey) => { + return { + body: JSON.stringify(body), + method: "POST", + headers: ApiUtils.getCommonHeaders(), + next: { + revalidate: 0, + }, + }; + }, + name: "POST Availability | OLA", + }, + detail: { + url: () => `${baseUrl}/api/providers/ola/detail`, + options: (body, cacheKey) => { + return { + body: JSON.stringify(body), + method: "POST", + headers: ApiUtils.getCommonHeaders(), + }; + }, + name: "POST Detail | OLA", + }, + getHotelsOla: async (request) => { + // console.log("avail request", request); + try { + const url = olaUrl; + const getHotelsOlaRequest = await XmlService.soap.request( + url, + request, + "GetHotelsOla" + ); + //console.log("getHotelsOlaRequest", getHotelsOlaRequest); + return getHotelsOlaRequest; + } catch (error) { + return error; + } + }, + getPackagesFaresDepartureDates: async (request) => { + // console.log("avail request", request); + try { + const url = olaUrl; + const getPackagesFaresDepartureDatesRequest = + await XmlService.soap.request( + url, + request, + "GetPackagesFaresDepartureDates" + ); + //console.log("getPackagesFaresDepartureDatesRequest", getPackagesFaresDepartureDatesRequest); + return getPackagesFaresDepartureDatesRequest; + } catch (error) { + return error; + } + }, +}; diff --git a/app/api/services/packages.service.js b/app/api/services/packages.service.js new file mode 100644 index 0000000..fe43ffc --- /dev/null +++ b/app/api/services/packages.service.js @@ -0,0 +1,125 @@ +import RedisService from "./redis.service"; + +const PackageApiService = { + departures: { + plum: { + getDeparturesGroup: (plumPkgAvailResponse) => { + return plumPkgAvailResponse.map((pkgItem) => ({ + pkgId: pkgItem._id, + departures: pkgItem.departures.map((pkgDepartureItem) => ({ + id: pkgDepartureItem.id, + date: pkgDepartureItem.departureFrom, + })), + })); + }, + }, + ola: { + getDeparturesGroup: (olaPkgAvailResponse) => { + const groupedPackages = {}; + olaPkgAvailResponse.forEach((pkg) => { + const packageId = pkg.Package.Code; // ID único del paquete + const departureId = pkg.id; + const departureDate = pkg.Flight?.Trips?.Trip?.[0]?.DepartureDate; + + if (!groupedPackages[packageId]) { + groupedPackages[packageId] = { + pkgId: packageId, + departures: [], + }; + } + + if (departureDate) { + groupedPackages[packageId].departures.push({ + id: departureId, + date: departureDate, + }); + } + }); + + return Object.values(groupedPackages).map((group) => ({ + ...group, + departures: Array.from( + new Map(group.departures.map((dep) => [dep.id, dep])).values() + ).sort((a, b) => a.date.localeCompare(b.date)), + })); + }, + }, + }, + cache: { + /** + * Obtiene un paquete en cache según la key (pkgId). + * @param {string} pkgId - La clave a consultar. + * @returns {Promise} Data almacenada o null si no existe. + */ + get: async (pkgId) => { + try { + return await RedisService.get(pkgId); + } catch (error) { + console.error(`Error al obtener ${pkgId} de cache:`, error); + return null; + } + }, + + /** + * Guarda múltiples pkgDepartures en cache usando pipelining. + * Se asume que pkgDepartures es un array de objetos con { pkgId, departures }. + * @param {Array} pkgDepartures + * @param {number} expireInSeconds + */ + set: async (pkgDepartures, expireInSeconds = 3600) => { + try { + const pipelineItems = pkgDepartures.map((pkg) => ({ + key: pkg.pkgId, + value: pkg.departures, + expireInSeconds, + })); + await RedisService.pipelineSet(pipelineItems); + } catch (error) { + console.error("Error al establecer valores en cache:", error); + } + }, + + /** + * Guarda en cache sólo aquellos pkgDepartures cuyo pkgId no exista ya en Redis. + * @param {Array} pkgDepartures + * @param {number} expireInSeconds + */ + setIfNotExists: async (pkgDepartures, expireInSeconds) => { + try { + // Extraer todas las keys (pkgId) del array + const keys = pkgDepartures.map((pkg) => pkg.pkgId); + // Consultar Redis por esos keys + const existingValues = await RedisService.pipelineGet(keys); + // Filtrar aquellos que no existen (null) + const newPackages = pkgDepartures.filter( + (pkg, index) => existingValues[index] === null + ); + if (newPackages.length > 0) { + const pipelineItems = newPackages.map((pkg) => ({ + key: pkg.pkgId, + value: pkg.departures, + expireInSeconds, + })); + await RedisService.pipelineSet(pipelineItems); + } + } catch (error) { + console.error("Error en setIfNotExists:", error); + } + }, + + /** + * Elimina la cache de paquetes de una consulta específica. + * @param {object} searchParams - Parámetros de búsqueda. + */ + delete: async (searchParams) => { + try { + const cacheKey = generateCacheKey(searchParams); + await RedisService.delete(cacheKey); + } catch (error) { + console.error("Error al eliminar cache:", error); + } + }, + }, +}; + +export default PackageApiService; diff --git a/app/api/services/pbase.service.js b/app/api/services/pbase.service.js new file mode 100644 index 0000000..e69de29 diff --git a/app/api/services/pcom.service.js b/app/api/services/pcom.service.js new file mode 100644 index 0000000..fe64d8d --- /dev/null +++ b/app/api/services/pcom.service.js @@ -0,0 +1,113 @@ +import { Filters } from "../../api/services/filters.service"; +const pcomService = { + avail: { + /** + * Extrae un precio representativo para un paquete. + * @param {Object} pkg - Un paquete con la propiedad departures. + * @returns {number} El precio representativo (el mínimo basePrice). + */ + getRepresentativePrice(pkg) { + if (Array.isArray(pkg.departures) && pkg.departures.length > 0) { + const prices = pkg.departures.map((dep) => { + const price = dep?.prices?.pricesDetail?.basePrice; + return Number(price) || Infinity; + }); + return Math.min(...prices); + } + return Infinity; + }, + + /** + * Ordena un array de paquetes por el basePrice sin modificar la estructura original. + * @param {Array} packages - Array de paquetes. + * @param {string} priceOrder - "high" para ordenar de mayor a menor, otro valor para menor a mayor. + * @returns {Array} Un nuevo array ordenado. + */ + sortPackagesByBasePrice(packages, priceOrder) { + const isDescending = priceOrder === "high"; + return [...packages].sort((a, b) => { + const priceA = this.getRepresentativePrice(a); + const priceB = this.getRepresentativePrice(b); + return isDescending ? priceB - priceA : priceA - priceB; + }); + }, + + /** + * Recorre un objeto o array según un camino (path) y retorna un array con los valores encontrados. + * @param {Object|Array} obj - Objeto o array actual. + * @param {Array} paths - Array de partes del camino. + * @returns {Array} - Array de valores encontrados. + */ + getValuesAtPath(obj, paths) { + if (paths.length === 0) return [obj]; + const [currentPath, ...restPaths] = paths; + let results = []; + if (typeof obj === "undefined" || obj === null) return results; + + if (currentPath.endsWith("[]")) { + const key = currentPath.slice(0, -2); + const arr = obj[key]; + if (!Array.isArray(arr)) return []; + arr.forEach((item) => { + results = results.concat(this.getValuesAtPath(item, restPaths)); + }); + } else { + const nextObj = obj[currentPath]; + results = results.concat(this.getValuesAtPath(nextObj, restPaths)); + } + return results; + }, + + /** + * Extrae valores de un paquete basado en la clave del filtro. + * @param {Object} pkg - El paquete. + * @param {string} filterKey - La clave del filtro. + * @returns {Array} - Lista de valores en minúscula. + */ + extractPackageValues(pkg, filterKey) { + const configItem = Filters.config.find( + (config) => config.id === filterKey + ); + if (!configItem || !configItem.grouper) return []; + const paths = configItem.grouper.split("."); + const values = this.getValuesAtPath(pkg, paths); + return values + .filter((val) => val !== undefined && val !== null) + .map((val) => String(val).toLowerCase()); + }, + + /** + * Aplica los filtros seleccionados a un array de paquetes. + * @param {Array} packages - Array de paquetes. + * @param {Object} selectedFilters - Filtros seleccionados. + * @returns {Array} - Paquetes filtrados. + */ + applySelectedFilters(packages, selectedFilters) { + if (!Array.isArray(packages) || packages.length === 0) return []; + if (!selectedFilters || Object.keys(selectedFilters).length === 0) + return packages; + + return packages.filter((pkg) => { + return Object.keys(selectedFilters).every((filterKey) => { + const filterValues = selectedFilters[filterKey]; + if (!Array.isArray(filterValues) || filterValues.length === 0) + return true; + + const packageValues = this.extractPackageValues(pkg, filterKey); + if (!Array.isArray(packageValues) || packageValues.length === 0) + return false; + + const normalizedFilterValues = filterValues.map((value) => + value.toLowerCase() + ); + return normalizedFilterValues.some((filterValue) => + packageValues.includes(filterValue) + ); + }); + }); + }, + }, + detail: {}, +}; + +export default pcomService; diff --git a/app/api/services/providers/config/availConfig.js b/app/api/services/providers/config/availConfig.js new file mode 100644 index 0000000..8222a1b --- /dev/null +++ b/app/api/services/providers/config/availConfig.js @@ -0,0 +1,158 @@ +export default { + id: { + plum: "_id", + julia: "IDPAQUETE", + ola: "Package.Code", + }, + title: { + plum: "title", + julia: "NOMBRE", + ola: "Package.Name", + }, + subtitle: { + plum: "subtitle", + julia: "subtitle", + ola: "Package.Description", + }, + nights: { + plum: "nights", + julia: "CANTNOCHES", + ola: "Package.Nights", + }, + thumbnails: { + isArray: true, + baseKey: { + ola: "Package.Pictures.Picture", + plum: "images", + }, + items: { + sourceUrl: { + plum: "asset", + ola: "$value", + }, + }, + }, + departures: { + isArray: true, + baseKey: { + plum: "departures", + ola: "@self", + }, + items: { + id: { + plum: "id", + ola: "id", + }, + hotels: { + isArray: true, + baseKey: { + plum: "hotels", + ola: "Descriptions", + }, + items: { + // TODO: Ajustar mealPlan, roomType y roomSize. Analizar si debe ser por hotel. + id: { + plum: "_id", + julia: "hotels", + ola: "Description.Name", + }, + name: { + plum: "name", + julia: "hotels", + ola: "Description.Name", + }, + rating: { + plum: "stars", + julia: "rating", + ola: "Description.HotelClass", + }, + mealPlan: { + plum: "mealPlan", + julia: "hotels", + ola: "Description.FareDescriptions.FareDescription.[1].$value", + }, + roomType: { + plum: "roomType", + julia: "hotels", + ola: "Description.FareDescriptions.FareDescription.[0].$value", + }, + roomSize: { + plum: "roomSize", + julia: "hotels", + ola: "Description.FareDescriptions.FareDescription.[2].$value", + }, + }, + }, + prices: { + id: { + plum: "1234", + ola: "FareCodes.FareOption", + }, + pricesDetail: { + basePrice: { + plum: "prices.[0].amount", + julia: "prices", + ola: "FareTotal.Net", + }, + currency: { + plum: "prices.[0].currency", + julia: "prices", + ola: "FareTotal.Currency", + }, + comission: { + plum: "prices.[0].amount", + julia: "prices", + ola: "FareTotal.Comission", + }, + }, + taxes: { + baseTax: { + plum: "prices.[0].taxes", + julia: "prices", + ola: "FareTotal.Tax", + }, + iva: { + plum: "prices.[0].iva", + julia: "prices", + ola: "FareTotal.Vat", + }, + ivaAgency: { + plum: "prices.[0].ivaAgency", + julia: "prices", + ola: "FareTotal.VatAgency", + }, + paisTax: { + plum: "prices.[0].paisTax", + julia: "prices", + ola: "FareTotal.R3450.$value", + }, + additionalTax: { + description: { + plum: "prices", + julia: "prices", + ola: "Taxes.Tax.Name", + }, + value: { + plum: "prices.[0].other", + julia: "prices", + ola: "Taxes.Tax.Value", + }, + }, + }, + }, + date: { + plum: "departureFrom", + ola: "Flight.Trips.Trip.[0].DepartureDate", + }, + seats: { + plum: "departureSeats", + ola: "departureSeats", + }, + }, + }, + specialOfferTags: { + plum: "specialOfferTags", + julia: "specialOfferTags", + ola: "Package.Tags.Tag", + }, +}; diff --git a/app/api/services/providers/config/detailConfig.js b/app/api/services/providers/config/detailConfig.js new file mode 100644 index 0000000..c51cc77 --- /dev/null +++ b/app/api/services/providers/config/detailConfig.js @@ -0,0 +1,254 @@ +export default { + id: { + plum: "_id", + julia: "IDPAQUETE", + ola: "Package.Code", + }, + title: { + plum: "title", + julia: "NOMBRE", + ola: "Package.Name", + }, + subtitle: { + plum: "subtitle", + julia: "subtitle", + ola: "Package.Description", + }, + description: { + plum: "longDescription", + julia: "description", + ola: "Package.Description", + }, + nights: { + plum: "nights", + julia: "CANTNOCHES", + ola: "Package.Nights", + }, + images: { + isArray: true, + baseKey: { + ola: "Package.Pictures.Picture", + plum: "images", + }, + items: { + sourceUrl: { + plum: "asset", + ola: "$value", + }, + }, + }, + departures: { + isArray: true, + baseKey: { + plum: "departures", + ola: "@self", + }, + items: { + id: { + plum: "departureId", + ola: "departureId", + }, + hotels: { + isArray: true, + baseKey: { + plum: "hotels", + ola: "Descriptions", + }, + items: { + // TODO: Ajustar mealPlan, roomType y roomSize. Analizar si debe ser por hotel. + id: { + plum: "_id", + julia: "hotels", + ola: "Description.Name", + }, + name: { + plum: "name", + julia: "hotels", + ola: "Description.Name", + }, + rating: { + plum: "stars", + julia: "rating", + ola: "Description.HotelClass", + }, + mealPlan: { + plum: "mealPlan", + julia: "hotels", + ola: "Description.FareDescriptions.FareDescription.[1].$value", + }, + roomType: { + plum: "roomType", + julia: "hotels", + ola: "Description.FareDescriptions.FareDescription.[0].$value", + }, + roomSize: { + plum: "roomSize", + julia: "hotels", + ola: "Description.FareDescriptions.FareDescription.[2].$value", + }, + }, + }, + prices: { + id: { + plum: "1234", + ola: "FareCodes.FareOption", + }, + pricesDetail: { + basePrice: { + plum: "prices.[0].amount", + julia: "prices", + ola: "FareTotal.Net", + }, + currency: { + plum: "prices.[0].currency", + julia: "prices", + ola: "FareTotal.Currency", + }, + comission: { + plum: "prices.[0].amount", + julia: "prices", + ola: "FareTotal.Comission", + }, + }, + taxes: { + baseTax: { + plum: "prices.[0].taxes", + julia: "prices", + ola: "FareTotal.Tax", + }, + iva: { + plum: "prices.[0].iva", + julia: "prices", + ola: "FareTotal.Vat", + }, + ivaAgency: { + plum: "prices.[0].ivaAgency", + julia: "prices", + ola: "FareTotal.VatAgency", + }, + paisTax: { + plum: "prices.[0].paisTax", + julia: "prices", + ola: "FareTotal.R3450.$value", + }, + additionalTax: { + description: { + plum: "prices", + julia: "prices", + ola: "Taxes.Tax.Name", + }, + value: { + plum: "prices.[0].other", + julia: "prices", + ola: "Taxes.Tax.Value", + }, + }, + }, + }, + flights: { + isArray: true, + baseKey: { + ola: "Flight.Trips.Trip", + plum: "flights", + }, // Define el arreglo base principal + items: { + segments: { + isArray: true, + baseKey: { + ola: "Segments.Segment", + plum: "segments", + }, // Define el arreglo de segmentos dentro de cada trip + items: { + flightNumber: { + ola: "FlightNumber", + plum: "flightNumber", + }, + departureDate: { + ola: "DepartureDate", + plum: "departureDate", + }, + departureHour: { + ola: "DepartureHour", + plum: "departureHour", + }, + airline: { + code: { + ola: "Supplier.Code", + plum: "airline.code", + }, + name: { + ola: "Supplier.Name", + plum: "airline.name", + }, + logo: { + ola: "Supplier.Code", + plum: "airline.logoUrl", + }, + }, + arrivalDate: { + ola: "ArrivalDate", + plum: "arrivalDate", + }, + arrivalHour: { + ola: "ArrivalHour", + plum: "arrivalHour", + }, + departureAirport: { + code: { + ola: "DepartureAirport.attributes.Iata", + plum: "departureCity", + }, + name: { + ola: "DepartureAirport.$value", + plum: "departureCity", + }, + }, + departureCity: { + code: { + ola: "DepartureCity.attributes.Iata", + plum: "departureCity", + }, + name: { + ola: "DepartureCity.$value", + plum: "departureCity", + }, + }, + arrivalCity: { + code: { + ola: "ArrivalCity.attributes.Iata", + plum: "arrivalCity", + }, + name: { + ola: "ArrivalCity.$value", + plum: "arrivalCity", + }, + }, + arrivalAirport: { + code: { + ola: "ArrivalAirport.attributes.Iata", + plum: "arrivalCity", + }, + name: { + ola: "ArrivalAirport.$value", + plum: "arrivalCity", + }, + }, + }, + }, + stopovers: { + plum: "segments.stopovers", + ola: "Stops", + }, + }, + }, + date: { + plum: "departureFrom", + ola: "Flight.Trips.Trip.[0].DepartureDate", + }, + seats: { + plum: "departureSeats", + ola: "departureSeats", + }, + }, + }, +}; diff --git a/app/api/services/providers/index.js b/app/api/services/providers/index.js new file mode 100644 index 0000000..32c778c --- /dev/null +++ b/app/api/services/providers/index.js @@ -0,0 +1,102 @@ +import { hasNestedProperty, getByDotOperator } from "./utils/object.js"; +import { mapProviderResponse } from "./utils/mapper.js"; +import { OlaProvider } from "./providers/ola.js"; +import { JuliaProvider } from "./providers/julia.js"; +import { PlumProvider } from "./providers/plum.js"; +import { + departureDateMonthYear, + departureDateFromTo, +} from "./services/datesService.js"; +import { getPkgAvailabilityAndFilters } from "./services/availabilityService.js"; +import { getPkgDetail } from "./services/detailService.js"; +import CitiesService from "../../../services/cities.service.js"; +import availResponseConfig from "./config/availConfig.js"; +import detailResponseConfig from "./config/detailConfig.js"; + +/** + * Service for handling Plum Viajes related operations + */ +export const PlumViajesService = { + aFunction: () => {}, +}; + +/** + * Service for handling provider-related operations + */ +export const ProviderService = { + availResponseConfig, + detailResponseConfig, + + /** + * Maps the response data according to the provider's configuration + */ + mapper: (response, provider, consumer) => + mapProviderResponse( + response, + provider, + availResponseConfig, + detailResponseConfig, + consumer + ), + + // API Services + getPkgAvailabilityAndFilters, + getPkgDetail, + + // Date Services + departureDateMonthYear, + departureDateFromTo, + + // Utility functions + hasNestedProperty, + getByDotOperator, + + /** + * Gets default values for search engine + */ + getSearchEngineDefaultValues: async ( + startDate, + arrivalCity, + departureCity + ) => { + const [arrivalCityData, departureCityData] = await Promise.all([ + CitiesService.getCityByCode(arrivalCity, true), + CitiesService.getCityByCode(departureCity, true), + ]); + + return { + packages: { + departureMonthYear: departureDateMonthYear(startDate), + arrivalCity: arrivalCityData, + departureCity: departureCityData, + }, + }; + }, + + /** + * Parses room configuration string + */ + getRoomsConfig: (configString) => { + if (!configString || configString.trim() === "") return []; + + return configString.split(",").map((room) => { + const [adultsPart, childrenPart] = room.split("|"); + const adults = parseInt(adultsPart, 10) || 0; + const children = childrenPart ? childrenPart.split("-").map(Number) : []; + + return { adults, children }; + }); + }, + + getHotelDetailInfo: async () => {}, + + // Provider-specific services + julia: JuliaProvider, + ola: OlaProvider, + plum: PlumProvider, + + /** + * Helper for fetch with json response + */ + clientFetch: (...args) => fetch(...args).then((res) => res.json()), +}; diff --git a/app/api/services/providers/providers/julia.js b/app/api/services/providers/providers/julia.js new file mode 100644 index 0000000..8214bf5 --- /dev/null +++ b/app/api/services/providers/providers/julia.js @@ -0,0 +1,6 @@ +/** + * Julia provider specific operations + */ +export const JuliaProvider = { + // Funcionalidad específica para Julia +}; diff --git a/app/api/services/providers/providers/ola.js b/app/api/services/providers/providers/ola.js new file mode 100644 index 0000000..980eeae --- /dev/null +++ b/app/api/services/providers/providers/ola.js @@ -0,0 +1,74 @@ +/** + * OLA provider specific operations + */ +export const OlaProvider = { + /** + * Converts a date from DD-MM-YYYY format to YYYY-MM-DD format + * @param {string} dayMonthYear - Date in DD-MM-YYYY format + * @returns {string} Date in YYYY-MM-DD format + */ + olaDateFormat: (dayMonthYear) => { + if (!dayMonthYear) return ""; + const [day, month, year] = dayMonthYear.split("-"); + return `${year}-${month}-${day}`; + }, + + /** + * Groups response by unique keys to remove duplicates + * @param {Array} mappedResponse - Array of response objects + * @param {string} criteria - Grouping criteria + * @returns {Array} Deduplicated array + */ + grouper: (mappedResponse, criteria = null) => { + if (!mappedResponse || !Array.isArray(mappedResponse)) return []; + + const uniqueResponse = []; + const seenItems = new Set(); + + mappedResponse.forEach((item) => { + if (!item) return; + + let uniqueKey; + if (criteria) { + uniqueKey = `${item[criteria]}`; + } else if (item.departures?.[0]?.hotels?.[0]) { + const hotel = item.departures[0].hotels[0]; + uniqueKey = `${item.id}-${hotel.name}-${hotel.roomType}-${hotel.roomSize}-${hotel.mealPlan}`; + } else { + uniqueKey = `${item.id}-${Date.now()}-${Math.random()}`; + } + + if (!seenItems.has(uniqueKey)) { + seenItems.add(uniqueKey); + uniqueResponse.push(item); + } + }); + + return uniqueResponse; + }, + + /** + * Generates XML for room configuration + * @param {Array} roomsConfig - Room configuration array + * @returns {string} XML string + */ + generateXMLRoomsByConfigString: (roomsConfig) => { + if (!roomsConfig || !Array.isArray(roomsConfig)) return ""; + + const roomStrings = roomsConfig.map((room) => { + if (!room) return ""; + + const adultStrings = Array(room.adults || 0) + .fill(' ') + .join("\n"); + + const childStrings = (room.children || []) + .map((age) => ` `) + .join("\n"); + + return ` \n${adultStrings}${adultStrings && childStrings ? "\n" : ""}${childStrings}\n `; + }); + + return `\n${roomStrings.join("\n")}\n`; + }, +}; diff --git a/app/api/services/providers/providers/plum.js b/app/api/services/providers/providers/plum.js new file mode 100644 index 0000000..3accbb4 --- /dev/null +++ b/app/api/services/providers/providers/plum.js @@ -0,0 +1,6 @@ +/** + * Plum provider specific operations + */ +export const PlumProvider = { + // Por ahora está vacío, podemos agregar funcionalidad específica para Plum +}; diff --git a/app/api/services/providers/services/availabilityService.js b/app/api/services/providers/services/availabilityService.js new file mode 100644 index 0000000..ac39827 --- /dev/null +++ b/app/api/services/providers/services/availabilityService.js @@ -0,0 +1,36 @@ +import { ApiUtils } from "../../apiUtils.service"; + +/** + * Fetches package availability based on search parameters + * @async + * @param {Object} searchParams - The search parameters + * @param {Object} selectedFilters - Filters to apply + * @returns {Promise} The package availability data + * @throws {Error} If the fetch request fails + */ +export const getPkgAvailabilityAndFilters = async ( + searchParams, + selectedFilters +) => { + try { + const pkgAvailabilityRequest = await fetch( + `${process.env.NEXT_PUBLIC_URL}/api/packages/availability`, + { + method: "POST", + body: JSON.stringify({ searchParams, selectedFilters }), + headers: ApiUtils.getCommonHeaders(), + } + ); + + if (!pkgAvailabilityRequest.ok) { + const response = await pkgAvailabilityRequest.json(); + throw new Error( + `Ocurrió un error en el avail de paquetes. Razón: ${response.reason || "Desconocida"}` + ); + } + + return pkgAvailabilityRequest.json(); + } catch (error) { + throw new Error(`Error en disponibilidad: ${error.message}`); + } +}; diff --git a/app/api/services/providers/services/datesService.js b/app/api/services/providers/services/datesService.js new file mode 100644 index 0000000..fff67c3 --- /dev/null +++ b/app/api/services/providers/services/datesService.js @@ -0,0 +1,95 @@ +import { MONTHS } from "../utils/constants.js"; + +/** + * Generates departure date options for the current month and year + * @param {string} [startDate] - Optional start date in 'YYYY-MM-DD' format + * @returns {Array|Object} Array of departure date options or single option + */ +export const departureDateMonthYear = (startDate) => { + const currentDate = startDate ? new Date(startDate) : new Date(); + const currentYear = currentDate.getFullYear(); + const currentMonth = startDate + ? parseInt(startDate.split("-")[1]) - 1 + : currentDate.getMonth(); + + // Si se proporciona startDate, devolver solo el mes correspondiente + if (startDate) { + const monthNumber = currentMonth + 1; + const monthString = monthNumber.toString().padStart(2, "0"); + const value = `${monthString}-${currentYear}`; + + return { + id: monthNumber, + value: value, + label: `${MONTHS[currentMonth]}, ${currentYear}`, + }; + } + + const options = []; + + // Agregar meses del año actual + const currentYearOptions = MONTHS.map((month, index) => { + const monthNumber = index + 1; + const monthString = monthNumber.toString().padStart(2, "0"); + const value = `${monthString}-${currentYear}`; + + return { + id: monthNumber, + value, + label: `${month}, ${currentYear}`, + }; + }).filter((_, index) => index >= currentMonth); + + if (currentYearOptions.length > 0) { + options.push({ + label: `Este año ${currentYear}`, + options: currentYearOptions, + }); + } + + // Agregar meses del próximo año + const nextYearOptions = MONTHS.map((month, index) => { + const monthNumber = index + 1; + const monthString = monthNumber.toString().padStart(2, "0"); + const value = `${monthString}-${currentYear + 1}`; + + return { + id: monthNumber + 12, + value, + label: `${month}, ${currentYear + 1}`, + }; + }); + + if (nextYearOptions.length > 0) { + options.push({ + label: `Próximo año ${currentYear + 1}`, + options: nextYearOptions, + }); + } + + return options; +}; + +/** + * Generates start and end dates for a given month and year + * @param {string} monthYear - Month and year in 'MM-YYYY' format + * @returns {Object|null} Object with startDate and endDate, or null if invalid input + */ +export const departureDateFromTo = (monthYear) => { + if (!monthYear) return null; + + const [month, year] = monthYear.split("-"); + const today = new Date(); + const currentMonthLastDay = new Date(year, month, 0).getDate(); + + // Verificar si el mes y año proporcionados coinciden con el mes y año actual + const isCurrentMonth = + today.getMonth() + 1 === parseInt(month) && + today.getFullYear() === parseInt(year); + const startDay = isCurrentMonth ? today.getDate() : 1; + + return { + startDate: `${year}-${month}-${String(startDay).padStart(2, "0")}`, + endDate: `${year}-${month}-${currentMonthLastDay}`, + }; +}; diff --git a/app/api/services/providers/services/detailService.js b/app/api/services/providers/services/detailService.js new file mode 100644 index 0000000..d6fb391 --- /dev/null +++ b/app/api/services/providers/services/detailService.js @@ -0,0 +1,53 @@ +import { ApiUtils } from "../../apiUtils.service.js"; + +/** + * Fetches package detail based on provider and id + * @async + * @param {Object} params - Parameters for fetching package details + * @returns {Promise} The package detail data + */ +export const getPkgDetail = async ({ + provider, + id, + arrivalCity, + departureCity, + startDate, + endDate, + priceId, + occupancy, + departureId, +}) => { + try { + const pkgDetailRequest = await fetch( + `${process.env.NEXT_PUBLIC_URL}/api/packages/detail`, + { + method: "POST", + body: JSON.stringify({ + provider, + id, + arrivalCity, + departureCity, + startDate, + endDate, + priceId, + occupancy, + departureId, + }), + headers: ApiUtils.getCommonHeaders(), + } + ); + + if (!pkgDetailRequest.ok) { + const response = await pkgDetailRequest.json(); + return { + error: `Ocurrió un error en el detail de paquetes. Razón: ${JSON.stringify(response)}`, + }; + } + + return pkgDetailRequest.json(); + } catch (error) { + return { + error: `Ocurrió un error en el detail de paquetes. Razón: ${error.message}`, + }; + } +}; diff --git a/app/api/services/providers/utils/constants.js b/app/api/services/providers/utils/constants.js new file mode 100644 index 0000000..ab500b1 --- /dev/null +++ b/app/api/services/providers/utils/constants.js @@ -0,0 +1,14 @@ +export const MONTHS = [ + "Enero", + "Febrero", + "Marzo", + "Abril", + "Mayo", + "Junio", + "Julio", + "Agosto", + "Septiembre", + "Octubre", + "Noviembre", + "Diciembre", +]; diff --git a/app/api/services/providers/utils/mapper.js b/app/api/services/providers/utils/mapper.js new file mode 100644 index 0000000..37ba64e --- /dev/null +++ b/app/api/services/providers/utils/mapper.js @@ -0,0 +1,83 @@ +import { isObject, hasNestedProperty, getByDotOperator } from "./object.js"; + +/** + * Maps nested object structure according to configuration + * @param {Object} pkg - Source package data + * @param {Object} configObj - Mapping configuration + * @param {string} provider - Provider identifier + * @returns {Object} - Mapped object + */ +export const mapNestedObject = (pkg, configObj, provider) => { + let result = {}; + + Object.entries(configObj).forEach(([key, value]) => { + if (value.isArray) { + let arrayData = []; + if (value.baseKey && value.baseKey[provider]) { + const baseKeyValue = value.baseKey[provider].trim(); + if (baseKeyValue === "@self" || baseKeyValue === "continue") { + arrayData = pkg; + } else { + arrayData = getByDotOperator(pkg, baseKeyValue) || []; + } + } else { + arrayData = pkg[key]; + } + + if (arrayData === undefined || arrayData === null) { + arrayData = []; + } else if (!Array.isArray(arrayData)) { + arrayData = [arrayData]; + } + + result[key] = arrayData.map((item) => + isObject(item) ? mapNestedObject(item, value.items, provider) : item + ); + return; + } + + if (isObject(value) && !value[provider]) { + result[key] = mapNestedObject(pkg, value, provider); + return; + } + + if (!isObject(value) || !value[provider]) { + return; + } + + const providerConfigProp = value[provider]; + result[key] = hasNestedProperty(providerConfigProp) + ? getByDotOperator(pkg, providerConfigProp) + : pkg[providerConfigProp]; + }); + + return result; +}; + +/** + * Maps the response data according to the provider's configuration + * @param {Array} response - The response data to map + * @param {string} provider - The provider identifier + * @param {Object} availConfig - Availability configuration + * @param {Object} detailConfig - Detail configuration + * @param {string} consumer - The consumer type ("detail" or other) + * @returns {Array} The mapped response data + */ +export const mapProviderResponse = ( + response, + provider, + availConfig, + detailConfig, + consumer +) => { + if (!response || !Array.isArray(response) || response.length === 0) + return response; + + const respConfig = consumer === "detail" ? detailConfig : availConfig; + + return response.map((pkg) => { + const mappedPkg = mapNestedObject(pkg, respConfig, provider); + mappedPkg.provider = provider; + return mappedPkg; + }); +}; diff --git a/app/api/services/providers/utils/object.js b/app/api/services/providers/utils/object.js new file mode 100644 index 0000000..3ae1412 --- /dev/null +++ b/app/api/services/providers/utils/object.js @@ -0,0 +1,55 @@ +/** + * Checks if a value is a non-null object + * @param {*} value - The value to check + * @returns {boolean} - True if value is an object + */ +export const isObject = (value) => value !== null && typeof value === "object"; + +/** + * Checks if a string contains nested properties (indicated by dots) + * @param {string} string - The string to check + * @returns {boolean} - True if the string contains dots + */ +export const hasNestedProperty = (string) => string && string.includes("."); + +/** + * Retrieves a value from an object using a dot-separated string path + * @param {Object} object - The object to search in + * @param {string} path - The dot-separated path to the desired property + * @param {boolean} isArray - Whether to return array or first element + * @returns {*} - The value at the specified path, or undefined if not found + */ +export const getByDotOperator = (object, path, isArray = false) => { + if (!object || !path) return null; + + const reduced = path.split(".").reduce((acc, curr) => { + // Handle array indices like "[0]", "[1]" + if (/^\[\d+\]$/.test(curr)) { + const index = parseInt(curr.slice(1, -1), 10); + return Array.isArray(acc) ? acc[index] : undefined; + } + // Handle "[]" notation + else if (curr === "[]") { + if (Array.isArray(acc) && isArray) { + return acc.map((item) => item); + } else { + return Array.isArray(acc) ? acc[0] : acc; + } + } + // Handle arrays by mapping over elements + else if (Array.isArray(acc)) { + return acc.map((item) => (item ? item[curr] : undefined)); + } + // Regular property access + else { + return acc ? acc[curr] : undefined; + } + }, object); + + // If result is an array with a single value, return that value unless isArray is true + if (Array.isArray(reduced) && reduced.length === 1 && !isArray) { + return reduced[0]; + } + + return reduced; +}; diff --git a/app/api/services/redis.service.js b/app/api/services/redis.service.js new file mode 100644 index 0000000..7f73362 --- /dev/null +++ b/app/api/services/redis.service.js @@ -0,0 +1,107 @@ +import redis from "../lib/redis"; + +const RedisService = { + /** + * Guarda un valor en Redis (convertido a JSON) con un TTL opcional. + * @param {string} key - La clave para el dato. + * @param {any} value - El valor a almacenar. + * @param {number} expireInSeconds - Tiempo de expiración en segundos (default: 3600). + */ + set: async (key, value, expireInSeconds = 3600) => { + try { + const jsonValue = JSON.stringify(value); + await redis.set(key, jsonValue, { EX: expireInSeconds }); + } catch (error) { + console.error(`Error setting key "${key}":`, error); + } + }, + + /** + * Obtiene un valor de Redis y lo convierte desde JSON. + * @param {string} key - La clave a consultar. + * @returns {Promise} - El valor almacenado o null si no existe. + */ + get: async (key) => { + try { + return await redis.get(key); + } catch (error) { + console.error(`Error getting key "${key}":`, error); + return null; + } + }, + + /** + * Elimina un valor de Redis. + * @param {string} key - La clave a eliminar. + */ + delete: async (key) => { + try { + await redis.del(key); + } catch (error) { + console.error(`Error deleting key "${key}":`, error); + } + }, + + /** + * Crea y retorna un pipeline para agrupar comandos. + * @returns {Pipeline} Instancia del pipeline. + */ + pipeline: () => redis.pipeline(), + + /** + * Ejecuta múltiples comandos SET en un pipeline. + * @param {Array<{ key: string, value: any, expireInSeconds?: number }>} items - Arreglo de operaciones. + */ + pipelineSet: async (items) => { + try { + const pipeline = redis.pipeline(); + items.forEach((item) => { + const jsonValue = JSON.stringify(item.value); // Asegurar serialización + pipeline.set(item.key, jsonValue, { ex: item.expireInSeconds || 3600 }); + }); + await pipeline.exec(); + } catch (error) { + console.error("Error in pipelineSet:", error); + } + }, + + /** + * Realiza múltiples GET en un pipeline para consultar la existencia de varias keys. + * @param {Array} keys - Array de claves a consultar. + * @returns {Promise>} - Array con los valores (o null para las keys no existentes). + */ + pipelineGet: async (keys) => { + try { + // Verificar si el array de keys está vacío + if (!keys || !Array.isArray(keys) || keys.length === 0) { + return []; + } + + const pipeline = redis.pipeline(); + keys.forEach((key) => pipeline.get(key)); + + const results = await pipeline.exec(); + + if (!Array.isArray(results)) { + console.error( + "pipelineGet failed: Unexpected response from Redis", + results + ); + return keys.map(() => null); // Retornar un array de nulls del mismo tamaño que keys + } + + return results.map(([error, data]) => { + if (error) { + console.error("Redis pipelineGet error:", error); + return null; + } + return data ? JSON.parse(data) : null; + }); + } catch (error) { + console.error("Error in pipelineGet:", error); + return keys.map(() => null); // En caso de error, devolver un array de nulls + } + }, +}; + +export default RedisService; diff --git a/app/api/services/sanity.service.js b/app/api/services/sanity.service.js new file mode 100644 index 0000000..31ab791 --- /dev/null +++ b/app/api/services/sanity.service.js @@ -0,0 +1,43 @@ +import { + sanityCreate, + sanityFetch, + sanityDelete, + sanityDeleteByQuery, +} from "../../lib/sanityFetch"; +import { ApiUtils } from "./apiUtils.service"; +// Database service object containing various methods for database operations +const SanityService = { + // Method to get all records from a specified table + getFromSanity: async (query) => { + const sanityQuery = await ApiUtils.requestHandler( + sanityFetch({ query }), + "Sanity fetch" + ); + const response = await sanityQuery; + return response; + }, + createObject: async (object) => { + const create = await ApiUtils.requestHandler( + sanityCreate(object), + "Sanity create" + ); + const response = await create; + return response; + }, + deleteByIds: async (documentIds) => { + const deleteResult = await ApiUtils.requestHandler( + sanityDelete(documentIds), + "Sanity delete by IDs" + ); + return deleteResult; + }, + deleteByQuery: async (query) => { + const deleteResult = await ApiUtils.requestHandler( + sanityDeleteByQuery(query), + "Sanity delete by query" + ); + return deleteResult; + }, +}; + +export default SanityService; diff --git a/app/api/services/xml.service.js b/app/api/services/xml.service.js new file mode 100644 index 0000000..99c4ac2 --- /dev/null +++ b/app/api/services/xml.service.js @@ -0,0 +1,140 @@ +import { parseStringPromise } from "xml2js"; // Para parsear el XML + +// Función general para simplificar arrays de un solo elemento, mapear "_" a "$value" y cambiar "$" a "attributes" +const simplifyAndMapValues = (data) => { + if (Array.isArray(data)) { + // Si es un array, procesar cada elemento + return data.map((item) => simplifyAndMapValues(item)); + } else if (typeof data === "object" && data !== null) { + Object.keys(data).forEach((key) => { + // Si existe la propiedad "_", mapearla a "$value" + if (data[key] && data[key]._ !== undefined) { + data[key].$value = data[key]._; // Asignar el valor de _ a $value + delete data[key]._; // Eliminar la propiedad "_" + } + + // Si el valor es un array de un solo elemento, simplificarlo + if (Array.isArray(data[key]) && data[key].length === 1) { + data[key] = data[key][0]; + } + + // Renombrar "$" a "attributes" + if (key === "$") { + data.attributes = data[key]; + delete data[key]; + } + + // Renombrar "$" a "attributes" + if (key === "_") { + data.$value = data[key]; + delete data[key]; + } + + // Llamada recursiva para procesar las subpropiedades + data[key] = simplifyAndMapValues(data[key]); + }); + } + return data; +}; + +const XmlService = { + buildXmlSet: (object) => { + const builder = require("xml2js").Builder(); + const xmlSet = builder.buildObject(object); + return xmlSet; + }, + /* parseXmlResults: async (xml, rootElement = "DocumentElement") => { + try { + const result = await parseStringPromise(xml, { explicitArray: false }); // Ajuste para evitar arrays innecesarios + const emptyResponse = XmlService.isEmptyString(result[rootElement]); + if (emptyResponse) return []; + + // Recorrer las propiedades del resultado + const rawResults = result.DocumentElement?.Row; + if (rawResults && Array.isArray(rawResults)) { + return rawResults.map((resultItem) => { + Object.keys(resultItem).forEach((resultProperty) => { + resultItem[resultProperty] = resultItem[resultProperty][0]; + }); + return resultItem; + }); + } + return []; + } catch (error) { + console.error("Error al parsear el XML:", error); + return []; + } + }, */ + isEmptyString: (string) => { + return string === "" ? true : false; + }, + soap: { + request: async (url, pBaseRequest, service) => { + const soapEnvelope = ` + + + + + + + `; + + try { + const response = await fetch(url, { + body: soapEnvelope, + method: "POST", + next: { + revalidate: 0, + }, + headers: { + "Content-Type": "text/xml", + SOAPAction: `"${service}"`, + }, + }); + + const responseJson = await response.text(); + + // Parsear la respuesta XML + const soapResponse = await parseStringPromise(responseJson, { + explicitArray: false, + }); + const envelope = soapResponse["SOAP-ENV:Envelope"]; + const body = envelope["SOAP-ENV:Body"]; + const serviceResponse = body[`ns1:${service}Response`]; + + if ( + !serviceResponse || + !serviceResponse.Response || + !serviceResponse.Response._ + ) { + return []; + } + + // Volver a parsear el contenido de Response._ + const nestedXml = serviceResponse.Response._; + const parsedNestedXml = await parseStringPromise(nestedXml, { + explicitArray: false, + }); + + // Simplificar arrays de un solo elemento + const simplifiedData = simplifyAndMapValues( + parsedNestedXml.GetPackagesFaresResponse?.PackageFare + ); + + return simplifiedData ? simplifiedData : []; + } catch (error) { + console.error("Error al llamar a OLA", error); + throw error; + } + }, + }, +}; + +export default XmlService; diff --git a/app/api/third-parties/sanity/ola-avail/route.js b/app/api/third-parties/sanity/ola-avail/route.js new file mode 100644 index 0000000..e5a508a --- /dev/null +++ b/app/api/third-parties/sanity/ola-avail/route.js @@ -0,0 +1,53 @@ +import { OLA } from "../../../services/ola.service"; +import PackageApiService from "../../../services/packages.service"; +import { ProviderService } from "../../../services/providers"; +import CryptoService from "../../../services/cypto.service"; + +async function fetchOlaPackages(searchParams) { + const getPackagesFaresRequest = OLA.avail.getRequest(searchParams); + + try { + const olaAvailRequest = await fetch( + OLA.avail.url(), + OLA.avail.options(getPackagesFaresRequest) + ); + const olaAvailResponse = await olaAvailRequest.json(); + + const pkgWithIdentifiedDepartures = olaAvailResponse.map((pkg) => { + return { + ...pkg, + id: CryptoService.generateDepartureId( + "ola", + pkg.Flight.Trips.Trip[0].DepartureDate + ), + }; + }); + + const departuresGroup = PackageApiService.departures.ola.getDeparturesGroup( + pkgWithIdentifiedDepartures + ); + + const mapResponse = ProviderService.mapper( + pkgWithIdentifiedDepartures, + "ola", + "avail" + ); + + const groupedResponse = ProviderService.ola.grouper(mapResponse, "id"); + + const response = { + packages: groupedResponse, + departuresGroup, + }; + + return response; + } catch (error) { + console.error("Error fetching OLA packages", error); + } +} + +export async function POST(req) { + const body = await req.json(); + const olaPackages = await fetchOlaPackages(body); + return Response.json(olaPackages); +} diff --git a/app/apple-icon.png b/app/apple-icon.png new file mode 100644 index 0000000..ac12620 Binary files /dev/null and b/app/apple-icon.png differ diff --git a/app/components/AddToBag.tsx b/app/components/AddToBag.tsx deleted file mode 100644 index f9ebebd..0000000 --- a/app/components/AddToBag.tsx +++ /dev/null @@ -1,43 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { useShoppingCart } from "use-shopping-cart"; -import { urlFor } from "../lib/sanity"; - -export interface ProductCart { - name: string; - description: string; - price: number; - currency: string; - image: any; - price_id: string; -} - -export default function AddToBag({ - currency, - description, - image, - name, - price, - price_id, -}: ProductCart) { - const { addItem, handleCartClick } = useShoppingCart(); - - const product = { - name: name, - description: description, - price: price, - currency: currency, - image: urlFor(image).url(), - price_id: price_id, - }; - return ( - - ); -} diff --git a/app/components/CheckoutNow.tsx b/app/components/CheckoutNow.tsx deleted file mode 100644 index 2147f62..0000000 --- a/app/components/CheckoutNow.tsx +++ /dev/null @@ -1,40 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import { useShoppingCart } from "use-shopping-cart"; -import { urlFor } from "../lib/sanity"; -import { ProductCart } from "./AddToBag"; - -export default function CheckoutNow({ - currency, - description, - image, - name, - price, - price_id, -}: ProductCart) { - const { checkoutSingleItem } = useShoppingCart(); - - function buyNow(priceId: string) { - checkoutSingleItem(priceId); - } - - const product = { - name: name, - description: description, - price: price, - currency: currency, - image: urlFor(image).url(), - price_id: price_id, - }; - return ( - - ); -} diff --git a/app/components/Cta/Cta.module.css b/app/components/Cta/Cta.module.css new file mode 100644 index 0000000..9194cbf --- /dev/null +++ b/app/components/Cta/Cta.module.css @@ -0,0 +1,21 @@ +.button { + color: inherit; + display: block; + border-radius: 0; + border: 1px solid currentColor; + background-color: transparent; + padding: 1em 1.5em; + text-decoration: inherit; + font-weight: 600; + max-width: fit-content; + transition: transform 50ms ease-in-out; + transform: scale(1); +} + +.button:hover { + transform: scale(1.05); +} + +.button:active { + transform: scale(0.95); +} diff --git a/app/components/Cta/index.js b/app/components/Cta/index.js new file mode 100644 index 0000000..a22a3e7 --- /dev/null +++ b/app/components/Cta/index.js @@ -0,0 +1,45 @@ +import React from "react"; +import PropTypes from "prop-types"; +import Link from "next/link"; +import styles from "./Cta.module.css"; + +function cta(props) { + const { title, route, link } = props; + + if (route && route.slug && route.slug.current) { + return ( + + {title} + + ); + } + + if (link) { + return ( + + {title} + + ); + } + + return {title}; +} + +cta.propTypes = { + title: PropTypes.string.isRequired, + route: PropTypes.shape({ + slug: PropTypes.shape({ + current: PropTypes.string, + }), + }), + link: PropTypes.string, +}; + +export default cta; diff --git a/app/components/EmbedHTML/EmbedHTML.js b/app/components/EmbedHTML/EmbedHTML.js new file mode 100644 index 0000000..86e1150 --- /dev/null +++ b/app/components/EmbedHTML/EmbedHTML.js @@ -0,0 +1,17 @@ +import React from 'react' +import PropTypes from 'prop-types' + +function EmbedHTML({node}) { + const {html} = node + if (!html) { + return undefined + } + return
+} + +EmbedHTML.propTypes = { + node: PropTypes.shape({ + html: PropTypes.string, + }), +} +export default EmbedHTML diff --git a/app/components/Figure/Figure.module.css b/app/components/Figure/Figure.module.css new file mode 100644 index 0000000..90127f8 --- /dev/null +++ b/app/components/Figure/Figure.module.css @@ -0,0 +1,73 @@ +@import "../../styles/custom-media.css"; + +.root { + position: relative; + padding: 2rem 0; + + @media (--media-min-medium) { + padding: 4rem 0; + } +} + +.label { + font-size: var(--font-micro-size); + line-height: var(--font-micro-line-height); + letter-spacing: 0.5px; + text-transform: uppercase; + margin-top: 1em; +} + +.label + .title { + margin-top: 0.2em; +} + +.title { + font-size: var(--font-title3-size); + line-height: var(--font-title3-line-height); +} + +.image { + display: block; + width: 100%; +} + +.content { + position: relative; + margin: 0 1.5rem; + max-width: var(--width-medium); + + @nest & figcaption { + width: 100%; + box-sizing: border-box; + } + + @media (--media-min-medium) { + margin: 0 auto; + padding-top: 2rem; + + @nest & figcaption { + position: absolute; + top: 0; + } + } +} + +.caption { + width: 100%; + margin: 0 auto; + box-sizing: border-box; + + @media (--media-min-medium) { + max-width: calc(var(--width-small) - 3rem); + } +} + +.captionBox { + background-color: var(--color-white); + border: 1px solid var(--color-black); + padding: 1.5rem; + + @media (--media-min-medium) { + max-width: calc((var(--width-small) / 2) - 3rem); + } +} diff --git a/app/components/Figure/index.js b/app/components/Figure/index.js new file mode 100644 index 0000000..4cc327c --- /dev/null +++ b/app/components/Figure/index.js @@ -0,0 +1,48 @@ +"use client"; +import React from "react"; +import PropTypes from "prop-types"; +import imageUrlBuilder from "@sanity/image-url"; +import styles from "./Figure.module.css"; +import Image from "next/image"; +import { client } from "../../lib/client"; + +const builder = imageUrlBuilder(client); + +function Figure({ node }) { + const { alt, caption, asset } = node; + if (!asset) { + return undefined; + } + return ( +
+ {alt} + {caption && ( +
+
+
+

{caption}

+
+
+
+ )} +
+ ); +} + +Figure.propTypes = { + node: PropTypes.shape({ + alt: PropTypes.string, + caption: PropTypes.string, + asset: PropTypes.shape({ + _ref: PropTypes.string, + }), + }), +}; +export default Figure; diff --git a/app/components/Footer/ContactInfo/index.jsx b/app/components/Footer/ContactInfo/index.jsx new file mode 100644 index 0000000..b280d0d --- /dev/null +++ b/app/components/Footer/ContactInfo/index.jsx @@ -0,0 +1,14 @@ +import React from "react"; + +const ContactInfo = ({ Icon, title, detail }) => { + return ( +
+
+ {Icon} {title} +
+ {detail} +
+ ); +}; + +export default ContactInfo; diff --git a/app/components/Footer/Newsletter/index.jsx b/app/components/Footer/Newsletter/index.jsx new file mode 100644 index 0000000..96d9c91 --- /dev/null +++ b/app/components/Footer/Newsletter/index.jsx @@ -0,0 +1,48 @@ +import React from "react"; + +const Newsletter = () => { + return ( +
+

+ Registrate y comenzá a recibir nuestras promociones +

+
+
+ + +
+
+ + +
+ +
+ + Recibirás emails promocionales, no compartiremos tus datos personales + con terceros. Para más información consulta las políticas de privacidad. + +
+ ); +}; + +export default Newsletter; diff --git a/app/components/Footer/SocialLinks/index.jsx b/app/components/Footer/SocialLinks/index.jsx new file mode 100644 index 0000000..dc84651 --- /dev/null +++ b/app/components/Footer/SocialLinks/index.jsx @@ -0,0 +1,29 @@ +import Link from "next/link"; +import React from "react"; +import { FaFacebook, FaInstagram } from "react-icons/fa"; + +const SocialLinks = () => { + return ( + <> + Seguinos +
+ + + + + + +
+ + ); +}; + +export default SocialLinks; diff --git a/app/components/Footer/index.js b/app/components/Footer/index.js new file mode 100644 index 0000000..995de96 --- /dev/null +++ b/app/components/Footer/index.js @@ -0,0 +1,104 @@ +import React from "react"; +import Link from "next/link"; +import { getPathFromSlug, slugParamToPath } from "../../../utils/urls"; +import SocialLinks from "./SocialLinks"; +import ContactInfo from "./ContactInfo"; +import { + FaClock, + FaMailBulk, + FaMap, + FaMapMarked, + FaMarker, + FaPhone, + FaWhatsapp, +} from "react-icons/fa"; +import Newsletter from "./Newsletter"; +import { openModalBase } from "../../helpers/modals"; +import SubFooter from "../SubFooter"; + +const contactInfo = [ + { + id: "address", + icon: , + title: "Dirección", + detail: "Sargento Cabral 2644 Loc 05", + }, + { + id: "workingTime", + icon: , + title: "Horarios", + detail: "Lunes a viernes de: 09:00 a 18:00 hs Sábados de 10:00 a 13:00 hs", + }, + { + id: "phone", + icon: , + title: "Teléfono", + detail: "+54 9 11 7079 7586", + }, + { + id: "whatsapp", + icon: , + title: "Whatsapp", + detail: "5491130875513", + }, + { + id: "mail", + icon: , + title: "E-mail", + detail: "ventas@plumviajes.com.ar", + }, +]; + +export default function Footer(props) { + const { navItems, text } = props; + return ( +
+ +
+
+ +
+
    + {navItems && + navItems.map((item) => { + return ( +
  • + + {item.title} + +
  • + ); + })} +
+
+
+
+

Dónde encontrarnos

+ {contactInfo.map((ci) => ( + + ))} +
+ {/*
+ +
*/} +
+ +
+ ); +} diff --git a/app/components/Header/MenuItem/index.jsx b/app/components/Header/MenuItem/index.jsx new file mode 100644 index 0000000..1f0b55f --- /dev/null +++ b/app/components/Header/MenuItem/index.jsx @@ -0,0 +1,22 @@ +import Link from "next/link"; +import React from "react"; +import { getPathFromSlug } from "../../../../utils/urls"; + +const MenuItem = ({ item, icon }) => { + const { slug, title, id } = item; + const isActive = true; + return ( + + {icon}{" "} + {title} + + ); +}; + +export default MenuItem; diff --git a/app/components/Header/index.jsx b/app/components/Header/index.jsx new file mode 100644 index 0000000..bd2e18b --- /dev/null +++ b/app/components/Header/index.jsx @@ -0,0 +1,73 @@ +"use client"; +import React from "react"; +import Link from "next/link"; +import { urlForImage } from "../../lib/image"; +import Image from "next/image"; +import MenuItem from "./MenuItem"; +import { getIconByName } from "../../helpers/iconHelper"; + +export default function Header(props) { + const { title = "Missing title", navItems, logo, contact } = props; + + return ( + + ); +} diff --git a/app/components/ImageGallery.tsx b/app/components/ImageGallery.tsx deleted file mode 100644 index 04cd7f3..0000000 --- a/app/components/ImageGallery.tsx +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import Image from "next/image"; -import { urlFor } from "../lib/sanity"; -import { useState } from "react"; - -interface iAppProps { - images: any; -} - -export default function ImageGallery({ images }: iAppProps) { - const [bigImage, setBigImage] = useState(images[0]); - - const handleSmallImageClick = (image: any) => { - setBigImage(image); - }; - return ( -
-
- {images.map((image: any, idx: any) => ( -
- photo handleSmallImageClick(image)} - /> -
- ))} -
- -
- Photo - - - Sale - -
-
- ); -} diff --git a/app/components/Navbar.tsx b/app/components/Navbar.tsx deleted file mode 100644 index 80574fc..0000000 --- a/app/components/Navbar.tsx +++ /dev/null @@ -1,65 +0,0 @@ -"use client"; - -import { Button } from "@/components/ui/button"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; -import { ShoppingBag } from "lucide-react"; -import { useShoppingCart } from "use-shopping-cart"; - -const links = [ - { name: "Home", href: "/" }, - { name: "Men", href: "/Men" }, - { name: "Women", href: "/Women" }, - { name: "Teens", href: "/Teens" }, -]; - -export default function Navbar() { - const pathname = usePathname(); - const { handleCartClick } = useShoppingCart(); - return ( -
-
- -

- NextCommerce -

- - - - -
- -
-
-
- ); -} diff --git a/app/components/Newest.tsx b/app/components/Newest.tsx deleted file mode 100644 index a2c170f..0000000 --- a/app/components/Newest.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import Link from "next/link"; -import { simplifiedProduct } from "../interface"; -import { client } from "../lib/sanity"; -import { ArrowRight } from "lucide-react"; -import Image from "next/image"; - -async function getData() { - const query = `*[_type == "product"][0...4] | order(_createdAt desc) { - _id, - price, - name, - "slug": slug.current, - "categoryName": category->name, - "imageUrl": images[0].asset->url - }`; - - const data = await client.fetch(query); - - return data; -} - -export default async function Newest() { - const data: simplifiedProduct[] = await getData(); - - return ( -
-
-
-

- Our Newest products -

- - - See All{" "} - - - - -
- -
- {data.map((product) => ( -
-
- Product image -
- -
-
-

- - {product.name} - -

-

- {product.categoryName} -

-
-

- ${product.price} -

-
-
- ))} -
-
-
- ); -} diff --git a/app/components/Providers.tsx b/app/components/Providers.tsx deleted file mode 100644 index 1e3dcd4..0000000 --- a/app/components/Providers.tsx +++ /dev/null @@ -1,22 +0,0 @@ -"use client"; - -import { ReactNode } from "react"; -import { CartProvider as USCProvider } from "use-shopping-cart"; - -export default function CartProvider({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/app/components/RenderSections.js b/app/components/RenderSections.js new file mode 100644 index 0000000..f92c53e --- /dev/null +++ b/app/components/RenderSections.js @@ -0,0 +1,54 @@ +import React, { Fragment } from "react"; +import PropTypes from "prop-types"; +import * as SectionComponents from "./sections"; +import capitalizeString from "../../utils/capitalizeString"; + +function resolveSections(section) { + const Section = SectionComponents[capitalizeString(section._type)]; + + if (Section) { + return Section; + } + + console.error("Cant find section", section); + return null; +} + +function RenderSections(props) { + const { sections } = props; + + if (!sections) { + console.error("Missing section"); + return
Missing sections
; + } + + return ( +
+ {sections.map((section, i) => { + const SectionComponent = resolveSections(section); + if (!SectionComponent) { + return ( +
Missing section {section._type}
+ ); + } + return ( +
+ +
+ ); + })} +
+ ); +} + +RenderSections.propTypes = { + sections: PropTypes.arrayOf( + PropTypes.shape({ + _type: PropTypes.string, + _key: PropTypes.string, + section: PropTypes.instanceOf(PropTypes.object), + }) + ), +}; + +export default RenderSections; diff --git a/app/components/ShoppingCartModal.tsx b/app/components/ShoppingCartModal.tsx deleted file mode 100644 index 4627de9..0000000 --- a/app/components/ShoppingCartModal.tsx +++ /dev/null @@ -1,123 +0,0 @@ -"use client"; -import { Button } from "@/components/ui/button"; -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle, -} from "@/components/ui/sheet"; - -import Image from "next/image"; -import { useShoppingCart } from "use-shopping-cart"; - -export default function ShoppingCartModal() { - const { - cartCount, - shouldDisplayCart, - handleCartClick, - cartDetails, - removeItem, - totalPrice, - redirectToCheckout, - } = useShoppingCart(); - - async function handleCheckoutClick(event: any) { - event.preventDefault(); - try { - const result = await redirectToCheckout(); - if (result?.error) { - console.log("result"); - } - } catch (error) { - console.log(error); - } - } - return ( - handleCartClick()}> - - - Shopping Cart - - -
-
-
    - {cartCount === 0 ? ( -

    You dont have any items

    - ) : ( - <> - {Object.values(cartDetails ?? {}).map((entry) => ( -
  • -
    - Product image -
    - -
    -
    -
    -

    {entry.name}

    -

    ${entry.price}

    -
    -

    - {entry.description} -

    -
    - -
    -

    QTY: {entry.quantity}

    - -
    - -
    -
    -
    -
  • - ))} - - )} -
-
- -
-
-

Subtotal:

-

${totalPrice}

-
-

- Shipping and taxes are calculated at checkout. -

- -
- -
- -
-

- OR{" "} - -

-
-
-
-
-
- ); -} diff --git a/app/components/SimpleBlockContent.js b/app/components/SimpleBlockContent.js new file mode 100644 index 0000000..b390784 --- /dev/null +++ b/app/components/SimpleBlockContent.js @@ -0,0 +1,32 @@ +import React from "react"; +import PropTypes from "prop-types"; +import { PortableText } from "@portabletext/react"; +import EmbedHTML from "./EmbedHTML/EmbedHTML"; +import Figure from "./Figure"; + +function SimpleBlockContent(props) { + const { blocks } = props; + + if (!blocks) { + console.error("Missing blocks"); + return null; + } + + return ( + + ); +} + +SimpleBlockContent.propTypes = { + blocks: PropTypes.arrayOf(PropTypes.object), +}; + +export default SimpleBlockContent; diff --git a/app/components/SubFooter/index.jsx b/app/components/SubFooter/index.jsx new file mode 100644 index 0000000..b063e42 --- /dev/null +++ b/app/components/SubFooter/index.jsx @@ -0,0 +1,74 @@ +import React from "react"; +import Link from "next/link"; +import Image from "next/image"; + +export default function SubFooter() { + return ( +
+
+

+ + Datos fiscales AFIP + +

+

+ Plum Viajes - Legajo 18.156 -  + + ver licencia + +

+
+ + Me arrepentí de mi compra. Para cancelarla{" "} + + ingrese aquí + + +
+ + Defensa del consumidor. Para reclamos{" "} + + ingrese aquí + + +
+ + Denuncia contra una agencia. Para reclamos{" "} + + ingrese aquí + + +
+
+ ); +} diff --git a/app/components/Widgets/SocialWidget/index.jsx b/app/components/Widgets/SocialWidget/index.jsx new file mode 100644 index 0000000..b497d3c --- /dev/null +++ b/app/components/Widgets/SocialWidget/index.jsx @@ -0,0 +1,20 @@ +import Link from "next/link"; +import { FaWhatsapp, FaFacebook, FaInstagram } from "react-icons/fa"; + +const SocialWidget = ({ whatsappLink, facebookLink, instagramLink }) => { + return ( +
+ + + + + + + + + +
+ ); +}; + +export default SocialWidget; diff --git a/app/components/Widgets/Whatsapp/index.jsx b/app/components/Widgets/Whatsapp/index.jsx new file mode 100644 index 0000000..f836827 --- /dev/null +++ b/app/components/Widgets/Whatsapp/index.jsx @@ -0,0 +1,48 @@ +"use client"; + +import Image from "next/image"; +import React from "react"; +import { FaWhatsapp, FaWhatsappSquare } from "react-icons/fa"; + +const Whatsapp = () => { + const handleClick = async () => { + // Check if WhatApp installed, if yes open whatsapp else open whatsapp web + const isAndroid = + navigator.userAgent.toLowerCase().indexOf("android") !== -1; + + if (isAndroid) { + // WhatsApp is installed + window.open(`whatsapp://send?phone=541161758142`); + } else { + // WhatsApp is not installed, open WhatsApp Web + window.open("https://web.whatsapp.com/send?phone=541161758142", "_blank"); + } + }; + + return ( + <> +
+ {/* Mostrar imagen en dispositivos de escritorio */} +
+ Whatsapp +
+ {/* Mostrar ícono en dispositivos móviles */} +
+
+ +
+
+
+ + ); +}; + +export default Whatsapp; diff --git a/app/components/commons/Carousel/index.jsx b/app/components/commons/Carousel/index.jsx new file mode 100644 index 0000000..454063c --- /dev/null +++ b/app/components/commons/Carousel/index.jsx @@ -0,0 +1,44 @@ +"use client"; +import React from "react"; +import Carousel from "react-multi-carousel"; +import "react-multi-carousel/lib/styles.css"; + +const DEFAULT_DESKTOP_ITEMS = 4; + +const CommonCarousel = ({ + desktopItems = DEFAULT_DESKTOP_ITEMS, + children = [], + itemClass = "p-1 md:p-5", +}) => { + const responsive = { + desktop: { + breakpoint: { max: 3000, min: 1024 }, + items: desktopItems, + slidesToSlide: 1, // optional, default to 1. + }, + tablet: { + breakpoint: { max: 1024, min: 768 }, + items: 3, + slidesToSlide: 3, // optional, default to 1. + }, + mobile: { + breakpoint: { max: 767, min: 200 }, + items: 1, + slidesToSlide: 1, // optional, default to 1. + }, + }; + return ( + + {children} + + ); +}; + +export default CommonCarousel; diff --git a/app/components/commons/Email/AgentEmailTemplate/index.jsx b/app/components/commons/Email/AgentEmailTemplate/index.jsx new file mode 100644 index 0000000..cbb39fb --- /dev/null +++ b/app/components/commons/Email/AgentEmailTemplate/index.jsx @@ -0,0 +1,33 @@ +import { Html, Heading, Text } from "@react-email/components"; + +const AgentEmailTemplate = ({ + name, + surname, + phoneType, + phoneNumber, + email, + inquiry, + ringMe, +}) => { + return ( + + Nueva consulta de agente + + Se ha recibido una nueva consulta a través del formulario de agentes de + viajes. + + Nombre: {name} + Apellido: {surname} + Email: {email} + + Teléfono: {phoneType} {phoneNumber} + + Consulta: {inquiry} + + ¿Desea ser contactado telefónicamente?: {ringMe ? "Sí" : "No"} + + + ); +}; + +export default AgentEmailTemplate; diff --git a/app/components/commons/Email/ContactEmailTemplate/index.jsx b/app/components/commons/Email/ContactEmailTemplate/index.jsx new file mode 100644 index 0000000..45ebc36 --- /dev/null +++ b/app/components/commons/Email/ContactEmailTemplate/index.jsx @@ -0,0 +1,42 @@ +import { Html, Heading, Text } from "@react-email/components"; +const ContactEmailTemplate = ({ + name, + surname, + phoneType, + phoneAreaCode, + phoneNumber, + contactTime, + email, + destination, + departureDate, + nightsQty, + adultsQty, + childQty, + mealPlan, + inquiry, + ringMe, + notifyPromotions, +}) => { + return ( + + Nueva consulta enviada + Entró una nueva consulta a través del formulario de contacto + Nombre: {name} + Apellido: {surname} + Email: {email} + + Teléfono: {phoneType} {phoneAreaCode} {phoneNumber}{" "} + + Destino: {destination} + Fecha de salida: {departureDate} + Noches: {nightsQty} + Adultos: {adultsQty} + Niños: {childQty} + Meal Plan: {mealPlan} + Su consulta fue: {inquiry} + Quiere que lo llamen?: {ringMe} + Quiere recibir promociones?: {notifyPromotions} + + ); +}; +export default ContactEmailTemplate; diff --git a/app/components/commons/Loading/index.jsx b/app/components/commons/Loading/index.jsx new file mode 100644 index 0000000..b93d61b --- /dev/null +++ b/app/components/commons/Loading/index.jsx @@ -0,0 +1,20 @@ +import Image from "next/image"; + +export default function Loading({ + message = "Buscando los mejores paquetes para vos...", +}) { + return ( +
+
+ loading... + {message &&

{message}

} +
+
+ ); +} diff --git a/app/components/commons/Modal/ModalBase.jsx b/app/components/commons/Modal/ModalBase.jsx new file mode 100644 index 0000000..7e37566 --- /dev/null +++ b/app/components/commons/Modal/ModalBase.jsx @@ -0,0 +1,61 @@ +"use client"; +import { Dialog, Transition } from "@headlessui/react"; +import { Fragment } from "react"; + +export default function ModalBase({ show = true, children, title, close }) { + const handleClose = () => { + close(); + }; + + return ( + + + +
+ + +
+
+ + + + {title} + +
{children}
+
+ +
+
+
+
+
+
+
+ ); +} diff --git a/app/components/commons/Modal/ModalRoot.jsx b/app/components/commons/Modal/ModalRoot.jsx new file mode 100644 index 0000000..28ab5d2 --- /dev/null +++ b/app/components/commons/Modal/ModalRoot.jsx @@ -0,0 +1,33 @@ +"use client"; +import { useState, useEffect } from "react"; +import ModalService from "../../../services/modal.service"; +export default function ModalRoot() { + const [modal, setModal] = useState({}); + + useEffect(() => { + ModalService.on("open", ({ component, props }) => { + setModal({ + component, + props, + close: () => { + props.close && props.close(); + setModal({}); + }, + }); + }); + }, []); + + const ModalComponent = modal.component ? modal.component : null; + + return ( +
+ {ModalComponent && ( + + )} +
+ ); +} diff --git a/app/components/commons/Slider/index.jsx b/app/components/commons/Slider/index.jsx new file mode 100644 index 0000000..29b1654 --- /dev/null +++ b/app/components/commons/Slider/index.jsx @@ -0,0 +1,70 @@ +"use client"; +import Image from "next/image"; +import React, { useState, useEffect } from "react"; +import CommonCarousel from "../Carousel"; + +// Skeleton Loader +const Skeleton = () => ( +
+ {Array(4) + .fill("") + .map((_, index) => ( +
+ ))} +
+); + +// Define a fallback image URL +const FALLBACK_IMAGE_URL = "/images/no-image.jpeg"; + +const Slider = ({ slides, deviceType = "desktop" }) => { + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + // Simplificar la lógica + setIsLoading(!slides); + }, [slides]); + + // onError handler simplificado + const handleImageError = (e) => { + e.target.onerror = null; + e.target.src = FALLBACK_IMAGE_URL; + }; + + if (isLoading) return ; + + if (!slides || slides.length === 0) { + return ( +

No hay imágenes disponibles.

+ ); + } + + return ( + + {slides.map((slide, index) => ( + {slide.alt + ))} + + ); +}; + +export default Slider; diff --git a/app/components/commons/Tabs.jsx b/app/components/commons/Tabs.jsx new file mode 100644 index 0000000..f781218 --- /dev/null +++ b/app/components/commons/Tabs.jsx @@ -0,0 +1,50 @@ +"use client"; +import React, { useState } from "react"; +const Tabs = ({ children }) => { + const [activeTab, setActiveTab] = useState(children[0]?.props.label); + const handleClick = (e, newActiveTab) => { + e.preventDefault(); + if (newActiveTab === "Hoteles") { + window.location.href = + "https://plumviajes.app.pricenavigator.net/#!/hotel"; + return; + } + setActiveTab(newActiveTab); + }; + return ( + <> +
+ {children?.map((child) => ( + + ))} +
+
+ {children?.map((child) => { + if (child.props.label === activeTab) { + return
{child.props.children}
; + } + return null; + })} +
+ + ); +}; +const Tab = ({ label = "", children }) => { + return ( +
+ {children} +
+ ); +}; +export { Tabs, Tab }; diff --git a/app/components/commons/TextComponent/index.jsx b/app/components/commons/TextComponent/index.jsx new file mode 100644 index 0000000..4960309 --- /dev/null +++ b/app/components/commons/TextComponent/index.jsx @@ -0,0 +1,7 @@ +import React from "react"; + +const TextComponent = ({ text, tag }) => { + {text}; +}; + +export default TextComponent; diff --git a/app/components/sections/Hero/Hero.module.css b/app/components/sections/Hero/Hero.module.css new file mode 100644 index 0000000..535c741 --- /dev/null +++ b/app/components/sections/Hero/Hero.module.css @@ -0,0 +1,83 @@ +@import "../../../styles/custom-media.css"; + +.root { + composes: center from "../../../styles/shared.module.css"; + position: relative; + background-color: var(--color-black, #000); + color: var(--color-white, #fff); + background-size: cover; + background-repeat: no-repeat; + background-position: center center; + padding-bottom: 2rem; + + @media (--media-min-medium) { + padding-bottom: 4rem; + } +} + +.root::before { + content: ""; + position: absolute; + top: 50%; + left: 0; + background-image: linear-gradient(#2220, #222e); + width: 100%; + height: 50%; + z-index: 0; +} + +.content { + width: 100%; + max-width: var(--width-small); + padding: 0 1.5em; + box-sizing: border-box; + z-index: 1; +} + +.title { + position: relative; + font-weight: 600; + font-size: var(--font-title2-size); + line-height: var(--font-title2-line-height); + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); + margin: 0; + padding: 0; + padding-top: 12.5rem; + + @media (--media-min-medium) { + font-size: var(--font-title1-size); + line-height: var(--font-title1-line-height); + padding-top: 15rem; + } +} + +.tagline { + position: relative; + margin: 0; + padding: 0; + margin-top: 0.5em; + margin-bottom: 1rem; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.5); + + @media (--media-min-medium) { + font-size: var(--font-large-size); + line-height: var(--font-large-line-height); + } +} + +.tagline > p { + margin: 0; +} + +.ctas { + margin-top: 3rem; + display: flex; +} + +.ctas > *:not(:first-child) { + margin-left: 1rem; +} + +.root p a { + color: inherit; +} diff --git a/app/components/sections/Hero/index.js b/app/components/sections/Hero/index.js new file mode 100644 index 0000000..074d038 --- /dev/null +++ b/app/components/sections/Hero/index.js @@ -0,0 +1,51 @@ +import React from "react"; +import PropTypes from "prop-types"; +import imageUrlBuilder from "@sanity/image-url"; +import styles from "./Hero.module.css"; +import { client } from "../../../lib/client"; +import SimpleBlockContent from "../../SimpleBlockContent"; +import Cta from "../../Cta"; + +function urlFor(source) { + return imageUrlBuilder(client).image(source); +} + +function Hero(props) { + const { heading, backgroundImage, tagline, ctas } = props; + + const style = backgroundImage + ? { + backgroundImage: `url("${urlFor(backgroundImage) + .width(2000) + .auto("format") + .url()}")`, + } + : {}; + + return ( +
+
+

{heading}

+
+ {tagline && } +
+ {ctas && ( +
+ {ctas.map((cta) => ( + + ))} +
+ )} +
+
+ ); +} + +Hero.propTypes = { + heading: PropTypes.string, + backgroundImage: PropTypes.object, + tagline: PropTypes.array, + ctas: PropTypes.arrayOf(PropTypes.object), +}; + +export default Hero; diff --git a/app/components/Hero.tsx b/app/components/sections/HeroWithImages/index.jsx similarity index 87% rename from app/components/Hero.tsx rename to app/components/sections/HeroWithImages/index.jsx index 6cd1d7c..81aa43e 100644 --- a/app/components/Hero.tsx +++ b/app/components/sections/HeroWithImages/index.jsx @@ -1,17 +1,8 @@ import Image from "next/image"; -import { client, urlFor } from "../lib/sanity"; import Link from "next/link"; +import { urlForImage } from "../../../lib/image"; -async function getData() { - const query = "*[_type == 'heroImage'][0]"; - - const data = await client.fetch(query); - - return data; -} - -export default async function Hero() { - const data = await getData(); +export default async function Hero({ image1 = "", image2 = "" }) { return (
@@ -28,7 +19,7 @@ export default async function Hero() {
Great Photo Great Photo ( + + {item.title + + ); + + return ( + <> +
+ {links.map((il, index) => ( +
+ +
+ ))} +
+ +
+ + {links.map((il, index) => ( +
+ +
+ ))} +
+
+ + ); +} diff --git a/app/components/sections/ImageSection/ImageSection.module.css b/app/components/sections/ImageSection/ImageSection.module.css new file mode 100644 index 0000000..67a59af --- /dev/null +++ b/app/components/sections/ImageSection/ImageSection.module.css @@ -0,0 +1,73 @@ +@import "../../../styles/custom-media.css"; + +.root { + position: relative; + padding: 2rem 0; + + @media (--media-min-medium) { + padding: 4rem 0; + } +} + +.label { + font-size: var(--font-micro-size); + line-height: var(--font-micro-line-height); + letter-spacing: 0.5px; + text-transform: uppercase; + margin-top: 1em; +} + +.label + .title { + margin-top: 0.2em; +} + +.title { + font-size: var(--font-title3-size); + line-height: var(--font-title3-line-height); +} + +.image { + display: block; + width: 100%; +} + +.content { + position: relative; + margin: 0 1.5rem; + max-width: var(--width-medium); + + @nest & figcaption { + width: 100%; + box-sizing: border-box; + } + + @media (--media-min-medium) { + margin: 0 auto; + padding-top: 2rem; + + @nest & figcaption { + position: absolute; + top: 0; + } + } +} + +.caption { + width: 100%; + margin: 0 auto; + box-sizing: border-box; + + @media (--media-min-medium) { + max-width: calc(var(--width-small) - 3rem); + } +} + +.captionBox { + background-color: var(--color-white); + border: 1px solid var(--color-black); + padding: 1.5rem; + + @media (--media-min-medium) { + max-width: calc((var(--width-small) / 2) - 3rem); + } +} diff --git a/app/components/sections/ImageSection/index.js b/app/components/sections/ImageSection/index.js new file mode 100644 index 0000000..02cb39f --- /dev/null +++ b/app/components/sections/ImageSection/index.js @@ -0,0 +1,58 @@ +import React from "react"; +import Image from "next/image"; +import PropTypes from "prop-types"; +import styles from "./ImageSection.module.css"; +import { urlForImage } from "../../../lib/image"; +import SimpleBlockContent from "../../SimpleBlockContent"; +import Cta from "../../Cta"; + +function ImageSection(props) { + const { heading, label, text, image, cta } = props; + ////console.log("image", JSON.stringify(props)); + if (!image) { + return null; + } + + const imageSrc = urlForImage(image); + + return ( +
+
+ {heading} +
+
+
+
{label}
+

{heading}

+ {text && } + {cta && cta.route && } +
+
+
+
+
+ ); +} + +ImageSection.propTypes = { + heading: PropTypes.string, + label: PropTypes.string, + text: PropTypes.array, + image: PropTypes.shape({ + asset: PropTypes.shape({ + _ref: PropTypes.string, + }), + }), + backgroundImage: PropTypes.string, + tagline: PropTypes.string, + cta: PropTypes.object, +}; + +export default ImageSection; diff --git a/app/components/sections/Reviews/index.jsx b/app/components/sections/Reviews/index.jsx new file mode 100644 index 0000000..e4d4bb5 --- /dev/null +++ b/app/components/sections/Reviews/index.jsx @@ -0,0 +1,13 @@ +import Image from "next/image"; +import React from "react"; +import { urlForImage } from "../../../lib/image"; + +const Reviews = ({ image }) => { + return ( +
+ reviews +
+ ); +}; + +export default Reviews; diff --git a/app/components/sections/SearchEngines/FlightsEngine/CheckboxGroup/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/CheckboxGroup/index.jsx new file mode 100644 index 0000000..8355554 --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/CheckboxGroup/index.jsx @@ -0,0 +1,44 @@ +import React from "react"; + +const CheckboxGroup = ({ + flexibleDates, + setFlexibleDates, + business, + setBusiness, + priceInUsd, + setPriceInUsd, +}) => { + return ( +
+ + + +
+ ); +}; + +export default CheckboxGroup; diff --git a/app/components/sections/SearchEngines/FlightsEngine/FormLayout/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/FormLayout/index.jsx new file mode 100644 index 0000000..0d22fb6 --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/FormLayout/index.jsx @@ -0,0 +1,14 @@ +import React from "react"; + +const FormLayout = ({ children, onSubmit }) => { + return ( +
+ {children} +
+ ); +}; + +export default FormLayout; diff --git a/app/components/sections/SearchEngines/FlightsEngine/InputGroup/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/InputGroup/index.jsx new file mode 100644 index 0000000..5c586c8 --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/InputGroup/index.jsx @@ -0,0 +1,101 @@ +import React from "react"; +import AsyncSelect from "react-select/async"; +import DatePicker from "react-datepicker"; +import "react-datepicker/dist/react-datepicker.css"; +import CitiesService from "../../../../../services/cities.service"; + +const getCitiesAutocompleteApi = async (query, inputName) => + await CitiesService.getCitiesAutocompleteApi(query, inputName); + +const InputGroup = ({ + origin, + setOrigin, + destination, + setDestination, + setDateRange, + dateRange, + startDate, + endDate, +}) => { + const loadOptions = async (inputValue, _, inputName) => { + if (inputValue.length < 4) { + return []; + } + try { + const citiesFetch = await getCitiesAutocompleteApi(inputValue, inputName); + + const formattedOptions = citiesFetch.map((city) => ({ + label: `${city.label}`, + value: city.value, + })); + return formattedOptions; + } catch (error) { + console.error("Error fetching cities:", error); + return []; + } + }; + + return ( + <> +
+ + + loadOptions(inputValue, callback, "origin") + } + onChange={(option) => setOrigin(option.value)} + defaultOptions + cacheOptions + /> +
+
+ + + loadOptions(inputValue, callback, "destination") + } + onChange={(option) => setDestination(option.value)} + defaultOptions + cacheOptions + /> +
+
+ + setDateRange(update)} + isClearable + placeholderText="Seleccione fechas" + className="col-span-3 p-3 rounded-md border border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500" + aria-labelledby="dateRange-label" // Add if needed for complex DatePickers + /> +
+ + ); +}; + +export default InputGroup; diff --git a/app/components/sections/SearchEngines/FlightsEngine/LegInputGroup/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/LegInputGroup/index.jsx new file mode 100644 index 0000000..a1cf633 --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/LegInputGroup/index.jsx @@ -0,0 +1,100 @@ +import React from "react"; +import AsyncSelect from "react-select/async"; +import CitiesService from "../../../../../services/cities.service"; +import { FiTrash2 } from "react-icons/fi"; + +const getCitiesAutocompleteApi = async (query, inputName) => + await CitiesService.getCitiesAutocompleteApi(query, inputName); + +const loadOptions = async (inputValue, _, inputName) => { + if (inputValue.length < 4) { + return []; + } + try { + const citiesFetch = await getCitiesAutocompleteApi(inputValue, inputName); + const formattedOptions = citiesFetch.map((city) => ({ + label: `${city.label}`, + value: city.value, + })); + return formattedOptions; + } catch (error) { + console.error("Error fetching cities:", error); + return []; + } +}; + +const LegInputGroup = ({ index, leg, updateLeg, removeLeg }) => { + return ( +
+ {/* Botón de eliminar tramo en la esquina superior derecha */} + {removeLeg && ( + + )} + {/* Contenedor responsivo: vertical en mobile, horizontal en md+ */} +
+
+ + + loadOptions(inputValue, callback, "origin") + } + onChange={(option) => updateLeg(index, "origin", option.value)} + defaultOptions + cacheOptions + className="mt-1" + /> +
+
+ + + loadOptions(inputValue, callback, "destination") + } + onChange={(option) => updateLeg(index, "destination", option.value)} + defaultOptions + cacheOptions + className="mt-1" + /> +
+
+ + updateLeg(index, "date", e.target.value)} + className="mt-1 p-2 w-full border border-gray-300 rounded-md text-sm" + /> +
+
+
+ ); +}; + +export default LegInputGroup; diff --git a/app/components/sections/SearchEngines/FlightsEngine/MultitripLegContainer/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/MultitripLegContainer/index.jsx new file mode 100644 index 0000000..b9523a3 --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/MultitripLegContainer/index.jsx @@ -0,0 +1,20 @@ +import React from "react"; +import LegInputGroup from "../LegInputGroup"; + +const MultiTripLegContainer = ({ legs, updateLeg, removeLeg }) => { + return ( +
+ {legs.map((leg, index) => ( + 1 ? removeLeg : null} + /> + ))} +
+ ); +}; + +export default MultiTripLegContainer; diff --git a/app/components/sections/SearchEngines/FlightsEngine/Passengers/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/Passengers/index.jsx new file mode 100644 index 0000000..cc4e6fe --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/Passengers/index.jsx @@ -0,0 +1,114 @@ +import React from "react"; + +const Passengers = ({ + showPassengersModal, + passengers, + setShowPassengersModal, + updatePassenger, +}) => { + return ( + <> + {/* Modal selector de pasajeros */} +
+ + + {showPassengersModal && ( +
+
+

+ Seleccionar pasajeros +

+
+ {/* Adultos */} +
+ Adultos (+12 años) +
+ + {passengers.adults} + +
+
+ {/* Niños */} +
+ Niños (+2 años) +
+ + {passengers.children} + +
+
+ {/* Bebés */} +
+ Bebés (0-2 años) +
+ + {passengers.babies} + +
+
+
+ +
+
+ )} +
+ + ); +}; + +export default Passengers; diff --git a/app/components/sections/SearchEngines/FlightsEngine/RadioGroup/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/RadioGroup/index.jsx new file mode 100644 index 0000000..c3495a1 --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/RadioGroup/index.jsx @@ -0,0 +1,43 @@ +import React from "react"; + +const RadioGroup = ({ tripType, setTripType }) => { + return ( +
+ + + +
+ ); +}; + +export default RadioGroup; diff --git a/app/components/sections/SearchEngines/FlightsEngine/SearchButton/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/SearchButton/index.jsx new file mode 100644 index 0000000..d4bd601 --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/SearchButton/index.jsx @@ -0,0 +1,14 @@ +import React from "react"; + +const SearchButton = () => { + return ( + + ); +}; + +export default SearchButton; diff --git a/app/components/sections/SearchEngines/FlightsEngine/index.jsx b/app/components/sections/SearchEngines/FlightsEngine/index.jsx new file mode 100644 index 0000000..7783e06 --- /dev/null +++ b/app/components/sections/SearchEngines/FlightsEngine/index.jsx @@ -0,0 +1,157 @@ +"use client"; +import React, { useState } from "react"; +import FormLayout from "./FormLayout"; +import InputGroup from "./InputGroup"; +import CheckboxGroup from "./CheckboxGroup"; +import RadioGroup from "./RadioGroup"; +import SearchButton from "./SearchButton"; +import Passengers from "./Passengers"; +import LegInputGroup from "./LegInputGroup"; +import MultiTripLegContainer from "./MultitripLegContainer"; + +const FlightsEngine = () => { + // Estados para viajes no multi + const [origin, setOrigin] = useState(""); + const [destination, setDestination] = useState(""); + const [dateRange, setDateRange] = useState([null, null]); + const [flexible, setFlexible] = useState(false); + const [business, setBusiness] = useState(false); + const [startDate, endDate] = dateRange; + const [priceInUsd, setPriceInUsd] = useState(false); + const [tripType, setTripType] = useState("roundTrip"); + + // Estado para pasajeros + const [passengers, setPassengers] = useState({ + adults: 1, + children: 0, + babies: 0, + }); + const [showPassengersModal, setShowPassengersModal] = useState(false); + const updatePassenger = (type, value) => { + setPassengers((prev) => ({ + ...prev, + [type]: Math.max(0, prev[type] + value), + })); + }; + + // Estado para tramos en modo multi (inicia con un tramo por defecto) + const [legs, setLegs] = useState([{ origin: "", destination: "", date: "" }]); + + const addLeg = () => { + setLegs([...legs, { origin: "", destination: "", date: "" }]); + }; + + // Función para actualizar un tramo en particular + const updateLeg = (index, field, value) => { + setLegs((prevLegs) => + prevLegs.map((leg, i) => (i === index ? { ...leg, [field]: value } : leg)) + ); + }; + + // Función para eliminar un tramo + const removeLeg = (index) => { + setLegs((prevLegs) => prevLegs.filter((_, i) => i !== index)); + }; + + // Función para formatear fechas + const formatDate = (date) => { + const d = new Date(date); + const day = String(d.getDate()).padStart(2, "0"); + const month = String(d.getMonth() + 1).padStart(2, "0"); + const year = d.getFullYear(); + return `${day}-${month}-${year}`; + }; + + const handleSubmit = (e) => { + e.preventDefault(); + const passengersStr = `${passengers.adults}-${passengers.children}-${passengers.babies}`; + const baseURL = "https://tucano.aereos.app/aereos/omg-travel"; + let finalURL = ""; + + if (tripType === "multi") { + // Validación: todos los tramos deben tener sus campos completos + for (let i = 0; i < legs.length; i++) { + const leg = legs[i]; + if (!leg.origin || !leg.destination || !leg.date) { + alert("Complete todos los campos en cada tramo"); + return; + } + } + // Armar la parte de rutas: concatenamos cada tramo con guión (-) + const routePart = legs + .map((leg) => `${leg.origin},${leg.destination}`) + .join("-"); + // Armar la parte de fechas: cada fecha formateada y separada por coma + const datesPart = legs.map((leg) => formatDate(leg.date)).join(","); + finalURL = `${baseURL}/multiples/${routePart}/${datesPart}/${passengersStr}/ALL/${flexible}/${business}/${priceInUsd ? "USD" : "false"}/`; + } else { + // Validación para roundTrip y oneWay + if (!origin || !destination || !startDate) { + alert("Complete los campos obligatorios"); + return; + } + if (tripType === "roundTrip" && !endDate) { + alert("Para viaje de ida y vuelta se requiere la fecha de regreso"); + return; + } + const formattedStartDate = formatDate(startDate); + const formattedEndDate = endDate ? formatDate(endDate) : null; + if (tripType === "roundTrip") { + finalURL = `${baseURL}/roundTrip/${origin}-${destination}/${formattedStartDate}_${formattedEndDate}/${passengersStr}/ALL/${flexible}/${business}/${priceInUsd ? "USD" : "false"}/`; + } else if (tripType === "oneWay") { + finalURL = `${baseURL}/oneWay/${origin}-${destination}/${formattedStartDate}/${passengersStr}/ALL/${flexible}/${business}/${priceInUsd ? "USD" : "false"}/`; + } + } + window.location.href = finalURL; + }; + + return ( + + {tripType === "multi" ? ( + <> + + + + ) : ( + + )} + + + + + + ); +}; + +export default FlightsEngine; diff --git a/app/components/sections/SearchEngines/PackagesEngine/PackageEngineItem/index.jsx b/app/components/sections/SearchEngines/PackagesEngine/PackageEngineItem/index.jsx new file mode 100644 index 0000000..37167fe --- /dev/null +++ b/app/components/sections/SearchEngines/PackagesEngine/PackageEngineItem/index.jsx @@ -0,0 +1,21 @@ +import React from "react"; + +const PackageEngineItem = ({ title, icon, children }) => { + //console.log("children props id", children.props.id); + return ( +
+ +
+
{icon}
+ {children} +
+
+ ); +}; + +export default PackageEngineItem; diff --git a/app/components/sections/SearchEngines/PackagesEngine/index.jsx b/app/components/sections/SearchEngines/PackagesEngine/index.jsx new file mode 100644 index 0000000..d16a3ca --- /dev/null +++ b/app/components/sections/SearchEngines/PackagesEngine/index.jsx @@ -0,0 +1,174 @@ +"use client"; + +import { useForm, Controller } from "react-hook-form"; +import AsyncSelect from "react-select/async"; +import { useEffect } from "react"; +import Select from "react-select"; +import { + FaCalendar, + FaGlobeAmericas, + FaMapMarked, + FaSearch, +} from "react-icons/fa"; +import PackageEngineItem from "./PackageEngineItem"; +import { ProviderService } from "../../../../api/services/providers"; +import CitiesService from "../../../../services/cities.service"; + +const getPackageEngineItems = () => { + function debounce(fn, delay) { + let timeoutId; + return (...args) => { + if (timeoutId) clearTimeout(timeoutId); // Reiniciar el temporizador + timeoutId = setTimeout(() => { + fn(...args); // Ejecutar la función después del tiempo de espera + }, delay); + }; + } + + const getCitiesAutocompleteApi = async (query, inputName) => + await CitiesService.getCitiesAutocompleteApi(query, inputName); + + const loadOptions = debounce(async (query, callback, inputName) => { + if (query.length >= 3) { + const citiesFetch = await getCitiesAutocompleteApi(query, inputName); + callback(citiesFetch); + } + }, 500); // Espera de 500ms después de que el usuario deje de escribir + + const packageEngineItems = [ + { + id: "arrivalCity", + title: "¿Dónde querés ir?", + name: "arrivalCity", + icon: , + children: (field) => ( +
+ + loadOptions(query, callback, "arrivalCity") + } + /> +
+ ), + }, + { + id: "departureMonthYear", + name: "departureMonthYear", + title: "¿Cuándo pensás viajar?", + icon: , + children: (field) => ( + +
+
+ +
+ + + +
+
+
+ + +
+
+
+ + setStartDate(date)} + dateFormat={"dd-MM-YYYY"} + className="w-2/3 rounded-md p-2 border-2 border-gray-300" + /> +
+
+ + +
+
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+ +
+ +